feat: add vendor purchase orders module

This commit is contained in:
Arthur Bassi 2026-06-12 11:55:14 -03:00
parent f01a502956
commit 1c474914f2
7 changed files with 1033 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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));
},
};

View 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) : [],
};
}

View 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[];
}

View 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),
});
}

View 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,
});
}