+
+
+
+
Shipper
+
${shipperLines.map((line) => `
${escapeHtml(line)}
`).join("")}
+
+
+
Consignee
+
${consigneeLines.map((line) => `
${escapeHtml(line)}
`).join("")}
+
+
+
+
Packages
${shipment.packageCount}
+
Line Count
${shipment.lines.length}
+
Total Qty
${totalQuantity}
+
Service
${escapeHtml(shipment.serviceLevel || "Not set")}
+
+
+
+
+ | SKU |
+ Description |
+ Qty |
+ UOM |
+
+
+ ${rows}
+
+
Logistics Notes
${escapeHtml(shipment.notes || "No shipment notes recorded.")}
+
+
+
+ `);
+}
+
documentsRouter.get("/company-profile-preview.pdf", requirePermissions([permissions.companyRead]), async (_request, response) => {
const profile = await getActiveCompanyProfile();
const pdf = await renderPdf(`
@@ -471,3 +670,49 @@ documentsRouter.get(
return response.send(pdf);
}
);
+
+documentsRouter.get(
+ "/shipping/shipments/:shipmentId/shipping-label.pdf",
+ requirePermissions([permissions.shippingRead]),
+ async (request, response) => {
+ const shipmentId = typeof request.params.shipmentId === "string" ? request.params.shipmentId : null;
+ if (!shipmentId) {
+ response.status(400);
+ return response.send("Invalid shipment id.");
+ }
+
+ const [profile, shipment] = await Promise.all([getActiveCompanyProfile(), getShipmentDocumentData(shipmentId)]);
+ if (!shipment) {
+ response.status(404);
+ return response.send("Shipment was not found.");
+ }
+
+ const pdf = await buildShippingLabelPdf({ company: profile, shipment });
+ response.setHeader("Content-Type", "application/pdf");
+ response.setHeader("Content-Disposition", `inline; filename=${shipment.shipmentNumber.toLowerCase()}-label.pdf`);
+ return response.send(pdf);
+ }
+);
+
+documentsRouter.get(
+ "/shipping/shipments/:shipmentId/bill-of-lading.pdf",
+ requirePermissions([permissions.shippingRead]),
+ async (request, response) => {
+ const shipmentId = typeof request.params.shipmentId === "string" ? request.params.shipmentId : null;
+ if (!shipmentId) {
+ response.status(400);
+ return response.send("Invalid shipment id.");
+ }
+
+ const [profile, shipment] = await Promise.all([getActiveCompanyProfile(), getShipmentDocumentData(shipmentId)]);
+ if (!shipment) {
+ response.status(404);
+ return response.send("Shipment was not found.");
+ }
+
+ const pdf = await buildBillOfLadingPdf({ company: profile, shipment });
+ response.setHeader("Content-Type", "application/pdf");
+ response.setHeader("Content-Disposition", `inline; filename=${shipment.shipmentNumber.toLowerCase()}-bill-of-lading.pdf`);
+ return response.send(pdf);
+ }
+);
diff --git a/server/src/modules/projects/router.ts b/server/src/modules/projects/router.ts
new file mode 100644
index 0000000..bf476b4
--- /dev/null
+++ b/server/src/modules/projects/router.ts
@@ -0,0 +1,139 @@
+import { permissions, projectPriorities, projectStatuses } from "@mrp/shared";
+import { Router } from "express";
+import { z } from "zod";
+
+import { fail, ok } from "../../lib/http.js";
+import { requirePermissions } from "../../lib/rbac.js";
+import {
+ createProject,
+ getProjectById,
+ listProjectCustomerOptions,
+ listProjectOrderOptions,
+ listProjectOwnerOptions,
+ listProjects,
+ listProjectQuoteOptions,
+ listProjectShipmentOptions,
+ updateProject,
+} from "./service.js";
+
+const projectSchema = z.object({
+ name: z.string().trim().min(1).max(160),
+ status: z.enum(projectStatuses),
+ priority: z.enum(projectPriorities),
+ customerId: z.string().trim().min(1),
+ salesQuoteId: z.string().trim().min(1).nullable(),
+ salesOrderId: z.string().trim().min(1).nullable(),
+ shipmentId: z.string().trim().min(1).nullable(),
+ ownerId: z.string().trim().min(1).nullable(),
+ dueDate: z.string().datetime().nullable(),
+ notes: z.string(),
+});
+
+const projectListQuerySchema = z.object({
+ q: z.string().optional(),
+ status: z.enum(projectStatuses).optional(),
+ priority: z.enum(projectPriorities).optional(),
+ customerId: z.string().optional(),
+ ownerId: z.string().optional(),
+});
+
+const projectOptionQuerySchema = z.object({
+ customerId: z.string().optional(),
+});
+
+function getRouteParam(value: unknown) {
+ return typeof value === "string" ? value : null;
+}
+
+export const projectsRouter = Router();
+
+projectsRouter.get("/customers/options", requirePermissions([permissions.projectsRead]), async (_request, response) => {
+ return ok(response, await listProjectCustomerOptions());
+});
+
+projectsRouter.get("/owners/options", requirePermissions([permissions.projectsRead]), async (_request, response) => {
+ return ok(response, await listProjectOwnerOptions());
+});
+
+projectsRouter.get("/quotes/options", requirePermissions([permissions.projectsRead]), async (request, response) => {
+ const parsed = projectOptionQuerySchema.safeParse(request.query);
+ if (!parsed.success) {
+ return fail(response, 400, "INVALID_INPUT", "Project quote filters are invalid.");
+ }
+
+ return ok(response, await listProjectQuoteOptions(parsed.data.customerId));
+});
+
+projectsRouter.get("/orders/options", requirePermissions([permissions.projectsRead]), async (request, response) => {
+ const parsed = projectOptionQuerySchema.safeParse(request.query);
+ if (!parsed.success) {
+ return fail(response, 400, "INVALID_INPUT", "Project order filters are invalid.");
+ }
+
+ return ok(response, await listProjectOrderOptions(parsed.data.customerId));
+});
+
+projectsRouter.get("/shipments/options", requirePermissions([permissions.projectsRead]), async (request, response) => {
+ const parsed = projectOptionQuerySchema.safeParse(request.query);
+ if (!parsed.success) {
+ return fail(response, 400, "INVALID_INPUT", "Project shipment filters are invalid.");
+ }
+
+ return ok(response, await listProjectShipmentOptions(parsed.data.customerId));
+});
+
+projectsRouter.get("/", requirePermissions([permissions.projectsRead]), async (request, response) => {
+ const parsed = projectListQuerySchema.safeParse(request.query);
+ if (!parsed.success) {
+ return fail(response, 400, "INVALID_INPUT", "Project filters are invalid.");
+ }
+
+ return ok(response, await listProjects(parsed.data));
+});
+
+projectsRouter.get("/:projectId", requirePermissions([permissions.projectsRead]), async (request, response) => {
+ const projectId = getRouteParam(request.params.projectId);
+ if (!projectId) {
+ return fail(response, 400, "INVALID_INPUT", "Project id is invalid.");
+ }
+
+ const project = await getProjectById(projectId);
+ if (!project) {
+ return fail(response, 404, "PROJECT_NOT_FOUND", "Project was not found.");
+ }
+
+ return ok(response, project);
+});
+
+projectsRouter.post("/", requirePermissions([permissions.projectsWrite]), async (request, response) => {
+ const parsed = projectSchema.safeParse(request.body);
+ if (!parsed.success) {
+ return fail(response, 400, "INVALID_INPUT", "Project payload is invalid.");
+ }
+
+ const result = await createProject(parsed.data);
+ if (!result.ok) {
+ return fail(response, 400, "INVALID_INPUT", result.reason);
+ }
+
+ return ok(response, result.project, 201);
+});
+
+projectsRouter.put("/:projectId", requirePermissions([permissions.projectsWrite]), async (request, response) => {
+ const projectId = getRouteParam(request.params.projectId);
+ if (!projectId) {
+ return fail(response, 400, "INVALID_INPUT", "Project id is invalid.");
+ }
+
+ const parsed = projectSchema.safeParse(request.body);
+ if (!parsed.success) {
+ return fail(response, 400, "INVALID_INPUT", "Project payload is invalid.");
+ }
+
+ const result = await updateProject(projectId, parsed.data);
+ if (!result.ok) {
+ return fail(response, 400, "INVALID_INPUT", result.reason);
+ }
+
+ return ok(response, result.project);
+});
diff --git a/server/src/modules/projects/service.ts b/server/src/modules/projects/service.ts
new file mode 100644
index 0000000..53f1f72
--- /dev/null
+++ b/server/src/modules/projects/service.ts
@@ -0,0 +1,425 @@
+import type {
+ ProjectCustomerOptionDto,
+ ProjectDetailDto,
+ ProjectDocumentOptionDto,
+ ProjectInput,
+ ProjectOwnerOptionDto,
+ ProjectPriority,
+ ProjectShipmentOptionDto,
+ ProjectStatus,
+ ProjectSummaryDto,
+} from "@mrp/shared";
+
+import { prisma } from "../../lib/prisma.js";
+
+const projectModel = (prisma as any).project;
+
+type ProjectRecord = {
+ id: string;
+ projectNumber: string;
+ name: string;
+ status: string;
+ priority: string;
+ dueDate: Date | null;
+ notes: string;
+ createdAt: Date;
+ updatedAt: Date;
+ customer: {
+ id: string;
+ name: string;
+ email: string;
+ phone: string;
+ };
+ owner: {
+ id: string;
+ firstName: string;
+ lastName: string;
+ } | null;
+ salesQuote: {
+ id: string;
+ documentNumber: string;
+ } | null;
+ salesOrder: {
+ id: string;
+ documentNumber: string;
+ } | null;
+ shipment: {
+ id: string;
+ shipmentNumber: string;
+ } | null;
+};
+
+function getOwnerName(owner: ProjectRecord["owner"]) {
+ return owner ? `${owner.firstName} ${owner.lastName}`.trim() : null;
+}
+
+function mapProjectSummary(record: ProjectRecord): ProjectSummaryDto {
+ return {
+ id: record.id,
+ projectNumber: record.projectNumber,
+ name: record.name,
+ status: record.status as ProjectStatus,
+ priority: record.priority as ProjectPriority,
+ customerId: record.customer.id,
+ customerName: record.customer.name,
+ ownerId: record.owner?.id ?? null,
+ ownerName: getOwnerName(record.owner),
+ dueDate: record.dueDate ? record.dueDate.toISOString() : null,
+ updatedAt: record.updatedAt.toISOString(),
+ };
+}
+
+function mapProjectDetail(record: ProjectRecord): ProjectDetailDto {
+ return {
+ ...mapProjectSummary(record),
+ notes: record.notes,
+ createdAt: record.createdAt.toISOString(),
+ salesQuoteId: record.salesQuote?.id ?? null,
+ salesQuoteNumber: record.salesQuote?.documentNumber ?? null,
+ salesOrderId: record.salesOrder?.id ?? null,
+ salesOrderNumber: record.salesOrder?.documentNumber ?? null,
+ shipmentId: record.shipment?.id ?? null,
+ shipmentNumber: record.shipment?.shipmentNumber ?? null,
+ customerEmail: record.customer.email,
+ customerPhone: record.customer.phone,
+ };
+}
+
+function buildInclude() {
+ return {
+ customer: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ phone: true,
+ },
+ },
+ owner: {
+ select: {
+ id: true,
+ firstName: true,
+ lastName: true,
+ },
+ },
+ salesQuote: {
+ select: {
+ id: true,
+ documentNumber: true,
+ },
+ },
+ salesOrder: {
+ select: {
+ id: true,
+ documentNumber: true,
+ },
+ },
+ shipment: {
+ select: {
+ id: true,
+ shipmentNumber: true,
+ },
+ },
+ };
+}
+
+async function nextProjectNumber() {
+ const next = (await projectModel.count()) + 1;
+ return `PRJ-${String(next).padStart(5, "0")}`;
+}
+
+async function validateProjectInput(payload: ProjectInput) {
+ const customer = await prisma.customer.findUnique({
+ where: { id: payload.customerId },
+ select: { id: true },
+ });
+
+ if (!customer) {
+ return { ok: false as const, reason: "Customer was not found." };
+ }
+
+ if (payload.ownerId) {
+ const owner = await prisma.user.findUnique({
+ where: { id: payload.ownerId },
+ select: { id: true, isActive: true },
+ });
+
+ if (!owner?.isActive) {
+ return { ok: false as const, reason: "Project owner was not found." };
+ }
+ }
+
+ if (payload.salesQuoteId) {
+ const quote = await prisma.salesQuote.findUnique({
+ where: { id: payload.salesQuoteId },
+ select: { id: true, customerId: true },
+ });
+
+ if (!quote) {
+ return { ok: false as const, reason: "Linked quote was not found." };
+ }
+
+ if (quote.customerId !== payload.customerId) {
+ return { ok: false as const, reason: "Linked quote must belong to the selected customer." };
+ }
+ }
+
+ if (payload.salesOrderId) {
+ const order = await prisma.salesOrder.findUnique({
+ where: { id: payload.salesOrderId },
+ select: { id: true, customerId: true },
+ });
+
+ if (!order) {
+ return { ok: false as const, reason: "Linked sales order was not found." };
+ }
+
+ if (order.customerId !== payload.customerId) {
+ return { ok: false as const, reason: "Linked sales order must belong to the selected customer." };
+ }
+ }
+
+ if (payload.shipmentId) {
+ const shipment = await prisma.shipment.findUnique({
+ where: { id: payload.shipmentId },
+ include: {
+ salesOrder: {
+ select: {
+ customerId: true,
+ },
+ },
+ },
+ });
+
+ if (!shipment) {
+ return { ok: false as const, reason: "Linked shipment was not found." };
+ }
+
+ if (shipment.salesOrder.customerId !== payload.customerId) {
+ return { ok: false as const, reason: "Linked shipment must belong to the selected customer." };
+ }
+ }
+
+ return { ok: true as const };
+}
+
+export async function listProjectCustomerOptions(): Promise