feat: crud devices (wip: notif success)

This commit is contained in:
HasanMu 2025-03-26 16:08:28 +07:00
parent f2bb8eb2bf
commit e13e236b4a
12 changed files with 481 additions and 42 deletions

View File

@ -11,6 +11,7 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.0.14",
"@tanstack/react-form": "^1.1.2",
"@tanstack/react-query": "^5.69.0",
"@tanstack/react-table": "^8.21.2",
"axios": "^1.8.4",

View File

@ -16,7 +16,7 @@ const Header: React.FC<HeaderProps> = ({
toggleSidebar,
}) => {
return (
<header className="p-4 sticky top-0 z-[2]">
<header className="p-4 sticky top-0 z-[1]">
<div className="flex justify-between items-center">
{/* Mobile Sidebar Toggle */}
<div className="flex items-center">
@ -84,13 +84,13 @@ const Header: React.FC<HeaderProps> = ({
className="dropdown-content bg-base-100 rounded-box w-52"
>
<Menu.Title>Hi, {user?.name}</Menu.Title>
<Dropdown.Item>
<NavLink to="#" className="justify-between">
<Dropdown.Item anchor={false}>
<NavLink to="/" className="justify-between">
Profile
</NavLink>
</Dropdown.Item>
<Dropdown.Item>
<NavLink to="#">Settings</NavLink>
<Dropdown.Item anchor={false}>
<NavLink to="/">Settings</NavLink>
</Dropdown.Item>
<Dropdown.Item anchor={false}>
<button onClick={handleLogout}>

View File

@ -69,6 +69,7 @@ const Sidebar: React.FC<SidebarProps> = ({ sidebarOpen, user, handleLogout }) =>
>
<ReactSVG src={BackboneIcon} title="Backbone" className="w-5 h-5 mr-2" />
Backbone
<div className="badge badge-neutral">WIP</div>
</NavLink>
</Menu.Item>
<Menu.Item>
@ -80,6 +81,7 @@ const Sidebar: React.FC<SidebarProps> = ({ sidebarOpen, user, handleLogout }) =>
>
<ReactSVG src={FishboneIcon} title="Fishbone" className="w-5 h-5 mr-2" />
Fishbone
<div className="badge badge-neutral">WIP</div>
</NavLink>
</Menu.Item>
<Menu.Item>
@ -102,6 +104,7 @@ const Sidebar: React.FC<SidebarProps> = ({ sidebarOpen, user, handleLogout }) =>
>
<ReactSVG src={TowerIcon} title="Tower" className="w-5 h-5 mr-2" />
Tower
<div className="badge badge-neutral">WIP</div>
</NavLink>
</Menu.Item>
</Menu>
@ -120,6 +123,7 @@ const Sidebar: React.FC<SidebarProps> = ({ sidebarOpen, user, handleLogout }) =>
>
<ReactSVG src={UserIcon} title="User" className="w-5 h-5 mr-2" />
User
<div className="badge badge-neutral">WIP</div>
</NavLink>
</Menu.Item>
)}

View File

@ -54,7 +54,7 @@ const DashboardLayout: React.FC = () => {
/>
{/* Main content area */}
<main className="flex-1 p-6 pt-0 z-[1]">
<main className="flex-1 p-6 pt-0 z-[0]">
<Outlet />
</main>

View File

@ -20,8 +20,6 @@ interface Callback {
export const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
const response = await apiClient.post<ApiResponse<LoginResponse>>('/api/v1/users/login', credentials);
console.log(response.data);
if (!response.data.data) {
throw new Error('Authentication failed');

View File

@ -1,7 +1,7 @@
// src/features/devices/api/devices.ts
import { apiClient } from '@/lib/api-client';
import { DeviceDataTable } from '../types';
import { useQuery } from '@tanstack/react-query';
import { apiClient, extractApiErrors } from '@/lib/api-client';
import { DeviceDataTable, DeviceRequest, DeviceSingleResponse } from '../types';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ApiResponse } from '@/lib/api-client'; // Pastikan Anda memiliki tipe ApiResponse
import axios, { AxiosError } from 'axios';
import { DataTableDummy } from '../dummy';
@ -28,7 +28,7 @@ export const getDevices = async (): Promise<DeviceDataTable[]> => {
throw new Error(response.data.status.description || 'Unknown error');
} catch (error) {
// Tangani berbagai jenis error
if (DataTableDummy.length > 0) {
if (DataTableDummy.length > 0 && false) {
return DataTableDummy as unknown as DeviceDataTable[];
}
@ -73,6 +73,71 @@ export const getDevices = async (): Promise<DeviceDataTable[]> => {
}
};
export const createDevice = async (deviceData: DeviceRequest): Promise<Pick<ApiResponse<null>, "status">> => {
try {
const response = await apiClient.post<ApiResponse<null>>('/api/v1/devices', deviceData);
return response.data.status as unknown as Pick<ApiResponse<null>, "status">;
} catch (error) {
if (axios.isAxiosError(error)) {
const apiError = error.response?.data?.status;
throw {
code: apiError?.code || 500,
message: apiError?.description || 'An unexpected error occurred',
errors: apiError?.errors || {}
};
}
throw error;
}
};
export const updateDevice = async (id: string, deviceData: Partial<DeviceRequest>): Promise<Pick<ApiResponse<null>, "status">> => {
try {
const response = await apiClient.put<ApiResponse<null>>(`/api/v1/devices/${id}`, deviceData);
return response.data.status as unknown as Pick<ApiResponse<null>, "status">;
} catch (error) {
if (axios.isAxiosError(error)) {
const apiError = error.response?.data?.status;
throw {
code: apiError?.code || 500,
message: apiError?.description || 'An unexpected error occurred',
errors: apiError?.errors || {}
};
}
throw error;
}
};
export const getDeviceById = async (id: string): Promise<DeviceSingleResponse> => {
console.log(id);
try {
const response = await apiClient.get<ApiResponse<DeviceSingleResponse>>(`/api/v1/devices/${id}`);
return response.data.data as DeviceSingleResponse;
} catch (error) {
const errors = extractApiErrors(error);
console.log(errors);
if (axios.isAxiosError(error)) {
const apiError = error.response?.data?.status;
throw {
code: apiError?.code || 500,
message: apiError?.description || 'An unexpected error occurred',
errors: apiError?.errors || {}
};
}
throw error;
}
};
/**
* ===========================================
*
*
* ===========================================
*/
export const useDevices = () => {
return useQuery<DeviceDataTable[], Error>({
queryKey: ['devices'],
@ -80,4 +145,41 @@ export const useDevices = () => {
retry: 1, // Mencoba ulang sekali jika error
retryDelay: 1000, // Delay 1 detik antara percobaan
});
};
export const useCreateDevice = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createDevice,
onSuccess: () => {
// Invalidate and refetch devices list
queryClient.invalidateQueries({ queryKey: ['devices'] });
}
});
};
export const useUpdateDevice = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, deviceData }: { id: string; deviceData: Partial<DeviceRequest> }) =>
updateDevice(id, deviceData),
onSuccess: () => {
// Invalidate and refetch devices list
queryClient.invalidateQueries({ queryKey: ['devices'] });
},
onError: (error) => {
// Handle error (you might want to add toast or error handling)
console.error('Device update failed', error);
}
});
};
export const useDeviceById = (id: string) => {
return useQuery<DeviceSingleResponse, Error>({
queryKey: ['device', id],
queryFn: () => getDeviceById(id),
enabled: !!id, // Hanya jalankan jika id ada
});
};

View File

@ -1,6 +1,5 @@
// src/features/devices/types.ts
export interface DeviceDataTable {
id: number;
id: string;
device_code: string;
device_type: string;
longitude: number;
@ -11,3 +10,24 @@ export interface DeviceDataTable {
status: string;
created_at: string;
}
export interface DeviceRequest {
device_code: string;
device_type: 'OTB' | 'ODP';
longitude: number;
latitude: number;
port_amount: number;
status: "maintenance" | "active" | "inactive";
}
export interface DeviceSingleResponse {
id: string;
device_code: string;
device_type: string;
longitude: number;
latitude: number;
port_amount: number;
total_used_port: number;
status: string;
updated_at: string;
}

View File

@ -0,0 +1,15 @@
import DeviceForm from './DeviceForm';
export default function CreateDevicePage() {
return (
<div>
<div className="bg-base-100 shadow-md p-4 rounded-lg mb-4">
<h1 className="text-2xl font-bold">Create New Device</h1>
</div>
<div className="mt-8">
<DeviceForm onClose={() => window.history.back()} />
</div>
</div>
);
}

View File

@ -0,0 +1,247 @@
import React from 'react';
import { useForm } from '@tanstack/react-form';
import { useCreateDevice, useUpdateDevice } from '@/features/devices/api/devices';
import { DeviceRequest, DeviceSingleResponse } from '@/features/devices/types';
import Input from '@/components/forms/Input';
interface DeviceFormProps {
initialData?: Partial<DeviceSingleResponse> | null;
onClose?: () => void;
}
const DeviceForm: React.FC<DeviceFormProps> = ({ initialData, onClose }) => {
const createDeviceMutation = useCreateDevice();
const updateDeviceMutation = useUpdateDevice();
const defaultValues = {
device_code: initialData?.device_code || '',
device_type: initialData?.device_type || 'ODP',
longitude: initialData?.longitude || 0,
latitude: initialData?.latitude || 0,
port_amount: initialData?.port_amount || 0,
status: initialData?.status || 'active',
};
const form = useForm({
defaultValues,
// validators: {
// onChange: ZodSchema
// },
onSubmit: async ({ value }) => {
try {
if (initialData?.id) {
// Update existing device
await updateDeviceMutation.mutateAsync({
id: initialData.id,
deviceData: value as Partial<DeviceRequest>
});
} else {
// Create new device
console.log(value);
// Create new device
await createDeviceMutation.mutateAsync(value as DeviceRequest);
}
// Close the form or reset
onClose?.();
} catch (error: any) {
// Error handling is done in the mutation hooks
if (error.errors) {
// set error field are {}
Object.entries(defaultValues).forEach(([key]) => {
form.setFieldMeta(key as keyof typeof defaultValues, (prev) => ({
...prev,
errorMessage: ''
}))
})
Object.entries(error.errors).forEach(([key, value]) => {
form.setFieldMeta(key as keyof typeof defaultValues, (prev) => ({
...prev,
errorMessage: value
}))
});
}
}
}
})
return (
<div className="p-6 bg-base-100 rounded-lg shadow-md">
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-4"
>
<form.Field
name="device_code"
children={(field) => (
<div>
<label htmlFor={field.name} className="block mb-2">Device Code</label>
<Input
id={field.name}
type="text"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
className='w-full'
bordered
color={(field.state.meta as unknown as Record<string, string>).errorMessage ? 'error' : undefined}
/>
{(field.state.meta as unknown as Record<string, string>).errorMessage && (
<div className="text-error mt-1">
{(field.state.meta as unknown as Record<string, string>).errorMessage}
</div>
)}
</div>
)} />
<form.Field
name="device_type"
children={(field) => (
<div>
<label htmlFor={field.name} className="block mb-2">Device Type</label>
<select
id={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value as "ODP" | "OTB")}
className="select select-bordered w-full"
>
<option value="ODP">ODP</option>
<option value="OTB">OTB</option>
</select>
{(field.state.meta as unknown as Record<string, string>).errorMessage && (
<div className="text-error mt-1">
{(field.state.meta as unknown as Record<string, string>).errorMessage}
</div>
)}
</div>
)}
/>
<div className="grid grid-cols-2 gap-4">
<form.Field
name="longitude"
children={(field) => (
<div>
<label htmlFor={field.name} className="block mb-2">Longitude</label>
<Input
id={field.name}
type="number"
step="0.000001"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(Number(e.target.value))}
className="input-bordered w-full"
color={field.state.meta.errors.length > 0 ? 'error' : undefined}
/>
{(field.state.meta as unknown as Record<string, string>).errorMessage && (
<div className="text-error mt-1">
{(field.state.meta as unknown as Record<string, string>).errorMessage}
</div>
)}
</div>
)}
/>
<form.Field
name="latitude"
children={(field) => (
<div>
<label htmlFor={field.name} className="block mb-2">Latitude</label>
<Input
id={field.name}
type="number"
step="0.000001"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(Number(e.target.value))}
className="input-bordered w-full"
color={field.state.meta.errors.length > 0 ? 'error' : undefined}
/>
{(field.state.meta as unknown as Record<string, string>).errorMessage && (
<div className="text-error mt-1">
{(field.state.meta as unknown as Record<string, string>).errorMessage}
</div>
)}
</div>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<form.Field
name="port_amount"
children={(field) => (
<div>
<label htmlFor={field.name} className="block mb-2">Port Amount</label>
<Input
id={field.name}
type="number"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(Number(e.target.value))}
className="input-bordered w-full"
color={field.state.meta.errors.length > 0 ? 'error' : undefined}
/>
{(field.state.meta as unknown as Record<string, string>).errorMessage && (
<div className="text-error mt-1">
{(field.state.meta as unknown as Record<string, string>).errorMessage}
</div>
)}
</div>
)}
/>
<form.Field
name="status"
children={(field) => (
<div>
<label htmlFor={field.name} className="block mb-2">Status</label>
<select
id={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value as "active" | "maintenance" | "inactive")}
className="select select-bordered w-full"
>
<option value="active">Active</option>
<option value="maintenance">Maintenance</option>
<option value="inactive">Inactive</option>
</select>
{(field.state.meta as unknown as Record<string, string>).errorMessage && (
<div className="text-error mt-1">
{(field.state.meta as unknown as Record<string, string>).errorMessage}
</div>
)}
</div>
)}
/>
</div>
<div className="flex justify-end space-x-4 mt-6">
<button
type="button"
onClick={onClose}
className="btn btn-ghost"
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={createDeviceMutation.isPending || updateDeviceMutation.isPending}
>
{initialData?.id ? 'Update Device' : 'Create Device'}
</button>
</div>
</form>
</div>
);
};
export default DeviceForm;

