manufacturing stabilization

This commit is contained in:
2026-03-15 11:30:10 -05:00
parent 0596970b99
commit e2254d020e
15 changed files with 492 additions and 60 deletions

View File

@@ -17,6 +17,7 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a
- CRM customers/vendors, hierarchy, contacts, lifecycle metadata, and attachments
- inventory items, BOMs, warehouses, locations, transactions, item attachments, and item pricing
- sales quotes, sales orders, and purchase orders
- purchase-order supporting documents and vendor-side purchasing visibility
- shipping shipments, packing-slip PDFs, shipping labels, bills of lading, and logistics attachments
- projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments
- manufacturing work orders with project linkage, material issue posting, completion posting, and attachments
@@ -116,11 +117,11 @@ If implementation changes invalidate those docs, update them in the same change
Near-term priorities are:
1. Vendor invoice/supporting-document attachments and broader vendor-side operational depth
2. Sales approvals and document revision history
3. Planning and gantt scheduling with live project/manufacturing data
4. Inventory transfers, reservations, and deeper stock controls
5. Broader audit-trail coverage and operational diagnostics
1. Sales approvals and document revision history
2. Planning and gantt scheduling with live project/manufacturing data
3. Inventory transfers, reservations, and deeper stock controls
4. Broader audit-trail coverage and operational diagnostics
5. Code-splitting and bundle-size reduction
When adding new modules, preserve the ability to extend the system without refactoring the existing app shell.

View File

@@ -13,12 +13,18 @@ This file is the running release and change log for MRP Codex. Keep it updated w
- Manufacturing foundation with work orders, optional project linkage, work-order attachments, and app-shell navigation entry
- BOM-based manufacturing requirement visibility plus material issue and completion posting through inventory transactions
- Dashboard manufacturing widgets for released, active, and overdue work visibility
- Purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and procurement backup files
- Vendor-detail purchasing visibility with recent purchase-order activity and PO launch shortcuts
### Changed
- The dashboard now treats Projects as a live first-class module alongside CRM, inventory, sales, and shipping
- The dashboard now treats Manufacturing as a live first-class module alongside CRM, inventory, sales, shipping, and projects
- Roadmap and project docs now treat vendor invoice/supporting-document attachments and broader vendor-side operational depth as the next active priority after the manufacturing foundation slice
- Project editing now uses searchable pickers for customer, owner, quote, sales-order, and shipment linkage instead of static operational dropdowns
- Project detail now surfaces linked work orders and can launch pre-seeded manufacturing records
- Purchase-order detail now links back to the vendor CRM record and supports direct supporting-document management on the PO itself
- Vendor CRM detail now exposes purchasing activity and can launch pre-seeded purchase orders
- Roadmap and project docs now treat sales approvals and document revision history as the next active priority after the vendor-document and stabilization pass
## 2026-03-15

View File

@@ -20,6 +20,7 @@ This repository implements the platform foundation milestone:
- purchase orders restricted to inventory items flagged as purchasable
- purchase receiving foundation with inventory posting and receipt history
- branded sales and purchasing PDFs through the shared Puppeteer document pipeline
- purchase-order supporting documents and vendor-side purchasing visibility
- shipping shipments linked to sales orders with packing slips, shipping labels, bills of lading, and logistics attachments
- projects with customer/commercial/shipment linkage, owners, due dates, notes, attachments, and dashboard visibility
- manufacturing work orders with project linkage, material issue posting, completion posting, attachments, and dashboard visibility
@@ -58,8 +59,8 @@ This repository implements the platform foundation milestone:
## Next roadmap candidates
- vendor invoice/supporting-document attachments and broader vendor-side operational depth
- sales approvals and document revision history
- planning and gantt scheduling with live project/manufacturing data
- inventory transfers, reservations, and deeper stock controls
- broader audit and operations maturity
- code-splitting and bundle-size reduction

View File

@@ -19,6 +19,7 @@ Current foundation scope includes:
- purchase orders with searchable vendor and SKU entry, restricted to purchasable inventory items
- purchase receiving with warehouse/location posting and receipt history against purchase orders
- branded quote, sales-order, and purchase-order PDFs through the shared backend document pipeline
- purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and backup files
- shipping shipments linked to sales orders with packing slips, shipping labels, bills of lading, and logistics attachments
- projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments
- manufacturing work orders with project linkage, material issue posting, completion posting, and work-order attachments
@@ -43,11 +44,11 @@ Planned cross-module execution areas:
Near-term priorities:
1. Vendor invoice/supporting-document attachments and broader vendor-facing operational depth
2. Sales approvals and revision history
3. Planning and gantt scheduling with live project/manufacturing data
4. Inventory transfers, reservations, and deeper stock controls
5. Broader audit-trail coverage and operational diagnostics
1. Sales approvals and revision history
2. Planning and gantt scheduling with live project/manufacturing data
3. Inventory transfers, reservations, and deeper stock controls
4. Broader audit-trail coverage and operational diagnostics
5. Code-splitting and bundle-size reduction
Revisit / deferred items:
@@ -246,8 +247,8 @@ The current purchasing foundation supports:
QOL direction:
- vendor invoice/supporting-document attachments
- richer dashboard widgets for vendor queues and inbound material exceptions
- vendor-side exception tracking around acknowledgements, invoice matching, and receipt discrepancies
This module introduces `purchasing.read` and `purchasing.write` permissions. After updating the code, restart the server against the migrated database so bootstrap can upsert the new permissions onto the default administrator role.

