mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 12:22:03 +00:00
feat: add vendor purchase orders module
This commit is contained in:
parent
f01a502956
commit
1c474914f2
7 changed files with 1033 additions and 0 deletions
345
src/app/(protected)/vendor-pos/[id].tsx
Normal file
345
src/app/(protected)/vendor-pos/[id].tsx
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
import { useState, type ReactNode } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useApproveUplift, useDenyUplift } from "@/domain/uplifts/use-cases/use-uplift-actions";
|
||||
import { useVendorPoDetail } from "@/domain/vendor-pos/use-cases/use-vendor-po-detail";
|
||||
import type { UpliftRequest } from "@/domain/work-orders/types/work-order";
|
||||
import { formatDateTime, toDateInputValue } from "@/lib/time-utils";
|
||||
|
||||
function formatMoney(value: number | null | undefined): string {
|
||||
if (value == null) return "—";
|
||||
return `$${Number(value).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function InfoRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{children == null || children === "" ? "—" : children}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VendorPoViewPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { data: dispatch, isLoading, error, refetch } = useVendorPoDetail(id);
|
||||
|
||||
const approveUplift = useApproveUplift(id);
|
||||
const denyUplift = useDenyUplift(id);
|
||||
|
||||
const [upliftAction, setUpliftAction] = useState<{
|
||||
type: "approve" | "deny";
|
||||
uplift: UpliftRequest;
|
||||
} | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const handleUpliftConfirm = () => {
|
||||
if (!upliftAction) return;
|
||||
if (upliftAction.type === "approve") {
|
||||
approveUplift.mutate(
|
||||
{ id: upliftAction.uplift.id, note: note || undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setUpliftAction(null);
|
||||
setNote("");
|
||||
void refetch();
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
if (!note.trim()) return;
|
||||
denyUplift.mutate(
|
||||
{ id: upliftAction.uplift.id, note: note.trim() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setUpliftAction(null);
|
||||
setNote("");
|
||||
void refetch();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box className="flex justify-center p-10">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !dispatch) {
|
||||
return (
|
||||
<Box className="p-4">
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Vendor PO not found"}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const vendor = dispatch.vendor;
|
||||
|
||||
return (
|
||||
<Box className="mx-auto max-w-5xl p-4">
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" className="mb-4">
|
||||
<Button variant="text" onClick={() => navigate(-1)}>
|
||||
← Back
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={() => window.print()}>
|
||||
Print
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1} className="mb-4">
|
||||
<Typography variant="h5">{dispatch.poNumber || "(no PO #)"}</Typography>
|
||||
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Dispatch {dispatch.dispatchNumber || "—"}
|
||||
</Typography>
|
||||
<Chip size="small" label={dispatch.status || "—"} />
|
||||
{vendor?.companyName && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
· {vendor.companyName}
|
||||
</Typography>
|
||||
)}
|
||||
{dispatch.nteAmount != null && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
· NTE {formatMoney(dispatch.nteAmount)}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={3}>
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
Vendor
|
||||
</Typography>
|
||||
{vendor ? (
|
||||
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
|
||||
<InfoRow label="Company">{vendor.companyName}</InfoRow>
|
||||
<InfoRow label="Contact">{vendor.contactName}</InfoRow>
|
||||
<InfoRow label="Email">{vendor.email}</InfoRow>
|
||||
<InfoRow label="Phone">{vendor.phone}</InfoRow>
|
||||
<InfoRow label="Address">
|
||||
{[vendor.address, vendor.city, vendor.state, vendor.zip].filter(Boolean).join(", ")}
|
||||
</InfoRow>
|
||||
<InfoRow label="Trades">{vendor.tradeSpecialties}</InfoRow>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography color="text.secondary">No vendor attached.</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
PO Details
|
||||
</Typography>
|
||||
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
|
||||
<InfoRow label="PO #">{dispatch.poNumber}</InfoRow>
|
||||
<InfoRow label="Dispatch #">{dispatch.dispatchNumber}</InfoRow>
|
||||
<InfoRow label="Status">{dispatch.status}</InfoRow>
|
||||
<InfoRow label="NTE">{formatMoney(dispatch.nteAmount)}</InfoRow>
|
||||
<InfoRow label="Dispatched">{formatDateTime(dispatch.dispatchedAt)}</InfoRow>
|
||||
<InfoRow label="Acknowledged">{formatDateTime(dispatch.acknowledgedAt)}</InfoRow>
|
||||
<InfoRow label="Scheduled">{toDateInputValue(dispatch.scheduledDate) || "—"}</InfoRow>
|
||||
<InfoRow label="Completed">{toDateInputValue(dispatch.completedDate) || "—"}</InfoRow>
|
||||
<InfoRow label="Verified">{formatDateTime(dispatch.verifiedAt)}</InfoRow>
|
||||
<InfoRow label="Email sent">{dispatch.emailSent ? "Yes" : "No"}</InfoRow>
|
||||
</Box>
|
||||
{dispatch.description && (
|
||||
<Box className="mt-3">
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Description
|
||||
</Typography>
|
||||
<Typography variant="body2" className="whitespace-pre-wrap">
|
||||
{dispatch.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{dispatch.primaryLocation && (
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
Site
|
||||
</Typography>
|
||||
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<InfoRow label="Location">{dispatch.primaryLocation.name}</InfoRow>
|
||||
<InfoRow label="Address">
|
||||
{[
|
||||
dispatch.primaryLocation.address,
|
||||
dispatch.primaryLocation.city,
|
||||
dispatch.primaryLocation.state,
|
||||
dispatch.primaryLocation.zipCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</InfoRow>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
Linked Work Orders ({dispatch.workOrders.length})
|
||||
</Typography>
|
||||
{dispatch.workOrders.length === 0 ? (
|
||||
<Typography color="text.secondary">No work orders linked.</Typography>
|
||||
) : (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>WO #</TableCell>
|
||||
<TableCell>Title</TableCell>
|
||||
<TableCell>Trade</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Location</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{dispatch.workOrders.map((wo) => (
|
||||
<TableRow key={String(wo.workOrderId)}>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
onClick={() => navigate(`/workorders/${wo.workOrderId}`)}
|
||||
>
|
||||
{wo.internalWONumber || wo.workOrderId}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>{wo.title}</TableCell>
|
||||
<TableCell>{wo.trade}</TableCell>
|
||||
<TableCell>{wo.status}</TableCell>
|
||||
<TableCell>{wo.locationName}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
NTE Uplift History ({dispatch.upliftRequests.length})
|
||||
</Typography>
|
||||
{dispatch.upliftRequests.length === 0 ? (
|
||||
<Typography color="text.secondary">No uplift requests on this PO.</Typography>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{dispatch.upliftRequests.map((u) => (
|
||||
<Box key={String(u.id)} className="rounded border border-border p-3">
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="body2" className="font-semibold">
|
||||
{formatMoney(u.currentNTE)} → {formatMoney(u.requestedNTE)}
|
||||
<Typography
|
||||
component="span"
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
className="ml-2"
|
||||
>
|
||||
(Δ {formatMoney(u.delta)}, Tier {u.requiredTier})
|
||||
</Typography>
|
||||
</Typography>
|
||||
<Chip size="small" label={u.status} />
|
||||
</Stack>
|
||||
{u.vendorReason && (
|
||||
<Typography variant="body2" className="mt-1">
|
||||
<strong>Reason:</strong> {u.vendorReason}
|
||||
</Typography>
|
||||
)}
|
||||
{u.status === "Pending" && (
|
||||
<Stack direction="row" spacing={1} className="mt-2">
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="success"
|
||||
onClick={() => {
|
||||
setUpliftAction({ type: "approve", uplift: u });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => {
|
||||
setUpliftAction({ type: "deny", uplift: u });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<Dialog open={Boolean(upliftAction)} onClose={() => setUpliftAction(null)}>
|
||||
<DialogTitle>
|
||||
{upliftAction?.type === "approve" ? "Approve Uplift" : "Deny Uplift"}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
className="mt-2"
|
||||
label={upliftAction?.type === "deny" ? "Denial reason (required)" : "Optional note"}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
required={upliftAction?.type === "deny"}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setUpliftAction(null)}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color={upliftAction?.type === "approve" ? "success" : "error"}
|
||||
disabled={
|
||||
upliftAction?.type === "deny"
|
||||
? !note.trim() || denyUplift.isPending
|
||||
: approveUplift.isPending
|
||||
}
|
||||
onClick={handleUpliftConfirm}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
281
src/app/(protected)/vendor-pos/index.tsx
Normal file
281
src/app/(protected)/vendor-pos/index.tsx
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TablePagination,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useVendorPosList } from "@/domain/vendor-pos/use-cases/use-vendor-pos-list";
|
||||
import { useVendorsList } from "@/domain/vendors/use-cases/use-vendors-list";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { toDateInputValue } from "@/lib/time-utils";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
"",
|
||||
"Sent",
|
||||
"Acknowledged",
|
||||
"In Progress",
|
||||
"Completed",
|
||||
"Verified",
|
||||
"Cancelled",
|
||||
];
|
||||
|
||||
function formatMoney(value: number | null): string {
|
||||
if (value == null) return "—";
|
||||
return `$${value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
if (!value) return "—";
|
||||
return toDateInputValue(value) || "—";
|
||||
}
|
||||
|
||||
export default function VendorPosListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(12);
|
||||
const [status, setStatus] = useState("");
|
||||
const [vendorId, setVendorId] = useState("");
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
const listParams = useMemo(
|
||||
() => ({
|
||||
search: debouncedSearch,
|
||||
page: page + 1,
|
||||
pageSize,
|
||||
status,
|
||||
vendorId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
}),
|
||||
[debouncedSearch, page, pageSize, status, vendorId, dateFrom, dateTo],
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetching, error } = useVendorPosList(listParams);
|
||||
const { data: vendorsData } = useVendorsList({ page: 1, pageSize: 500 });
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const totalCount = data?.totalCount ?? 0;
|
||||
const vendors = vendorsData?.items ?? [];
|
||||
|
||||
const clearFilters = () => {
|
||||
setStatus("");
|
||||
setVendorId("");
|
||||
setDateFrom("");
|
||||
setDateTo("");
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: "stretch", md: "center" }}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||
<Typography variant="h5" component="h1">
|
||||
Vendor POs
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{totalCount} total
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
placeholder="Search PO #, dispatch, vendor, WO..."
|
||||
className="min-w-[280px]"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2} flexWrap="wrap">
|
||||
<FormControl size="small" className="min-w-[140px]">
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
label="Status"
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<MenuItem key={s || "all"} value={s}>
|
||||
{s || "All"}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" className="min-w-[180px]">
|
||||
<InputLabel>Vendor</InputLabel>
|
||||
<Select
|
||||
label="Vendor"
|
||||
value={vendorId}
|
||||
onChange={(e) => {
|
||||
setVendorId(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
{vendors.map((v) => (
|
||||
<MenuItem key={String(v.id)} value={String(v.id)}>
|
||||
{v.companyName}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
type="date"
|
||||
label="From"
|
||||
value={dateFrom}
|
||||
onChange={(e) => {
|
||||
setDateFrom(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
type="date"
|
||||
label="To"
|
||||
value={dateTo}
|
||||
onChange={(e) => {
|
||||
setDateTo(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<Button variant="text" onClick={clearFilters}>
|
||||
Clear filters
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load vendor POs"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell className="font-semibold">PO #</TableCell>
|
||||
<TableCell className="font-semibold">Dispatch #</TableCell>
|
||||
<TableCell className="font-semibold">Vendor</TableCell>
|
||||
<TableCell className="font-semibold">WO #</TableCell>
|
||||
<TableCell className="font-semibold">Location</TableCell>
|
||||
<TableCell className="font-semibold">NTE</TableCell>
|
||||
<TableCell className="font-semibold">Status</TableCell>
|
||||
<TableCell className="font-semibold">Uplifts</TableCell>
|
||||
<TableCell className="font-semibold">Dispatched</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} align="center" className="py-10">
|
||||
<CircularProgress size={28} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} align="center" className="py-10">
|
||||
<Typography color="text.secondary">No vendor POs found</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow
|
||||
key={String(row.id)}
|
||||
hover
|
||||
className="cursor-pointer"
|
||||
onClick={() => navigate(`/vendor-pos/${row.id}`)}
|
||||
>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
className="font-semibold"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/vendor-pos/${row.id}`);
|
||||
}}
|
||||
>
|
||||
{row.poNumber || "—"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>{row.dispatchNumber || "—"}</TableCell>
|
||||
<TableCell>{row.vendorName || "—"}</TableCell>
|
||||
<TableCell>{row.primaryWOInternalNumber || "—"}</TableCell>
|
||||
<TableCell>{row.locationName || "—"}</TableCell>
|
||||
<TableCell>{formatMoney(row.nteAmount)}</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={row.status || "—"} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.pendingUpliftCount > 0 ? (
|
||||
<Chip
|
||||
size="small"
|
||||
color="warning"
|
||||
label={`${row.pendingUpliftCount} pending`}
|
||||
/>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(row.dispatchedAt)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={(_e, nextPage) => setPage(nextPage)}
|
||||
rowsPerPage={pageSize}
|
||||
onRowsPerPageChange={(e) => {
|
||||
setPageSize(Number(e.target.value));
|
||||
setPage(0);
|
||||
}}
|
||||
rowsPerPageOptions={[12, 24, 48]}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
57
src/domain/vendor-pos/api/vendor-pos-api.ts
Normal file
57
src/domain/vendor-pos/api/vendor-pos-api.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { apiGet } from "@/api/api";
|
||||
import { handleApiResponse } from "@/api/handle-api-response";
|
||||
import {
|
||||
mapVendorPoDetail,
|
||||
mapVendorPoListItem,
|
||||
} from "@/domain/vendor-pos/mappers/vendor-po-mapper";
|
||||
import type {
|
||||
VendorPoDetail,
|
||||
VendorPoListParams,
|
||||
VendorPoListResult,
|
||||
} from "@/domain/vendor-pos/types/vendor-po";
|
||||
|
||||
function buildListParams(params: VendorPoListParams): Record<string, string | number> {
|
||||
const searchParams: Record<string, string | number> = {
|
||||
page: params.page ?? 1,
|
||||
pageSize: params.pageSize ?? 12,
|
||||
sortBy: params.sortBy ?? "dispatchedAt",
|
||||
sortDir: params.sortDir ?? "desc",
|
||||
};
|
||||
if (params.search) searchParams.search = params.search;
|
||||
if (params.status) searchParams.status = params.status;
|
||||
if (params.vendorId) searchParams.vendorId = params.vendorId;
|
||||
if (params.dateFrom) searchParams.dateFrom = params.dateFrom;
|
||||
if (params.dateTo) searchParams.dateTo = params.dateTo;
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
export const vendorPosApi = {
|
||||
getList: async (params: VendorPoListParams = {}): Promise<VendorPoListResult> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.rest.dispatches, {
|
||||
searchParams: buildListParams(params),
|
||||
});
|
||||
const response = handleApiResponse<Record<string, unknown>>(data);
|
||||
const items = Array.isArray(response?.items)
|
||||
? response.items
|
||||
: Array.isArray((response as { data?: unknown[] })?.data)
|
||||
? (response as { data: unknown[] }).data
|
||||
: [];
|
||||
const total =
|
||||
(typeof response?.total === "number" ? response.total : undefined) ??
|
||||
(typeof response?.totalCount === "number" ? response.totalCount : undefined) ??
|
||||
items.length;
|
||||
|
||||
return {
|
||||
items: items.map(mapVendorPoListItem),
|
||||
totalCount: total,
|
||||
page: params.page ?? 1,
|
||||
pageSize: params.pageSize ?? 12,
|
||||
};
|
||||
},
|
||||
|
||||
getById: async (id: string | number): Promise<VendorPoDetail> => {
|
||||
const data = await apiGet<unknown>(`${API_PATHS.rest.dispatches}/${id}`);
|
||||
return mapVendorPoDetail(handleApiResponse(data));
|
||||
},
|
||||
};
|
||||
196
src/domain/vendor-pos/mappers/vendor-po-mapper.ts
Normal file
196
src/domain/vendor-pos/mappers/vendor-po-mapper.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import type {
|
||||
VendorPoAuditEntry,
|
||||
VendorPoChecklistItem,
|
||||
VendorPoComment,
|
||||
VendorPoDetail,
|
||||
VendorPoListItem,
|
||||
VendorPoLocation,
|
||||
VendorPoSignoff,
|
||||
VendorPoVendor,
|
||||
VendorPoWorkOrder,
|
||||
} from "@/domain/vendor-pos/types/vendor-po";
|
||||
import { mapUpliftRequests } from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
|
||||
function asRecord(raw: unknown): Record<string, unknown> {
|
||||
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number") return String(value);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function readId(record: Record<string, unknown>): string | number {
|
||||
const value = record.id ?? record.Id;
|
||||
if (typeof value === "string" || typeof value === "number") return value;
|
||||
return "";
|
||||
}
|
||||
|
||||
function readNumber(record: Record<string, unknown>, ...keys: string[]): number | null {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "number") return value;
|
||||
if (typeof value === "string" && value !== "") {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isNaN(parsed)) return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "boolean") return value;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function mapVendorPoListItem(raw: unknown): VendorPoListItem {
|
||||
const item = asRecord(raw);
|
||||
return {
|
||||
id: readId(item),
|
||||
poNumber: readString(item, "poNumber", "PONumber"),
|
||||
dispatchNumber: readString(item, "dispatchNumber", "DispatchNumber"),
|
||||
vendorName: readString(item, "vendorName", "VendorName"),
|
||||
primaryWOInternalNumber: readString(item, "primaryWOInternalNumber", "PrimaryWOInternalNumber"),
|
||||
workOrderCount: readNumber(item, "workOrderCount", "WorkOrderCount") ?? 0,
|
||||
locationName: readString(item, "locationName", "LocationName"),
|
||||
nteAmount: readNumber(item, "nteAmount", "NTEAmount"),
|
||||
status: readString(item, "status", "Status"),
|
||||
pendingUpliftCount: readNumber(item, "pendingUpliftCount", "PendingUpliftCount") ?? 0,
|
||||
dispatchedAt: readString(item, "dispatchedAt", "DispatchedAt"),
|
||||
scheduledDate: readString(item, "scheduledDate", "ScheduledDate"),
|
||||
completedDate: readString(item, "completedDate", "CompletedDate"),
|
||||
};
|
||||
}
|
||||
|
||||
function mapVendor(raw: unknown): VendorPoVendor | null {
|
||||
if (!raw) return null;
|
||||
const item = asRecord(raw);
|
||||
return {
|
||||
companyName: readString(item, "companyName", "CompanyName"),
|
||||
contactName: readString(item, "contactName", "ContactName"),
|
||||
email: readString(item, "email", "Email"),
|
||||
phone: readString(item, "phone", "Phone"),
|
||||
address: readString(item, "address", "Address"),
|
||||
city: readString(item, "city", "City"),
|
||||
state: readString(item, "state", "State"),
|
||||
zip: readString(item, "zip", "Zip", "zipCode"),
|
||||
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
||||
};
|
||||
}
|
||||
|
||||
function mapLocation(raw: unknown): VendorPoLocation | null {
|
||||
if (!raw) return null;
|
||||
const item = asRecord(raw);
|
||||
return {
|
||||
name: readString(item, "name", "Name"),
|
||||
address: readString(item, "address", "Address"),
|
||||
city: readString(item, "city", "City"),
|
||||
state: readString(item, "state", "State"),
|
||||
zipCode: readString(item, "zipCode", "ZipCode", "zip"),
|
||||
};
|
||||
}
|
||||
|
||||
function mapWorkOrder(raw: unknown): VendorPoWorkOrder {
|
||||
const item = asRecord(raw);
|
||||
return {
|
||||
workOrderId: readId(item) || readString(item, "workOrderId", "WorkOrderId"),
|
||||
internalWONumber: readString(item, "internalWONumber", "InternalWONumber"),
|
||||
title: readString(item, "title", "Title"),
|
||||
trade: readString(item, "trade", "Trade"),
|
||||
subTrade: readString(item, "subTrade", "SubTrade"),
|
||||
problem: readString(item, "problem", "Problem"),
|
||||
priority: readString(item, "priority", "Priority"),
|
||||
status: readString(item, "status", "Status"),
|
||||
locationName: readString(item, "locationName", "LocationName"),
|
||||
};
|
||||
}
|
||||
|
||||
function mapChecklistItem(raw: unknown): VendorPoChecklistItem {
|
||||
const item = asRecord(raw);
|
||||
return {
|
||||
id: readId(item),
|
||||
itemText: readString(item, "itemText", "ItemText"),
|
||||
isCompleted: readBool(item, "isCompleted", "IsCompleted"),
|
||||
completedBy: readString(item, "completedBy", "CompletedBy"),
|
||||
completedAt: readString(item, "completedAt", "CompletedAt"),
|
||||
};
|
||||
}
|
||||
|
||||
function mapSignoff(raw: unknown): VendorPoSignoff {
|
||||
const item = asRecord(raw);
|
||||
return {
|
||||
id: readId(item),
|
||||
signoffType: readString(item, "signoffType", "SignoffType"),
|
||||
name: readString(item, "name", "Name"),
|
||||
signatureMethod: readString(item, "signatureMethod", "SignatureMethod"),
|
||||
signedAt: readString(item, "signedAt", "SignedAt"),
|
||||
signature: readString(item, "signature", "Signature"),
|
||||
};
|
||||
}
|
||||
|
||||
function mapComment(raw: unknown): VendorPoComment {
|
||||
const item = asRecord(raw);
|
||||
return {
|
||||
id: readId(item),
|
||||
userName: readString(item, "userName", "UserName"),
|
||||
commenter: readString(item, "commenter", "Commenter"),
|
||||
commentType: readString(item, "commentType", "CommentType"),
|
||||
createdDate: readString(item, "createdDate", "CreatedDate"),
|
||||
commenttext: readString(item, "commenttext", "CommentText", "commentText"),
|
||||
};
|
||||
}
|
||||
|
||||
function mapAuditEntry(raw: unknown): VendorPoAuditEntry {
|
||||
const item = asRecord(raw);
|
||||
return {
|
||||
id: readId(item),
|
||||
createdAt: readString(item, "createdAt", "CreatedAt"),
|
||||
action: readString(item, "action", "Action"),
|
||||
fieldName: readString(item, "fieldName", "FieldName"),
|
||||
oldValue: readString(item, "oldValue", "OldValue"),
|
||||
newValue: readString(item, "newValue", "NewValue"),
|
||||
userName: readString(item, "userName", "UserName"),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapVendorPoDetail(raw: unknown): VendorPoDetail {
|
||||
const item = asRecord(raw);
|
||||
const vendor = item.vendor ?? item.Vendor;
|
||||
const location = item.primaryLocation ?? item.PrimaryLocation;
|
||||
const workOrders = (item.workOrders ?? item.WorkOrders) as unknown[];
|
||||
const checklist = (item.checklist ?? item.Checklist) as unknown[];
|
||||
const signoffs = (item.signoffs ?? item.Signoffs) as unknown[];
|
||||
const comments = (item.comments ?? item.Comments) as unknown[];
|
||||
const uplifts = (item.upliftRequests ?? item.UpliftRequests) as unknown[];
|
||||
const auditLog = (item.auditLog ?? item.AuditLog) as unknown[];
|
||||
|
||||
return {
|
||||
id: readId(item),
|
||||
poNumber: readString(item, "poNumber", "PONumber"),
|
||||
dispatchNumber: readString(item, "dispatchNumber", "DispatchNumber"),
|
||||
status: readString(item, "status", "Status"),
|
||||
nteAmount: readNumber(item, "nteAmount", "NTEAmount"),
|
||||
dispatchedAt: readString(item, "dispatchedAt", "DispatchedAt"),
|
||||
acknowledgedAt: readString(item, "acknowledgedAt", "AcknowledgedAt"),
|
||||
scheduledDate: readString(item, "scheduledDate", "ScheduledDate"),
|
||||
completedDate: readString(item, "completedDate", "CompletedDate"),
|
||||
verifiedAt: readString(item, "verifiedAt", "VerifiedAt"),
|
||||
emailSent: readBool(item, "emailSent", "EmailSent"),
|
||||
description: readString(item, "description", "Description"),
|
||||
vendor: mapVendor(vendor),
|
||||
primaryLocation: mapLocation(location),
|
||||
workOrders: Array.isArray(workOrders) ? workOrders.map(mapWorkOrder) : [],
|
||||
checklist: Array.isArray(checklist) ? checklist.map(mapChecklistItem) : [],
|
||||
signoffs: Array.isArray(signoffs) ? signoffs.map(mapSignoff) : [],
|
||||
comments: Array.isArray(comments) ? comments.map(mapComment) : [],
|
||||
upliftRequests: Array.isArray(uplifts) ? mapUpliftRequests(uplifts) : [],
|
||||
auditLog: Array.isArray(auditLog) ? auditLog.map(mapAuditEntry) : [],
|
||||
};
|
||||
}
|
||||
127
src/domain/vendor-pos/types/vendor-po.ts
Normal file
127
src/domain/vendor-pos/types/vendor-po.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import type { UpliftRequest } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
export interface VendorPoListItem {
|
||||
id: string | number;
|
||||
poNumber: string;
|
||||
dispatchNumber: string;
|
||||
vendorName: string;
|
||||
primaryWOInternalNumber: string;
|
||||
workOrderCount: number;
|
||||
locationName: string;
|
||||
nteAmount: number | null;
|
||||
status: string;
|
||||
pendingUpliftCount: number;
|
||||
dispatchedAt: string;
|
||||
scheduledDate: string;
|
||||
completedDate: string;
|
||||
}
|
||||
|
||||
export interface VendorPoListParams {
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortDir?: string;
|
||||
status?: string;
|
||||
vendorId?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
export interface VendorPoListResult {
|
||||
items: VendorPoListItem[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface VendorPoVendor {
|
||||
companyName: string;
|
||||
contactName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zip: string;
|
||||
tradeSpecialties: string;
|
||||
}
|
||||
|
||||
export interface VendorPoLocation {
|
||||
name: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zipCode: string;
|
||||
}
|
||||
|
||||
export interface VendorPoWorkOrder {
|
||||
workOrderId: string | number;
|
||||
internalWONumber: string;
|
||||
title: string;
|
||||
trade: string;
|
||||
subTrade: string;
|
||||
problem: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
locationName: string;
|
||||
}
|
||||
|
||||
export interface VendorPoChecklistItem {
|
||||
id: string | number;
|
||||
itemText: string;
|
||||
isCompleted: boolean;
|
||||
completedBy: string;
|
||||
completedAt: string;
|
||||
}
|
||||
|
||||
export interface VendorPoSignoff {
|
||||
id: string | number;
|
||||
signoffType: string;
|
||||
name: string;
|
||||
signatureMethod: string;
|
||||
signedAt: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface VendorPoComment {
|
||||
id: string | number;
|
||||
userName: string;
|
||||
commenter: string;
|
||||
commentType: string;
|
||||
createdDate: string;
|
||||
commenttext: string;
|
||||
}
|
||||
|
||||
export interface VendorPoAuditEntry {
|
||||
id: string | number;
|
||||
createdAt: string;
|
||||
action: string;
|
||||
fieldName: string;
|
||||
oldValue: string;
|
||||
newValue: string;
|
||||
userName: string;
|
||||
}
|
||||
|
||||
export interface VendorPoDetail {
|
||||
id: string | number;
|
||||
poNumber: string;
|
||||
dispatchNumber: string;
|
||||
status: string;
|
||||
nteAmount: number | null;
|
||||
dispatchedAt: string;
|
||||
acknowledgedAt: string;
|
||||
scheduledDate: string;
|
||||
completedDate: string;
|
||||
verifiedAt: string;
|
||||
emailSent: boolean;
|
||||
description: string;
|
||||
vendor: VendorPoVendor | null;
|
||||
primaryLocation: VendorPoLocation | null;
|
||||
workOrders: VendorPoWorkOrder[];
|
||||
checklist: VendorPoChecklistItem[];
|
||||
signoffs: VendorPoSignoff[];
|
||||
comments: VendorPoComment[];
|
||||
upliftRequests: UpliftRequest[];
|
||||
auditLog: VendorPoAuditEntry[];
|
||||
}
|
||||
13
src/domain/vendor-pos/use-cases/use-vendor-po-detail.ts
Normal file
13
src/domain/vendor-pos/use-cases/use-vendor-po-detail.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { vendorPosApi } from "@/domain/vendor-pos/api/vendor-pos-api";
|
||||
import type { VendorPoDetail } from "@/domain/vendor-pos/types/vendor-po";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
import { requireQueryParam } from "@/lib/query/require-query-param";
|
||||
|
||||
export function useVendorPoDetail(id: string | undefined): UseQueryResult<VendorPoDetail, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.vendorPos.detail(id ?? ""),
|
||||
queryFn: () => vendorPosApi.getById(requireQueryParam(id, "Vendor PO id")),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
14
src/domain/vendor-pos/use-cases/use-vendor-pos-list.ts
Normal file
14
src/domain/vendor-pos/use-cases/use-vendor-pos-list.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { vendorPosApi } from "@/domain/vendor-pos/api/vendor-pos-api";
|
||||
import type { VendorPoListParams, VendorPoListResult } from "@/domain/vendor-pos/types/vendor-po";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export function useVendorPosList(
|
||||
params: VendorPoListParams,
|
||||
): UseQueryResult<VendorPoListResult, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.vendorPos.list(params as Record<string, unknown>),
|
||||
queryFn: () => vendorPosApi.getList(params),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue