This commit is contained in:
2026-03-15 10:13:53 -05:00
parent 552d4e2844
commit 6644ba2932
30 changed files with 1768 additions and 64 deletions

View File

@@ -0,0 +1,26 @@
CREATE TABLE "Project" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectNumber" TEXT NOT NULL,
"name" TEXT NOT NULL,
"status" TEXT NOT NULL,
"priority" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"salesQuoteId" TEXT,
"salesOrderId" TEXT,
"shipmentId" TEXT,
"ownerId" TEXT,
"dueDate" DATETIME,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Project_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Project_salesQuoteId_fkey" FOREIGN KEY ("salesQuoteId") REFERENCES "SalesQuote" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Project_salesOrderId_fkey" FOREIGN KEY ("salesOrderId") REFERENCES "SalesOrder" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Project_shipmentId_fkey" FOREIGN KEY ("shipmentId") REFERENCES "Shipment" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "Project_projectNumber_key" ON "Project"("projectNumber");
CREATE INDEX "Project_customerId_createdAt_idx" ON "Project"("customerId", "createdAt");
CREATE INDEX "Project_ownerId_dueDate_idx" ON "Project"("ownerId", "dueDate");
CREATE INDEX "Project_status_priority_idx" ON "Project"("status", "priority");

View File

@@ -21,6 +21,7 @@ model User {
contactEntries CrmContactEntry[]
inventoryTransactions InventoryTransaction[]
purchaseReceipts PurchaseReceipt[]
ownedProjects Project[] @relation("ProjectOwner")
}
model Role {
@@ -171,6 +172,7 @@ model Customer {
childCustomers Customer[] @relation("CustomerHierarchy")
salesQuotes SalesQuote[]
salesOrders SalesOrder[]
projects Project[]
}
model InventoryBomLine {
@@ -303,6 +305,7 @@ model SalesQuote {
updatedAt DateTime @updatedAt
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
lines SalesQuoteLine[]
projects Project[]
}
model SalesQuoteLine {
@@ -337,6 +340,7 @@ model SalesOrder {
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
lines SalesOrderLine[]
shipments Shipment[]
projects Project[]
}
model SalesOrderLine {
@@ -370,10 +374,37 @@ model Shipment {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
salesOrder SalesOrder @relation(fields: [salesOrderId], references: [id], onDelete: Restrict)
projects Project[]
@@index([salesOrderId, createdAt])
}
model Project {
id String @id @default(cuid())
projectNumber String @unique
name String
status String
priority String
customerId String
salesQuoteId String?
salesOrderId String?
shipmentId String?
ownerId String?
dueDate DateTime?
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
salesQuote SalesQuote? @relation(fields: [salesQuoteId], references: [id], onDelete: SetNull)
salesOrder SalesOrder? @relation(fields: [salesOrderId], references: [id], onDelete: SetNull)
shipment Shipment? @relation(fields: [shipmentId], references: [id], onDelete: SetNull)
owner User? @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: SetNull)
@@index([customerId, createdAt])
@@index([ownerId, dueDate])
@@index([status, priority])
}
model PurchaseOrder {
id String @id @default(cuid())
documentNumber String @unique

View File

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

View File

@@ -18,6 +18,8 @@ const permissionDescriptions: Record<PermissionKey, string> = {
[permissions.ganttRead]: "View gantt timelines",
[permissions.salesRead]: "View sales data",
[permissions.salesWrite]: "Manage quotes and sales orders",
[permissions.projectsRead]: "View projects and program records",
[permissions.projectsWrite]: "Manage projects and program records",
"purchasing.read": "View purchasing data",
"purchasing.write": "Manage purchase orders",
[permissions.shippingRead]: "View shipping data",

View File

@@ -5,7 +5,7 @@ import { renderPdf } from "../../lib/pdf.js";
import { requirePermissions } from "../../lib/rbac.js";
import { getPurchaseOrderPdfData } from "../purchasing/service.js";
import { getSalesDocumentPdfData } from "../sales/service.js";
import { getShipmentPackingSlipData } from "../shipping/service.js";
import { getShipmentDocumentData, getShipmentPackingSlipData } from "../shipping/service.js";
import { getActiveCompanyProfile } from "../settings/service.js";
export const documentsRouter = Router();
@@ -136,6 +136,205 @@ function renderCommercialDocumentPdf(options: {
`);
}
function buildShippingLabelPdf(options: {
company: Awaited<ReturnType<typeof getActiveCompanyProfile>>;
shipment: Awaited<ReturnType<typeof getShipmentDocumentData>>;
}) {
const { company, shipment } = options;
if (!shipment) {
throw new Error("Shipment data is required.");
}
const shipToLines = buildAddressLines(shipment.customer);
const topLine = shipment.lines[0];
return renderPdf(`
<html>
<head>
<style>
@page { size: 4in 6in; margin: 8mm; }
body { font-family: ${company.theme.fontFamily}, Arial, sans-serif; color: #111827; font-size: 11px; }
.label { border: 2px solid #111827; border-radius: 12px; padding: 12px; display: flex; flex-direction: column; gap: 12px; min-height: calc(6in - 16mm); box-sizing: border-box; }
.row { display: flex; justify-content: space-between; gap: 12px; }
.muted { font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em; color: #4b5563; }
.brand { border-bottom: 2px solid ${company.theme.primaryColor}; padding-bottom: 10px; }
.brand h1 { margin: 0; font-size: 18px; color: ${company.theme.primaryColor}; }
.block { border: 1px solid #d1d5db; border-radius: 10px; padding: 10px; }
.stack { display: flex; flex-direction: column; gap: 4px; }
.barcode { border: 2px solid #111827; border-radius: 10px; padding: 8px; text-align: center; font-family: monospace; font-size: 18px; letter-spacing: 0.18em; }
.strong { font-weight: 700; }
.big { font-size: 16px; font-weight: 700; }
</style>
</head>
<body>
<div class="label">
<div class="brand">
<div class="row">
<div>
<div class="muted">From</div>
<h1>${escapeHtml(company.companyName)}</h1>
</div>
<div style="text-align:right;">
<div class="muted">Shipment</div>
<div class="big">${escapeHtml(shipment.shipmentNumber)}</div>
</div>
</div>
</div>
<div class="block">
<div class="muted">Ship To</div>
<div class="stack" style="margin-top:8px;">
${shipToLines.map((line) => `<div class="${line === shipment.customer.name ? "strong" : ""}">${escapeHtml(line)}</div>`).join("")}
</div>
</div>
<div class="row">
<div class="block" style="flex:1;">
<div class="muted">Service</div>
<div class="big" style="margin-top:6px;">${escapeHtml(shipment.serviceLevel || "GROUND")}</div>
</div>
<div class="block" style="width:90px;">
<div class="muted">Pkgs</div>
<div class="big" style="margin-top:6px;">${shipment.packageCount}</div>
</div>
</div>
<div class="row">
<div class="block" style="flex:1;">
<div class="muted">Sales Order</div>
<div class="strong" style="margin-top:6px;">${escapeHtml(shipment.salesOrderNumber)}</div>
</div>
<div class="block" style="width:110px;">
<div class="muted">Ship Date</div>
<div class="strong" style="margin-top:6px;">${escapeHtml(formatDate(shipment.shipDate))}</div>
</div>
</div>
<div class="block">
<div class="muted">Reference</div>
<div style="margin-top:6px;">${escapeHtml(topLine ? `${topLine.itemSku} · ${topLine.itemName}` : "Shipment record")}</div>
</div>
<div class="barcode">
*${escapeHtml(shipment.trackingNumber || shipment.shipmentNumber)}*
</div>
<div style="text-align:center; font-size:10px; color:#4b5563;">${escapeHtml(shipment.carrier || "Carrier pending")} · ${escapeHtml(shipment.trackingNumber || "Tracking pending")}</div>
</div>
</body>
</html>
`);
}
function buildBillOfLadingPdf(options: {
company: Awaited<ReturnType<typeof getActiveCompanyProfile>>;
shipment: Awaited<ReturnType<typeof getShipmentDocumentData>>;
}) {
const { company, shipment } = options;
if (!shipment) {
throw new Error("Shipment data is required.");
}
const shipperLines = [
company.companyName,
company.addressLine1,
company.addressLine2,
`${company.city}, ${company.state} ${company.postalCode}`.trim(),
company.country,
company.phone,
company.email,
].filter((line) => line.trim().length > 0);
const consigneeLines = [
shipment.customer.name,
shipment.customer.addressLine1,
shipment.customer.addressLine2,
`${shipment.customer.city}, ${shipment.customer.state} ${shipment.customer.postalCode}`.trim(),
shipment.customer.country,
shipment.customerPhone,
shipment.customerEmail,
].filter((line) => line.trim().length > 0);
const totalQuantity = shipment.lines.reduce((sum, line) => sum + line.quantity, 0);
const rows = shipment.lines.map((line) => `
<tr>
<td>${escapeHtml(line.itemSku)}</td>
<td><div class="item-name">${escapeHtml(line.itemName)}</div><div class="item-desc">${escapeHtml(line.description || "")}</div></td>
<td class="number">${line.quantity}</td>
<td class="number">${escapeHtml(line.unitOfMeasure)}</td>
</tr>
`).join("");
return renderPdf(`
<html>
<head>
<style>
@page { margin: 16mm; }
body { font-family: ${company.theme.fontFamily}, Arial, sans-serif; color: #1b1f29; font-size: 12px; }
.page { display: flex; flex-direction: column; gap: 16px; }
.header { display: flex; justify-content: space-between; gap: 24px; border-bottom: 2px solid ${company.theme.primaryColor}; padding-bottom: 16px; }
.brand h1 { margin: 0; font-size: 24px; color: ${company.theme.primaryColor}; }
.brand p { margin: 6px 0 0; color: #5a6a85; line-height: 1.45; }
.meta { min-width: 320px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px 18px; }
.label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: #5a6a85; }
.value { margin-top: 4px; font-size: 13px; font-weight: 600; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.card { border: 1px solid #d7deeb; border-radius: 14px; padding: 14px 16px; }
.card-title { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: #5a6a85; margin-bottom: 8px; }
.stack { display: flex; flex-direction: column; gap: 4px; }
table { width: 100%; border-collapse: collapse; }
thead th { text-align: left; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: #5a6a85; background: #f4f7fb; padding: 10px 12px; border-bottom: 1px solid #d7deeb; }
tbody td { padding: 12px; border-bottom: 1px solid #e6ebf3; vertical-align: top; }
.number { text-align: right; white-space: nowrap; }
.item-name { font-weight: 600; }
.item-desc { margin-top: 4px; color: #5a6a85; font-size: 11px; }
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.summary-card { border: 1px solid #d7deeb; border-radius: 14px; padding: 12px 14px; }
.notes { border: 1px solid #d7deeb; border-radius: 14px; padding: 14px 16px; min-height: 72px; white-space: pre-line; }
</style>
</head>
<body>
<div class="page">
<div class="header">
<div class="brand">
<h1>${escapeHtml(company.companyName)}</h1>
<p>Bill of Lading</p>
</div>
<div class="meta">
<div><div class="label">Shipment</div><div class="value">${escapeHtml(shipment.shipmentNumber)}</div></div>
<div><div class="label">Sales Order</div><div class="value">${escapeHtml(shipment.salesOrderNumber)}</div></div>
<div><div class="label">Ship Date</div><div class="value">${escapeHtml(formatDate(shipment.shipDate))}</div></div>
<div><div class="label">Status</div><div class="value">${escapeHtml(shipment.status)}</div></div>
<div><div class="label">Carrier</div><div class="value">${escapeHtml(shipment.carrier || "Not set")}</div></div>
<div><div class="label">Tracking</div><div class="value">${escapeHtml(shipment.trackingNumber || "Not set")}</div></div>
</div>
</div>
<div class="grid">
<div class="card">
<div class="card-title">Shipper</div>
<div class="stack">${shipperLines.map((line) => `<div>${escapeHtml(line)}</div>`).join("")}</div>
</div>
<div class="card">
<div class="card-title">Consignee</div>
<div class="stack">${consigneeLines.map((line) => `<div>${escapeHtml(line)}</div>`).join("")}</div>
</div>
</div>
<div class="summary">
<div class="summary-card"><div class="label">Packages</div><div class="value">${shipment.packageCount}</div></div>
<div class="summary-card"><div class="label">Line Count</div><div class="value">${shipment.lines.length}</div></div>
<div class="summary-card"><div class="label">Total Qty</div><div class="value">${totalQuantity}</div></div>
<div class="summary-card"><div class="label">Service</div><div class="value">${escapeHtml(shipment.serviceLevel || "Not set")}</div></div>
</div>
<table>
<thead>
<tr>
<th style="width: 18%;">SKU</th>
<th>Description</th>
<th style="width: 12%;" class="number">Qty</th>
<th style="width: 10%;" class="number">UOM</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
<div class="notes"><div class="card-title">Logistics Notes</div>${escapeHtml(shipment.notes || "No shipment notes recorded.")}</div>
</div>
</body>
</html>
`);
}
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);
}
);

View File

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

View File

@@ -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<ProjectCustomerOptionDto[]> {
const customers = await prisma.customer.findMany({
where: {
status: {
not: "INACTIVE",
},
},
select: {
id: true,
name: true,
email: true,
},
orderBy: [{ name: "asc" }],
});
return customers;
}
export async function listProjectOwnerOptions(): Promise<ProjectOwnerOptionDto[]> {
const users = await prisma.user.findMany({
where: {
isActive: true,
},
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
orderBy: [{ firstName: "asc" }, { lastName: "asc" }],
});
return users.map((user) => ({
id: user.id,
fullName: `${user.firstName} ${user.lastName}`.trim(),
email: user.email,
}));
}
export async function listProjectQuoteOptions(customerId?: string | null): Promise<ProjectDocumentOptionDto[]> {
const quotes = await prisma.salesQuote.findMany({
where: {
...(customerId ? { customerId } : {}),
},
include: {
customer: {
select: {
name: true,
},
},
},
orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }],
});
return quotes.map((quote) => ({
id: quote.id,
documentNumber: quote.documentNumber,
customerName: quote.customer.name,
status: quote.status,
}));
}
export async function listProjectOrderOptions(customerId?: string | null): Promise<ProjectDocumentOptionDto[]> {
const orders = await prisma.salesOrder.findMany({
where: {
...(customerId ? { customerId } : {}),
},
include: {
customer: {
select: {
name: true,
},
},
},
orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }],
});
return orders.map((order) => ({
id: order.id,
documentNumber: order.documentNumber,
customerName: order.customer.name,
status: order.status,
}));
}
export async function listProjectShipmentOptions(customerId?: string | null): Promise<ProjectShipmentOptionDto[]> {
const shipments = await prisma.shipment.findMany({
where: {
...(customerId ? { salesOrder: { customerId } } : {}),
},
include: {
salesOrder: {
include: {
customer: {
select: {
name: true,
},
},
},
},
},
orderBy: [{ createdAt: "desc" }],
});
return shipments.map((shipment) => ({
id: shipment.id,
shipmentNumber: shipment.shipmentNumber,
salesOrderNumber: shipment.salesOrder.documentNumber,
customerName: shipment.salesOrder.customer.name,
status: shipment.status,
}));
}
export async function listProjects(filters: {
q?: string;
status?: ProjectStatus;
priority?: ProjectPriority;
customerId?: string;
ownerId?: string;
} = {}) {
const query = filters.q?.trim();
const projects = await projectModel.findMany({
where: {
...(filters.status ? { status: filters.status } : {}),
...(filters.priority ? { priority: filters.priority } : {}),
...(filters.customerId ? { customerId: filters.customerId } : {}),
...(filters.ownerId ? { ownerId: filters.ownerId } : {}),
...(query
? {
OR: [
{ projectNumber: { contains: query } },
{ name: { contains: query } },
{ customer: { name: { contains: query } } },
],
}
: {}),
},
include: buildInclude(),
orderBy: [{ dueDate: "asc" }, { updatedAt: "desc" }],
});
return projects.map((project: unknown) => mapProjectSummary(project as ProjectRecord));
}
export async function getProjectById(projectId: string) {
const project = await projectModel.findUnique({
where: { id: projectId },
include: buildInclude(),
});
return project ? mapProjectDetail(project as ProjectRecord) : null;
}
export async function createProject(payload: ProjectInput) {
const validated = await validateProjectInput(payload);
if (!validated.ok) {
return { ok: false as const, reason: validated.reason };
}
const projectNumber = await nextProjectNumber();
const created = await projectModel.create({
data: {
projectNumber,
name: payload.name.trim(),
status: payload.status,
priority: payload.priority,
customerId: payload.customerId,
salesQuoteId: payload.salesQuoteId,
salesOrderId: payload.salesOrderId,
shipmentId: payload.shipmentId,
ownerId: payload.ownerId,
dueDate: payload.dueDate ? new Date(payload.dueDate) : null,
notes: payload.notes,
},
select: {
id: true,
},
});
const project = await getProjectById(created.id);
return project ? { ok: true as const, project } : { ok: false as const, reason: "Unable to load saved project." };
}
export async function updateProject(projectId: string, payload: ProjectInput) {
const existing = await projectModel.findUnique({
where: { id: projectId },
select: { id: true },
});
if (!existing) {
return { ok: false as const, reason: "Project was not found." };
}
const validated = await validateProjectInput(payload);
if (!validated.ok) {
return { ok: false as const, reason: validated.reason };
}
await projectModel.update({
where: { id: projectId },
data: {
name: payload.name.trim(),
status: payload.status,
priority: payload.priority,
customerId: payload.customerId,
salesQuoteId: payload.salesQuoteId,
salesOrderId: payload.salesOrderId,
shipmentId: payload.shipmentId,
ownerId: payload.ownerId,
dueDate: payload.dueDate ? new Date(payload.dueDate) : null,
notes: payload.notes,
},
select: {
id: true,
},
});
const project = await getProjectById(projectId);
return project ? { ok: true as const, project } : { ok: false as const, reason: "Unable to load saved project." };
}

View File

@@ -36,6 +36,12 @@ export interface ShipmentPackingSlipData {
}>;
}
export interface ShipmentDocumentData extends ShipmentPackingSlipData {
salesOrderId: string;
customerEmail: string;
customerPhone: string;
}
type ShipmentRecord = {
id: string;
shipmentNumber: string;
@@ -251,6 +257,16 @@ export async function updateShipmentStatus(shipmentId: string, status: ShipmentS
}
export async function getShipmentPackingSlipData(shipmentId: string): Promise<ShipmentPackingSlipData | null> {
const shipment = await getShipmentDocumentData(shipmentId);
if (!shipment) {
return null;
}
return shipment;
}
export async function getShipmentDocumentData(shipmentId: string): Promise<ShipmentDocumentData | null> {
const shipment = await prisma.shipment.findUnique({
where: { id: shipmentId },
include: {
@@ -259,6 +275,8 @@ export async function getShipmentPackingSlipData(shipmentId: string): Promise<Sh
customer: {
select: {
name: true,
email: true,
phone: true,
addressLine1: true,
addressLine2: true,
city: true,
@@ -288,6 +306,7 @@ export async function getShipmentPackingSlipData(shipmentId: string): Promise<Sh
}
return {
salesOrderId: shipment.salesOrder.id,
shipmentNumber: shipment.shipmentNumber,
status: shipment.status as ShipmentStatus,
shipDate: shipment.shipDate ? shipment.shipDate.toISOString() : null,
@@ -297,6 +316,8 @@ export async function getShipmentPackingSlipData(shipmentId: string): Promise<Sh
packageCount: shipment.packageCount,
notes: shipment.notes,
salesOrderNumber: shipment.salesOrder.documentNumber,
customerEmail: shipment.salesOrder.customer.email,
customerPhone: shipment.salesOrder.customer.phone,
customer: shipment.salesOrder.customer,
lines: shipment.salesOrder.lines.map((line) => ({
itemSku: line.item.sku,