This commit is contained in:
2026-03-14 23:48:27 -05:00
parent ff37ad6f06
commit 7b85d14ff6
21 changed files with 1072 additions and 1 deletions

View File

@@ -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" },
];

View File

@@ -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<ShipmentOrderOptionDto[]>("/api/v1/shipping/orders/options", undefined, token);
},
getShipments(token: string, filters?: { q?: string; status?: ShipmentStatus; salesOrderId?: string }) {
return request<ShipmentSummaryDto[]>(
`/api/v1/shipping/shipments${buildQueryString({
q: filters?.q,
status: filters?.status,
salesOrderId: filters?.salesOrderId,
})}`,
undefined,
token
);
},
getShipment(token: string, shipmentId: string) {
return request<ShipmentDetailDto>(`/api/v1/shipping/shipments/${shipmentId}`, undefined, token);
},
createShipment(token: string, payload: ShipmentInput) {
return request<ShipmentDetailDto>("/api/v1/shipping/shipments", { method: "POST", body: JSON.stringify(payload) }, token);
},
updateShipment(token: string, shipmentId: string, payload: ShipmentInput) {
return request<ShipmentDetailDto>(`/api/v1/shipping/shipments/${shipmentId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
},
updateShipmentStatus(token: string, shipmentId: string, status: ShipmentStatus) {
return request<ShipmentDetailDto>(
`/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: {

View File

@@ -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: <SalesDetailPage entity="order" /> },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.shippingRead]} />,
children: [
{ path: "/shipping/shipments", element: <ShipmentListPage /> },
{ path: "/shipping/shipments/:shipmentId", element: <ShipmentDetailPage /> },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.crmWrite]} />,
children: [
@@ -87,6 +97,13 @@ const router = createBrowserRouter([
{ path: "/sales/orders/:orderId/edit", element: <SalesFormPage entity="order" mode="edit" /> },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.shippingWrite]} />,
children: [
{ path: "/shipping/shipments/new", element: <ShipmentFormPage mode="create" /> },
{ path: "/shipping/shipments/:shipmentId/edit", element: <ShipmentFormPage mode="edit" /> },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.inventoryWrite]} />,
children: [

View File

@@ -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<ShipmentSummaryDto[]>([]);
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 <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
@@ -116,6 +124,11 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
{isConverting ? "Converting..." : "Convert to sales order"}
</button>
) : null}
{entity === "order" && canManageShipping ? (
<Link to={`/shipping/shipments/new?orderId=${activeDocument.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
New shipment
</Link>
) : null}
</>
) : null}
</div>
@@ -239,6 +252,40 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
</div>
)}
</section>
{entity === "order" && canReadShipping ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipping</p>
<p className="mt-2 text-sm text-muted">Shipment records currently tied to this sales order.</p>
</div>
{canManageShipping ? (
<Link to={`/shipping/shipments/new?orderId=${activeDocument.id}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Create shipment
</Link>
) : null}
</div>
{shipments.length === 0 ? (
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
No shipments have been created for this sales order yet.
</div>
) : (
<div className="mt-6 space-y-3">
{shipments.map((shipment) => (
<Link key={shipment.id} to={`/shipping/shipments/${shipment.id}`} className="block rounded-3xl border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{shipment.shipmentNumber}</div>
<div className="mt-1 text-xs text-muted">{shipment.carrier || "Carrier not set"} · {shipment.trackingNumber || "No tracking"}</div>
</div>
<ShipmentStatusBadge status={shipment.status} />
</div>
</Link>
))}
</div>
)}
</section>
) : null}
</section>
);
}

View File