View File

@@ -43,6 +43,8 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
- Projects foundation with customer, quote, sales-order, shipment, owner, due-date, notes, and attachment linkage
- Project list/detail/create/edit workflows and dashboard program widgets
- Manufacturing foundation with work orders, project linkage, material issue posting, completion posting, and work-order attachments
- Vendor invoice/supporting-document attachments directly on purchase orders
- Vendor-detail purchasing visibility with recent purchase-order activity
- SKU-searchable BOM component selection for inventory-scale datasets
- Theme persistence fixes and denser responsive workspace layouts
- Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens
@@ -56,7 +58,7 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
- Prisma migration execution is committed and documented, but local Windows Node 24 schema-engine behavior remains inconsistent; use Node 22 or Docker for migration execution
- The frontend bundle is functional but should be code-split later, especially around the gantt module
- CRM reporting is now functional, but broader account-role depth and downstream document rollups can still evolve later
- The current sales/purchasing/shipping foundation still does not include approvals, revisions, vendor-side attachment handling, or deeper carrier integration
- The current sales/purchasing/shipping foundation still does not include approvals, revisions, vendor exception handling, or deeper carrier integration
- The dashboard is now live-data driven, but still needs richer KPI widgets, alerts, recent-activity queues, and exception reporting as more transactional depth is added
- The new projects domain is foundational but still needs milestones, project rollups, and deeper inventory/purchasing/manufacturing tie-ins
- The new manufacturing domain is foundational but still needs routings, labor capture, work-center views, and capacity-aware planning tie-ins
@@ -115,6 +117,11 @@ QOL subfeatures:
- Branded PDF templates rendered through Puppeteer
- Attachments for vendor invoices and supporting documents
Foundation slice shipped:
- Purchase-order supporting documents through the shared attachment pipeline
- Vendor-detail purchasing visibility for recent purchase-order activity
QOL subfeatures:
- Line duplication, drag ordering, and keyboard-first line editing
@@ -251,7 +258,6 @@ QOL subfeatures:
- Local Windows Prisma migration reliability still needs a cleaner documented workflow or tooling wrapper
- Frontend bundle splitting is still deferred; the Vite chunk-size warning remains
- Sales approvals and document revision history were planned but not yet built
- Vendor invoice/supporting-document attachments still need to be added
- Inventory transactions exist, but transfers, reservations, and more advanced stock controls still need follow-up
- CRM document rollups and broader account-role depth were deferred until more downstream modules exist
- Audit-trail depth is still thin outside the current record/update flows
@@ -269,8 +275,8 @@ QOL subfeatures:
## Near-term priority order
1. Vendor invoice/supporting-document attachments and broader vendor-side operational depth
2. Sales approvals and document revision history
3. Planning and scheduling with live project/manufacturing data
4. Inventory transfers, reservations, and deeper stock controls
5. Broader audit-trail coverage and operational diagnostics
1. Sales approvals and document revision history
2. Planning and scheduling with live project/manufacturing data
3. Inventory transfers, reservations, and deeper stock controls
4. Broader audit-trail coverage and operational diagnostics
5. Code-splitting and bundle-size reduction

View File

@@ -566,11 +566,12 @@ export const api = {
token
);
},
getPurchaseOrders(token: string, filters?: { q?: string; status?: PurchaseOrderStatus }) {
getPurchaseOrders(token: string, filters?: { q?: string; status?: PurchaseOrderStatus; vendorId?: string }) {
return request<PurchaseOrderSummaryDto[]>(
`/api/v1/purchasing/orders${buildQueryString({
q: filters?.q,
status: filters?.status,
vendorId: filters?.vendorId,
})}`,
undefined,
token

View File

@@ -1,5 +1,6 @@
import { permissions } from "@mrp/shared";
import type { CrmContactDto, CrmContactEntryInput, CrmRecordDetailDto } from "@mrp/shared/dist/crm/types.js";
import type { PurchaseOrderSummaryDto } from "@mrp/shared";
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
@@ -23,6 +24,7 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
const recordId = entity === "customer" ? customerId : vendorId;
const config = crmConfigs[entity];
const [record, setRecord] = useState<CrmRecordDetailDto | null>(null);
const [relatedPurchaseOrders, setRelatedPurchaseOrders] = useState<PurchaseOrderSummaryDto[]>([]);
const [status, setStatus] = useState(`Loading ${config.singularLabel.toLowerCase()}...`);
const [contactEntryForm, setContactEntryForm] = useState<CrmContactEntryInput>(emptyCrmContactEntryInput);
const [contactEntryStatus, setContactEntryStatus] = useState("Add a timeline entry for this account.");
@@ -42,7 +44,13 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
setRecord(nextRecord);
setStatus(`${config.singularLabel} record loaded.`);
setContactEntryStatus("Add a timeline entry for this account.");
if (entity === "vendor") {
return api.getPurchaseOrders(token, { vendorId: nextRecord.id });
}
return [];
})
.then((purchaseOrders) => setRelatedPurchaseOrders(purchaseOrders))
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : `Unable to load ${config.singularLabel.toLowerCase()}.`;
setStatus(message);
@@ -250,6 +258,43 @@ export function CrmDetailPage({ entity }: CrmDetailPageProps) {
</div>
</section>
) : null}
{entity === "vendor" ? (
<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">Purchasing Activity</p>
<h4 className="mt-2 text-lg font-bold text-text">Recent purchase orders</h4>
</div>
<div className="flex flex-wrap gap-2">
{canManage ? (
<Link to={`/purchasing/orders/new?vendorId=${record.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 purchase order
</Link>
) : null}
<Link to="/purchasing/orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Open purchasing
</Link>
</div>
</div>
{relatedPurchaseOrders.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 purchase orders exist for this vendor yet.</div>
) : (
<div className="mt-6 space-y-3">
{relatedPurchaseOrders.slice(0, 8).map((order) => (
<Link key={order.id} to={`/purchasing/orders/${order.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">{order.documentNumber}</div>
<div className="mt-1 text-xs text-muted">{new Date(order.issueDate).toLocaleDateString()} · {order.lineCount} lines</div>
</div>
<div className="text-sm font-semibold text-text">${order.total.toFixed(2)}</div>
</div>
</Link>
))}
</div>
)}
</section>
) : null}
<CrmContactsPanel
entity={entity}
ownerId={record.id}

View File

