diff --git a/client/src/components/AppShell.tsx b/client/src/components/AppShell.tsx index 5bc7a94..28e315f 100644 --- a/client/src/components/AppShell.tsx +++ b/client/src/components/AppShell.tsx @@ -12,6 +12,7 @@ const links = [ { to: "/inventory/warehouses", label: "Warehouses" }, { to: "/sales/quotes", label: "Quotes" }, { to: "/sales/orders", label: "Sales Orders" }, + { to: "/shipping/shipments", label: "Shipments" }, { to: "/planning/gantt", label: "Gantt" }, ]; diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index f38db62..4f768ee 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -40,6 +40,13 @@ import type { SalesDocumentStatus, SalesDocumentSummaryDto, } from "@mrp/shared/dist/sales/types.js"; +import type { + ShipmentDetailDto, + ShipmentInput, + ShipmentOrderOptionDto, + ShipmentStatus, + ShipmentSummaryDto, +} from "@mrp/shared/dist/shipping/types.js"; export class ApiError extends Error { constructor(message: string, public readonly code: string) { @@ -427,6 +434,36 @@ export const api = { token ); }, + getShipmentOrderOptions(token: string) { + return request("/api/v1/shipping/orders/options", undefined, token); + }, + getShipments(token: string, filters?: { q?: string; status?: ShipmentStatus; salesOrderId?: string }) { + return request( + `/api/v1/shipping/shipments${buildQueryString({ + q: filters?.q, + status: filters?.status, + salesOrderId: filters?.salesOrderId, + })}`, + undefined, + token + ); + }, + getShipment(token: string, shipmentId: string) { + return request(`/api/v1/shipping/shipments/${shipmentId}`, undefined, token); + }, + createShipment(token: string, payload: ShipmentInput) { + return request("/api/v1/shipping/shipments", { method: "POST", body: JSON.stringify(payload) }, token); + }, + updateShipment(token: string, shipmentId: string, payload: ShipmentInput) { + return request(`/api/v1/shipping/shipments/${shipmentId}`, { method: "PUT", body: JSON.stringify(payload) }, token); + }, + updateShipmentStatus(token: string, shipmentId: string, status: ShipmentStatus) { + return request( + `/api/v1/shipping/shipments/${shipmentId}/status`, + { method: "PATCH", body: JSON.stringify({ status }) }, + token + ); + }, async getCompanyProfilePreviewPdf(token: string) { const response = await fetch("/api/v1/documents/company-profile-preview.pdf", { headers: { diff --git a/client/src/main.tsx b/client/src/main.tsx index 2c9de68..172382c 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -24,6 +24,9 @@ import { WarehousesPage } from "./modules/inventory/WarehousesPage"; import { SalesDetailPage } from "./modules/sales/SalesDetailPage"; import { SalesFormPage } from "./modules/sales/SalesFormPage"; import { SalesListPage } from "./modules/sales/SalesListPage"; +import { ShipmentDetailPage } from "./modules/shipping/ShipmentDetailPage"; +import { ShipmentFormPage } from "./modules/shipping/ShipmentFormPage"; +import { ShipmentListPage } from "./modules/shipping/ShipmentListPage"; import { ThemeProvider } from "./theme/ThemeProvider"; import "./index.css"; @@ -69,6 +72,13 @@ const router = createBrowserRouter([ { path: "/sales/orders/:orderId", element: }, ], }, + { + element: , + children: [ + { path: "/shipping/shipments", element: }, + { path: "/shipping/shipments/:shipmentId", element: }, + ], + }, { element: , children: [ @@ -87,6 +97,13 @@ const router = createBrowserRouter([ { path: "/sales/orders/:orderId/edit", element: }, ], }, + { + element: , + children: [ + { path: "/shipping/shipments/new", element: }, + { path: "/shipping/shipments/:shipmentId/edit", element: }, + ], + }, { element: , children: [ diff --git a/client/src/modules/sales/SalesDetailPage.tsx b/client/src/modules/sales/SalesDetailPage.tsx index 2c895b9..a3c3787 100644 --- a/client/src/modules/sales/SalesDetailPage.tsx +++ b/client/src/modules/sales/SalesDetailPage.tsx @@ -1,5 +1,6 @@ import { permissions } from "@mrp/shared"; import type { SalesDocumentDetailDto, SalesDocumentStatus } from "@mrp/shared/dist/sales/types.js"; +import type { ShipmentSummaryDto } from "@mrp/shared/dist/shipping/types.js"; import { useEffect, useState } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; @@ -7,6 +8,7 @@ import { useAuth } from "../../auth/AuthProvider"; import { api, ApiError } from "../../lib/api"; import { salesConfigs, salesStatusOptions, type SalesDocumentEntity } from "./config"; import { SalesStatusBadge } from "./SalesStatusBadge"; +import { ShipmentStatusBadge } from "../shipping/ShipmentStatusBadge"; export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) { const { token, user } = useAuth(); @@ -18,8 +20,11 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) { const [status, setStatus] = useState(`Loading ${config.singularLabel.toLowerCase()}...`); const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); const [isConverting, setIsConverting] = useState(false); + const [shipments, setShipments] = useState([]); const canManage = user?.permissions.includes(permissions.salesWrite) ?? false; + const canManageShipping = user?.permissions.includes(permissions.shippingWrite) ?? false; + const canReadShipping = user?.permissions.includes(permissions.shippingRead) ?? false; useEffect(() => { if (!token || !documentId) { @@ -31,12 +36,15 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) { .then((nextDocument) => { setDocument(nextDocument); setStatus(`${config.singularLabel} loaded.`); + if (entity === "order" && canReadShipping) { + api.getShipments(token, { salesOrderId: nextDocument.id }).then(setShipments).catch(() => setShipments([])); + } }) .catch((error: unknown) => { const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`; setStatus(message); }); - }, [config.singularLabel, documentId, entity, token]); + }, [canReadShipping, config.singularLabel, documentId, entity, token]); if (!document) { return
{status}
; @@ -116,6 +124,11 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) { {isConverting ? "Converting..." : "Convert to sales order"} ) : null} + {entity === "order" && canManageShipping ? ( + + New shipment + + ) : null} ) : null} @@ -239,6 +252,40 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) { )} + {entity === "order" && canReadShipping ? ( +
+
+
+

Shipping

+

Shipment records currently tied to this sales order.

+
+ {canManageShipping ? ( + + Create shipment + + ) : null} +
+ {shipments.length === 0 ? ( +
+ No shipments have been created for this sales order yet. +
+ ) : ( +
+ {shipments.map((shipment) => ( + +
+
+
{shipment.shipmentNumber}
+
{shipment.carrier || "Carrier not set"} · {shipment.trackingNumber || "No tracking"}
+
+ +
+ + ))} +
+ )} +
+ ) : null} ); } diff --git a/client/src/modules/shipping/ShipmentDetailPage.tsx b/client/src/modules/shipping/ShipmentDetailPage.tsx new file mode 100644 index 0000000..51fb07e --- /dev/null +++ b/client/src/modules/shipping/ShipmentDetailPage.tsx @@ -0,0 +1,148 @@ +import { permissions } from "@mrp/shared"; +import type { ShipmentDetailDto, ShipmentStatus, ShipmentSummaryDto } from "@mrp/shared/dist/shipping/types.js"; +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; + +import { useAuth } from "../../auth/AuthProvider"; +import { api, ApiError } from "../../lib/api"; +import { shipmentStatusOptions } from "./config"; +import { ShipmentStatusBadge } from "./ShipmentStatusBadge"; + +export function ShipmentDetailPage() { + const { token, user } = useAuth(); + const { shipmentId } = useParams(); + const [shipment, setShipment] = useState(null); + const [relatedShipments, setRelatedShipments] = useState([]); + const [status, setStatus] = useState("Loading shipment..."); + const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); + + const canManage = user?.permissions.includes(permissions.shippingWrite) ?? false; + + useEffect(() => { + if (!token || !shipmentId) { + return; + } + + api.getShipment(token, shipmentId) + .then((nextShipment) => { + setShipment(nextShipment); + setStatus("Shipment loaded."); + return api.getShipments(token, { salesOrderId: nextShipment.salesOrderId }); + }) + .then((shipments) => setRelatedShipments(shipments.filter((candidate) => candidate.id !== shipmentId))) + .catch((error: unknown) => { + const message = error instanceof ApiError ? error.message : "Unable to load shipment."; + setStatus(message); + }); + }, [shipmentId, token]); + + async function handleStatusChange(nextStatus: ShipmentStatus) { + if (!token || !shipment) { + return; + } + + setIsUpdatingStatus(true); + setStatus("Updating shipment status..."); + try { + const nextShipment = await api.updateShipmentStatus(token, shipment.id, nextStatus); + setShipment(nextShipment); + setStatus("Shipment status updated."); + } catch (error: unknown) { + const message = error instanceof ApiError ? error.message : "Unable to update shipment status."; + setStatus(message); + } finally { + setIsUpdatingStatus(false); + } + } + + if (!shipment) { + return
{status}
; + } + + return ( +
+
+
+
+

Shipment

+

{shipment.shipmentNumber}

+

{shipment.salesOrderNumber} · {shipment.customerName}

+
+
+
+ Back to shipments + Open sales order + {canManage ? ( + Edit shipment + ) : null} +
+
+
+ {canManage ? ( +
+
+
+

Quick Actions

+

Update shipment status without opening the editor.

+
+
+ {shipmentStatusOptions.map((option) => ( + + ))} +
+
+
+ ) : null} +
+

Carrier

{shipment.carrier || "Not set"}
+

Service

{shipment.serviceLevel || "Not set"}
+

Tracking

{shipment.trackingNumber || "Not set"}
+

Packages

{shipment.packageCount}
+
+
+
+

Shipment Notes

+

{shipment.notes || "No notes recorded for this shipment."}

+
+
+

Timing

+
+
Ship Date
{shipment.shipDate ? new Date(shipment.shipDate).toLocaleDateString() : "Not set"}
+
Created
{new Date(shipment.createdAt).toLocaleString()}
+
Updated
{new Date(shipment.updatedAt).toLocaleString()}
+
+
+
+
+
+
+

Related Shipments

+

Other shipments already tied to this sales order.

+
+ {canManage ? ( + Add another shipment + ) : null} +
+ {relatedShipments.length === 0 ? ( +
No additional shipments exist for this sales order.
+ ) : ( +
+ {relatedShipments.map((related) => ( + +
+
+
{related.shipmentNumber}
+
{related.carrier || "Carrier not set"} · {related.trackingNumber || "No tracking"}
+
+ +
+ + ))} +
+ )} +
+
+ ); +} diff --git a/client/src/modules/shipping/ShipmentFormPage.tsx b/client/src/modules/shipping/ShipmentFormPage.tsx new file mode 100644 index 0000000..bd0067e --- /dev/null +++ b/client/src/modules/shipping/ShipmentFormPage.tsx @@ -0,0 +1,201 @@ +import type { ShipmentInput, ShipmentOrderOptionDto } from "@mrp/shared/dist/shipping/types.js"; +import { useEffect, useState } from "react"; +import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; + +import { useAuth } from "../../auth/AuthProvider"; +import { api, ApiError } from "../../lib/api"; +import { emptyShipmentInput, shipmentStatusOptions } from "./config"; + +export function ShipmentFormPage({ mode }: { mode: "create" | "edit" }) { + const { token } = useAuth(); + const navigate = useNavigate(); + const { shipmentId } = useParams(); + const [searchParams] = useSearchParams(); + const seededOrderId = searchParams.get("orderId") ?? ""; + const [form, setForm] = useState({ ...emptyShipmentInput, salesOrderId: seededOrderId }); + const [orderOptions, setOrderOptions] = useState([]); + const [orderSearchTerm, setOrderSearchTerm] = useState(""); + const [orderPickerOpen, setOrderPickerOpen] = useState(false); + const [status, setStatus] = useState(mode === "create" ? "Create a new shipment." : "Loading shipment..."); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + if (!token) { + return; + } + + api.getShipmentOrderOptions(token).then((options) => { + setOrderOptions(options); + const seeded = options.find((option) => option.id === seededOrderId); + if (seeded && mode === "create") { + setOrderSearchTerm(`${seeded.documentNumber} - ${seeded.customerName}`); + } + }).catch(() => setOrderOptions([])); + }, [mode, seededOrderId, token]); + + useEffect(() => { + if (!token || mode !== "edit" || !shipmentId) { + return; + } + + api.getShipment(token, shipmentId) + .then((shipment) => { + setForm({ + salesOrderId: shipment.salesOrderId, + status: shipment.status, + shipDate: shipment.shipDate, + carrier: shipment.carrier, + serviceLevel: shipment.serviceLevel, + trackingNumber: shipment.trackingNumber, + packageCount: shipment.packageCount, + notes: shipment.notes, + }); + setOrderSearchTerm(`${shipment.salesOrderNumber} - ${shipment.customerName}`); + setStatus("Shipment loaded."); + }) + .catch((error: unknown) => { + const message = error instanceof ApiError ? error.message : "Unable to load shipment."; + setStatus(message); + }); + }, [mode, shipmentId, token]); + + function updateField(key: Key, value: ShipmentInput[Key]) { + setForm((current) => ({ ...current, [key]: value })); + } + + function getSelectedOrder(orderId: string) { + return orderOptions.find((option) => option.id === orderId) ?? null; + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!token) { + return; + } + + setIsSaving(true); + setStatus("Saving shipment..."); + try { + const saved = mode === "create" ? await api.createShipment(token, form) : await api.updateShipment(token, shipmentId ?? "", form); + navigate(`/shipping/shipments/${saved.id}`); + } catch (error: unknown) { + const message = error instanceof ApiError ? error.message : "Unable to save shipment."; + setStatus(message); + setIsSaving(false); + } + } + + return ( +
+
+
+
+

Shipping Editor

+

{mode === "create" ? "New Shipment" : "Edit Shipment"}

+
+ + Cancel + +
+
+
+ +
+ + + +
+
+ + + +
+