@@ -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<ShipmentDetailDto | null>(null);
const [relatedShipments, setRelatedShipments] = useState<ShipmentSummaryDto[]>([]);
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 <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
}
return (
<section className="space-y-4">
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipment</p>
<h3 className="mt-2 text-xl font-bold text-text">{shipment.shipmentNumber}</h3>
<p className="mt-1 text-sm text-text">{shipment.salesOrderNumber} · {shipment.customerName}</p>
<div className="mt-3"><ShipmentStatusBadge status={shipment.status} /></div>
</div>
<div className="flex flex-wrap gap-3">
<Link to="/shipping/shipments" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Back to shipments</Link>
<Link to={`/sales/orders/${shipment.salesOrderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open sales order</Link>
{canManage ? (
<Link to={`/shipping/shipments/${shipment.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">Edit shipment</Link>
) : null}
</div>
</div>
</div>
{canManage ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p>
<p className="mt-2 text-sm text-muted">Update shipment status without opening the editor.</p>
</div>
<div className="flex flex-wrap gap-2">
{shipmentStatusOptions.map((option) => (
<button key={option.value} type="button" onClick={() => handleStatusChange(option.value)} disabled={isUpdatingStatus || shipment.status === option.value} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
{option.label}
</button>
))}
</div>
</div>
</section>
) : null}
<section className="grid gap-3 xl:grid-cols-4">
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Carrier</p><div className="mt-2 text-base font-bold text-text">{shipment.carrier || "Not set"}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Service</p><div className="mt-2 text-base font-bold text-text">{shipment.serviceLevel || "Not set"}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Tracking</p><div className="mt-2 text-base font-bold text-text">{shipment.trackingNumber || "Not set"}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Packages</p><div className="mt-2 text-base font-bold text-text">{shipment.packageCount}</div></article>
</section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]">
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipment Notes</p>
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{shipment.notes || "No notes recorded for this shipment."}</p>
</article>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Timing</p>
<dl className="mt-5 grid gap-3">
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Ship Date</dt><dd className="mt-1 text-sm text-text">{shipment.shipDate ? new Date(shipment.shipDate).toLocaleDateString() : "Not set"}</dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Created</dt><dd className="mt-1 text-sm text-text">{new Date(shipment.createdAt).toLocaleString()}</dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Updated</dt><dd className="mt-1 text-sm text-text">{new Date(shipment.updatedAt).toLocaleString()}</dd></div>
</dl>
</article>
</div>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Related Shipments</p>
<p className="mt-2 text-sm text-muted">Other shipments already tied to this sales order.</p>
</div>
{canManage ? (
<Link to={`/shipping/shipments/new?orderId=${shipment.salesOrderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Add another shipment</Link>
) : null}
</div>
{relatedShipments.length === 0 ? (
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No additional shipments exist for this sales order.</div>
) : (
<div className="mt-6 space-y-3">
{relatedShipments.map((related) => (
<Link key={related.id} to={`/shipping/shipments/${related.id}`} className="block rounded-3xl border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{related.shipmentNumber}</div>
<div className="mt-1 text-xs text-muted">{related.carrier || "Carrier not set"} · {related.trackingNumber || "No tracking"}</div>
</div>
<ShipmentStatusBadge status={related.status} />
</div>
</Link>
))}
</div>
)}
</section>
</section>
);
}

View File

@@ -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<ShipmentInput>({ ...emptyShipmentInput, salesOrderId: seededOrderId });
const [orderOptions, setOrderOptions] = useState<ShipmentOrderOptionDto[]>([]);
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 extends keyof ShipmentInput>(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<HTMLFormElement>) {
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 (
<form className="space-y-6" onSubmit={handleSubmit}>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipping Editor</p>
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Shipment" : "Edit Shipment"}</h3>
</div>
<Link to={mode === "create" ? "/shipping/shipments" : `/shipping/shipments/${shipmentId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Cancel
</Link>
</div>
</section>
<section className="space-y-4 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Sales Order</span>
<div className="relative">
<input
value={orderSearchTerm}
onChange={(event) => {
setOrderSearchTerm(event.target.value);
updateField("salesOrderId", "");
setOrderPickerOpen(true);
}}
onFocus={() => setOrderPickerOpen(true)}
onBlur={() => {
window.setTimeout(() => {
setOrderPickerOpen(false);
const selected = getSelectedOrder(form.salesOrderId);
if (selected) {
setOrderSearchTerm(`${selected.documentNumber} - ${selected.customerName}`);
}
}, 120);
}}
placeholder="Search sales order"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{orderPickerOpen ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
{orderOptions
.filter((option) => {
const query = orderSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return option.documentNumber.toLowerCase().includes(query) || option.customerName.toLowerCase().includes(query);
})
.slice(0, 12)
.map((option) => (
<button
key={option.id}
type="button"
onMouseDown={(event) => {
event.preventDefault();
updateField("salesOrderId", option.id);
setOrderSearchTerm(`${option.documentNumber} - ${option.customerName}`);
setOrderPickerOpen(false);
}}
className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70"
>
<div className="font-semibold text-text">{option.documentNumber}</div>
<div className="mt-1 text-xs text-muted">{option.customerName}</div>
</button>
))}
</div>
) : null}
</div>
</label>
<div className="grid gap-3 xl:grid-cols-3">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
<select value={form.status} onChange={(event) => updateField("status", event.target.value as ShipmentInput["status"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
{shipmentStatusOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Ship Date</span>
<input type="date" value={form.shipDate ? form.shipDate.slice(0, 10) : ""} onChange={(event) => updateField("shipDate", event.target.value ? new Date(event.target.value).toISOString() : null)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Packages</span>
<input type="number" min={1} step={1} value={form.packageCount} onChange={(event) => updateField("packageCount", Number.parseInt(event.target.value, 10) || 1)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
</div>
<div className="grid gap-3 xl:grid-cols-3">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Carrier</span>
<input value={form.carrier} onChange={(event) => updateField("carrier", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Service Level</span>
<input value={form.serviceLevel} onChange={(event) => updateField("serviceLevel", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Tracking Number</span>
<input value={form.trackingNumber} onChange={(event) => updateField("trackingNumber", event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
</div>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={4} className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
<span className="min-w-0 text-sm text-muted">{status}</span>
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isSaving ? "Saving..." : mode === "create" ? "Create shipment" : "Save changes"}
</button>
</div>
</section>
</form>
);
}

View File

@@ -0,0 +1,116 @@
import { permissions } from "@mrp/shared";
import type { ShipmentStatus, ShipmentSummaryDto } from "@mrp/shared/dist/shipping/types.js";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { shipmentStatusFilters } from "./config";
import { ShipmentStatusBadge } from "./ShipmentStatusBadge";
export function ShipmentListPage() {
const { token, user } = useAuth();
const [shipments, setShipments] = useState<ShipmentSummaryDto[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<"ALL" | ShipmentStatus>("ALL");
const [status, setStatus] = useState("Loading shipments...");
const canManage = user?.permissions.includes(permissions.shippingWrite) ?? false;
useEffect(() => {
if (!token) {
return;
}
api
.getShipments(token, {
q: searchTerm.trim() || undefined,
status: statusFilter === "ALL" ? undefined : statusFilter,
})
.then((nextShipments) => {
setShipments(nextShipments);
setStatus(`${nextShipments.length} shipments matched the current filters.`);
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load shipments.";
setStatus(message);
});
}, [searchTerm, statusFilter, token]);
return (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipping</p>
<h3 className="mt-2 text-lg font-bold text-text">Shipments</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">Outbound shipment records tied to sales orders, carriers, and tracking details.</p>
</div>
{canManage ? (
<Link to="/shipping/shipments/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
New shipment
</Link>
) : null}
</div>
<div className="mt-6 grid gap-3 rounded-3xl border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.35fr_0.8fr]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
<input
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="Search by shipment, order, customer, carrier, or tracking"
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as "ALL" | ShipmentStatus)}
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
>
{shipmentStatusFilters.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
<div className="mt-6 rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
{shipments.length === 0 ? (
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No shipments have been added yet.</div>
) : (
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted">
<tr>
<th className="px-2 py-2">Shipment</th>
<th className="px-2 py-2">Sales Order</th>
<th className="px-2 py-2">Customer</th>
<th className="px-2 py-2">Status</th>
<th className="px-2 py-2">Carrier</th>
<th className="px-2 py-2">Ship Date</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{shipments.map((shipment) => (
<tr key={shipment.id} className="transition hover:bg-page/70">
<td className="px-2 py-2">
<Link to={`/shipping/shipments/${shipment.id}`} className="font-semibold text-text hover:text-brand">
{shipment.shipmentNumber}
</Link>
</td>
<td className="px-2 py-2 text-muted">{shipment.salesOrderNumber}</td>
<td className="px-2 py-2 text-muted">{shipment.customerName}</td>
<td className="px-2 py-2"><ShipmentStatusBadge status={shipment.status} /></td>
<td className="px-2 py-2 text-muted">{shipment.carrier || "Not set"}</td>
<td className="px-2 py-2 text-muted">{shipment.shipDate ? new Date(shipment.shipDate).toLocaleDateString() : "Not set"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,8 @@
import type { ShipmentStatus } from "@mrp/shared/dist/shipping/types.js";
import { shipmentStatusOptions, shipmentStatusPalette } from "./config";
export function ShipmentStatusBadge({ status }: { status: ShipmentStatus }) {
const label = shipmentStatusOptions.find((option) => option.value === status)?.label ?? status;
return <span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${shipmentStatusPalette[status]}`}>{label}</span>;
}

View File

@@ -0,0 +1,5 @@
import { Outlet } from "react-router-dom";
export function ShipmentsPage() {
return <Outlet />;
}

View File

@@ -0,0 +1,33 @@
import type { ShipmentInput, ShipmentStatus } from "@mrp/shared/dist/shipping/types.js";
export const shipmentStatusOptions: Array<{ value: ShipmentStatus; label: string }> = [
{ value: "DRAFT", label: "Draft" },
{ value: "PICKING", label: "Picking" },
{ value: "PACKED", label: "Packed" },
{ value: "SHIPPED", label: "Shipped" },
{ value: "DELIVERED", label: "Delivered" },
];
export const shipmentStatusFilters: Array<{ value: "ALL" | ShipmentStatus; label: string }> = [
{ value: "ALL", label: "All statuses" },
...shipmentStatusOptions,
];
export const shipmentStatusPalette: Record<ShipmentStatus, string> = {
DRAFT: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
PICKING: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
PACKED: "border border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300",
SHIPPED: "border border-brand/30 bg-brand/10 text-brand",
DELIVERED: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
};
export const emptyShipmentInput: ShipmentInput = {
salesOrderId: "",
status: "DRAFT",
shipDate: null,
carrier: "",
serviceLevel: "",
trackingNumber: "",
packageCount: 1,
notes: "",
};

View File

@@ -0,0 +1,18 @@
CREATE TABLE "Shipment" (
"id" TEXT NOT NULL PRIMARY KEY,
"shipmentNumber" TEXT NOT NULL,
"salesOrderId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"shipDate" DATETIME,
"carrier" TEXT NOT NULL,
"serviceLevel" TEXT NOT NULL,
"trackingNumber" TEXT NOT NULL,
"packageCount" INTEGER NOT NULL DEFAULT 1,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Shipment_salesOrderId_fkey" FOREIGN KEY ("salesOrderId") REFERENCES "SalesOrder" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "Shipment_shipmentNumber_key" ON "Shipment"("shipmentNumber");
CREATE INDEX "Shipment_salesOrderId_createdAt_idx" ON "Shipment"("salesOrderId", "createdAt");

View File

@@ -331,6 +331,7 @@ model SalesOrder {
updatedAt DateTime @updatedAt
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
lines SalesOrderLine[]
shipments Shipment[]
}
model SalesOrderLine {
@@ -349,3 +350,21 @@ model SalesOrderLine {
@@index([orderId, position])
}
model Shipment {
id String @id @default(cuid())
shipmentNumber String @unique
salesOrderId String
status String
shipDate DateTime?
carrier String
serviceLevel String
trackingNumber String
packageCount Int @default(1)
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
salesOrder SalesOrder @relation(fields: [salesOrderId], references: [id], onDelete: Restrict)
@@index([salesOrderId, createdAt])
}

View File

@@ -18,6 +18,7 @@ import { filesRouter } from "./modules/files/router.js";
import { ganttRouter } from "./modules/gantt/router.js";
import { inventoryRouter } from "./modules/inventory/router.js";
import { salesRouter } from "./modules/sales/router.js";
import { shippingRouter } from "./modules/shipping/router.js";
import { settingsRouter } from "./modules/settings/router.js";
export function createApp() {
@@ -54,6 +55,7 @@ export function createApp() {
app.use("/api/v1/crm", crmRouter);
app.use("/api/v1/inventory", inventoryRouter);
app.use("/api/v1/sales", salesRouter);
app.use("/api/v1/shipping", shippingRouter);
app.use("/api/v1/gantt", ganttRouter);
app.use("/api/v1/documents", documentsRouter);

View File

@@ -19,6 +19,7 @@ const permissionDescriptions: Record<PermissionKey, string> = {
[permissions.salesRead]: "View sales data",
[permissions.salesWrite]: "Manage quotes and sales orders",
[permissions.shippingRead]: "View shipping data",
[permissions.shippingWrite]: "Manage shipments",
};
export async function bootstrapAppData() {

View File

@@ -12,6 +12,7 @@ import {
getSalesDocumentById,
listSalesCustomerOptions,
listSalesDocuments,
listSalesOrderOptions,
updateSalesDocumentStatus,
updateSalesDocument,
} from "./service.js";
@@ -67,6 +68,10 @@ salesRouter.get("/customers/options", requirePermissions([permissions.salesRead]
return ok(response, await listSalesCustomerOptions());
});
salesRouter.get("/orders/options", requirePermissions([permissions.salesRead]), async (_request, response) => {
return ok(response, await listSalesOrderOptions());
});
salesRouter.get("/quotes", requirePermissions([permissions.salesRead]), async (request, response) => {
const parsed = salesListQuerySchema.safeParse(request.query);
if (!parsed.success) {

View File

@@ -246,6 +246,38 @@ export async function listSalesCustomerOptions(): Promise<SalesCustomerOptionDto
return customers;
}
export async function listSalesOrderOptions() {
const orders = await prisma.salesOrder.findMany({
include: {
customer: {
select: {
name: true,
},
},
lines: {
select: {
quantity: true,
unitPrice: true,
},
},
},
orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }],
});
return orders.map((order) => ({
id: order.id,
documentNumber: order.documentNumber,
customerName: order.customer.name,
status: order.status,
total: calculateTotals(
order.lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0),
order.discountPercent,
order.taxPercent,
order.freightAmount
).total,
}));
}
export async function listSalesDocuments(type: SalesDocumentType, filters: { q?: string; status?: SalesDocumentStatus } = {}) {
const query = filters.q?.trim();
const records = await documentConfig[type].findMany({

View File

@@ -0,0 +1,114 @@
import { permissions } from "@mrp/shared";
import { shipmentStatuses } from "@mrp/shared/dist/shipping/types.js";
import { Router } from "express";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import { createShipment, getShipmentById, listShipmentOrderOptions, listShipments, updateShipment, updateShipmentStatus } from "./service.js";
const shipmentSchema = z.object({
salesOrderId: z.string().trim().min(1),
status: z.enum(shipmentStatuses),
shipDate: z.string().datetime().nullable(),
carrier: z.string(),
serviceLevel: z.string(),
trackingNumber: z.string(),
packageCount: z.number().int().positive(),
notes: z.string(),
});
const shipmentListQuerySchema = z.object({
q: z.string().optional(),
status: z.enum(shipmentStatuses).optional(),
salesOrderId: z.string().optional(),
});
const shipmentStatusUpdateSchema = z.object({
status: z.enum(shipmentStatuses),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
export const shippingRouter = Router();
shippingRouter.get("/orders/options", requirePermissions([permissions.shippingRead]), async (_request, response) => {
return ok(response, await listShipmentOrderOptions());
});
shippingRouter.get("/shipments", requirePermissions([permissions.shippingRead]), async (request, response) => {
const parsed = shipmentListQuerySchema.safeParse(request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Shipment filters are invalid.");
}
return ok(response, await listShipments(parsed.data));
});
shippingRouter.get("/shipments/:shipmentId", requirePermissions([permissions.shippingRead]), async (request, response) => {
const shipmentId = getRouteParam(request.params.shipmentId);
if (!shipmentId) {
return fail(response, 400, "INVALID_INPUT", "Shipment id is invalid.");
}
const shipment = await getShipmentById(shipmentId);
if (!shipment) {
return fail(response, 404, "SHIPMENT_NOT_FOUND", "Shipment was not found.");
}
return ok(response, shipment);
});
shippingRouter.post("/shipments", requirePermissions([permissions.shippingWrite]), async (request, response) => {
const parsed = shipmentSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Shipment payload is invalid.");
}
const result = await createShipment(parsed.data);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.shipment, 201);
});
shippingRouter.put("/shipments/:shipmentId", requirePermissions([permissions.shippingWrite]), async (request, response) => {
const shipmentId = getRouteParam(request.params.shipmentId);
if (!shipmentId) {
return fail(response, 400, "INVALID_INPUT", "Shipment id is invalid.");
}
const parsed = shipmentSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Shipment payload is invalid.");
}
const result = await updateShipment(shipmentId, parsed.data);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.shipment);
});
shippingRouter.patch("/shipments/:shipmentId/status", requirePermissions([permissions.shippingWrite]), async (request, response) => {
const shipmentId = getRouteParam(request.params.shipmentId);
if (!shipmentId) {
return fail(response, 400, "INVALID_INPUT", "Shipment id is invalid.");
}
const parsed = shipmentStatusUpdateSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Shipment status payload is invalid.");
}
const result = await updateShipmentStatus(shipmentId, parsed.data.status);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.shipment);
});

View File

@@ -0,0 +1,223 @@
import type {
ShipmentDetailDto,
ShipmentInput,
ShipmentOrderOptionDto,
ShipmentStatus,
ShipmentSummaryDto,
} from "@mrp/shared/dist/shipping/types.js";
import { prisma } from "../../lib/prisma.js";
type ShipmentRecord = {
id: string;
shipmentNumber: string;
status: string;
shipDate: Date | null;
carrier: string;
serviceLevel: string;
trackingNumber: string;
packageCount: number;
notes: string;
createdAt: Date;
updatedAt: Date;
salesOrder: {
id: string;
documentNumber: string;
customer: {
name: string;
};
};
};
function mapShipment(record: ShipmentRecord): ShipmentDetailDto {
return {
id: record.id,
shipmentNumber: record.shipmentNumber,
salesOrderId: record.salesOrder.id,
salesOrderNumber: record.salesOrder.documentNumber,
customerName: record.salesOrder.customer.name,
status: record.status as ShipmentStatus,
carrier: record.carrier,
serviceLevel: record.serviceLevel,
trackingNumber: record.trackingNumber,
packageCount: record.packageCount,
shipDate: record.shipDate ? record.shipDate.toISOString() : null,
notes: record.notes,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
async function nextShipmentNumber() {
const next = (await prisma.shipment.count()) + 1;
return `SHP-${String(next).padStart(5, "0")}`;
}
export async function listShipmentOrderOptions(): Promise<ShipmentOrderOptionDto[]> {
const orders = await prisma.salesOrder.findMany({
include: {
customer: {
select: {
name: true,
},
},
lines: {
select: {
quantity: true,
unitPrice: true,
},
},
},
orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }],
});
return orders.map((order) => ({
id: order.id,
documentNumber: order.documentNumber,
customerName: order.customer.name,
status: order.status,
total: order.lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0),
}));
}
export async function listShipments(filters: { q?: string; status?: ShipmentStatus; salesOrderId?: string } = {}) {
const query = filters.q?.trim();
const shipments = await prisma.shipment.findMany({
where: {
...(filters.status ? { status: filters.status } : {}),
...(filters.salesOrderId ? { salesOrderId: filters.salesOrderId } : {}),
...(query
? {
OR: [
{ shipmentNumber: { contains: query } },
{ trackingNumber: { contains: query } },
{ carrier: { contains: query } },
{ salesOrder: { documentNumber: { contains: query } } },
{ salesOrder: { customer: { name: { contains: query } } } },
],
}
: {}),
},
include: {
salesOrder: {
include: {
customer: {
select: {
name: true,
},
},
},
},
},
orderBy: [{ createdAt: "desc" }],
});
return shipments.map((shipment) => mapShipment(shipment));
}
export async function getShipmentById(shipmentId: string) {
const shipment = await prisma.shipment.findUnique({
where: { id: shipmentId },
include: {
salesOrder: {
include: {
customer: {
select: {
name: true,
},
},
},
},
},
});
return shipment ? mapShipment(shipment) : null;
}
export async function createShipment(payload: ShipmentInput) {
const order = await prisma.salesOrder.findUnique({
where: { id: payload.salesOrderId },
select: { id: true },
});
if (!order) {
return { ok: false as const, reason: "Sales order was not found." };
}
const shipmentNumber = await nextShipmentNumber();
const shipment = await prisma.shipment.create({
data: {
shipmentNumber,
salesOrderId: payload.salesOrderId,
status: payload.status,
shipDate: payload.shipDate ? new Date(payload.shipDate) : null,
carrier: payload.carrier.trim(),
serviceLevel: payload.serviceLevel.trim(),
trackingNumber: payload.trackingNumber.trim(),
packageCount: payload.packageCount,
notes: payload.notes,
},
select: { id: true },
});
const detail = await getShipmentById(shipment.id);
return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load saved shipment." };
}
export async function updateShipment(shipmentId: string, payload: ShipmentInput) {
const existing = await prisma.shipment.findUnique({
where: { id: shipmentId },
select: { id: true },
});
if (!existing) {
return { ok: false as const, reason: "Shipment was not found." };
}
const order = await prisma.salesOrder.findUnique({
where: { id: payload.salesOrderId },
select: { id: true },
});
if (!order) {
return { ok: false as const, reason: "Sales order was not found." };
}
await prisma.shipment.update({
where: { id: shipmentId },
data: {
salesOrderId: payload.salesOrderId,
status: payload.status,
shipDate: payload.shipDate ? new Date(payload.shipDate) : null,
carrier: payload.carrier.trim(),
serviceLevel: payload.serviceLevel.trim(),
trackingNumber: payload.trackingNumber.trim(),
packageCount: payload.packageCount,
notes: payload.notes,
},
select: { id: true },
});
const detail = await getShipmentById(shipmentId);
return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load saved shipment." };
}
export async function updateShipmentStatus(shipmentId: string, status: ShipmentStatus) {
const existing = await prisma.shipment.findUnique({
where: { id: shipmentId },
select: { id: true },
});
if (!existing) {
return { ok: false as const, reason: "Shipment was not found." };
}
await prisma.shipment.update({
where: { id: shipmentId },
data: { status },
select: { id: true },
});
const detail = await getShipmentById(shipmentId);
return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load updated shipment." };
}

View File

@@ -12,6 +12,7 @@ export const permissions = {
salesRead: "sales.read",
salesWrite: "sales.write",
shippingRead: "shipping.read",
shippingWrite: "shipping.write",
} as const;
export type PermissionKey = (typeof permissions)[keyof typeof permissions];

View File

@@ -7,3 +7,4 @@ export * from "./files/types.js";
export * from "./gantt/types.js";
export * from "./inventory/types.js";
export * from "./sales/types.js";
export * from "./shipping/types.js";

View File

@@ -0,0 +1,42 @@
export const shipmentStatuses = ["DRAFT", "PICKING", "PACKED", "SHIPPED", "DELIVERED"] as const;
export type ShipmentStatus = (typeof shipmentStatuses)[number];
export interface ShipmentOrderOptionDto {
id: string;
documentNumber: string;
customerName: string;
status: string;
total: number;
}
export interface ShipmentSummaryDto {
id: string;
shipmentNumber: string;
salesOrderId: string;
salesOrderNumber: string;
customerName: string;
status: ShipmentStatus;
carrier: string;
trackingNumber: string;
packageCount: number;
shipDate: string | null;
updatedAt: string;
}
export interface ShipmentDetailDto extends ShipmentSummaryDto {
serviceLevel: string;
notes: string;
createdAt: string;
}
export interface ShipmentInput {
salesOrderId: string;
status: ShipmentStatus;
shipDate: string | null;
carrier: string;
serviceLevel: string;
trackingNumber: string;
packageCount: number;
notes: string;
}