@@ -10,6 +10,7 @@ interface DashboardSnapshot {
vendors: Awaited<ReturnType<typeof api.getVendors>> | null;
items: Awaited<ReturnType<typeof api.getInventoryItems>> | null;
warehouses: Awaited<ReturnType<typeof api.getWarehouses>> | null;
purchaseOrders: Awaited<ReturnType<typeof api.getPurchaseOrders>> | null;
workOrders: Awaited<ReturnType<typeof api.getWorkOrders>> | null;
quotes: Awaited<ReturnType<typeof api.getQuotes>> | null;
orders: Awaited<ReturnType<typeof api.getSalesOrders>> | null;
@@ -69,6 +70,7 @@ export function DashboardPage() {
const canReadCrm = hasPermission(user.permissions, permissions.crmRead);
const canReadInventory = hasPermission(user.permissions, permissions.inventoryRead);
const canReadPurchasing = hasPermission(user.permissions, permissions.purchasingRead);
const canReadManufacturing = hasPermission(user.permissions, permissions.manufacturingRead);
const canReadSales = hasPermission(user.permissions, permissions.salesRead);
const canReadShipping = hasPermission(user.permissions, permissions.shippingRead);
@@ -80,6 +82,7 @@ export function DashboardPage() {
canReadCrm ? api.getVendors(authToken) : Promise.resolve(null),
canReadInventory ? api.getInventoryItems(authToken) : Promise.resolve(null),
canReadInventory ? api.getWarehouses(authToken) : Promise.resolve(null),
canReadPurchasing ? api.getPurchaseOrders(authToken) : Promise.resolve(null),
canReadManufacturing ? api.getWorkOrders(authToken) : Promise.resolve(null),
canReadSales ? api.getQuotes(authToken) : Promise.resolve(null),
canReadSales ? api.getSalesOrders(authToken) : Promise.resolve(null),
@@ -102,11 +105,12 @@ export function DashboardPage() {
vendors: results[1].status === "fulfilled" ? results[1].value : null,
items: results[2].status === "fulfilled" ? results[2].value : null,
warehouses: results[3].status === "fulfilled" ? results[3].value : null,
workOrders: results[4].status === "fulfilled" ? results[4].value : null,
quotes: results[5].status === "fulfilled" ? results[5].value : null,
orders: results[6].status === "fulfilled" ? results[6].value : null,
shipments: results[7].status === "fulfilled" ? results[7].value : null,
projects: results[8].status === "fulfilled" ? results[8].value : null,
purchaseOrders: results[4].status === "fulfilled" ? results[4].value : null,
workOrders: results[5].status === "fulfilled" ? results[5].value : null,
quotes: results[6].status === "fulfilled" ? results[6].value : null,
orders: results[7].status === "fulfilled" ? results[7].value : null,
shipments: results[8].status === "fulfilled" ? results[8].value : null,
projects: results[9].status === "fulfilled" ? results[9].value : null,
refreshedAt: new Date().toISOString(),
});
setIsLoading(false);
@@ -131,6 +135,7 @@ export function DashboardPage() {
const vendors = snapshot?.vendors ?? [];
const items = snapshot?.items ?? [];
const warehouses = snapshot?.warehouses ?? [];
const purchaseOrders = snapshot?.purchaseOrders ?? [];
const workOrders = snapshot?.workOrders ?? [];
const quotes = snapshot?.quotes ?? [];
const orders = snapshot?.orders ?? [];
@@ -140,6 +145,7 @@ export function DashboardPage() {
const accessibleModules = [
snapshot?.customers !== null || snapshot?.vendors !== null,
snapshot?.items !== null || snapshot?.warehouses !== null,
snapshot?.purchaseOrders !== null,
snapshot?.workOrders !== null,
snapshot?.quotes !== null || snapshot?.orders !== null,
snapshot?.shipments !== null,
@@ -159,6 +165,11 @@ export function DashboardPage() {
const warehouseCount = warehouses.length;
const locationCount = sumNumber(warehouses.map((warehouse) => warehouse.locationCount));
const purchaseOrderCount = purchaseOrders.length;
const openPurchaseOrderCount = purchaseOrders.filter((order) => order.status !== "CLOSED").length;
const issuedPurchaseOrderCount = purchaseOrders.filter((order) => order.status === "ISSUED" || order.status === "APPROVED").length;
const purchaseOrderValue = sumNumber(purchaseOrders.map((order) => order.total));
const workOrderCount = workOrders.length;
const activeWorkOrderCount = workOrders.filter((workOrder) => workOrder.status === "RELEASED" || workOrder.status === "IN_PROGRESS" || workOrder.status === "ON_HOLD").length;
const releasedWorkOrderCount = workOrders.filter((workOrder) => workOrder.status === "RELEASED").length;
@@ -192,6 +203,7 @@ export function DashboardPage() {
...vendors.map((vendor) => vendor.updatedAt),
...items.map((item) => item.updatedAt),
...warehouses.map((warehouse) => warehouse.updatedAt),
...purchaseOrders.map((order) => order.updatedAt),
...workOrders.map((workOrder) => workOrder.updatedAt),
...quotes.map((quote) => quote.updatedAt),
...orders.map((order) => order.updatedAt),
@@ -220,6 +232,15 @@ export function DashboardPage() {
: "Inventory metrics are permission-gated.",
tone: "border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
},
{
label: "Purchasing Queue",
value: snapshot?.purchaseOrders !== null ? `${openPurchaseOrderCount}` : "No access",
detail:
snapshot?.purchaseOrders !== null
? `${issuedPurchaseOrderCount} issued/approved and ${formatCurrency(purchaseOrderValue)} committed`
: "Purchasing metrics are permission-gated.",
tone: "border-teal-400/30 bg-teal-500/12 text-teal-700 dark:text-teal-300",
},
{
label: "Manufacturing Load",
value: snapshot?.workOrders !== null ? `${activeWorkOrderCount}` : "No access",
@@ -293,6 +314,22 @@ export function DashboardPage() {
{ label: "Open warehouses", to: "/inventory/warehouses" },
],
},
{
title: "Purchasing",
eyebrow: "Inbound Supply",
summary:
snapshot?.purchaseOrders !== null
? "Purchase orders, open commitments, and current inbound procurement load are now visible from the dashboard."
: "Purchasing read permission is required to surface procurement metrics here.",
metrics: [
{ label: "Open POs", value: snapshot?.purchaseOrders !== null ? `${openPurchaseOrderCount}` : "No access" },
{ label: "Issued", value: snapshot?.purchaseOrders !== null ? `${issuedPurchaseOrderCount}` : "No access" },
{ label: "Committed", value: snapshot?.purchaseOrders !== null ? formatCurrency(purchaseOrderValue) : "No access" },
],
links: [
{ label: "Open purchase orders", to: "/purchasing/orders" },
],
},
{
title: "Manufacturing",
eyebrow: "Execution Load",
@@ -364,9 +401,9 @@ export function DashboardPage() {
];
const futureModules = [
"Vendor invoice attachments and supplier exception queues",
"Stock transfers, allocations, and cycle counts",
"Planning timeline, milestones, and dependency views",
"Sales approvals, revisions, and change history",
"Audit trails, diagnostics, and system health checks",
];
@@ -388,8 +425,8 @@ export function DashboardPage() {
</div>
</div>
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted">
This landing page now reads directly from live CRM, inventory, manufacturing, sales, shipping, and project data. It is intentionally
modular so future purchasing, planning, and audit slices can slot into the same command surface without a redesign.
This landing page now reads directly from live CRM, inventory, purchasing, manufacturing, sales, shipping, and project data. It is
intentionally modular so future planning, approvals, and audit slices can slot into the same command surface without a redesign.
</p>
<div className="mt-5 grid gap-2 sm:grid-cols-3">
<div className="rounded-2xl border border-line/70 bg-surface/80 px-2 py-2">
@@ -415,6 +452,9 @@ export function DashboardPage() {
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/inventory/items">
Open inventory
</Link>
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/purchasing/orders">
Open purchasing
</Link>
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/projects">
Open projects
</Link>
@@ -437,7 +477,7 @@ export function DashboardPage() {
</div>
</div>
</section>
<section className="grid gap-3 xl:grid-cols-6">
<section className="grid gap-3 xl:grid-cols-7">
{metricCards.map((card) => (
<article key={card.label} 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">{card.label}</p>
@@ -449,7 +489,7 @@ export function DashboardPage() {
</article>
))}
</section>
<section className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-6">
<section className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-7">
{modulePanels.map((panel) => (
<article key={panel.title} 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">{panel.eyebrow}</p>
@@ -473,7 +513,7 @@ export function DashboardPage() {
</article>
))}
</section>
<section className="grid gap-3 xl:grid-cols-5">
<section className="grid gap-3 xl:grid-cols-6">
<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">Inventory Watch</p>
<h4 className="mt-2 text-lg font-bold text-text">Master data pressure points</h4>
@@ -510,6 +550,24 @@ export function DashboardPage() {
</div>
</div>
</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">Purchasing Watch</p>
<h4 className="mt-2 text-lg font-bold text-text">Inbound supply and commitment load</h4>
<div className="mt-4 grid gap-2">
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Total purchase orders</span>
<span className="font-semibold text-text">{snapshot?.purchaseOrders !== null ? `${purchaseOrderCount}` : "No access"}</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Open queue</span>
<span className="font-semibold text-text">{snapshot?.purchaseOrders !== null ? `${openPurchaseOrderCount}` : "No access"}</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Committed value</span>
<span className="font-semibold text-text">{snapshot?.purchaseOrders !== null ? formatCurrency(purchaseOrderValue) : "No access"}</span>
</div>
</div>
</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">Manufacturing Watch</p>
<h4 className="mt-2 text-lg font-bold text-text">Build execution and due-date pressure</h4>

View File

@@ -5,7 +5,7 @@ import type {
} from "@mrp/shared";
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
@@ -15,6 +15,8 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
const { token } = useAuth();
const navigate = useNavigate();
const { workOrderId } = useParams();
const [searchParams] = useSearchParams();
const seededProjectId = searchParams.get("projectId");
const [form, setForm] = useState<WorkOrderInput>(emptyWorkOrderInput);
const [itemOptions, setItemOptions] = useState<ManufacturingItemOptionDto[]>([]);
const [projectOptions, setProjectOptions] = useState<ManufacturingProjectOptionDto[]>([]);
@@ -32,9 +34,18 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
}
api.getManufacturingItemOptions(token).then(setItemOptions).catch(() => setItemOptions([]));
api.getManufacturingProjectOptions(token).then(setProjectOptions).catch(() => setProjectOptions([]));
api.getManufacturingProjectOptions(token).then((options) => {
setProjectOptions(options);
if (mode === "create" && seededProjectId) {
const seededProject = options.find((option) => option.id === seededProjectId);
if (seededProject) {
setForm((current) => ({ ...current, projectId: seededProject.id }));
setProjectSearchTerm(`${seededProject.projectNumber} - ${seededProject.name}`);
}
}
}).catch(() => setProjectOptions([]));
api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([]));
}, [token]);
}, [mode, seededProjectId, token]);
useEffect(() => {
if (!token || mode !== "edit" || !workOrderId) {

View File

@@ -1,5 +1,6 @@
import { permissions } from "@mrp/shared";
import type { ProjectDetailDto } from "@mrp/shared/dist/projects/types.js";
import type { WorkOrderSummaryDto } from "@mrp/shared";
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
@@ -13,6 +14,7 @@ export function ProjectDetailPage() {
const { token, user } = useAuth();
const { projectId } = useParams();
const [project, setProject] = useState<ProjectDetailDto | null>(null);
const [workOrders, setWorkOrders] = useState<WorkOrderSummaryDto[]>([]);
const [status, setStatus] = useState("Loading project...");
const canManage = user?.permissions.includes(permissions.projectsWrite) ?? false;
@@ -26,7 +28,9 @@ export function ProjectDetailPage() {
.then((nextProject) => {
setProject(nextProject);
setStatus("Project loaded.");
return api.getWorkOrders(token, { projectId: nextProject.id });
})
.then((nextWorkOrders) => setWorkOrders(nextWorkOrders))
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load project.";
setStatus(message);
@@ -93,6 +97,36 @@ export function ProjectDetailPage() {
</div>
</div>
</section>
<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">Manufacturing Links</p>
<p className="mt-2 text-sm text-muted">Work orders already linked to this project.</p>
</div>
{canManage ? (
<Link to={`/manufacturing/work-orders/new?projectId=${project.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 work order
</Link>
) : null}
</div>
{workOrders.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 work orders are linked to this project yet.</div>
) : (
<div className="mt-6 space-y-3">
{workOrders.map((workOrder) => (
<Link key={workOrder.id} to={`/manufacturing/work-orders/${workOrder.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">{workOrder.workOrderNumber}</div>
<div className="mt-1 text-xs text-muted">{workOrder.itemSku} · {workOrder.completedQuantity}/{workOrder.quantity} complete</div>
</div>
<div className="text-sm font-semibold text-text">{workOrder.status.replace("_", " ")}</div>
</div>
</Link>
))}
</div>
)}
</section>
<FileAttachmentsPanel
ownerType="PROJECT"
ownerId={project.id}

View File

@@ -22,6 +22,16 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
const [quoteOptions, setQuoteOptions] = useState<ProjectDocumentOptionDto[]>([]);
const [orderOptions, setOrderOptions] = useState<ProjectDocumentOptionDto[]>([]);
const [shipmentOptions, setShipmentOptions] = useState<ProjectShipmentOptionDto[]>([]);
const [customerSearchTerm, setCustomerSearchTerm] = useState("");
const [ownerSearchTerm, setOwnerSearchTerm] = useState("");
const [quoteSearchTerm, setQuoteSearchTerm] = useState("");
const [orderSearchTerm, setOrderSearchTerm] = useState("");
const [shipmentSearchTerm, setShipmentSearchTerm] = useState("");
const [customerPickerOpen, setCustomerPickerOpen] = useState(false);
const [ownerPickerOpen, setOwnerPickerOpen] = useState(false);
const [quotePickerOpen, setQuotePickerOpen] = useState(false);
const [orderPickerOpen, setOrderPickerOpen] = useState(false);
const [shipmentPickerOpen, setShipmentPickerOpen] = useState(false);
const [status, setStatus] = useState(mode === "create" ? "Create a new project." : "Loading project...");
const [isSaving, setIsSaving] = useState(false);
@@ -66,6 +76,11 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
dueDate: project.dueDate,
notes: project.notes,
});
setCustomerSearchTerm(project.customerName);
setOwnerSearchTerm(project.ownerName ?? "");
setQuoteSearchTerm(project.salesQuoteNumber ?? "");
setOrderSearchTerm(project.salesOrderNumber ?? "");
setShipmentSearchTerm(project.shipmentNumber ?? "");
setStatus("Project loaded.");
})
.catch((error: unknown) => {
@@ -88,6 +103,20 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
}));
}
function restoreSearchTerms() {
const selectedCustomer = customerOptions.find((customer) => customer.id === form.customerId);
const selectedOwner = ownerOptions.find((owner) => owner.id === form.ownerId);
const selectedQuote = quoteOptions.find((quote) => quote.id === form.salesQuoteId);
const selectedOrder = orderOptions.find((order) => order.id === form.salesOrderId);
const selectedShipment = shipmentOptions.find((shipment) => shipment.id === form.shipmentId);
setCustomerSearchTerm(selectedCustomer?.name ?? "");
setOwnerSearchTerm(selectedOwner?.fullName ?? "");
setQuoteSearchTerm(selectedQuote?.documentNumber ?? "");
setOrderSearchTerm(selectedOrder?.documentNumber ?? "");
setShipmentSearchTerm(selectedShipment?.shipmentNumber ?? "");
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) {
@@ -128,10 +157,47 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Customer</span>
<select value={form.customerId} onChange={(event) => updateField("customerId", 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">
<option value="">Select customer</option>
{customerOptions.map((customer) => <option key={customer.id} value={customer.id}>{customer.name}</option>)}
</select>
<div className="relative">
<input
value={customerSearchTerm}
onChange={(event) => {
setCustomerSearchTerm(event.target.value);
updateField("customerId", "");
setCustomerPickerOpen(true);
}}
onFocus={() => setCustomerPickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setCustomerPickerOpen(false);
restoreSearchTerms();
}, 120)}
placeholder="Search customer"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{customerPickerOpen ? (
<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">
{customerOptions
.filter((customer) => {
const query = customerSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return customer.name.toLowerCase().includes(query) || customer.email.toLowerCase().includes(query);
})
.slice(0, 12)
.map((customer) => (
<button key={customer.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("customerId", customer.id);
setCustomerSearchTerm(customer.name);
setCustomerPickerOpen(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">{customer.name}</div>
<div className="mt-1 text-xs text-muted">{customer.email}</div>
</button>
))}
</div>
) : null}
</div>
</label>
</div>
<div className="grid gap-3 xl:grid-cols-4">
@@ -149,10 +215,55 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Owner</span>
<select value={form.ownerId ?? ""} onChange={(event) => updateField("ownerId", event.target.value || null)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
<option value="">Unassigned</option>
{ownerOptions.map((owner) => <option key={owner.id} value={owner.id}>{owner.fullName}</option>)}
</select>
<div className="relative">
<input
value={ownerSearchTerm}
onChange={(event) => {
setOwnerSearchTerm(event.target.value);
updateField("ownerId", null);
setOwnerPickerOpen(true);
}}
onFocus={() => setOwnerPickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setOwnerPickerOpen(false);
restoreSearchTerms();
}, 120)}
placeholder="Search owner"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{ownerPickerOpen ? (
<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">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("ownerId", null);
setOwnerSearchTerm("");
setOwnerPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">Unassigned</div>
</button>
{ownerOptions
.filter((owner) => {
const query = ownerSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return owner.fullName.toLowerCase().includes(query) || owner.email.toLowerCase().includes(query);
})
.slice(0, 12)
.map((owner) => (
<button key={owner.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("ownerId", owner.id);
setOwnerSearchTerm(owner.fullName);
setOwnerPickerOpen(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">{owner.fullName}</div>
<div className="mt-1 text-xs text-muted">{owner.email}</div>
</button>
))}
</div>
) : null}
</div>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Due date</span>
@@ -162,24 +273,159 @@ export function ProjectFormPage({ mode }: { mode: "create" | "edit" }) {
<div className="grid gap-3 xl:grid-cols-3">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quote</span>
<select value={form.salesQuoteId ?? ""} onChange={(event) => updateField("salesQuoteId", event.target.value || null)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
<option value="">No linked quote</option>
{quoteOptions.map((quote) => <option key={quote.id} value={quote.id}>{quote.documentNumber}</option>)}
</select>
<div className="relative">
<input
value={quoteSearchTerm}
onChange={(event) => {
setQuoteSearchTerm(event.target.value);
updateField("salesQuoteId", null);
setQuotePickerOpen(true);
}}
onFocus={() => setQuotePickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setQuotePickerOpen(false);
restoreSearchTerms();
}, 120)}
placeholder="Search quote"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{quotePickerOpen ? (
<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">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("salesQuoteId", null);
setQuoteSearchTerm("");
setQuotePickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">No linked quote</div>
</button>
{quoteOptions
.filter((quote) => {
const query = quoteSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return quote.documentNumber.toLowerCase().includes(query) || quote.customerName.toLowerCase().includes(query) || quote.status.toLowerCase().includes(query);
})
.slice(0, 12)
.map((quote) => (
<button key={quote.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("salesQuoteId", quote.id);
setQuoteSearchTerm(quote.documentNumber);
setQuotePickerOpen(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">{quote.documentNumber}</div>
<div className="mt-1 text-xs text-muted">{quote.customerName} · {quote.status}</div>
</button>
))}
</div>
) : null}
</div>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Sales order</span>
<select value={form.salesOrderId ?? ""} onChange={(event) => updateField("salesOrderId", event.target.value || null)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
<option value="">No linked sales order</option>
{orderOptions.map((order) => <option key={order.id} value={order.id}>{order.documentNumber}</option>)}
</select>
<div className="relative">
<input
value={orderSearchTerm}
onChange={(event) => {
setOrderSearchTerm(event.target.value);
updateField("salesOrderId", null);
setOrderPickerOpen(true);
}}
onFocus={() => setOrderPickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setOrderPickerOpen(false);
restoreSearchTerms();
}, 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">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("salesOrderId", null);
setOrderSearchTerm("");
setOrderPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">No linked sales order</div>
</button>
{orderOptions
.filter((order) => {
const query = orderSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return order.documentNumber.toLowerCase().includes(query) || order.customerName.toLowerCase().includes(query) || order.status.toLowerCase().includes(query);
})
.slice(0, 12)
.map((order) => (
<button key={order.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("salesOrderId", order.id);
setOrderSearchTerm(order.documentNumber);
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">{order.documentNumber}</div>
<div className="mt-1 text-xs text-muted">{order.customerName} · {order.status}</div>
</button>
))}
</div>
) : null}
</div>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Shipment</span>
<select value={form.shipmentId ?? ""} onChange={(event) => updateField("shipmentId", event.target.value || null)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
<option value="">No linked shipment</option>
{shipmentOptions.map((shipment) => <option key={shipment.id} value={shipment.id}>{shipment.shipmentNumber}</option>)}
</select>
<div className="relative">
<input
value={shipmentSearchTerm}
onChange={(event) => {
setShipmentSearchTerm(event.target.value);
updateField("shipmentId", null);
setShipmentPickerOpen(true);
}}
onFocus={() => setShipmentPickerOpen(true)}
onBlur={() => window.setTimeout(() => {
setShipmentPickerOpen(false);
restoreSearchTerms();
}, 120)}
placeholder="Search shipment"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{shipmentPickerOpen ? (
<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">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("shipmentId", null);
setShipmentSearchTerm("");
setShipmentPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">No linked shipment</div>
</button>
{shipmentOptions
.filter((shipment) => {
const query = shipmentSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return shipment.shipmentNumber.toLowerCase().includes(query) || shipment.salesOrderNumber.toLowerCase().includes(query) || shipment.customerName.toLowerCase().includes(query) || shipment.status.toLowerCase().includes(query);
})
.slice(0, 12)
.map((shipment) => (
<button key={shipment.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("shipmentId", shipment.id);
setShipmentSearchTerm(shipment.shipmentNumber);
setShipmentPickerOpen(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">{shipment.shipmentNumber}</div>
<div className="mt-1 text-xs text-muted">{shipment.salesOrderNumber} · {shipment.status}</div>
</button>
))}
</div>
) : null}
</div>
</label>
</div>
<label className="block">

View File

@@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
import { api, ApiError } from "../../lib/api";
import { emptyPurchaseReceiptInput, purchaseStatusOptions } from "./config";
import { PurchaseStatusBadge } from "./PurchaseStatusBadge";
@@ -248,7 +249,7 @@ export function PurchaseDetailPage() {
<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">Vendor</p>
<dl className="mt-5 grid gap-3">
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account</dt><dd className="mt-1 text-sm text-text">{activeDocument.vendorName}</dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Account</dt><dd className="mt-1 text-sm text-text"><Link to={`/crm/vendors/${activeDocument.vendorId}`} className="hover:text-brand">{activeDocument.vendorName}</Link></dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Email</dt><dd className="mt-1 text-sm text-text">{activeDocument.vendorEmail}</dd></div>
</dl>
</article>
@@ -422,6 +423,14 @@ export function PurchaseDetailPage() {
)}
</article>
</section>
<FileAttachmentsPanel
ownerType="PURCHASE_ORDER"
ownerId={activeDocument.id}
eyebrow="Supporting Documents"
title="Vendor invoices and backup"
description="Store vendor invoices, acknowledgements, certifications, and supporting procurement documents directly on the purchase order."
emptyMessage="No vendor supporting documents have been uploaded for this purchase order yet."
/>
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
</section>
);

View File

@@ -1,6 +1,6 @@
import type { InventoryItemOptionDto, PurchaseLineInput, PurchaseOrderInput, PurchaseVendorOptionDto } from "@mrp/shared";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
@@ -11,6 +11,8 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
const { token } = useAuth();
const navigate = useNavigate();
const { orderId } = useParams();
const [searchParams] = useSearchParams();
const seededVendorId = searchParams.get("vendorId");
const [form, setForm] = useState<PurchaseOrderInput>(emptyPurchaseOrderInput);
const [status, setStatus] = useState(mode === "create" ? "Create a new purchase order." : "Loading purchase order...");
const [vendors, setVendors] = useState<PurchaseVendorOptionDto[]>([]);
@@ -30,9 +32,18 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
return;
}
api.getPurchaseVendors(token).then(setVendors).catch(() => setVendors([]));
api.getPurchaseVendors(token).then((nextVendors) => {
setVendors(nextVendors);
if (mode === "create" && seededVendorId) {
const seededVendor = nextVendors.find((vendor) => vendor.id === seededVendorId);
if (seededVendor) {
setForm((current: PurchaseOrderInput) => ({ ...current, vendorId: seededVendor.id }));
setVendorSearchTerm(seededVendor.name);
}
}
}).catch(() => setVendors([]));
api.getInventoryItemOptions(token).then((options) => setItemOptions(options.filter((option: InventoryItemOptionDto) => option.isPurchasable))).catch(() => setItemOptions([]));
}, [token]);
}, [mode, seededVendorId, token]);
useEffect(() => {
if (!token || mode !== "edit" || !orderId) {

View File

@@ -37,6 +37,7 @@ const purchaseOrderSchema = z.object({
const purchaseListQuerySchema = z.object({
q: z.string().optional(),
status: z.enum(purchaseOrderStatuses).optional(),
vendorId: z.string().optional(),
});
const purchaseStatusUpdateSchema = z.object({

View File

@@ -480,11 +480,12 @@ export async function listPurchaseVendorOptions(): Promise<PurchaseVendorOptionD
return vendors;
}
export async function listPurchaseOrders(filters: { q?: string; status?: PurchaseOrderStatus } = {}) {
export async function listPurchaseOrders(filters: { q?: string; status?: PurchaseOrderStatus; vendorId?: string } = {}) {
const query = filters.q?.trim();
const records = await purchaseOrderModel.findMany({
where: {
...(filters.status ? { status: filters.status } : {}),
...(filters.vendorId ? { vendorId: filters.vendorId } : {}),
...(query
? {
OR: [