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_API_URL="http://localhost:8000"
VITE_APP_KEY="4ae931db63f0d18f5082781be5e2c622" 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", "@tanstack/react-table": "^8.21.2",
"axios": "^1.7.9", "axios": "^1.7.9",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.13",
"iconsax-react": "^0.0.8", "iconsax-react": "^0.0.8",
"immer": "^10.1.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@ -2414,6 +2416,11 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" "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": { "node_modules/debug": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
@ -2959,6 +2966,15 @@
"node": ">= 4" "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": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "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", "@tanstack/react-table": "^8.21.2",
"axios": "^1.7.9", "axios": "^1.7.9",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.13",
"iconsax-react": "^0.0.8", "iconsax-react": "^0.0.8",
"immer": "^10.1.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^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 ( return (
<Box> <Box>
<Box justifyContent={"space-between"} display="flex"> <Box justifyContent={"space-between"} display="flex" sx={{ px: 2}}>
<Box> <Box>
</Box> </Box>
@ -106,7 +106,13 @@ function ComponentReactTable<T extends object>({
))} ))}
</TableHead> </TableHead>
<TableBody className={striped ? "striped" : undefined}> <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}> <TableRow key={row.id}>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}> <TableCell key={cell.id}>

View File

@ -14,33 +14,18 @@ import { ColumnDef } from "@tanstack/react-table";
import IconButton from "../components/IconButton"; import IconButton from "../components/IconButton";
import { Edit } from "iconsax-react"; import { Edit } from "iconsax-react";
import useTitle from "../hooks/useTitle"; import useTitle from "../hooks/useTitle";
import { useDeviceStore } from "../stores/deviceStore";
interface UserData { import { Device } from "../types/device.types";
device_code: string; import { useEffect } from "react";
device_type: string; import FlashMessage from "../components/FlashMessage";
address: string;
port_amount: string;
status: string;
}
export default function Devices() { export default function Devices() {
useTitle("Devices"); useTitle("Devices");
const data: UserData[] = [ const { data, cacheExpired, getAll } = useDeviceStore();
{ 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" },
];
// Define columns // Define columns
const columns: ColumnDef<UserData>[] = [ const columns: ColumnDef<Device>[] = [
{ {
header: "Device Code", header: "Device Code",
accessorKey: "device_code", accessorKey: "device_code",
@ -61,25 +46,41 @@ export default function Devices() {
header: "Status", header: "Status",
accessorKey: "status", accessorKey: "status",
cell: ({ row }) => ( 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} {row.original.status}
</Typography> </Typography>
) ),
}, },
{ {
header: '', header: "",
accessorKey: "action", accessorKey: "action",
enableSorting: false, 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" /> <Edit variant="Bulk" color="currentColor" />
</IconButton> </IconButton>
</> </>
) ),
} },
]; ];
useEffect(() => {
if (cacheExpired()) {
(async () => {
await getAll()
})()
}
}, []);
return ( return (
<Box component="main"> <Box component="main">
<Grid container spacing={2} alignItems={"center"} mb={4}> <Grid container spacing={2} alignItems={"center"} mb={4}>
@ -87,7 +88,11 @@ export default function Devices() {
<Typography variant="h3">Devices</Typography> <Typography variant="h3">Devices</Typography>
</Grid> </Grid>
<Grid size="auto"> <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 Add New
</Button> </Button>
</Grid> </Grid>
@ -101,16 +106,15 @@ export default function Devices() {
</Grid> </Grid>
</Grid> </Grid>
<FlashMessage store={useDeviceStore} />
<Card> <Card>
<Box sx={{ p: 2 }}> <Box sx={{ p: 2 }}>
<Typography variant="h5">List Devices</Typography> <Typography variant="h5">List Devices</Typography>
</Box> </Box>
<Divider /> <Divider />
<CardContent sx={{ flex: "1 0 auto", pt: 2 }}> <CardContent sx={{ flex: "1 0 auto", pt: 2, px: 0 }}>
<ReactTable <ReactTable data={data || []} columns={columns} />
data={data}
columns={columns}
/>
</CardContent> </CardContent>
</Card> </Card>
</Box> </Box>

View File

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

View File

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

View File

@ -6,15 +6,42 @@ const api = createAPI();
export const deviceRepository: DeviceRepositoryProps = { export const deviceRepository: DeviceRepositoryProps = {
async getAll() { 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) { 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) { async createDevice(data: Device) {

View File

@ -51,6 +51,10 @@ export const useAuthStore = create<AuthState>()(
} finally { } finally {
set({ token: null, user: null, error: null, isLoading: false }); 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 { deviceRepository } from "../repositories/deviceRepository";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
import { createJSONStorage } from "zustand/middleware"; import { createJSONStorage } from "zustand/middleware";
import { immer } from 'zustand/middleware/immer'
import { secureStorage } from "./storage"; import { secureStorage } from "./storage";
import { Device, DeviceState } from "../types/device.types"; import { Device, DeviceState } from "../types/device.types";
import dayjs from "dayjs";
export const useDeviceStore = create<DeviceState>()( export const useDeviceStore = create<DeviceState>()(
persist( persist(
(set) => ({ immer((set, get) => ({
data: [], data: [],
device: null, device: null,
isLoading: false, isLoading: false,
error: null, error: null,
lastUpdate: null,
getAll: async () => { getAll: async () => {
set({ isLoading: true }); set({ isLoading: true });
try { try {
const devices = await deviceRepository.getAll(); const devices = await deviceRepository.getAll();
set({ data: devices }); set({ data: devices, lastUpdate: dayjs().valueOf() });
} catch (error: any) { } catch (error: any) {
if (error.status === 401) { if (error.status === 401) {
set({ error: error.response.status.description }); set({ error: error.response?.status?.description });
} else { } else {
set({ error: error.response.status.description || error.message }); set({
error: error.response?.status?.description || error.message,
});
} }
} finally { } finally {
set({ isLoading: false }); set({ isLoading: false, lastUpdate: dayjs().valueOf() });
} }
}, },
find: async (id: string) => { find: async (id: string) => {
@ -37,45 +42,69 @@ export const useDeviceStore = create<DeviceState>()(
if (error.status === 401) { if (error.status === 401) {
set({ error: error.response.status.description }); set({ error: error.response.status.description });
} else { } else {
set({ error: error.response?.status?.description || error.message }); set({
error: error.response?.status?.description || error.message,
});
} }
} finally { } finally {
set({ isLoading: false }); 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 }); set({ isLoading: true });
try { try {
await deviceRepository.createDevice(device); await deviceRepository.createDevice(device);
} catch (error: any) { } catch (error: any) {
console.log(error); console.log(error);
if (error.status === 401) { if (error.status === 401) {
set({ error: error.response.status.description }); set({ error: error.response.status.description });
} else { } else {
set({ error: error.response?.status?.description || error.message }); set({
error: error.response?.status?.description || error.message,
});
} }
} finally { } finally {
set({ isLoading: false }); 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 }); set({ isLoading: true });
try { try {
await deviceRepository.updateDevice(id, device); await deviceRepository.updateDevice(id, device);
} catch (error: any) { } catch (error: any) {
if (error.status === 401) { if (error.status === 401) {
set({ error: error.response.status.description }); set({ error: error.response.status.description });
} else { } else {
set({ error: error.response?.status?.description || error.message }); set({
error: error.response?.status?.description || error.message,
});
} }
} finally { } finally {
set({ isLoading: false }); 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) } { name: "devices", storage: createJSONStorage(() => secureStorage) }
) )
); );

View File

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

View File

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

4
vite-env.d.ts vendored
View File

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