View File

@ -20,19 +20,33 @@ import { Pencil } from "lucide-react";
import dayjs from "dayjs";
import id from "dayjs/locale/id";
import Dropdown from "@/components/actions/Dropdown/Dropdown";
import { useNavigate } from "react-router";
dayjs.locale(id);
export default function DevicePage() {
const { data: devices, isLoading, isError, error } = useDevices();
// State untuk sorting dan filtering
const [sorting, setSorting] = useState<SortingState>([]);
const [sorting] = useState<SortingState>([]);
const [globalFilter, setGlobalFilter] = useState("");
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 10, // Default 10 data per halaman
});
const navigate = useNavigate();
// Replace handleCreateDevice with navigation
const handleCreateDevice = () => {
navigate("/devices/create");
};
// Replace handleEditDevice with navigation
const handleEditDevice = (deviceId: string) => {
navigate(`/devices/${deviceId}/edit`);
};
const columnHelper = createColumnHelper<DeviceDataTable>();
const columns = useMemo(
@ -41,33 +55,30 @@ export default function DevicePage() {
id: "actions",
header: "",
cell: ({ row }) => (
<Dropdown hover horizontal="right">
<Dropdown.Toggle
button={false}
className="btn btn-ghost btn-sm"
<Dropdown hover horizontal="right">
<Dropdown.Toggle button={false} className="btn btn-ghost btn-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="w-4 h-4"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="w-4 h-4"
>
<circle cx={12} cy={12} r={1} />
<circle cx={12} cy={5} r={1} />
<circle cx={12} cy={19} r={1} />
</svg>
</Dropdown.Toggle>
<Dropdown.Menu className="shadow-2xl border p-1 bg-base-100 w-32">
<Dropdown.Item>
<circle cx={12} cy={12} r={1} />
<circle cx={12} cy={5} r={1} />
<circle cx={12} cy={19} r={1} />
</svg>
</Dropdown.Toggle>
<Dropdown.Menu className="shadow-2xl border p-1 bg-base-100 w-32">
<Dropdown.Item onClick={() => handleEditDevice(row.original.id)}>
<Pencil className="w-4 h-4 mr-2 text-info" />
Edit
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
Edit
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
),
size: 10,
}),
@ -143,10 +154,12 @@ export default function DevicePage() {
});
return (
<div>
<div className="z-10">
<div className="bg-base-100 shadow-md p-4 rounded-lg flex justify-between items-center">
<h1 className="text-2xl font-bold">Devices</h1>
<Button color="primary">Add Device</Button>
<Button color="primary" onClick={handleCreateDevice}>
Add Device
</Button>
</div>
<Card className="bg-base-100 shadow-lg mt-8">

View File

@ -0,0 +1,27 @@
import { useParams } from 'react-router';
import DeviceForm from './DeviceForm';
import { useDeviceById } from '@/features/devices/api/devices';
import Loading from '@/components/feedback/Loading';
export default function EditDevicePage() {
const { id } = useParams<{ id: string }>();
const { data: device, isLoading } = useDeviceById(id as string);
if (isLoading) return <Loading />;
return (
<div>
<div className="bg-base-100 shadow-md p-4 rounded-lg mb-4">
<h1 className="text-2xl font-bold">Edit Device</h1>
</div>
<div className="mt-8">
<DeviceForm
initialData={device}
onClose={() => window.history.back()}
/>
</div>
</div>
);
}

View File

@ -7,6 +7,8 @@ import UsersListPage from '@/pages/users/UsersListPage';
import UserFormPage from '@/pages/users/UserFormPage';
import NotFoundPage from '@/pages/NotFoundPage';
import DevicePage from '@/pages/device/DevicePage';
import CreateDevicePage from '@/pages/device/CreateDevicePage';
import EditDevicePage from '@/pages/device/EditDevicePage';
const router = createBrowserRouter([
// Public routes
@ -33,6 +35,16 @@ const router = createBrowserRouter([
{
path: '/devices',
element: <DevicePage />,
// children: [
// ]
},
{
path: '/devices/create',
element: <CreateDevicePage />,
},
{
path: '/devices/:id/edit',
element: <EditDevicePage />,
},
{
path: '/users',