Compare commits
20 Commits
00a4da346f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e65ed892f1 | |||
|
|
ce2d52db53 | ||
|
|
39fd876d51 | ||
|
|
0c3b2cf6fe | ||
|
|
6423dfb91b | ||
|
|
26b188de87 | ||
|
|
0b43b4ebf5 | ||
|
|
3c312733ca | ||
|
|
9d54dc2ecd | ||
|
|
b762c70238 | ||
|
|
9562c1cc9c | ||
| 3eba7c5fa6 | |||
| 4949b6033f | |||
| cf54e4ba58 | |||
| 061057339b | |||
| 7b65fe06cf | |||
| d22e715f00 | |||
| 5fdd366bc3 | |||
| afad00bf46 | |||
| 28ea1ee6b9 |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -33,6 +33,20 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
|
|||||||
- Continued density standardization across shared attachment and revision-comparison surfaces, and changed inventory item-editor exit actions to hard navigation so SKU master and cancel transitions no longer depend on client-side router state
|
- Continued density standardization across shared attachment and revision-comparison surfaces, and changed inventory item-editor exit actions to hard navigation so SKU master and cancel transitions no longer depend on client-side router state
|
||||||
- Continued density standardization across the SKU master builder and planning workbench, including tighter tree and board panels, denser exception and focus surfaces, shorter empty states, and less helper copy on those operational screens
|
- Continued density standardization across the SKU master builder and planning workbench, including tighter tree and board panels, denser exception and focus surfaces, shorter empty states, and less helper copy on those operational screens
|
||||||
- Continued density standardization across warehouse list/detail/editor screens and the manufacturing station surface, including tighter status blocks, denser location/station cards, and removal of older roomy header patterns
|
- Continued density standardization across warehouse list/detail/editor screens and the manufacturing station surface, including tighter status blocks, denser location/station cards, and removal of older roomy header patterns
|
||||||
|
- Continued density standardization across company settings and deeper manufacturing detail surfaces, including tighter admin/profile/theme sections, denser work-order execution panels, and compact issue/completion history cards
|
||||||
|
- Continued density standardization across project cockpit/detail internals, including tighter cockpit cards, denser purchasing and readiness panels, and compact milestone, manufacturing-link, and activity-timeline surfaces
|
||||||
|
- Continued density standardization across admin diagnostics, user management, and CRM contacts, including tighter filter/forms, denser summary cards, and compact contact/account management surfaces
|
||||||
|
- Workbench usability pass with sticky planner controls, stronger selected-row and selected-day state, clearer heatmap/day context, and more explicit dispatch-oriented action affordances
|
||||||
|
- Workbench usability depth with keyboard row navigation, enter-to-open behavior, escape-to-clear, and inline readiness/shortage/hold signal pills across planner rows and day-detail cards
|
||||||
|
- Workbench dispatch workflow depth with saved planner views, a release queue for visible ready work, queued-record visibility in the sticky control bar, and batch release directly from the workbench
|
||||||
|
- Workbench batch operation rebalance with multi-operation selection, sticky-bar batch reschedule controls, station reassignment across selected operations, and selected-operation visibility in row signals and focus context
|
||||||
|
- Workbench conflict-intelligence pass with projected batch target load, overload warnings before batch station moves, and best-alternate-station suggestions inside the sticky rebalance controls
|
||||||
|
- Workbench date-aware slot guidance using station working-day calendars and queue settings to suggest the next workable batch landing dates directly from the sticky rebalance controls
|
||||||
|
- Planning timeline now includes station day-load rollups, and Workbench slot suggestions use that server-backed per-day capacity data instead of only summary-level utilization heuristics
|
||||||
|
- Workbench now surfaces day-level capacity directly in the planner, including hot-station day counts on heatmap cells, selected-day station load breakdowns, and per-station hot-day chips in station grouping mode
|
||||||
|
- Workbench exception prioritization now scores and ranks projects, work orders, agenda rows, and dispatch exceptions by lateness, blockage, shortage, readiness, and overload pressure, with inline priority chips for faster triage
|
||||||
|
- Workbench now surfaces top-priority action lanes for `DO NOW`, `UNBLOCK`, and `RELEASE READY` records so planners can jump straight into ranked dispatch queues before working deeper lists
|
||||||
|
- Workbench action lanes now support direct follow-through from the lane cards themselves, including queue-release and the first inline build/buy/open actions without requiring a second step into the focus drawer
|
||||||
- Project-side milestone and work-order rollups surfaced on project list and detail pages
|
- Project-side milestone and work-order rollups surfaced on project list and detail pages
|
||||||
- Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form
|
- Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form
|
||||||
- Thumbnail image attachment staging on inventory item create/edit pages, with upload-on-save and replacement/removal support
|
- Thumbnail image attachment staging on inventory item create/edit pages, with upload-on-save and replacement/removal support
|
||||||
@@ -89,6 +103,7 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- Shipping-label PDFs now render inside an explicit single-page 4x6 canvas with tighter print-safe spacing and overflow-safe text wrapping to prevent second-sheet runover on label printers
|
||||||
- Project records now persist milestone plans directly on create/edit instead of treating schedule checkpoints as freeform notes only
|
- Project records now persist milestone plans directly on create/edit instead of treating schedule checkpoints as freeform notes only
|
||||||
- Company theme colors and font now persist correctly across refresh through startup brand-profile hydration in the frontend theme provider
|
- Company theme colors and font now persist correctly across refresh through startup brand-profile hydration in the frontend theme provider
|
||||||
- Demand-planning purchase-order draft generation now links sales-order lines only when the purchase item matches the originating sales item
|
- Demand-planning purchase-order draft generation now links sales-order lines only when the purchase item matches the originating sales item
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ This file tracks work that still needs to be completed. Shipped phase history an
|
|||||||
|
|
||||||
### Planning and scheduling
|
### Planning and scheduling
|
||||||
|
|
||||||
|
- Standardize dense UI primitives and shared page shells so future Workbench, dashboard, and operational screens reuse the same cards, filter bars, empty states, and section wrappers instead of reintroducing ad hoc layout patterns
|
||||||
- Task dependencies, milestones, and progress updates
|
- Task dependencies, milestones, and progress updates
|
||||||
- Manufacturing calendar views and deeper bottleneck visibility beyond the shipped station load and overload workbench summaries
|
- Manufacturing calendar views and deeper bottleneck visibility beyond the shipped station load and overload workbench summaries
|
||||||
- Labor and machine scheduling support beyond the shipped station calendar/capacity foundation
|
- Labor and machine scheduling support beyond the shipped station calendar/capacity foundation
|
||||||
|
|||||||
@@ -28,6 +28,10 @@
|
|||||||
.module-title {
|
.module-title {
|
||||||
@apply mt-1 text-xl font-bold uppercase tracking-[0.08em] text-text;
|
@apply mt-1 text-xl font-bold uppercase tracking-[0.08em] text-text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.planner-sticky-bar {
|
||||||
|
@apply sticky top-3 z-20 rounded-[18px] border border-line/70 bg-surface/90 p-3 shadow-panel backdrop-blur;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -58,12 +58,11 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="min-w-0 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel min-w-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Contacts</p>
|
<p className="section-kicker">CONTACTS</p>
|
||||||
<h4 className="mt-2 text-lg font-bold text-text">People on this account</h4>
|
<div className="mt-3 space-y-2">
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{contacts.length === 0 ? (
|
{contacts.length === 0 ? (
|
||||||
<div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
<div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
||||||
No contacts have been added yet.
|
No contacts have been added yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -72,7 +71,7 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
|
|||||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold text-text">
|
<div className="text-sm font-semibold text-text">
|
||||||
{contact.fullName} {contact.isPrimary ? <span className="text-brand">• Primary</span> : null}
|
{contact.fullName} {contact.isPrimary ? <span className="text-brand">- PRIMARY</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-sm text-muted">{crmContactRoleOptions.find((option) => option.value === contact.role)?.label ?? contact.role}</div>
|
<div className="mt-1 text-sm text-muted">{crmContactRoleOptions.find((option) => option.value === contact.role)?.label ?? contact.role}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,22 +85,22 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<form className="mt-5 space-y-4" onSubmit={handleSubmit}>
|
<form className="mt-3 space-y-3" onSubmit={handleSubmit}>
|
||||||
<div className="grid gap-3 xl:grid-cols-2">
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Full name</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Full name</span>
|
||||||
<input
|
<input
|
||||||
value={form.fullName}
|
value={form.fullName}
|
||||||
onChange={(event) => updateField("fullName", event.target.value)}
|
onChange={(event) => updateField("fullName", 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"
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Role</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Role</span>
|
||||||
<select
|
<select
|
||||||
value={form.role}
|
value={form.role}
|
||||||
onChange={(event) => updateField("role", event.target.value as CrmContactInput["role"])}
|
onChange={(event) => updateField("role", event.target.value as CrmContactInput["role"])}
|
||||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
>
|
>
|
||||||
{crmContactRoleOptions.map((option) => (
|
{crmContactRoleOptions.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>
|
||||||
@@ -111,24 +110,24 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Email</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Email</span>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={form.email}
|
value={form.email}
|
||||||
onChange={(event) => updateField("email", event.target.value)}
|
onChange={(event) => updateField("email", 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"
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Phone</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Phone</span>
|
||||||
<input
|
<input
|
||||||
value={form.phone}
|
value={form.phone}
|
||||||
onChange={(event) => updateField("phone", event.target.value)}
|
onChange={(event) => updateField("phone", 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"
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
|
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={form.isPrimary}
|
checked={form.isPrimary}
|
||||||
@@ -136,12 +135,12 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
|
|||||||
/>
|
/>
|
||||||
<span className="text-sm font-semibold text-text">Primary contact</span>
|
<span className="text-sm font-semibold text-text">Primary contact</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<span className="text-sm text-muted">{status}</span>
|
<span className="text-sm text-muted">{status}</span>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
|
className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{isSaving ? "Saving..." : "Add contact"}
|
{isSaving ? "Saving..." : "Add contact"}
|
||||||
</button>
|
</button>
|
||||||
@@ -151,4 +150,3 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
|
|||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -433,18 +433,18 @@ export function WorkOrderDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
<section className="grid gap-2 xl:grid-cols-6">
|
<section className="grid gap-2 xl:grid-cols-6">
|
||||||
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Planned</p><div className="mt-2 text-base font-bold text-text">{workOrder.quantity}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Planned</p><div className="mt-2 text-base font-bold text-text">{workOrder.quantity}</div></article>
|
||||||
<article className="rounded-[18px] 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">Completed</p><div className="mt-2 text-base font-bold text-text">{workOrder.completedQuantity}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Completed</p><div className="mt-1 text-base font-bold text-text">{workOrder.completedQuantity}</div></article>
|
||||||
<article className="rounded-[18px] 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">Remaining</p><div className="mt-2 text-base font-bold text-text">{workOrder.dueQuantity}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Remaining</p><div className="mt-1 text-base font-bold text-text">{workOrder.dueQuantity}</div></article>
|
||||||
<article className="rounded-[18px] 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">Project</p><div className="mt-2 text-base font-bold text-text">{workOrder.projectNumber || "Unlinked"}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Project</p><div className="mt-1 text-base font-bold text-text">{workOrder.projectNumber || "Unlinked"}</div></article>
|
||||||
<article className="rounded-[18px] 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">Operations</p><div className="mt-2 text-base font-bold text-text">{workOrder.operations.length}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operations</p><div className="mt-1 text-base font-bold text-text">{workOrder.operations.length}</div></article>
|
||||||
<article className="rounded-[18px] 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">Due Date</p><div className="mt-2 text-base font-bold text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Due Date</p><div className="mt-1 text-base font-bold text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</div></article>
|
||||||
<article className="rounded-[18px] 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">Material Shortage</p><div className="mt-2 text-base font-bold text-text">{workOrder.materialRequirements.reduce((sum, requirement) => sum + requirement.shortageQuantity, 0)}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Material Shortage</p><div className="mt-1 text-base font-bold text-text">{workOrder.materialRequirements.reduce((sum, requirement) => sum + requirement.shortageQuantity, 0)}</div></article>
|
||||||
<article className="rounded-[18px] 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">Actual Hours</p><div className="mt-2 text-base font-bold text-text">{(workOrder.totalActualMinutes / 60).toFixed(1)}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Actual Hours</p><div className="mt-1 text-base font-bold text-text">{(workOrder.totalActualMinutes / 60).toFixed(1)}</div></article>
|
||||||
</section>
|
</section>
|
||||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]">
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]">
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Execution Context</p>
|
<p className="section-kicker">EXECUTION CONTEXT</p>
|
||||||
<dl className="mt-5 grid gap-3">
|
<dl className="mt-3 grid gap-3">
|
||||||
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build item</dt><dd className="mt-1 text-sm text-text">{workOrder.itemSku} - {workOrder.itemName}</dd></div>
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build item</dt><dd className="mt-1 text-sm text-text">{workOrder.itemSku} - {workOrder.itemName}</dd></div>
|
||||||
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Item type</dt><dd className="mt-1 text-sm text-text">{workOrder.itemType}</dd></div>
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Item type</dt><dd className="mt-1 text-sm text-text">{workOrder.itemType}</dd></div>
|
||||||
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Output location</dt><dd className="mt-1 text-sm text-text">{workOrder.warehouseCode} / {workOrder.locationCode}</dd></div>
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Output location</dt><dd className="mt-1 text-sm text-text">{workOrder.warehouseCode} / {workOrder.locationCode}</dd></div>
|
||||||
@@ -452,17 +452,17 @@ export function WorkOrderDetailPage() {
|
|||||||
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Demand source</dt><dd className="mt-1 text-sm text-text">{workOrder.salesOrderNumber ?? "Not linked"}</dd></div>
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Demand source</dt><dd className="mt-1 text-sm text-text">{workOrder.salesOrderNumber ?? "Not linked"}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Work Instructions</p>
|
<p className="section-kicker">WORK INSTRUCTIONS</p>
|
||||||
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{workOrder.notes || "No work-order notes recorded."}</p>
|
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{workOrder.notes || "No work-order notes recorded."}</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Operation Plan</p>
|
<p className="section-kicker">OPERATION PLAN</p>
|
||||||
{workOrder.operations.length === 0 ? (
|
{workOrder.operations.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">This work order has no inherited station operations. Add routing steps on the item record to automate planning.</div>
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">This work order has no inherited station operations. Add routing steps on the item record to automate planning.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-5 overflow-hidden rounded-[18px] border border-line/70">
|
<div className="mt-3 overflow-hidden rounded-[18px] border border-line/70">
|
||||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
<thead className="bg-page/70">
|
<thead className="bg-page/70">
|
||||||
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||||
@@ -647,11 +647,11 @@ export function WorkOrderDetailPage() {
|
|||||||
</section>
|
</section>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<section className="grid gap-3 xl:grid-cols-2">
|
<section className="grid gap-3 xl:grid-cols-2">
|
||||||
<form onSubmit={handleIssueSubmit} className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<form onSubmit={handleIssueSubmit} className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Material Issue</p>
|
<p className="section-kicker">MATERIAL ISSUE</p>
|
||||||
<div className="mt-4 grid gap-3">
|
<div className="mt-3 grid gap-3">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Component</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Component</span>
|
||||||
<select value={issueForm.componentItemId} onChange={(event) => setIssueForm((current) => ({ ...current, componentItemId: 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">
|
<select value={issueForm.componentItemId} onChange={(event) => setIssueForm((current) => ({ ...current, componentItemId: 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 component</option>
|
<option value="">Select component</option>
|
||||||
{workOrder.materialRequirements.map((requirement) => (
|
{workOrder.materialRequirements.map((requirement) => (
|
||||||
@@ -661,7 +661,7 @@ export function WorkOrderDetailPage() {
|
|||||||
</label>
|
</label>
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Warehouse</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Warehouse</span>
|
||||||
<select value={issueForm.warehouseId} onChange={(event) => setIssueForm((current) => ({ ...current, warehouseId: event.target.value, locationId: "" }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
<select value={issueForm.warehouseId} onChange={(event) => setIssueForm((current) => ({ ...current, warehouseId: event.target.value, locationId: "" }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||||
{[...new Map(locationOptions.map((option) => [option.warehouseId, option])).values()].map((option) => (
|
{[...new Map(locationOptions.map((option) => [option.warehouseId, option])).values()].map((option) => (
|
||||||
<option key={option.warehouseId} value={option.warehouseId}>{option.warehouseCode} - {option.warehouseName}</option>
|
<option key={option.warehouseId} value={option.warehouseId}>{option.warehouseCode} - {option.warehouseName}</option>
|
||||||
@@ -669,7 +669,7 @@ export function WorkOrderDetailPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Location</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Location</span>
|
||||||
<select value={issueForm.locationId} onChange={(event) => setIssueForm((current) => ({ ...current, locationId: 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">
|
<select value={issueForm.locationId} onChange={(event) => setIssueForm((current) => ({ ...current, locationId: 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 location</option>
|
<option value="">Select location</option>
|
||||||
{filteredLocationOptions.map((option) => (
|
{filteredLocationOptions.map((option) => (
|
||||||
@@ -678,12 +678,12 @@ export function WorkOrderDetailPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Quantity</span>
|
||||||
<input type="number" min={1} step={1} value={issueForm.quantity} onChange={(event) => setIssueForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<input type="number" min={1} step={1} value={issueForm.quantity} onChange={(event) => setIssueForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span>
|
||||||
<textarea value={issueForm.notes} onChange={(event) => setIssueForm((current) => ({ ...current, notes: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<textarea value={issueForm.notes} onChange={(event) => setIssueForm((current) => ({ ...current, notes: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" disabled={isPostingIssue} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
<button type="submit" disabled={isPostingIssue} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
@@ -691,15 +691,15 @@ export function WorkOrderDetailPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<form onSubmit={handleCompletionSubmit} className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<form onSubmit={handleCompletionSubmit} className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Production Completion</p>
|
<p className="section-kicker">PRODUCTION COMPLETION</p>
|
||||||
<div className="mt-4 grid gap-3">
|
<div className="mt-3 grid gap-3">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Quantity</span>
|
||||||
<input type="number" min={1} step={1} value={completionForm.quantity} onChange={(event) => setCompletionForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<input type="number" min={1} step={1} value={completionForm.quantity} onChange={(event) => setCompletionForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span>
|
||||||
<textarea value={completionForm.notes} onChange={(event) => setCompletionForm((current) => ({ ...current, notes: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<textarea value={completionForm.notes} onChange={(event) => setCompletionForm((current) => ({ ...current, notes: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
</label>
|
</label>
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">Finished goods receipt posts back to {workOrder.warehouseCode} / {workOrder.locationCode}.</div>
|
<div className="rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">Finished goods receipt posts back to {workOrder.warehouseCode} / {workOrder.locationCode}.</div>
|
||||||
@@ -710,12 +710,12 @@ export function WorkOrderDetailPage() {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Material Requirements</p>
|
<p className="section-kicker">MATERIAL REQUIREMENTS</p>
|
||||||
{workOrder.materialRequirements.length === 0 ? (
|
{workOrder.materialRequirements.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">This build item does not currently have BOM material requirements.</div>
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">This build item does not currently have BOM material requirements.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-5 overflow-hidden rounded-[18px] border border-line/70">
|
<div className="mt-3 overflow-hidden rounded-[18px] border border-line/70">
|
||||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
<thead className="bg-page/70">
|
<thead className="bg-page/70">
|
||||||
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||||
@@ -746,14 +746,14 @@ export function WorkOrderDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
<section className="grid gap-3 xl:grid-cols-2">
|
<section className="grid gap-3 xl:grid-cols-2">
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Issue History</p>
|
<p className="section-kicker">ISSUE HISTORY</p>
|
||||||
{workOrder.materialIssues.length === 0 ? (
|
{workOrder.materialIssues.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No material issues have been posted yet.</div>
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No material issues have been posted yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{workOrder.materialIssues.map((issue) => (
|
{workOrder.materialIssues.map((issue) => (
|
||||||
<div key={issue.id} className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
|
<div key={issue.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-text">{issue.componentSku} - {issue.componentName}</div>
|
<div className="font-semibold text-text">{issue.componentSku} - {issue.componentName}</div>
|
||||||
@@ -768,14 +768,14 @@ export function WorkOrderDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Completion History</p>
|
<p className="section-kicker">COMPLETION HISTORY</p>
|
||||||
{workOrder.completions.length === 0 ? (
|
{workOrder.completions.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No production completions have been posted yet.</div>
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No production completions have been posted yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{workOrder.completions.map((completion) => (
|
{workOrder.completions.map((completion) => (
|
||||||
<div key={completion.id} className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
|
<div key={completion.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="font-semibold text-text">{completion.quantity} completed</div>
|
<div className="font-semibold text-text">{completion.quantity} completed</div>
|
||||||
<div className="text-xs text-muted">{completion.createdByName}</div>
|
<div className="text-xs text-muted">{completion.createdByName}</div>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function ProjectDetailPage() {
|
|||||||
}, [projectId, token]);
|
}, [projectId, token]);
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
return <div className="surface-panel text-sm text-muted">{status}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedMilestones = [...project.milestones].sort((left, right) => {
|
const sortedMilestones = [...project.milestones].sort((left, right) => {
|
||||||
@@ -172,16 +172,16 @@ export function ProjectDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<section className="grid gap-3 xl:grid-cols-4">
|
<section className="grid gap-3 xl:grid-cols-4">
|
||||||
<article className="rounded-[18px] 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">Customer</p><div className="mt-2 text-base font-bold text-text">{project.customerName}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Customer</p><div className="mt-1 text-base font-bold text-text">{project.customerName}</div></article>
|
||||||
<article className="rounded-[18px] 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">Owner</p><div className="mt-2 text-base font-bold text-text">{project.ownerName || "Unassigned"}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Owner</p><div className="mt-1 text-base font-bold text-text">{project.ownerName || "Unassigned"}</div></article>
|
||||||
<article className="rounded-[18px] 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">Due Date</p><div className="mt-2 text-base font-bold text-text">{project.dueDate ? new Date(project.dueDate).toLocaleDateString() : "Not set"}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Due Date</p><div className="mt-1 text-base font-bold text-text">{project.dueDate ? new Date(project.dueDate).toLocaleDateString() : "Not set"}</div></article>
|
||||||
<article className="rounded-[18px] 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">Created</p><div className="mt-2 text-base font-bold text-text">{new Date(project.createdAt).toLocaleDateString()}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Created</p><div className="mt-1 text-base font-bold text-text">{new Date(project.createdAt).toLocaleDateString()}</div></article>
|
||||||
</section>
|
</section>
|
||||||
<section className="grid gap-3 xl:grid-cols-4">
|
<section className="grid gap-3 xl:grid-cols-4">
|
||||||
<article className="rounded-[18px] 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">Milestones</p><div className="mt-2 text-base font-bold text-text">{project.rollups.completedMilestoneCount}/{project.rollups.milestoneCount}</div><div className="mt-1 text-xs text-muted">{project.rollups.openMilestoneCount} open</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Milestones</p><div className="mt-1 text-base font-bold text-text">{project.rollups.completedMilestoneCount}/{project.rollups.milestoneCount}</div><div className="mt-1 text-xs text-muted">{project.rollups.openMilestoneCount} open</div></article>
|
||||||
<article className="rounded-[18px] 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">Overdue Milestones</p><div className="mt-2 text-base font-bold text-text">{project.rollups.overdueMilestoneCount}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Overdue Milestones</p><div className="mt-1 text-base font-bold text-text">{project.rollups.overdueMilestoneCount}</div></article>
|
||||||
<article className="rounded-[18px] 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">Linked Work Orders</p><div className="mt-2 text-base font-bold text-text">{project.rollups.workOrderCount}</div><div className="mt-1 text-xs text-muted">{project.rollups.activeWorkOrderCount} active</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Linked Work Orders</p><div className="mt-1 text-base font-bold text-text">{project.rollups.workOrderCount}</div><div className="mt-1 text-xs text-muted">{project.rollups.activeWorkOrderCount} active</div></article>
|
||||||
<article className="rounded-[18px] 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">Overdue Work Orders</p><div className="mt-2 text-base font-bold text-text">{project.rollups.overdueWorkOrderCount}</div><div className="mt-1 text-xs text-muted">{project.rollups.completedWorkOrderCount} complete</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Overdue Work Orders</p><div className="mt-1 text-base font-bold text-text">{project.rollups.overdueWorkOrderCount}</div><div className="mt-1 text-xs text-muted">{project.rollups.completedWorkOrderCount} complete</div></article>
|
||||||
</section>
|
</section>
|
||||||
<section className="surface-panel">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
@@ -284,88 +284,88 @@ export function ProjectDetailPage() {
|
|||||||
<div className="flex items-center justify-between gap-3"><div><p className="section-kicker">LINKED PURCHASING</p></div>{project.salesOrderId ? <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> : null}</div>
|
<div className="flex items-center justify-between gap-3"><div><p className="section-kicker">LINKED PURCHASING</p></div>{project.salesOrderId ? <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> : null}</div>
|
||||||
{project.cockpit.purchasing.purchaseOrders.length === 0 ? <div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No linked purchase orders are tied to this project yet.</div> : <div className="mt-4 space-y-2.5">{project.cockpit.purchasing.purchaseOrders.slice(0, 5).map((purchaseOrder) => (<Link key={purchaseOrder.id} to={`/purchasing/orders/${purchaseOrder.id}`} className="block rounded-[16px] border border-line/70 bg-page/60 px-3 py-2.5 transition hover:bg-page/80"><div className="flex flex-wrap items-start justify-between gap-3"><div><div className="font-semibold text-text">{purchaseOrder.documentNumber}</div><div className="mt-1 text-xs text-muted">{purchaseOrder.vendorName} - {purchaseOrder.status.replaceAll("_", " ")}</div></div><div className="text-right text-xs text-muted"><div>${purchaseOrder.linkedLineValue.toFixed(2)} linked value</div><div>{purchaseOrder.totalReceivedQuantity}/{purchaseOrder.totalOrderedQuantity} received</div></div></div></Link>))}</div>}
|
{project.cockpit.purchasing.purchaseOrders.length === 0 ? <div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No linked purchase orders are tied to this project yet.</div> : <div className="mt-4 space-y-2.5">{project.cockpit.purchasing.purchaseOrders.slice(0, 5).map((purchaseOrder) => (<Link key={purchaseOrder.id} to={`/purchasing/orders/${purchaseOrder.id}`} className="block rounded-[16px] border border-line/70 bg-page/60 px-3 py-2.5 transition hover:bg-page/80"><div className="flex flex-wrap items-start justify-between gap-3"><div><div className="font-semibold text-text">{purchaseOrder.documentNumber}</div><div className="mt-1 text-xs text-muted">{purchaseOrder.vendorName} - {purchaseOrder.status.replaceAll("_", " ")}</div></div><div className="text-right text-xs text-muted"><div>${purchaseOrder.linkedLineValue.toFixed(2)} linked value</div><div>{purchaseOrder.totalReceivedQuantity}/{purchaseOrder.totalOrderedQuantity} received</div></div></div></Link>))}</div>}
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Readiness Drivers</p>
|
<p className="section-kicker">READINESS DRIVERS</p>
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Risk posture</div><div className={`mt-2 text-lg font-bold ${riskTone}`}>{project.cockpit.risk.riskLevel}</div><div className="mt-1 text-xs text-muted">{project.cockpit.risk.outstandingPurchaseOrderCount} PO(s) still waiting on receipts.</div></div>
|
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Risk posture</div><div className={`mt-2 text-lg font-bold ${riskTone}`}>{project.cockpit.risk.riskLevel}</div><div className="mt-1 text-xs text-muted">{project.cockpit.risk.outstandingPurchaseOrderCount} PO(s) still waiting on receipts.</div></div>
|
||||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3 text-sm text-text">Blocked milestones: <span className="font-semibold">{project.cockpit.risk.blockedMilestoneCount}</span></div>
|
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 text-sm text-text">Blocked milestones: <span className="font-semibold">{project.cockpit.risk.blockedMilestoneCount}</span></div>
|
||||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3 text-sm text-text">Overdue execution items: <span className="font-semibold">{project.cockpit.risk.overdueMilestoneCount + project.cockpit.risk.overdueWorkOrderCount}</span></div>
|
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 text-sm text-text">Overdue execution items: <span className="font-semibold">{project.cockpit.risk.overdueMilestoneCount + project.cockpit.risk.overdueWorkOrderCount}</span></div>
|
||||||
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3 text-sm text-text">Uncovered material quantity: <span className="font-semibold">{project.cockpit.risk.totalUncoveredQuantity}</span></div>
|
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 text-sm text-text">Uncovered material quantity: <span className="font-semibold">{project.cockpit.risk.totalUncoveredQuantity}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
<section className="grid gap-3 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
<section className="grid gap-3 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Vendor Exposure</p>
|
<p className="section-kicker">VENDOR EXPOSURE</p>
|
||||||
{project.cockpit.purchasing.vendors.length === 0 ? <div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No supplier exposure exists until purchasing is linked.</div> : <div className="mt-5 space-y-3">{project.cockpit.purchasing.vendors.slice(0, 4).map((vendor) => (<div key={vendor.vendorId} className="rounded-[18px] border border-line/70 bg-page/60 p-3"><div className="flex flex-wrap items-center justify-between gap-3"><div><div className="font-semibold text-text">{vendor.vendorName}</div><div className="mt-1 text-xs text-muted">{vendor.orderCount} linked order(s)</div></div><div className="text-right text-xs text-muted"><div>${vendor.linkedLineValue.toFixed(2)}</div><div>{vendor.outstandingQuantity} outstanding qty</div></div></div></div>))}</div>}
|
{project.cockpit.purchasing.vendors.length === 0 ? <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No supplier exposure exists until purchasing is linked.</div> : <div className="mt-3 space-y-2">{project.cockpit.purchasing.vendors.slice(0, 4).map((vendor) => (<div key={vendor.vendorId} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2"><div className="flex flex-wrap items-center justify-between gap-3"><div><div className="font-semibold text-text">{vendor.vendorName}</div><div className="mt-1 text-xs text-muted">{vendor.orderCount} linked order(s)</div></div><div className="text-right text-xs text-muted"><div>${vendor.linkedLineValue.toFixed(2)}</div><div>{vendor.outstandingQuantity} outstanding qty</div></div></div></div>))}</div>}
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Recent Receipts</p>
|
<p className="section-kicker">RECENT RECEIPTS</p>
|
||||||
{project.cockpit.purchasing.recentReceipts.length === 0 ? <div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No purchase receipts have been posted against linked project supply.</div> : <div className="mt-5 space-y-3">{project.cockpit.purchasing.recentReceipts.map((receipt) => (<div key={receipt.receiptId} className="rounded-[18px] border border-line/70 bg-page/60 p-3"><div className="flex flex-wrap items-start justify-between gap-3"><div><div className="font-semibold text-text">{receipt.receiptNumber}</div><div className="mt-1 text-xs text-muted">{receipt.vendorName} - {receipt.purchaseOrderNumber}</div></div><div className="text-right text-xs text-muted"><div>{new Date(receipt.receivedAt).toLocaleDateString()}</div><div>{receipt.totalQuantity} units received</div></div></div></div>))}</div>}
|
{project.cockpit.purchasing.recentReceipts.length === 0 ? <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No purchase receipts have been posted against linked project supply.</div> : <div className="mt-3 space-y-2">{project.cockpit.purchasing.recentReceipts.map((receipt) => (<div key={receipt.receiptId} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2"><div className="flex flex-wrap items-start justify-between gap-3"><div><div className="font-semibold text-text">{receipt.receiptNumber}</div><div className="mt-1 text-xs text-muted">{receipt.vendorName} - {receipt.purchaseOrderNumber}</div></div><div className="text-right text-xs text-muted"><div>{new Date(receipt.receivedAt).toLocaleDateString()}</div><div>{receipt.totalQuantity} units received</div></div></div></div>))}</div>}
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]">
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]">
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Customer Linkage</p>
|
<p className="section-kicker">CUSTOMER LINKAGE</p>
|
||||||
<dl className="mt-5 grid gap-3">
|
<dl className="mt-3 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"><Link to={`/crm/customers/${project.customerId}`} className="hover:text-brand">{project.customerName}</Link></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/customers/${project.customerId}`} className="hover:text-brand">{project.customerName}</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">{project.customerEmail}</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">{project.customerEmail}</dd></div>
|
||||||
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Phone</dt><dd className="mt-1 text-sm text-text">{project.customerPhone}</dd></div>
|
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Phone</dt><dd className="mt-1 text-sm text-text">{project.customerPhone}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Program Notes</p>
|
<p className="section-kicker">PROGRAM NOTES</p>
|
||||||
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{project.notes || "No project notes recorded."}</p>
|
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{project.notes || "No project notes recorded."}</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Commercial + Delivery Links</p>
|
<p className="section-kicker">COMMERCIAL + DELIVERY LINKS</p>
|
||||||
<div className="mt-5 grid gap-3 xl:grid-cols-3">
|
<div className="mt-3 grid gap-3 xl:grid-cols-3">
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Quote</div><div className="mt-2 font-semibold text-text">{project.salesQuoteNumber ? <Link to={`/sales/quotes/${project.salesQuoteId}`} className="hover:text-brand">{project.salesQuoteNumber}</Link> : "Not linked"}</div></div>
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Quote</div><div className="mt-2 font-semibold text-text">{project.salesQuoteNumber ? <Link to={`/sales/quotes/${project.salesQuoteId}`} className="hover:text-brand">{project.salesQuoteNumber}</Link> : "Not linked"}</div></div>
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Sales Order</div><div className="mt-2 font-semibold text-text">{project.salesOrderNumber ? <Link to={`/sales/orders/${project.salesOrderId}`} className="hover:text-brand">{project.salesOrderNumber}</Link> : "Not linked"}</div></div>
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Sales Order</div><div className="mt-2 font-semibold text-text">{project.salesOrderNumber ? <Link to={`/sales/orders/${project.salesOrderId}`} className="hover:text-brand">{project.salesOrderNumber}</Link> : "Not linked"}</div></div>
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Shipment</div><div className="mt-2 font-semibold text-text">{project.shipmentNumber ? <Link to={`/shipping/shipments/${project.shipmentId}`} className="hover:text-brand">{project.shipmentNumber}</Link> : "Not linked"}</div></div>
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm"><div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Shipment</div><div className="mt-2 font-semibold text-text">{project.shipmentNumber ? <Link to={`/shipping/shipments/${project.shipmentId}`} className="hover:text-brand">{project.shipmentNumber}</Link> : "Not linked"}</div></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div><p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Milestones</p><p className="mt-2 text-sm text-muted">Track project checkpoints, blockers, and completion progress.</p></div>
|
<div><p className="section-kicker">MILESTONES</p></div>
|
||||||
{canManage ? <Link to={`/projects/${project.id}/edit`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Edit milestones</Link> : null}
|
{canManage ? <Link to={`/projects/${project.id}/edit`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Edit milestones</Link> : null}
|
||||||
</div>
|
</div>
|
||||||
{project.milestones.length === 0 ? <div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No milestones are defined for this project yet.</div> : <div className="mt-6 space-y-3">{project.milestones.map((milestone) => (<div key={milestone.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3"><div className="flex flex-wrap items-start justify-between gap-3"><div className="min-w-0"><div className="font-semibold text-text">{milestone.title}</div><div className="mt-2 flex flex-wrap items-center gap-2"><span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${projectMilestoneStatusPalette[milestone.status]}`}>{milestone.status.replace("_", " ")}</span><span className="text-xs text-muted">Due {milestone.dueDate ? new Date(milestone.dueDate).toLocaleDateString() : "not scheduled"}</span>{milestone.completedAt ? <span className="text-xs text-muted">Completed {new Date(milestone.completedAt).toLocaleDateString()}</span> : null}</div>{milestone.notes ? <div className="mt-3 whitespace-pre-line text-sm text-text">{milestone.notes}</div> : null}</div>{canManage ? <div className="flex flex-wrap gap-2">{milestoneQuickActions(milestone.status).map((action) => (<button key={action.status} type="button" onClick={() => void updateMilestoneStatus(milestone.id, action.status)} disabled={updatingMilestoneId === milestone.id} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">{updatingMilestoneId === milestone.id ? "Saving..." : action.label}</button>))}</div> : null}</div></div>))}</div>}
|
{project.milestones.length === 0 ? <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No milestones are defined for this project yet.</div> : <div className="mt-3 space-y-2">{project.milestones.map((milestone) => (<div key={milestone.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2"><div className="flex flex-wrap items-start justify-between gap-3"><div className="min-w-0"><div className="font-semibold text-text">{milestone.title}</div><div className="mt-2 flex flex-wrap items-center gap-2"><span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${projectMilestoneStatusPalette[milestone.status]}`}>{milestone.status.replace("_", " ")}</span><span className="text-xs text-muted">Due {milestone.dueDate ? new Date(milestone.dueDate).toLocaleDateString() : "not scheduled"}</span>{milestone.completedAt ? <span className="text-xs text-muted">Completed {new Date(milestone.completedAt).toLocaleDateString()}</span> : null}</div>{milestone.notes ? <div className="mt-3 whitespace-pre-line text-sm text-text">{milestone.notes}</div> : null}</div>{canManage ? <div className="flex flex-wrap gap-2">{milestoneQuickActions(milestone.status).map((action) => (<button key={action.status} type="button" onClick={() => void updateMilestoneStatus(milestone.id, action.status)} disabled={updatingMilestoneId === milestone.id} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">{updatingMilestoneId === milestone.id ? "Saving..." : action.label}</button>))}</div> : null}</div></div>))}</div>}
|
||||||
</section>
|
</section>
|
||||||
{planning ? (
|
{planning ? (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Material Readiness</p>
|
<p className="section-kicker">MATERIAL READINESS</p>
|
||||||
<div className="mt-5 grid gap-3 xl:grid-cols-4">
|
<div className="mt-3 grid gap-3 xl:grid-cols-4">
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Qty</p><div className="mt-2 text-base font-bold text-text">{planning.summary.totalBuildQuantity}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Qty</p><div className="mt-1 text-base font-bold text-text">{planning.summary.totalBuildQuantity}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Buy Qty</p><div className="mt-2 text-base font-bold text-text">{planning.summary.totalPurchaseQuantity}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Buy Qty</p><div className="mt-1 text-base font-bold text-text">{planning.summary.totalPurchaseQuantity}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Uncovered Qty</p><div className="mt-2 text-base font-bold text-text">{planning.summary.totalUncoveredQuantity}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Uncovered Qty</p><div className="mt-1 text-base font-bold text-text">{planning.summary.totalUncoveredQuantity}</div></article>
|
||||||
<article className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Shortage Items</p><div className="mt-2 text-base font-bold text-text">{planning.summary.uncoveredItemCount}</div></article>
|
<article className="surface-panel-tight"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Shortage Items</p><div className="mt-1 text-base font-bold text-text">{planning.summary.uncoveredItemCount}</div></article>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{planning.items.filter((item) => item.recommendedBuildQuantity > 0 || item.recommendedPurchaseQuantity > 0 || item.uncoveredQuantity > 0).slice(0, 8).map((item) => (
|
{planning.items.filter((item) => item.recommendedBuildQuantity > 0 || item.recommendedPurchaseQuantity > 0 || item.uncoveredQuantity > 0).slice(0, 8).map((item) => (
|
||||||
<div key={item.itemId} className="rounded-[18px] border border-line/70 bg-page/60 p-3"><div className="flex flex-wrap items-center justify-between gap-3"><div><div className="font-semibold text-text">{item.itemSku}</div><div className="mt-1 text-xs text-muted">{item.itemName}</div></div><div className="text-sm text-muted">Build {item.recommendedBuildQuantity} - Buy {item.recommendedPurchaseQuantity} - Uncovered {item.uncoveredQuantity}</div></div></div>
|
<div key={item.itemId} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2"><div className="flex flex-wrap items-center justify-between gap-3"><div><div className="font-semibold text-text">{item.itemSku}</div><div className="mt-1 text-xs text-muted">{item.itemName}</div></div><div className="text-sm text-muted">Build {item.recommendedBuildQuantity} - Buy {item.recommendedPurchaseQuantity} - Uncovered {item.uncoveredQuantity}</div></div></div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<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>
|
<div><p className="section-kicker">MANUFACTURING LINKS</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}
|
{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>
|
</div>
|
||||||
{workOrders.length === 0 ? <div className="mt-6 rounded-[18px] 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-[18px] 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>}
|
{workOrders.length === 0 ? <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No work orders are linked to this project yet.</div> : <div className="mt-3 space-y-2">{workOrders.map((workOrder) => (<Link key={workOrder.id} to={`/manufacturing/work-orders/${workOrder.id}`} className="block rounded-[18px] border border-line/70 bg-page/60 px-2 py-2 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>
|
</section>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div><p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Activity Timeline</p><p className="mt-2 text-sm text-muted">Chronological project, milestone, purchasing, manufacturing, sales, and shipping history.</p></div>
|
<div><p className="section-kicker">ACTIVITY TIMELINE</p></div>
|
||||||
</div>
|
</div>
|
||||||
{project.timeline.length === 0 ? (
|
{project.timeline.length === 0 ? (
|
||||||
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No timeline activity is available for this project yet.</div>
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">No timeline activity is available for this project yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{project.timeline.map((entry) => (
|
{project.timeline.map((entry) => (
|
||||||
<div key={entry.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
<div key={entry.id} className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{entry.sourceType}</div>
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{entry.sourceType}</div>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function AdminDiagnosticsPage() {
|
|||||||
}, [token, supportLogLevel, supportLogSource, supportLogQuery, supportLogWindowDays]);
|
}, [token, supportLogLevel, supportLogSource, supportLogQuery, supportLogWindowDays]);
|
||||||
|
|
||||||
if (!diagnostics || !backupGuidance) {
|
if (!diagnostics || !backupGuidance) {
|
||||||
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
return <div className="surface-panel text-sm text-muted">{status}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleExportSupportSnapshot() {
|
async function handleExportSupportSnapshot() {
|
||||||
@@ -156,7 +156,7 @@ export function AdminDiagnosticsPage() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="page-stack">
|
||||||
<section className="surface-panel backdrop-blur">
|
<section className="surface-panel backdrop-blur">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -186,11 +186,11 @@ export function AdminDiagnosticsPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
{summaryCards.map(([label, value]) => (
|
{summaryCards.map(([label, value]) => (
|
||||||
<div key={label} className="rounded-[18px] border border-line/70 bg-page/70 p-4">
|
<div key={label} className="rounded-[18px] border border-line/70 bg-page/70 px-3 py-3">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">{label}</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">{label}</p>
|
||||||
<p className="mt-3 text-lg font-bold text-text">{value}</p>
|
<p className="mt-2 text-lg font-bold text-text">{value}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -201,16 +201,16 @@ export function AdminDiagnosticsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="section-kicker">BACKUP AND RESTORE</p>
|
<p className="section-kicker">BACKUP AND RESTORE</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-muted">
|
<div className="rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">
|
||||||
<div>Data: {backupGuidance.dataPath}</div>
|
<div>Data: {backupGuidance.dataPath}</div>
|
||||||
<div>DB: {backupGuidance.databasePath}</div>
|
<div>DB: {backupGuidance.databasePath}</div>
|
||||||
<div>Uploads: {backupGuidance.uploadsPath}</div>
|
<div>Uploads: {backupGuidance.uploadsPath}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid gap-4 xl:grid-cols-2">
|
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/70 p-4">
|
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
<p className="text-sm font-semibold text-text">Backup checklist</p>
|
<p className="text-sm font-semibold text-text">Backup checklist</p>
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{backupGuidance.backupSteps.map((step) => (
|
{backupGuidance.backupSteps.map((step) => (
|
||||||
<div key={step.id}>
|
<div key={step.id}>
|
||||||
<p className="text-sm font-semibold text-text">{step.label}</p>
|
<p className="text-sm font-semibold text-text">{step.label}</p>
|
||||||
@@ -219,9 +219,9 @@ export function AdminDiagnosticsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/70 p-4">
|
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
<p className="text-sm font-semibold text-text">Restore checklist</p>
|
<p className="text-sm font-semibold text-text">Restore checklist</p>
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{backupGuidance.restoreSteps.map((step) => (
|
{backupGuidance.restoreSteps.map((step) => (
|
||||||
<div key={step.id}>
|
<div key={step.id}>
|
||||||
<p className="text-sm font-semibold text-text">{step.label}</p>
|
<p className="text-sm font-semibold text-text">{step.label}</p>
|
||||||
@@ -231,10 +231,10 @@ export function AdminDiagnosticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid gap-4 xl:grid-cols-2">
|
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/70 p-4">
|
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
<p className="text-sm font-semibold text-text">Backup verification checklist</p>
|
<p className="text-sm font-semibold text-text">Backup verification checklist</p>
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{backupGuidance.verificationChecklist.map((item) => (
|
{backupGuidance.verificationChecklist.map((item) => (
|
||||||
<div key={item.id}>
|
<div key={item.id}>
|
||||||
<p className="text-sm font-semibold text-text">{item.label}</p>
|
<p className="text-sm font-semibold text-text">{item.label}</p>
|
||||||
@@ -244,9 +244,9 @@ export function AdminDiagnosticsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/70 p-4">
|
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
<p className="text-sm font-semibold text-text">Restore drill runbook</p>
|
<p className="text-sm font-semibold text-text">Restore drill runbook</p>
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-2">
|
||||||
{backupGuidance.restoreDrillSteps.map((step) => (
|
{backupGuidance.restoreDrillSteps.map((step) => (
|
||||||
<div key={step.id}>
|
<div key={step.id}>
|
||||||
<p className="text-sm font-semibold text-text">{step.label}</p>
|
<p className="text-sm font-semibold text-text">{step.label}</p>
|
||||||
@@ -268,7 +268,7 @@ export function AdminDiagnosticsPage() {
|
|||||||
{diagnostics.startup.status}
|
{diagnostics.startup.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid gap-3 xl:grid-cols-2">
|
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
||||||
{diagnostics.startup.checks.map((check) => (
|
{diagnostics.startup.checks.map((check) => (
|
||||||
<div key={check.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
<div key={check.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
@@ -279,7 +279,7 @@ export function AdminDiagnosticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid gap-3 lg:grid-cols-3">
|
<div className="mt-3 grid gap-3 lg:grid-cols-3">
|
||||||
{startupSummaryCards.map(([label, value]) => (
|
{startupSummaryCards.map(([label, value]) => (
|
||||||
<div key={label} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
<div key={label} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
|
||||||
@@ -291,7 +291,7 @@ export function AdminDiagnosticsPage() {
|
|||||||
|
|
||||||
<section className="surface-panel backdrop-blur">
|
<section className="surface-panel backdrop-blur">
|
||||||
<p className="section-kicker">SYSTEM FOOTPRINT</p>
|
<p className="section-kicker">SYSTEM FOOTPRINT</p>
|
||||||
<div className="mt-5 grid gap-3 xl:grid-cols-2">
|
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
||||||
{footprintCards.map(([label, value]) => (
|
{footprintCards.map(([label, value]) => (
|
||||||
<div key={label} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
<div key={label} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
|
||||||
@@ -310,9 +310,9 @@ export function AdminDiagnosticsPage() {
|
|||||||
{supportLogSummary ? `${supportLogSummary.filteredCount} of ${supportLogSummary.totalCount} entries` : "No entries loaded"}
|
{supportLogSummary ? `${supportLogSummary.filteredCount} of ${supportLogSummary.totalCount} entries` : "No entries loaded"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
<div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Search</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
||||||
<input
|
<input
|
||||||
value={supportLogQuery}
|
value={supportLogQuery}
|
||||||
onChange={(event) => setSupportLogQuery(event.target.value)}
|
onChange={(event) => setSupportLogQuery(event.target.value)}
|
||||||
@@ -321,7 +321,7 @@ export function AdminDiagnosticsPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Level</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Level</span>
|
||||||
<select value={supportLogLevel} onChange={(event) => setSupportLogLevel(event.target.value as "ALL" | SupportLogEntryDto["level"])} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
<select value={supportLogLevel} onChange={(event) => setSupportLogLevel(event.target.value as "ALL" | SupportLogEntryDto["level"])} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
||||||
<option value="ALL">All levels</option>
|
<option value="ALL">All levels</option>
|
||||||
<option value="ERROR">Error</option>
|
<option value="ERROR">Error</option>
|
||||||
@@ -330,7 +330,7 @@ export function AdminDiagnosticsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Source</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Source</span>
|
||||||
<select value={supportLogSource} onChange={(event) => setSupportLogSource(event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
<select value={supportLogSource} onChange={(event) => setSupportLogSource(event.target.value)} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
||||||
<option value="ALL">All sources</option>
|
<option value="ALL">All sources</option>
|
||||||
{supportLogSources.map((source) => (
|
{supportLogSources.map((source) => (
|
||||||
@@ -339,7 +339,7 @@ export function AdminDiagnosticsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Window</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Window</span>
|
||||||
<select value={supportLogWindowDays} onChange={(event) => setSupportLogWindowDays(event.target.value as "ALL" | "1" | "7" | "14")} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
<select value={supportLogWindowDays} onChange={(event) => setSupportLogWindowDays(event.target.value as "ALL" | "1" | "7" | "14")} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
||||||
<option value="ALL">All retained</option>
|
<option value="ALL">All retained</option>
|
||||||
<option value="1">Last 24 hours</option>
|
<option value="1">Last 24 hours</option>
|
||||||
@@ -347,13 +347,13 @@ export function AdminDiagnosticsPage() {
|
|||||||
<option value="14">Last 14 days</option>
|
<option value="14">Last 14 days</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-muted">
|
<div className="rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">
|
||||||
<div>Errors: {supportLogSummary?.levelCounts.ERROR ?? 0}</div>
|
<div>Errors: {supportLogSummary?.levelCounts.ERROR ?? 0}</div>
|
||||||
<div>Warnings: {supportLogSummary?.levelCounts.WARN ?? 0}</div>
|
<div>Warnings: {supportLogSummary?.levelCounts.WARN ?? 0}</div>
|
||||||
<div>Info: {supportLogSummary?.levelCounts.INFO ?? 0}</div>
|
<div>Info: {supportLogSummary?.levelCounts.INFO ?? 0}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 overflow-x-auto">
|
<div className="mt-3 overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||||
@@ -402,7 +402,7 @@ export function AdminDiagnosticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted">{status}</p>
|
<p className="text-sm text-muted">{status}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 overflow-x-auto">
|
<div className="mt-3 overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export function CompanySettingsPage() {
|
|||||||
}, [logoUrl]);
|
}, [logoUrl]);
|
||||||
|
|
||||||
if (!form || !token) {
|
if (!form || !token) {
|
||||||
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
return <div className="surface-panel text-sm text-muted">{status}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave(event: React.FormEvent<HTMLFormElement>) {
|
async function handleSave(event: React.FormEvent<HTMLFormElement>) {
|
||||||
@@ -145,14 +145,13 @@ export function CompanySettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="space-y-6" onSubmit={handleSave}>
|
<form className="page-stack" onSubmit={handleSave}>
|
||||||
{user?.permissions.includes("admin.manage") ? (
|
{user?.permissions.includes("admin.manage") ? (
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Admin</p>
|
<p className="section-kicker">ADMIN</p>
|
||||||
<h3 className="mt-2 text-lg font-bold text-text">Admin access and diagnostics</h3>
|
<h3 className="module-title">ADMIN SURFACES</h3>
|
||||||
<p className="mt-2 text-sm text-muted">Manage users, roles, and system diagnostics from the linked admin surfaces.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Link to="/settings/users" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
<Link to="/settings/users" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||||
@@ -165,14 +164,13 @@ export function CompanySettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Company Profile</p>
|
<p className="section-kicker">COMPANY PROFILE</p>
|
||||||
<h3 className="mt-2 text-lg font-bold text-text">Branding and legal identity</h3>
|
<h3 className="module-title">BRANDING AND LEGAL IDENTITY</h3>
|
||||||
<p className="mt-2 max-w-2xl text-sm text-muted">Every internal document and PDF template will inherit its company identity from this profile.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-[18px] border border-dashed border-line/70 bg-page/80 p-4">
|
<div className="rounded-[18px] border border-dashed border-line/70 bg-page/80 px-3 py-3">
|
||||||
{logoUrl ? <img alt="Company logo" className="h-20 w-20 rounded-2xl object-cover" src={logoUrl} /> : <div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-brand text-sm font-bold text-white">LOGO</div>}
|
{logoUrl ? <img alt="Company logo" className="h-20 w-20 rounded-2xl object-cover" src={logoUrl} /> : <div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-brand text-sm font-bold text-white">LOGO</div>}
|
||||||
<label className="mt-3 block cursor-pointer text-sm font-semibold text-brand">
|
<label className="mt-3 block cursor-pointer text-sm font-semibold text-brand">
|
||||||
Upload logo
|
Upload logo
|
||||||
@@ -180,7 +178,7 @@ export function CompanySettingsPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 grid gap-4 xl:grid-cols-2 2xl:grid-cols-3">
|
<div className="mt-3 grid gap-3 xl:grid-cols-2 2xl:grid-cols-3">
|
||||||
{[
|
{[
|
||||||
["companyName", "Company name"],
|
["companyName", "Company name"],
|
||||||
["legalName", "Legal name"],
|
["legalName", "Legal name"],
|
||||||
@@ -196,37 +194,37 @@ export function CompanySettingsPage() {
|
|||||||
["country", "Country"],
|
["country", "Country"],
|
||||||
].map(([key, label]) => (
|
].map(([key, label]) => (
|
||||||
<label key={key} className="block">
|
<label key={key} className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">{label}</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">{label}</span>
|
||||||
<input
|
<input
|
||||||
value={String(form[key as keyof CompanyProfileInput])}
|
value={String(form[key as keyof CompanyProfileInput])}
|
||||||
onChange={(event) => updateField(key as keyof CompanyProfileInput, event.target.value as never)}
|
onChange={(event) => updateField(key as keyof CompanyProfileInput, event.target.value as never)}
|
||||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
<section className="surface-panel">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Theme</p>
|
<p className="section-kicker">THEME</p>
|
||||||
<div className="mt-5 grid gap-4 md:grid-cols-2 2xl:grid-cols-4">
|
<div className="mt-3 grid gap-3 md:grid-cols-2 2xl:grid-cols-4">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Primary color</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Primary color</span>
|
||||||
<input type="color" value={form.theme.primaryColor} onChange={(event) => updateField("theme", { ...form.theme, primaryColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" />
|
<input type="color" value={form.theme.primaryColor} onChange={(event) => updateField("theme", { ...form.theme, primaryColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Accent color</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Accent color</span>
|
||||||
<input type="color" value={form.theme.accentColor} onChange={(event) => updateField("theme", { ...form.theme, accentColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" />
|
<input type="color" value={form.theme.accentColor} onChange={(event) => updateField("theme", { ...form.theme, accentColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Surface color</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Surface color</span>
|
||||||
<input type="color" value={form.theme.surfaceColor} onChange={(event) => updateField("theme", { ...form.theme, surfaceColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" />
|
<input type="color" value={form.theme.surfaceColor} onChange={(event) => updateField("theme", { ...form.theme, surfaceColor: event.target.value })} className="h-10 w-full rounded-2xl border border-line/70 bg-page p-2" />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Font family</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Font family</span>
|
||||||
<input value={form.theme.fontFamily} onChange={(event) => updateField("theme", { ...form.theme, fontFamily: 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" />
|
<input value={form.theme.fontFamily} onChange={(event) => updateField("theme", { ...form.theme, fontFamily: event.target.value })} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 lg:flex-row lg:items-center lg:justify-between">
|
<div className="mt-3 flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<span className="min-w-0 text-sm text-muted">{status}</span>
|
<span className="min-w-0 text-sm text-muted">{status}</span>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ export function UserManagementPage() {
|
|||||||
const reviewSessionCount = sessions.filter((session) => session.reviewState === "REVIEW").length;
|
const reviewSessionCount = sessions.filter((session) => session.reviewState === "REVIEW").length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="page-stack">
|
||||||
<section className="surface-panel backdrop-blur">
|
<section className="surface-panel backdrop-blur">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -290,7 +290,7 @@ export function UserManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-6 xl:grid-cols-2">
|
<section className="grid gap-3 xl:grid-cols-2">
|
||||||
<form className="surface-panel backdrop-blur" onSubmit={handleUserSave}>
|
<form className="surface-panel backdrop-blur" onSubmit={handleUserSave}>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -310,9 +310,9 @@ export function UserManagementPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Email</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Email</span>
|
||||||
<input
|
<input
|
||||||
value={userForm.email}
|
value={userForm.email}
|
||||||
onChange={(event) => setUserForm((current) => ({ ...current, email: event.target.value }))}
|
onChange={(event) => setUserForm((current) => ({ ...current, email: event.target.value }))}
|
||||||
@@ -320,7 +320,7 @@ export function UserManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Password</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Password</span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={userForm.password ?? ""}
|
value={userForm.password ?? ""}
|
||||||
@@ -330,7 +330,7 @@ export function UserManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">First name</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">First name</span>
|
||||||
<input
|
<input
|
||||||
value={userForm.firstName}
|
value={userForm.firstName}
|
||||||
onChange={(event) => setUserForm((current) => ({ ...current, firstName: event.target.value }))}
|
onChange={(event) => setUserForm((current) => ({ ...current, firstName: event.target.value }))}
|
||||||
@@ -338,7 +338,7 @@ export function UserManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Last name</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Last name</span>
|
||||||
<input
|
<input
|
||||||
value={userForm.lastName}
|
value={userForm.lastName}
|
||||||
onChange={(event) => setUserForm((current) => ({ ...current, lastName: event.target.value }))}
|
onChange={(event) => setUserForm((current) => ({ ...current, lastName: event.target.value }))}
|
||||||
@@ -347,7 +347,7 @@ export function UserManagementPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="mt-4 flex items-center gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-text">
|
<label className="mt-3 flex items-center gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-text">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={userForm.isActive}
|
checked={userForm.isActive}
|
||||||
@@ -356,11 +356,11 @@ export function UserManagementPage() {
|
|||||||
User can sign in
|
User can sign in
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="mt-5">
|
<div className="mt-3">
|
||||||
<p className="text-sm font-semibold text-text">Assigned roles</p>
|
<p className="section-kicker">ASSIGNED ROLES</p>
|
||||||
<div className="mt-3 grid gap-3">
|
<div className="mt-3 grid gap-2">
|
||||||
{roles.map((role) => (
|
{roles.map((role) => (
|
||||||
<label key={role.id} className="flex items-start gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-text">
|
<label key={role.id} className="flex items-start gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-text">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={userForm.roleIds.includes(role.id)}
|
checked={userForm.roleIds.includes(role.id)}
|
||||||
@@ -375,7 +375,7 @@ export function UserManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
<div className="mt-3 flex items-center justify-between gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
|
||||||
<span className="text-sm text-muted">{status}</span>
|
<span className="text-sm text-muted">{status}</span>
|
||||||
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
|
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
|
||||||
{selectedUserId === "new" ? "Create user" : "Save user"}
|
{selectedUserId === "new" ? "Create user" : "Save user"}
|
||||||
@@ -402,9 +402,9 @@ export function UserManagementPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 grid gap-4">
|
<div className="mt-3 grid gap-3">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Role name</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Role name</span>
|
||||||
<input
|
<input
|
||||||
value={roleForm.name}
|
value={roleForm.name}
|
||||||
onChange={(event) => setRoleForm((current) => ({ ...current, name: event.target.value }))}
|
onChange={(event) => setRoleForm((current) => ({ ...current, name: event.target.value }))}
|
||||||
@@ -412,7 +412,7 @@ export function UserManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Description</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
|
||||||
<textarea
|
<textarea
|
||||||
value={roleForm.description}
|
value={roleForm.description}
|
||||||
onChange={(event) => setRoleForm((current) => ({ ...current, description: event.target.value }))}
|
onChange={(event) => setRoleForm((current) => ({ ...current, description: event.target.value }))}
|
||||||
@@ -422,11 +422,11 @@ export function UserManagementPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5">
|
<div className="mt-3">
|
||||||
<p className="text-sm font-semibold text-text">Role permissions</p>
|
<p className="section-kicker">ROLE PERMISSIONS</p>
|
||||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||||
{permissions.map((permission) => (
|
{permissions.map((permission) => (
|
||||||
<label key={permission.key} className="flex items-start gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-text">
|
<label key={permission.key} className="flex items-start gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-text">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={roleForm.permissionKeys.includes(permission.key)}
|
checked={roleForm.permissionKeys.includes(permission.key)}
|
||||||
@@ -441,9 +441,9 @@ export function UserManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 grid gap-3 md:grid-cols-3">
|
<div className="mt-3 grid gap-2 md:grid-cols-3">
|
||||||
{roles.map((role) => (
|
{roles.map((role) => (
|
||||||
<div key={role.id} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
<div key={role.id} className="rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
|
||||||
<p className="text-sm font-semibold text-text">{role.name}</p>
|
<p className="text-sm font-semibold text-text">{role.name}</p>
|
||||||
<p className="mt-1 text-xs text-muted">{role.userCount} assigned users</p>
|
<p className="mt-1 text-xs text-muted">{role.userCount} assigned users</p>
|
||||||
<p className="mt-2 text-xs text-muted">{role.permissionKeys.length} permissions</p>
|
<p className="mt-2 text-xs text-muted">{role.permissionKeys.length} permissions</p>
|
||||||
@@ -451,7 +451,7 @@ export function UserManagementPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
<div className="mt-3 flex items-center justify-between gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
|
||||||
<span className="text-sm text-muted">{status}</span>
|
<span className="text-sm text-muted">{status}</span>
|
||||||
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
|
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
|
||||||
{selectedRoleId === "new" ? "Create role" : "Save role"}
|
{selectedRoleId === "new" ? "Create role" : "Save role"}
|
||||||
@@ -467,7 +467,7 @@ export function UserManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Search</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Search</span>
|
||||||
<input
|
<input
|
||||||
value={sessionQuery}
|
value={sessionQuery}
|
||||||
onChange={(event) => setSessionQuery(event.target.value)}
|
onChange={(event) => setSessionQuery(event.target.value)}
|
||||||
@@ -476,7 +476,7 @@ export function UserManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">User</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">User</span>
|
||||||
<select
|
<select
|
||||||
value={sessionUserFilter}
|
value={sessionUserFilter}
|
||||||
onChange={(event) => setSessionUserFilter(event.target.value)}
|
onChange={(event) => setSessionUserFilter(event.target.value)}
|
||||||
@@ -491,7 +491,7 @@ export function UserManagementPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Status</span>
|
||||||
<select
|
<select
|
||||||
value={sessionStatusFilter}
|
value={sessionStatusFilter}
|
||||||
onChange={(event) => setSessionStatusFilter(event.target.value as "ALL" | AdminAuthSessionDto["status"])}
|
onChange={(event) => setSessionStatusFilter(event.target.value as "ALL" | AdminAuthSessionDto["status"])}
|
||||||
@@ -504,7 +504,7 @@ export function UserManagementPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Review</span>
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Review</span>
|
||||||
<select
|
<select
|
||||||
value={sessionReviewFilter}
|
value={sessionReviewFilter}
|
||||||
onChange={(event) => setSessionReviewFilter(event.target.value as "ALL" | AdminAuthSessionDto["reviewState"])}
|
onChange={(event) => setSessionReviewFilter(event.target.value as "ALL" | AdminAuthSessionDto["reviewState"])}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,15 @@
|
|||||||
import puppeteer from "puppeteer";
|
import puppeteer, { PaperFormat } from "puppeteer";
|
||||||
|
|
||||||
import { env } from "../config/env.js";
|
import { env } from "../config/env.js";
|
||||||
|
|
||||||
export async function renderPdf(html: string) {
|
interface PdfOptions {
|
||||||
|
format?: PaperFormat;
|
||||||
|
width?: string;
|
||||||
|
height?: string;
|
||||||
|
margin?: { top?: string; right?: string; bottom?: string; left?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderPdf(html: string, options?: PdfOptions) {
|
||||||
const browser = await puppeteer.launch({
|
const browser = await puppeteer.launch({
|
||||||
executablePath: env.PUPPETEER_EXECUTABLE_PATH,
|
executablePath: env.PUPPETEER_EXECUTABLE_PATH,
|
||||||
headless: true,
|
headless: true,
|
||||||
@@ -14,7 +21,10 @@ export async function renderPdf(html: string) {
|
|||||||
await page.setContent(html, { waitUntil: "networkidle0" });
|
await page.setContent(html, { waitUntil: "networkidle0" });
|
||||||
|
|
||||||
const pdf = await page.pdf({
|
const pdf = await page.pdf({
|
||||||
format: "A4",
|
format: options?.width || options?.height ? undefined : (options?.format || "A4"),
|
||||||
|
width: options?.width,
|
||||||
|
height: options?.height,
|
||||||
|
margin: options?.margin,
|
||||||
printBackground: true,
|
printBackground: true,
|
||||||
preferCSSPageSize: true,
|
preferCSSPageSize: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -152,29 +152,40 @@ function buildShippingLabelPdf(options: {
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<style>
|
<style>
|
||||||
@page { size: 4in 6in; margin: 8mm; }
|
@page { size: 4in 6in; margin: 0; }
|
||||||
body { font-family: ${company.theme.fontFamily}, Arial, sans-serif; color: #111827; font-size: 11px; }
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
.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; }
|
html, body { width: 4in; min-width: 4in; max-width: 4in; height: 6in; min-height: 6in; max-height: 6in; margin: 0; padding: 0; overflow: hidden; background: white; }
|
||||||
.row { display: flex; justify-content: space-between; gap: 12px; }
|
body { font-family: ${company.theme.fontFamily}, Arial, sans-serif; color: #111827; font-size: 10px; line-height: 1.2; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.page { width: 4in; height: 6in; padding: 0.14in; overflow: hidden; page-break-after: avoid; break-after: avoid-page; }
|
||||||
|
.label { width: 100%; height: 100%; border: 2px solid #111827; border-radius: 10px; padding: 0.11in; display: flex; flex-direction: column; gap: 0.09in; overflow: hidden; }
|
||||||
|
.row { display: flex; justify-content: space-between; gap: 0.09in; }
|
||||||
.muted { font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em; color: #4b5563; }
|
.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 { border-bottom: 2px solid ${company.theme.primaryColor}; padding-bottom: 0.09in; }
|
||||||
.brand h1 { margin: 0; font-size: 18px; color: ${company.theme.primaryColor}; }
|
.brand-row { align-items: flex-start; }
|
||||||
.block { border: 1px solid #d1d5db; border-radius: 10px; padding: 10px; }
|
.brand-company { flex: 1; min-width: 0; padding-right: 0.06in; }
|
||||||
.stack { display: flex; flex-direction: column; gap: 4px; }
|
.brand h1 { margin: 0; font-size: 16px; line-height: 1.05; color: ${company.theme.primaryColor}; overflow-wrap: anywhere; }
|
||||||
.barcode { border: 2px solid #111827; border-radius: 10px; padding: 8px; text-align: center; font-family: monospace; font-size: 18px; letter-spacing: 0.18em; }
|
.shipment-number { width: 1.25in; flex: 0 0 1.25in; text-align: right; }
|
||||||
|
.block { border: 1px solid #d1d5db; border-radius: 10px; padding: 0.08in; min-width: 0; }
|
||||||
|
.stack { display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.barcode { border: 2px solid #111827; border-radius: 8px; padding: 0.08in; text-align: center; font-family: monospace; font-size: 16px; line-height: 1; letter-spacing: 0.15em; }
|
||||||
.strong { font-weight: 700; }
|
.strong { font-weight: 700; }
|
||||||
.big { font-size: 16px; font-weight: 700; }
|
.big { font-size: 15px; line-height: 1.05; font-weight: 700; }
|
||||||
|
.footer { text-align: center; font-size: 9px; color: #4b5563; overflow-wrap: anywhere; }
|
||||||
|
.reference-text { margin-top: 6px; overflow-wrap: anywhere; word-break: break-word; }
|
||||||
|
.block > div[style="margin-top:6px;"] { overflow-wrap: anywhere; word-break: break-word; }
|
||||||
|
div[style="text-align:center; font-size:10px; color:#4b5563;"] { text-align: center; font-size: 9px; color: #4b5563; overflow-wrap: anywhere; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="label">
|
<div class="page">
|
||||||
|
<div class="label">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="row">
|
<div class="row brand-row">
|
||||||
<div>
|
<div class="brand-company">
|
||||||
<div class="muted">From</div>
|
<div class="muted">From</div>
|
||||||
<h1>${escapeHtml(company.companyName)}</h1>
|
<h1>${escapeHtml(company.companyName)}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:right;">
|
<div class="shipment-number">
|
||||||
<div class="muted">Shipment</div>
|
<div class="muted">Shipment</div>
|
||||||
<div class="big">${escapeHtml(shipment.shipmentNumber)}</div>
|
<div class="big">${escapeHtml(shipment.shipmentNumber)}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,7 +228,7 @@ function buildShippingLabelPdf(options: {
|
|||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`);
|
`, { width: "4in", height: "6in", margin: { top: "0", right: "0", bottom: "0", left: "0" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBillOfLadingPdf(options: {
|
function buildBillOfLadingPdf(options: {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
GanttLinkDto,
|
GanttLinkDto,
|
||||||
GanttTaskDto,
|
GanttTaskDto,
|
||||||
PlanningReadinessState,
|
PlanningReadinessState,
|
||||||
|
PlanningStationDayLoadDto,
|
||||||
PlanningStationLoadDto,
|
PlanningStationLoadDto,
|
||||||
PlanningTaskActionDto,
|
PlanningTaskActionDto,
|
||||||
PlanningTimelineDto,
|
PlanningTimelineDto,
|
||||||
@@ -94,6 +95,15 @@ type StationAccumulator = {
|
|||||||
workingDays: number[];
|
workingDays: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type StationDayAccumulator = {
|
||||||
|
stationId: string;
|
||||||
|
dateKey: string;
|
||||||
|
plannedMinutes: number;
|
||||||
|
actualMinutes: number;
|
||||||
|
operationCount: number;
|
||||||
|
capacityMinutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
function clampProgress(value: number) {
|
function clampProgress(value: number) {
|
||||||
return Math.max(0, Math.min(100, Math.round(value)));
|
return Math.max(0, Math.min(100, Math.round(value)));
|
||||||
}
|
}
|
||||||
@@ -199,6 +209,23 @@ function createStationLoad(record: StationAccumulator): PlanningStationLoadDto {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createStationDayLoad(record: StationDayAccumulator): PlanningStationDayLoadDto {
|
||||||
|
const capacityMinutes = Math.max(record.capacityMinutes, 1);
|
||||||
|
const utilizationPercent = Math.round((record.plannedMinutes / capacityMinutes) * 100);
|
||||||
|
const actualUtilizationPercent = Math.round((record.actualMinutes / capacityMinutes) * 100);
|
||||||
|
return {
|
||||||
|
stationId: record.stationId,
|
||||||
|
dateKey: record.dateKey,
|
||||||
|
plannedMinutes: record.plannedMinutes,
|
||||||
|
actualMinutes: record.actualMinutes,
|
||||||
|
capacityMinutes,
|
||||||
|
utilizationPercent,
|
||||||
|
actualUtilizationPercent,
|
||||||
|
operationCount: record.operationCount,
|
||||||
|
overloaded: utilizationPercent > 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildProjectTask(
|
function buildProjectTask(
|
||||||
project: PlanningProjectRecord,
|
project: PlanningProjectRecord,
|
||||||
projectWorkOrders: PlanningWorkOrderRecord[],
|
projectWorkOrders: PlanningWorkOrderRecord[],
|
||||||
@@ -492,6 +519,7 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stationAccumulators = new Map<string, StationAccumulator>();
|
const stationAccumulators = new Map<string, StationAccumulator>();
|
||||||
|
const stationDayAccumulators = new Map<string, StationDayAccumulator>();
|
||||||
for (const workOrder of openWorkOrders) {
|
for (const workOrder of openWorkOrders) {
|
||||||
const insight = workOrderInsights.get(workOrder.id);
|
const insight = workOrderInsights.get(workOrder.id);
|
||||||
for (const operation of workOrder.operations) {
|
for (const operation of workOrder.operations) {
|
||||||
@@ -525,7 +553,22 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
|||||||
current.lateCount += 1;
|
current.lateCount += 1;
|
||||||
}
|
}
|
||||||
for (let cursor = startOfDay(operation.plannedStart).getTime(); cursor <= startOfDay(operation.plannedEnd).getTime(); cursor += DAY_MS) {
|
for (let cursor = startOfDay(operation.plannedStart).getTime(); cursor <= startOfDay(operation.plannedEnd).getTime(); cursor += DAY_MS) {
|
||||||
current.dayKeys.add(dateKey(new Date(cursor)));
|
const currentDate = new Date(cursor);
|
||||||
|
const currentDateKey = dateKey(currentDate);
|
||||||
|
current.dayKeys.add(currentDateKey);
|
||||||
|
const dayAccumulatorKey = `${operation.station.id}:${currentDateKey}`;
|
||||||
|
const dayAccumulator = stationDayAccumulators.get(dayAccumulatorKey) ?? {
|
||||||
|
stationId: operation.station.id,
|
||||||
|
dateKey: currentDateKey,
|
||||||
|
plannedMinutes: 0,
|
||||||
|
actualMinutes: 0,
|
||||||
|
operationCount: 0,
|
||||||
|
capacityMinutes: Math.max(operation.station.dailyCapacityMinutes, 60) * Math.max(operation.station.parallelCapacity, 1),
|
||||||
|
};
|
||||||
|
dayAccumulator.plannedMinutes += operation.plannedMinutes;
|
||||||
|
dayAccumulator.actualMinutes += operation.actualMinutes;
|
||||||
|
dayAccumulator.operationCount += 1;
|
||||||
|
stationDayAccumulators.set(dayAccumulatorKey, dayAccumulator);
|
||||||
}
|
}
|
||||||
stationAccumulators.set(operation.station.id, current);
|
stationAccumulators.set(operation.station.id, current);
|
||||||
}
|
}
|
||||||
@@ -537,6 +580,14 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
|||||||
}
|
}
|
||||||
return left.stationCode.localeCompare(right.stationCode);
|
return left.stationCode.localeCompare(right.stationCode);
|
||||||
});
|
});
|
||||||
|
const stationDayLoads = [...stationDayAccumulators.values()]
|
||||||
|
.map(createStationDayLoad)
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.dateKey !== right.dateKey) {
|
||||||
|
return left.dateKey.localeCompare(right.dateKey);
|
||||||
|
}
|
||||||
|
return left.stationId.localeCompare(right.stationId);
|
||||||
|
});
|
||||||
const stationLoadById = new Map(stationLoads.map((load) => [load.stationId, load]));
|
const stationLoadById = new Map(stationLoads.map((load) => [load.stationId, load]));
|
||||||
|
|
||||||
const tasks: GanttTaskDto[] = [];
|
const tasks: GanttTaskDto[] = [];
|
||||||
@@ -863,5 +914,6 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
|
|||||||
})
|
})
|
||||||
.slice(0, 12),
|
.slice(0, 12),
|
||||||
stationLoads,
|
stationLoads,
|
||||||
|
stationDayLoads,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,10 +95,23 @@ export interface PlanningStationLoadDto {
|
|||||||
lateCount: number;
|
lateCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlanningStationDayLoadDto {
|
||||||
|
stationId: string;
|
||||||
|
dateKey: string;
|
||||||
|
plannedMinutes: number;
|
||||||
|
actualMinutes: number;
|
||||||
|
capacityMinutes: number;
|
||||||
|
utilizationPercent: number;
|
||||||
|
actualUtilizationPercent: number;
|
||||||
|
operationCount: number;
|
||||||
|
overloaded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlanningTimelineDto {
|
export interface PlanningTimelineDto {
|
||||||
tasks: GanttTaskDto[];
|
tasks: GanttTaskDto[];
|
||||||
links: GanttLinkDto[];
|
links: GanttLinkDto[];
|
||||||
summary: PlanningSummaryDto;
|
summary: PlanningSummaryDto;
|
||||||
exceptions: PlanningExceptionDto[];
|
exceptions: PlanningExceptionDto[];
|
||||||
stationLoads: PlanningStationLoadDto[];
|
stationLoads: PlanningStationLoadDto[];
|
||||||
|
stationDayLoads: PlanningStationDayLoadDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
96
test-puppeteer.js
Normal file
96
test-puppeteer.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
@page { size: 4in 6in; margin: 0; }
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
html, body { width: 4in; height: 6in; margin: 0; padding: 0.5in; overflow: hidden; }
|
||||||
|
body { font-family: 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; height: 100%; overflow: hidden; }
|
||||||
|
.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 #ed8936; padding-bottom: 10px; }
|
||||||
|
.brand h1 { margin: 0; font-size: 18px; color: #ed8936; }
|
||||||
|
.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>Message Point Media</h1>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right;">
|
||||||
|
<div class="muted">Shipment</div>
|
||||||
|
<div class="big">SHP-00003</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="block">
|
||||||
|
<div class="muted">Ship To</div>
|
||||||
|
<div class="stack" style="margin-top:8px;">
|
||||||
|
<div class="strong">Northwind Fabrication</div>
|
||||||
|
<div>42 Assembly Ave</div>
|
||||||
|
<div>Milwaukee, WI 53202</div>
|
||||||
|
<div>USA</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="block" style="flex:1;">
|
||||||
|
<div class="muted">Service</div>
|
||||||
|
<div class="big" style="margin-top:6px;">GROUND</div>
|
||||||
|
</div>
|
||||||
|
<div class="block" style="width:90px;">
|
||||||
|
<div class="muted">Pkgs</div>
|
||||||
|
<div class="big" style="margin-top:6px;">1</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;">SO-00002</div>
|
||||||
|
</div>
|
||||||
|
<div class="block" style="width:110px;">
|
||||||
|
<div class="muted">Ship Date</div>
|
||||||
|
<div class="strong" style="margin-top:6px;">N/A</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="block">
|
||||||
|
<div class="muted">Reference</div>
|
||||||
|
<div style="margin-top:6px;">FG-CTRL-BASE · Control Base Assembly</div>
|
||||||
|
</div>
|
||||||
|
<div class="barcode">
|
||||||
|
*SHP-00003*
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center; font-size:10px; color:#4b5563;">Carrier pending · Tracking pending</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const browser = await puppeteer.launch();
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setContent(html, { waitUntil: "networkidle0" });
|
||||||
|
const pdf = await page.pdf({
|
||||||
|
format: "A4",
|
||||||
|
printBackground: true,
|
||||||
|
preferCSSPageSize: true,
|
||||||
|
});
|
||||||
|
fs.writeFileSync('/tmp/test-label.pdf', pdf);
|
||||||
|
console.log("PDF generated at /tmp/test-label.pdf");
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run();
|
||||||
71
usage_guide.md
Normal file
71
usage_guide.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# CODEXIUM: End-to-End Workflow Guide
|
||||||
|
|
||||||
|
This guide walks through the core operational workflow in CODEXIUM, starting from capturing a customer request in a Quote, all the way to shipping the final product.
|
||||||
|
|
||||||
|
## 1. Create a Quote
|
||||||
|
The process begins when a customer requests pricing.
|
||||||
|
|
||||||
|
1. Navigate to the **Quotes** module.
|
||||||
|
2. Click to create a **New Quote**.
|
||||||
|
3. Select the Customer using the searchable lookup.
|
||||||
|
4. Add **Quote Lines** by searching for the requested Inventory SKUs. The default price from the item master will automatically populate.
|
||||||
|
5. Add any necessary discounts, freight, and notes.
|
||||||
|
6. The Quote can go through an approval process. Once the customer accepts the terms, proceed to the next step.
|
||||||
|
|
||||||
|
## 2. Convert to Sales Order
|
||||||
|
Once the Quote is accepted, it becomes firm demand.
|
||||||
|
|
||||||
|
1. Open the approved **Quote**.
|
||||||
|
2. Use the action menu to **Convert to Sales Order**.
|
||||||
|
3. This creates a new Sales Order record, carrying over all lines, pricing, and customer information.
|
||||||
|
4. The Sales Order is now the authoritative commercial record for this demand.
|
||||||
|
|
||||||
|
## 3. Create a Project
|
||||||
|
For complex or long-running deliveries, create a Project to track the execution.
|
||||||
|
|
||||||
|
1. Navigate to the **Projects** module.
|
||||||
|
2. Click **New Project** and select the same Customer.
|
||||||
|
3. Define the project scope, priority, due dates, and owner.
|
||||||
|
4. Set up high-level **Milestones** to track progress on deliverables.
|
||||||
|
|
||||||
|
## 4. Assign Quote and Sales Order to Project
|
||||||
|
Link the commercial documents to the execution tracker.
|
||||||
|
|
||||||
|
1. Open the newly created **Project**.
|
||||||
|
2. In the project details, link the original **Quote** and the active **Sales Order**.
|
||||||
|
3. *Note: You can also link the Project from within the Quote and Sales Order detail pages. This reverse-link ensures that quote conversion automatically carries the project context into the Sales Order.*
|
||||||
|
4. The Project dashboard now provides visibility into the commercial value and linked deliverables.
|
||||||
|
|
||||||
|
## 5. Determine Manufacturing & Supply Requirements (Demand Planning)
|
||||||
|
With the Sales Order firm and linked to a Project, determine what needs to be made or bought.
|
||||||
|
|
||||||
|
1. Navigate to the **Workbench** module, where the **Demand Planning** view is available for the Sales Order.
|
||||||
|
2. The system runs a **Multi-level BOM explosion** against the Sales Order lines, netting against current stock and open supply (existing POs/MOs).
|
||||||
|
3. The system will generate **Build/Buy recommendations** for any shortages.
|
||||||
|
|
||||||
|
## 6. Issue Purchase Orders (POs)
|
||||||
|
Fulfill the "Buy" recommendations.
|
||||||
|
|
||||||
|
1. From the Demand Planning recommendations, use the planner-assisted conversion to draft **Purchase Orders** for the required buyout items and raw materials.
|
||||||
|
2. The POs will automatically peg back to the source Sales Order and carry the Project context.
|
||||||
|
3. Preferred vendors from the inventory item master are selected by default.
|
||||||
|
4. Send the PO PDFs to vendors and manage receiving in the **Purchase Orders** and **Warehouse** modules.
|
||||||
|
|
||||||
|
## 7. Issue Manufacturing Orders (MOs/Work Orders)
|
||||||
|
Fulfill the "Build" recommendations.
|
||||||
|
|
||||||
|
1. Again, from the Demand Planning recommendations, convert the "Build" shortages into **Work Orders**.
|
||||||
|
2. The Work Orders define the execution plan (Operations/Stations) based on the item's routing templates in the **Manufacturing** module.
|
||||||
|
3. Provide the Work Order to the shop floor.
|
||||||
|
4. As operators log labor and issue materials against the Work Order, the costs roll up, and final completion posts the finished goods to inventory, making them available for shipment.
|
||||||
|
|
||||||
|
## 8. Assign Shipping
|
||||||
|
Once production is complete, deliver the goods.
|
||||||
|
|
||||||
|
1. Navigate to the **Shipments** module and create a **New Shipment**.
|
||||||
|
2. Link the Shipment directly to the **Sales Order**.
|
||||||
|
3. The system shows the ordered vs. picked vs. remaining quantities.
|
||||||
|
4. Execute **Shipment Picking**, which pulls stock from specific warehouse locations and posts the inventory issue transactions.
|
||||||
|
5. Update logistics details: Carrier, Service Level, Tracking Number, and Package Count.
|
||||||
|
6. Generate branded logistics PDFs directly from the Shipment: **Packing Slips, Shipping Labels, and Bills of Lading**.
|
||||||
|
7. The Shipment can also be seen from the linked Project, closing the loop on the delivery lifecycle.
|
||||||
Reference in New Issue
Block a user