feat: impl cache & crud mock in devices

This commit is contained in:
HasanMu 2025-02-14 17:00:08 +07:00
parent 5e471b7e20
commit 778b05fd50
16 changed files with 252 additions and 75 deletions

View File

@ -1,2 +1,4 @@
VITE_API_URL="http://localhost:8000"
VITE_APP_KEY="4ae931db63f0d18f5082781be5e2c622"
VITE_APP_CACHE_TIME_VALUE=30
VITE_APP_CACHE_TIME_UNIT="seconds"

16
package-lock.json generated
View File

@ -17,7 +17,9 @@
"@tanstack/react-table": "^8.21.2",
"axios": "^1.7.9",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.13",
"iconsax-react": "^0.0.8",
"immer": "^10.1.1",
"lodash": "^4.17.21",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@ -2414,6 +2416,11 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="
},
"node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
@ -2959,6 +2966,15 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",

View File

@ -19,7 +19,9 @@
"@tanstack/react-table": "^8.21.2",
"axios": "^1.7.9",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.13",
"iconsax-react": "^0.0.8",
"immer": "^10.1.1",
"lodash": "^4.17.21",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@ -0,0 +1,43 @@
import { useEffect, useState } from "react";
import Box from "@mui/material/Box";
import Alert from "@mui/material/Alert";
import { DeviceState } from "../types/device.types";
import { AuthState } from "../types/auth.types";
interface FlashMessageProps {
store: () => DeviceState | AuthState;
}
export default function FlashMessage(props: FlashMessageProps) {
const { error, clearError } = props.store();
const [errorMessage, setErrorMessage] = useState<string | null>("");
const onCloseError = () => {
setErrorMessage(null);
clearError()
};
useEffect(() => {
if (error) {
setErrorMessage(error as string);
}
setTimeout(() => {
clearError()
}, 500);
}, [error]);
if (!errorMessage) return null;
return (
<Box display={"flex"} mb={2}>
<Alert
severity="error"
variant="border"
onClose={onCloseError}
sx={{ width: "100%" }}
>
{errorMessage}
</Alert>
</Box>
);
}

View File

