wip: impl crud devices

This commit is contained in:
HasanMu 2025-02-13 17:27:59 +07:00
parent 8a2a93a288
commit 5e471b7e20
17 changed files with 417 additions and 31 deletions

View File

@ -154,6 +154,7 @@ export interface Props extends IconButtonProps {
variant?: ButtonVariantProps; variant?: ButtonVariantProps;
children: ReactNode; children: ReactNode;
tooltip?: boolean | ReactNode | ReactPortal; tooltip?: boolean | ReactNode | ReactPortal;
href?: string;
} }
function IconButton( function IconButton(

View File

@ -1,9 +1,18 @@
import { Outlet } from "react-router"; import { Outlet } from "react-router";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { useAuthStore } from "../stores/authStore";
export default function AuthLayout() { export default function AuthLayout() {
const theme = useTheme(); const theme = useTheme();
const { token } = useAuthStore();
if (token) {
window.location.href = "/dashboard";
return;
}
return ( return (
<Box <Box
sx={{ sx={{

View File

@ -6,16 +6,16 @@ import MenuItem from "@mui/material/MenuItem";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import MenuIcon from "@mui/icons-material/Menu"; import MenuIcon from "@mui/icons-material/Menu";
import React from "react"; import React from "react";
import { useNavigate } from "react-router"; import { useAuthStore } from "../../stores/authStore";
export default function Header({ export default function Header({
handleDrawerToggle, handleDrawerToggle,
}: { }: {
handleDrawerToggle: () => void; handleDrawerToggle: () => void;
}) { }) {
const { user, logout } = useAuthStore();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl); const open = Boolean(anchorEl);
let navigate = useNavigate();
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => { const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);
@ -25,10 +25,10 @@ export default function Header({
setAnchorEl(null); setAnchorEl(null);
}; };
const onLogout = () => { const onLogout = async () => {
setAnchorEl(null); setAnchorEl(null);
navigate("/login") await logout();
}; };
return ( return (
@ -61,7 +61,7 @@ export default function Header({
}} }}
> >
<Typography variant="subtitle1" noWrap component="div"> <Typography variant="subtitle1" noWrap component="div">
Hi, Admin Hi, {user?.name || ''}
</Typography> </Typography>
</Box> </Box>
<Menu <Menu

View File

@ -112,7 +112,7 @@ export default function Sidebar({
<ListItemButton <ListItemButton
component={Link} component={Link}
href={menu.link} href={menu.link}
selected={menu.link === location.pathname} selected={location.pathname.startsWith(menu.link)}
> >
<ListItemIcon> <ListItemIcon>
<ReactSVG src={menu.icon} /> <ReactSVG src={menu.icon} />

View File

@ -3,6 +3,7 @@ import Breadcrumbs from "@mui/material/Breadcrumbs";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import Card from "@mui/material/Card"; import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent"; import CardContent from "@mui/material/CardContent";
import Divider from "@mui/material/Divider";
import Link from "@mui/material/Link"; import Link from "@mui/material/Link";
import Grid from "@mui/material/Grid2"; import Grid from "@mui/material/Grid2";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
@ -71,7 +72,7 @@ export default function Devices() {
enableSorting: false, enableSorting: false,
cell: () => ( cell: () => (
<> <>
<IconButton variant="light" color="info" > <IconButton variant="text" color="secondary" >
<Edit variant="Bulk" color="currentColor" /> <Edit variant="Bulk" color="currentColor" />
</IconButton> </IconButton>
</> </>
@ -86,7 +87,7 @@ export default function Devices() {
<Typography variant="h3">Devices</Typography> <Typography variant="h3">Devices</Typography>
</Grid> </Grid>
<Grid size="auto"> <Grid size="auto">
<Button variant="contained" startIcon={<AddIcon color="inherit" />}> <Button href="/devices/create" variant="contained" startIcon={<AddIcon color="inherit" />}>
Add New Add New
</Button> </Button>
</Grid> </Grid>
@ -100,8 +101,12 @@ export default function Devices() {
</Grid> </Grid>
</Grid> </Grid>
<Card sx={{ display: "flex" }}> <Card>
<CardContent sx={{ flex: "1 0 auto" }}> <Box sx={{ p: 2 }}>
<Typography variant="h5">List Devices</Typography>
</Box>
<Divider />
<CardContent sx={{ flex: "1 0 auto", pt: 2 }}>
<ReactTable <ReactTable
data={data} data={data}
columns={columns} columns={columns}

View File

@ -0,0 +1,95 @@
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Breadcrumbs from "@mui/material/Breadcrumbs";
import Button from "@mui/material/Button";
import Card from "@mui/material/Card";
import Divider from "@mui/material/Divider";
import CardContent from "@mui/material/CardContent";
import Grid from "@mui/material/Grid2";
import Link from "@mui/material/Link";
import Typography from "@mui/material/Typography";
import { Home, Save } from "@mui/icons-material";
import useTitle from "../../hooks/useTitle";
import IconButton from "../../components/IconButton";
import { Back } from "iconsax-react";
import { useParams } from "react-router";
import Form from "./Form";
import { FormEvent } from "react";
import { useDeviceStore } from "../../stores/deviceStore";
import { DeviceRequest } from "../../types/device.types";
export default function DeviceCreate() {
useTitle("Devices");
const { pid } = useParams();
const { update, create, error } = useDeviceStore();
const IS_EDIT_PAGE = pid!!;
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const data = Object.fromEntries(formData.entries()) as unknown as DeviceRequest;
if (IS_EDIT_PAGE) {
await update(pid!!, data);
} else {
await create(data);
}
}
return (
<Box component="main">
<Grid container spacing={2} alignItems={"center"} mb={4}>
<Grid size="auto">
<Box display={"flex"} alignItems={"center"} gap={1}>
<IconButton variant="dashed" color="primary" href="/devices">
<Back variant="Outline" color="currentColor" />
</IconButton>
<Typography variant="h3">{IS_EDIT_PAGE ? `Edit Device - ${pid}` : 'Create Device'}</Typography>
</Box>
</Grid>
<Grid size={12}>
<Breadcrumbs aria-label="breadcrumb">
<Link underline="hover" color="inherit" href="/dashboard">
<Home />
</Link>
<Link underline="hover" color="inherit" href="/devices">
<Typography sx={{ color: "text.primary" }}>Devices</Typography>
</Link>
<Typography sx={{ color: "text.primary" }}>{IS_EDIT_PAGE ? pid : 'Create'}</Typography>
</Breadcrumbs>
</Grid>
</Grid>
{error && (
<Box display={"flex"} mb={2}>
<Alert severity="error" variant="border" sx={{ width: "100%" }}>{error.toString()} asass</Alert>
</Box>
)}
<Card component="form" onSubmit={onSubmit}>
<Box
sx={{ px: 3, py: 1 }}
display={"flex"}
justifyContent={"space-between"}
alignItems={"center"}
>
<Typography variant="h5">Fill the form</Typography>
<Button
type="submit"
variant="contained"
size="small"
startIcon={<Save color="inherit" />}
>
{IS_EDIT_PAGE ? "Save Changes" : "Create"}
</Button>
</Box>
<Divider />
<CardContent>
<Form />
</CardContent>
</Card>
</Box>
);
}

View File

@ -0,0 +1,92 @@
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import FormControl from "@mui/material/FormControl";
import Grid from "@mui/material/Grid2";
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 { DeviceStatus } from '../../types/device.types';
export default function Form() {
const [select, setSelect] = useState<DeviceStatus>('active');
const STATUS = ["active", "inactive", "maintenance"];
return (
<Grid container spacing={2}>
{/* Device Code */}
<Grid size={{ md: 4, xs: 12 }}>
<TextField fullWidth name='device_code' label="Device Code" variant="outlined" />
</Grid>
{/* Total Port */}
<Grid size={{ md: 4, xs: 12 }}>
<TextField
fullWidth
name="port_count"
label="Total Port"
variant="outlined"
type="number"
/>
</Grid>
{/* Status */}
<Grid size={{ md: 4, xs: 12 }}>
<FormControl fullWidth>
<InputLabel id="status-label">Status</InputLabel>
<Select
name="status"
labelId="status-label"
label="Status"
value={select}
onChange={(e) => setSelect(e.target.value as DeviceStatus)}
renderValue={(selected: string) => (
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
<Chip
label={selected.toUpperCase()}
size="medium"
variant="filled"
color={
selected === "active"
? "success"
: selected === "inactive"
? "error"
: "warning"
}
/>
</Box>
)}
>
{STATUS.map((status) => (
<MenuItem key={status} value={status}>
{status.toUpperCase()}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ md: 6, xs: 12 }}>
<TextField
fullWidth
name="longitude"
label="Longitude"
variant="outlined"
type="number"
/>
</Grid>
<Grid size={{ md: 6, xs: 12 }}>
<TextField
fullWidth
name="latitude"
label="Latitude"
variant="outlined"
type="number"
/>
</Grid>
</Grid>
);
}

View File

@ -5,11 +5,23 @@ const api = createAPI();
export const authRepository = { export const authRepository = {
async login(payload: LoginPayload): Promise<LoginResponse> { async login(payload: LoginPayload): Promise<LoginResponse> {
const response = await api.post<LoginResponse>('/auth/login', payload); // const response = await api.post<LoginResponse>('/auth/login', payload);
return response.data;
// return response.data;
return Promise.resolve({
status: {
code: 200,
description: 'OK',
},
data: {
token: 'sample-token',
user: {
name: 'John Doe',
}
},
});
}, },
async logout(): Promise<void> { async logout(): Promise<void> {
await api.post('/auth/logout'); await api.post('/auth/logout');
}, },};
};

View File

@ -0,0 +1,29 @@
import { createAPI } from "../services/api";
import { DeviceRepositoryProps, Device } from "../types/device.types";
import { ResponseApi } from "../types/response.types";
const api = createAPI();
export const deviceRepository: DeviceRepositoryProps = {
async getAll() {
const response = await api.get<ResponseApi<Device[]>>("/api/v1/devices");
return response.data.data as Device[];
},
async getDeviceById(id: string) {
const response = await api.get<ResponseApi<Device>>(`/api/v1/devices/${id}`);
return response.data.data as Device;
},
async createDevice(data: Device) {
const response = await api.post<ResponseApi<null>>("/api/v1/devices", data);
return response.data;
},
async updateDevice(id: string, data: Device) {
const response = await api.put<ResponseApi<null>>(`/api/v1/devices/${id}`, data);
return response.data;
},
};

View File

@ -9,6 +9,7 @@ import Devices from "../pages/Devices";
import Fishbone from "../pages/Fishbone"; import Fishbone from "../pages/Fishbone";
import Towers from "../pages/Towers"; import Towers from "../pages/Towers";
import RequiredAuth from "./RequiredAuth"; import RequiredAuth from "./RequiredAuth";
import DeviceCreate from "../pages/Devices/Create";
export default function Routes() { export default function Routes() {
return ( return (
@ -28,6 +29,10 @@ export default function Routes() {
<Route path="/fishbone" element={<Fishbone />} /> <Route path="/fishbone" element={<Fishbone />} />
<Route path="/devices" element={<Devices />} /> <Route path="/devices" element={<Devices />} />
<Route path="/towers" element={<Towers />} /> <Route path="/towers" element={<Towers />} />
{/* Create Page */}
<Route path="/devices/:pid" element={<DeviceCreate />} />
<Route path="/devices/create" element={<DeviceCreate />} />
</Route> </Route>
</Route> </Route>

View File

@ -1,9 +1,9 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { authRepository } from '../repositories/authRepository'; import { authRepository } from '../repositories/authRepository';
import { AuthState } from './types';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { createJSONStorage } from 'zustand/middleware'; import { createJSONStorage } from 'zustand/middleware';
import { secureStorage } from './storage'; import { secureStorage } from './storage';
import { AuthState } from '../types/auth.types';
type PersistedAuthState = Pick<AuthState, 'token' | 'user'>; type PersistedAuthState = Pick<AuthState, 'token' | 'user'>;

81
src/stores/deviceStore.ts Normal file
View File

@ -0,0 +1,81 @@
import { create } from "zustand";
import { deviceRepository } from "../repositories/deviceRepository";
import { persist } from "zustand/middleware";
import { createJSONStorage } from "zustand/middleware";
import { secureStorage } from "./storage";
import { Device, DeviceState } from "../types/device.types";
export const useDeviceStore = create<DeviceState>()(
persist(
(set) => ({
data: [],
device: null,
isLoading: false,
error: null,
getAll: async () => {
set({ isLoading: true });
try {
const devices = await deviceRepository.getAll();
set({ data: devices });
} catch (error: any) {
if (error.status === 401) {
set({ error: error.response.status.description });
} else {
set({ error: error.response.status.description || error.message });
}
} finally {
set({ isLoading: false });
}
},
find: async (id: string) => {
set({ isLoading: true });
try {
const device = await deviceRepository.getDeviceById(id);
set({ device: device });
} catch (error: any) {
if (error.status === 401) {
set({ error: error.response.status.description });
} else {
set({ error: error.response?.status?.description || error.message });
}
} finally {
set({ isLoading: false });
}
},
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 });
}
} finally {
set({ isLoading: false });
}
},
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 });
}
} finally {
set({ isLoading: false });
}
},
}),
{ name: "devices", storage: createJSONStorage(() => secureStorage) }
)
);

View File

@ -1,15 +0,0 @@
import { LoginPayload, UserProfile } from "../types/auth.types";
export interface AuthState {
token: string | null;
user: UserProfile | null;
isLoading: boolean;
error: string | null | AuthErrorInputState;
login: (payload: LoginPayload) => Promise<boolean>;
logout: () => Promise<void>;
}
export interface AuthErrorInputState {
username?: string | null;
password?: string | null;
}

View File

@ -1,5 +1,5 @@
// MATERIAL - UI // MATERIAL - UI
import { Theme } from '@mui/material/styles'; import { Theme } from '@mui/material';
// PROJECT IMPORTS // PROJECT IMPORTS
import getColors from '../../utils/getColors'; import getColors from '../../utils/getColors';

View File

@ -17,3 +17,21 @@ export interface LoginResponse {
export interface UserProfile { export interface UserProfile {
name: string; name: string;
} }
/**
* Auth state
* ===========
*/
export interface AuthState {
token: string | null;
user: UserProfile | null;
isLoading: boolean;
error: string | null | AuthErrorInputState;
login: (payload: LoginPayload) => Promise<boolean>;
logout: () => Promise<void>;
}
export interface AuthErrorInputState {
username?: string | null;
password?: string | null;
}

44
src/types/device.types.ts Normal file
View File

@ -0,0 +1,44 @@
import { ResponseApi } from "./response.types";
export interface DeviceRepositoryProps {
getAll: () => Promise<Device[] | []>;
getDeviceById: (id: string) => Promise<Device>;
createDevice: (device: DeviceRequest) => Promise<Pick<ResponseApi<any>, "status">>;
updateDevice: (id: string, device: DeviceRequest) => Promise<Pick<ResponseApi<any>, "status">>;
}
export interface Device {
id: string;
device_code: string;
device_type: string;
longitude: string | number;
latitude: string | number;
port_amount: number;
status: string;
created_at: string;
updated_at: string;
}
export type DeviceStatus = "active" | "inactive" | "maintenance";
export interface DeviceRequest extends Omit<Device, "id" & "created_at" & "updated_at"> {}
/**
* Device State
* =============
*/
export interface DeviceState {
data?: Device[];
device?: Device | null;
isLoading: boolean;
error: string | null | DeviceErrorInputState;
getAll: () => Promise<void>;
find: (id: string) => Promise<void>;
create: (device: DeviceRequest) => Promise<void>;
update: (id: string, device: DeviceRequest) => Promise<void>;
}
export interface DeviceErrorInputState {
username?: string | null;
password?: string | null;
}

View File

@ -0,0 +1,10 @@
interface ResponseApiStatus {
status: string | number;
description: string;
}
export interface ResponseApi<T> {
status: ResponseApiStatus;
message: string;
data?: T | [] | null;
}