@ -58,7 +58,7 @@ function ComponentReactTable<T extends object>({
return (
<Box>
<Box justifyContent={"space-between"} display="flex">
<Box justifyContent={"space-between"} display="flex" sx={{ px: 2}}>
<Box>
</Box>
@ -106,7 +106,13 @@ function ComponentReactTable<T extends object>({
))}
</TableHead>
<TableBody className={striped ? "striped" : undefined}>
{table.getRowModel().rows.map((row) => (
{table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} align="center">
No data.
</TableCell>
</TableRow>
) : table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>

View File

@ -14,33 +14,18 @@ import { ColumnDef } from "@tanstack/react-table";
import IconButton from "../components/IconButton";
import { Edit } from "iconsax-react";
import useTitle from "../hooks/useTitle";
interface UserData {
device_code: string;
device_type: string;
address: string;
port_amount: string;
status: string;
}
import { useDeviceStore } from "../stores/deviceStore";
import { Device } from "../types/device.types";
import { useEffect } from "react";
import FlashMessage from "../components/FlashMessage";
export default function Devices() {
useTitle("Devices");
const data: UserData[] = [
{ device_code: 'DV-CODE-1', device_type: "OTB", address: "Jl. Contoh 1", port_amount: '16', status: "Active" },
{ device_code: 'DV-CODE-2', device_type: "OTB", address: "Jl. Contoh 2", port_amount: '16', status: "Active" },
{ device_code: 'DV-CODE-3', device_type: "OTB", address: "Jl. Contoh 3", port_amount: '16', status: "Active" },
{ device_code: 'DV-CODE-4', device_type: "OTB", address: "Jl. Contoh 4", port_amount: '16', status: "Active" },
{ device_code: 'DV-CODE-5', device_type: "OTB", address: "Jl. Contoh 5", port_amount: '16', status: "Active" },
{ device_code: 'DV-CODE-6', device_type: "OTB", address: "Jl. Contoh 6", port_amount: '16', status: "Active" },
{ device_code: 'DV-CODE-7', device_type: "OTB", address: "Jl. Contoh 7", port_amount: '16', status: "Active" },
{ device_code: 'DV-CODE-8', device_type: "OTB", address: "Jl. Contoh 8", port_amount: '16', status: "Active" },
{ device_code: 'DV-CODE-9', device_type: "OTB", address: "Jl. Contoh 9", port_amount: '16', status: "Active" },
{ device_code: 'DV-CODE-10', device_type: "OTB", address: "Jl. Contoh 10", port_amount: '16', status: "Active" },
];
const { data, cacheExpired, getAll } = useDeviceStore();
// Define columns
const columns: ColumnDef<UserData>[] = [
const columns: ColumnDef<Device>[] = [
{
header: "Device Code",
accessorKey: "device_code",
@ -61,25 +46,41 @@ export default function Devices() {
header: "Status",
accessorKey: "status",
cell: ({ row }) => (
<Typography color={row.original.status === "Active" ? "success.main" : "error.main"}>
<Typography
color={
row.original.status.toLowerCase() === "active"
? "success.main"
: row.original.status.toLowerCase() === "inactive"
? "error.main"
: "warning.main"
}
>
{row.original.status}
</Typography>
)
),
},
{
header: '',
header: "",
accessorKey: "action",
enableSorting: false,
cell: () => (
cell: ({ row }) => (
<>
<IconButton variant="text" color="secondary" >
<IconButton variant="text" color="secondary" href={`/devices/${row.original.id}`}>
<Edit variant="Bulk" color="currentColor" />
</IconButton>
</>
)
}
),
},
];
useEffect(() => {
if (cacheExpired()) {
(async () => {
await getAll()
})()
}
}, []);
return (
<Box component="main">
<Grid container spacing={2} alignItems={"center"} mb={4}>
@ -87,7 +88,11 @@ export default function Devices() {
<Typography variant="h3">Devices</Typography>
</Grid>
<Grid size="auto">
<Button href="/devices/create" variant="contained" startIcon={<AddIcon color="inherit" />}>
<Button
href="/devices/create"
variant="contained"
startIcon={<AddIcon color="inherit" />}
>
Add New
</Button>
</Grid>
@ -101,16 +106,15 @@ export default function Devices() {
</Grid>
</Grid>
<FlashMessage store={useDeviceStore} />
<Card>
<Box sx={{ p: 2 }}>
<Typography variant="h5">List Devices</Typography>
</Box>
<Divider />
<CardContent sx={{ flex: "1 0 auto", pt: 2 }}>
<ReactTable
data={data}
columns={columns}
/>
<CardContent sx={{ flex: "1 0 auto", pt: 2, px: 0 }}>
<ReactTable data={data || []} columns={columns} />
</CardContent>
</Card>
</Box>

View File

@ -1,4 +1,3 @@
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Breadcrumbs from "@mui/material/Breadcrumbs";
import Button from "@mui/material/Button";
@ -14,14 +13,15 @@ import IconButton from "../../components/IconButton";
import { Back } from "iconsax-react";
import { useParams } from "react-router";
import Form from "./Form";
import { FormEvent } from "react";
import { FormEvent, useEffect } from "react";
import { useDeviceStore } from "../../stores/deviceStore";
import { DeviceRequest } from "../../types/device.types";
import FlashMessage from "../../components/FlashMessage";
export default function DeviceCreate() {
useTitle("Devices");
const { pid } = useParams();
const { update, create, error } = useDeviceStore();
const { update, create, isLoading, find, clearDevice, device } = useDeviceStore();
const IS_EDIT_PAGE = pid!!;
@ -38,6 +38,19 @@ export default function DeviceCreate() {
await create(data);
}
}
useEffect(() => {
if (pid!!) {
(async () => {
await find(pid!!);
})()
} else {
(async () => {
await clearDevice()
})()
}
}, [])
return (
<Box component="main">
<Grid container spacing={2} alignItems={"center"} mb={4}>
@ -62,11 +75,7 @@ export default function DeviceCreate() {
</Grid>
</Grid>
{error && (
<Box display={"flex"} mb={2}>
<Alert severity="error" variant="border" sx={{ width: "100%" }}>{error.toString()} asass</Alert>
</Box>
)}
<FlashMessage store={useDeviceStore} />
<Card component="form" onSubmit={onSubmit}>
<Box
@ -75,8 +84,9 @@ export default function DeviceCreate() {
justifyContent={"space-between"}
alignItems={"center"}
>
<Typography variant="h5">Fill the form</Typography>
<Typography variant="h5">Fill the form {device?.device_code}</Typography>
<Button
disabled={isLoading}
type="submit"
variant="contained"
size="small"

View File

@ -6,21 +6,34 @@ import InputLabel from "@mui/material/InputLabel";
import MenuItem from "@mui/material/MenuItem";
import Select from "@mui/material/Select";
import TextField from "@mui/material/TextField";
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { DeviceStatus } from '../../types/device.types';
import { useDeviceStore } from '../../stores/deviceStore';
export default function Form() {
const { device, clearDevice } = useDeviceStore();
const [select, setSelect] = useState<DeviceStatus>('active');
const STATUS = ["active", "inactive", "maintenance"];
useEffect(() => {
setSelect(device?.status || 'active');
const clear = async () => {
await clearDevice();
};
return () => {
clear();
};
}, [device]);
return (
<Grid container spacing={2}>
{/* Device Code */}
<Grid size={{ md: 4, xs: 12 }}>
<TextField fullWidth name='device_code' label="Device Code" variant="outlined" />
<TextField fullWidth name='device_code' label="Device Code" variant="outlined" value={device?.device_code} />
</Grid>
{/* Total Port */}
<Grid size={{ md: 4, xs: 12 }}>
<TextField
@ -29,6 +42,7 @@ export default function Form() {
label="Total Port"
variant="outlined"
type="number"
value={device?.port_amount}
/>
</Grid>
@ -75,6 +89,7 @@ export default function Form() {
label="Longitude"
variant="outlined"
type="number"
value={device?.longitude}
/>
</Grid>
@ -85,6 +100,7 @@ export default function Form() {
label="Latitude"
variant="outlined"
type="number"
value={device?.latitude}
/>
</Grid>
</Grid>

View File

@ -6,15 +6,42 @@ const api = createAPI();
export const deviceRepository: DeviceRepositoryProps = {
async getAll() {
const response = await api.get<ResponseApi<Device[]>>("/api/v1/devices");
// const response = await api.get<ResponseApi<Device[]>>("/api/v1/devices");
return response.data.data as Device[];
// return response.data.data as Device[];
return Promise.resolve( [
{
id: "1",
device_code: "DV-EX-001",
device_type: "OTB",
longitude: "1",
latitude: "1",
address: "Jl. Jend Sudirman No. XX",
port_amount: 10,
status: "active",
created_at: "2024-01-01",
updated_at: "2024-01-01",
}
])
},
async getDeviceById(id: string) {
const response = await api.get<ResponseApi<Device>>(`/api/v1/devices/${id}`);
// const response = await api.get<ResponseApi<Device>>(`/api/v1/devices/${id}`);
return response.data.data as Device;
// return response.data.data as Device;
return Promise.resolve({
id: "1",
device_code: "DV-EX-001",
device_type: "OTB",
longitude: "1",
latitude: "1",
address: "Jl. Jend Sudirman No. XX",
port_amount: 10,
status: "active",
created_at: "2024-01-01",
updated_at: "2024-01-01",
})
},
async createDevice(data: Device) {

View File

@ -51,6 +51,10 @@ export const useAuthStore = create<AuthState>()(
} finally {
set({ token: null, user: null, error: null, isLoading: false });
}
},
clearError: () => {
set({ error: null });
}
}),
{

View File

@ -2,29 +2,34 @@ import { create } from "zustand";
import { deviceRepository } from "../repositories/deviceRepository";
import { persist } from "zustand/middleware";
import { createJSONStorage } from "zustand/middleware";
import { immer } from 'zustand/middleware/immer'
import { secureStorage } from "./storage";
import { Device, DeviceState } from "../types/device.types";
import dayjs from "dayjs";
export const useDeviceStore = create<DeviceState>()(
persist(
(set) => ({
immer((set, get) => ({
data: [],
device: null,
isLoading: false,
error: null,
lastUpdate: null,
getAll: async () => {
set({ isLoading: true });
try {
const devices = await deviceRepository.getAll();
set({ data: devices });
set({ data: devices, lastUpdate: dayjs().valueOf() });
} catch (error: any) {
if (error.status === 401) {
set({ error: error.response.status.description });
set({ error: error.response?.status?.description });
} else {
set({ error: error.response.status.description || error.message });
set({
error: error.response?.status?.description || error.message,
});
}
} finally {
set({ isLoading: false });
set({ isLoading: false, lastUpdate: dayjs().valueOf() });
}
},
find: async (id: string) => {
@ -37,45 +42,69 @@ export const useDeviceStore = create<DeviceState>()(
if (error.status === 401) {
set({ error: error.response.status.description });
} else {
set({ error: error.response?.status?.description || error.message });
set({
error: error.response?.status?.description || error.message,
});
}
} finally {
set({ isLoading: false });
}
},
create: async (device: Omit<Device, "id" & "created_at" & "updated_at">) => {
create: async (
device: Omit<Device, "id" & "created_at" & "updated_at">
) => {
set({ isLoading: true });
try {
await deviceRepository.createDevice(device);
} catch (error: any) {
console.log(error);
if (error.status === 401) {
set({ error: error.response.status.description });
} else {
set({ error: error.response?.status?.description || error.message });
set({
error: error.response?.status?.description || error.message,
});
}
} finally {
set({ isLoading: false });
}
},
update: async (id: string, device: Omit<Device, "id" & "created_at" & "updated_at">) => {
update: async (
id: string,
device: Omit<Device, "id" & "created_at" & "updated_at">
) => {
set({ isLoading: true });
try {
await deviceRepository.updateDevice(id, device);
} catch (error: any) {
if (error.status === 401) {
set({ error: error.response.status.description });
} else {
set({ error: error.response?.status?.description || error.message });
set({
error: error.response?.status?.description || error.message,
});
}
} finally {
set({ isLoading: false });
}
},
}),
clearError: () => set({ error: null }),
clearDevice: () => {
set({ device: null })
},
cacheExpired: () => {
return (
get().lastUpdate === null ||
dayjs(get().lastUpdate).isBefore(
dayjs().subtract(
import.meta.env.VITE_APP_CACHE_TIME_VALUE,
import.meta.env.VITE_APP_CACHE_TIME_UNIT
)
)
);
},
})),
{ name: "devices", storage: createJSONStorage(() => secureStorage) }
)
);

View File

@ -1,3 +1,5 @@
import { ErrorState } from "./store.types";
export interface LoginPayload {
username: string;
password: string;
@ -22,11 +24,10 @@ export interface UserProfile {
* Auth state
* ===========
*/
export interface AuthState {
export interface AuthState extends ErrorState<AuthErrorInputState> {
token: string | null;
user: UserProfile | null;
isLoading: boolean;
error: string | null | AuthErrorInputState;
login: (payload: LoginPayload) => Promise<boolean>;
logout: () => Promise<void>;
}

View File

@ -1,4 +1,5 @@
import { ResponseApi } from "./response.types";
import { CacheState, ErrorState } from "./store.types";
export interface DeviceRepositoryProps {
getAll: () => Promise<Device[] | []>;
@ -14,7 +15,7 @@ export interface Device {
longitude: string | number;
latitude: string | number;
port_amount: number;
status: string;
status: DeviceStatus;
created_at: string;
updated_at: string;
}
@ -27,15 +28,16 @@ export interface DeviceRequest extends Omit<Device, "id" & "created_at" & "updat
* Device State
* =============
*/
export interface DeviceState {
export interface DeviceState extends ErrorState, CacheState {
data?: Device[];
device?: Device | null;
isLoading: boolean;
error: string | null | DeviceErrorInputState;
lastUpdate: number | null;
getAll: () => Promise<void>;
find: (id: string) => Promise<void>;
create: (device: DeviceRequest) => Promise<void>;
update: (id: string, device: DeviceRequest) => Promise<void>;
clearDevice: () => void;
}
export interface DeviceErrorInputState {

8
src/types/store.types.ts Normal file
View File

@ -0,0 +1,8 @@
export interface ErrorState<T = undefined> {
error: string | null | T;
clearError: () => void;
}
export interface CacheState {
cacheExpired: () => boolean;
}

View File

@ -20,7 +20,10 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"noUncheckedSideEffectImports": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
},
"include": ["src"]
}

4
vite-env.d.ts vendored
View File

@ -1,8 +1,12 @@
/// <reference types="vite/client" />
import { ManipulateType } from "dayjs"
interface ImportMetaEnv {
readonly VITE_APP_KEY: string
readonly VITE_API_URL: string
readonly VITE_APP_CACHE_TIME_VALUE: number
readonly VITE_APP_CACHE_TIME_UNIT: ManipulateType
// more env variables...
}