Compare commits

...

18 Commits

Author SHA1 Message Date
e65ed892f1 shipping label fix - codex 2026-03-19 21:47:26 -05:00
jason
ce2d52db53 hopeful 2026-03-19 16:38:41 -05:00
jason
39fd876d51 fixing 2026-03-19 16:34:18 -05:00
jason
0c3b2cf6fe fixes 2026-03-19 16:19:48 -05:00
jason
6423dfb91b again 2026-03-19 16:14:35 -05:00
jason
26b188de87 fix 2026-03-19 16:12:10 -05:00
jason
0b43b4ebf5 shipping fix 2026-03-19 16:06:50 -05:00
jason
3c312733ca shipping label 2 2026-03-19 16:01:32 -05:00
jason
9d54dc2ecd shipping label 2026-03-19 15:57:39 -05:00
jason
b762c70238 clean up usage guide 2026-03-19 15:37:51 -05:00
jason
9562c1cc9c usage guide 2026-03-19 13:09:29 -05:00
3eba7c5fa6 workbench 2026-03-19 07:41:06 -05:00
4949b6033f more workbench usability 2026-03-19 07:38:08 -05:00
cf54e4ba58 usability workbench 2026-03-18 23:48:14 -05:00
061057339b more 2026-03-18 23:42:30 -05:00
7b65fe06cf more workbench 2026-03-18 23:32:12 -05:00
d22e715f00 workbench 2026-03-18 23:28:27 -05:00
5fdd366bc3 last cleanup 2026-03-18 23:22:11 -05:00
13 changed files with 1280 additions and 125 deletions

View File

@@ -35,6 +35,18 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
- 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
- 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
@@ -91,6 +103,7 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
### 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
- 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

View File

@@ -112,6 +112,7 @@ This file tracks work that still needs to be completed. Shipped phase history an
### 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
- 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

View File

@@ -28,6 +28,10 @@
.module-title {
@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 {

View File

@@ -58,12 +58,11 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
}
return (
<article className="min-w-0 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Contacts</p>
<h4 className="mt-2 text-lg font-bold text-text">People on this account</h4>
<div className="mt-5 space-y-3">
<article className="surface-panel min-w-0">
<p className="section-kicker">CONTACTS</p>
<div className="mt-3 space-y-2">
{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.
</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>
<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 className="mt-1 text-sm text-muted">{crmContactRoleOptions.find((option) => option.value === contact.role)?.label ?? contact.role}</div>
</div>
@@ -86,22 +85,22 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
)}
</div>
{canManage ? (
<form className="mt-5 space-y-4" onSubmit={handleSubmit}>
<div className="grid gap-3 xl:grid-cols-2">
<form className="mt-3 space-y-3" onSubmit={handleSubmit}>
<div className="grid gap-3 xl:grid-cols-2">
<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
value={form.fullName}
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 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
value={form.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) => (
<option key={option.value} value={option.value}>
@@ -111,24 +110,24 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
</select>
</label>
<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
type="email"
value={form.email}
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 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
value={form.phone}
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>
</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
type="checkbox"
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>
</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>
<button
type="submit"
disabled={isSaving}
className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
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"}
</button>
@@ -151,4 +150,3 @@ export function CrmContactsPanel({ entity, ownerId, contacts, onContactsChange }
</article>
);
}

View File

@@ -74,7 +74,7 @@ export function AdminDiagnosticsPage() {
}, [token, supportLogLevel, supportLogSource, supportLogQuery, supportLogWindowDays]);
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() {
@@ -156,7 +156,7 @@ export function AdminDiagnosticsPage() {
];
return (
<div className="space-y-6">
<div className="page-stack">
<section className="surface-panel backdrop-blur">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
@@ -186,11 +186,11 @@ export function AdminDiagnosticsPage() {
</Link>
</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]) => (
<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="mt-3 text-lg font-bold text-text">{value}</p>
<p className="mt-2 text-lg font-bold text-text">{value}</p>
</div>
))}
</div>
@@ -201,16 +201,16 @@ export function AdminDiagnosticsPage() {
<div>
<p className="section-kicker">BACKUP AND RESTORE</p>
</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>DB: {backupGuidance.databasePath}</div>
<div>Uploads: {backupGuidance.uploadsPath}</div>
</div>
</div>
<div className="mt-5 grid gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-line/70 bg-page/70 p-4">
<div className="mt-3 grid gap-3 xl:grid-cols-2">
<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>
<div className="mt-3 space-y-3">
<div className="mt-3 space-y-2">
{backupGuidance.backupSteps.map((step) => (
<div key={step.id}>
<p className="text-sm font-semibold text-text">{step.label}</p>
@@ -219,9 +219,9 @@ export function AdminDiagnosticsPage() {
))}
</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>
<div className="mt-3 space-y-3">
<div className="mt-3 space-y-2">
{backupGuidance.restoreSteps.map((step) => (
<div key={step.id}>
<p className="text-sm font-semibold text-text">{step.label}</p>
@@ -231,10 +231,10 @@ export function AdminDiagnosticsPage() {
</div>
</div>
</div>
<div className="mt-5 grid gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-line/70 bg-page/70 p-4">
<div className="mt-3 grid gap-3 xl:grid-cols-2">
<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>
<div className="mt-3 space-y-3">
<div className="mt-3 space-y-2">
{backupGuidance.verificationChecklist.map((item) => (
<div key={item.id}>
<p className="text-sm font-semibold text-text">{item.label}</p>
@@ -244,9 +244,9 @@ export function AdminDiagnosticsPage() {
))}
</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>
<div className="mt-3 space-y-3">
<div className="mt-3 space-y-2">
{backupGuidance.restoreDrillSteps.map((step) => (
<div key={step.id}>
<p className="text-sm font-semibold text-text">{step.label}</p>
@@ -268,7 +268,7 @@ export function AdminDiagnosticsPage() {
{diagnostics.startup.status}
</span>
</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) => (
<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">
@@ -279,7 +279,7 @@ export function AdminDiagnosticsPage() {
</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]) => (
<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>
@@ -291,7 +291,7 @@ export function AdminDiagnosticsPage() {
<section className="surface-panel backdrop-blur">
<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]) => (
<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>
@@ -310,9 +310,9 @@ export function AdminDiagnosticsPage() {
{supportLogSummary ? `${supportLogSummary.filteredCount} of ${supportLogSummary.totalCount} entries` : "No entries loaded"}
</p>
</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">
<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
value={supportLogQuery}
onChange={(event) => setSupportLogQuery(event.target.value)}
@@ -321,7 +321,7 @@ export function AdminDiagnosticsPage() {
/>
</label>
<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">
<option value="ALL">All levels</option>
<option value="ERROR">Error</option>
@@ -330,7 +330,7 @@ export function AdminDiagnosticsPage() {
</select>
</label>
<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">
<option value="ALL">All sources</option>
{supportLogSources.map((source) => (
@@ -339,7 +339,7 @@ export function AdminDiagnosticsPage() {
</select>
</label>
<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">
<option value="ALL">All retained</option>
<option value="1">Last 24 hours</option>
@@ -347,13 +347,13 @@ export function AdminDiagnosticsPage() {
<option value="14">Last 14 days</option>
</select>
</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>Warnings: {supportLogSummary?.levelCounts.WARN ?? 0}</div>
<div>Info: {supportLogSummary?.levelCounts.INFO ?? 0}</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">
<thead>
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
@@ -402,7 +402,7 @@ export function AdminDiagnosticsPage() {
</div>
<p className="text-sm text-muted">{status}</p>
</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">
<thead>
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">

View File

@@ -272,7 +272,7 @@ export function UserManagementPage() {
const reviewSessionCount = sessions.filter((session) => session.reviewState === "REVIEW").length;
return (
<div className="space-y-6">
<div className="page-stack">
<section className="surface-panel backdrop-blur">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
@@ -290,7 +290,7 @@ export function UserManagementPage() {
</div>
</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}>
<div className="flex items-center justify-between gap-3">
<div>
@@ -310,9 +310,9 @@ export function UserManagementPage() {
</select>
</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">
<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
value={userForm.email}
onChange={(event) => setUserForm((current) => ({ ...current, email: event.target.value }))}
@@ -320,7 +320,7 @@ export function UserManagementPage() {
/>
</label>
<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
type="password"
value={userForm.password ?? ""}
@@ -330,7 +330,7 @@ export function UserManagementPage() {
/>
</label>
<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
value={userForm.firstName}
onChange={(event) => setUserForm((current) => ({ ...current, firstName: event.target.value }))}
@@ -338,7 +338,7 @@ export function UserManagementPage() {
/>
</label>
<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
value={userForm.lastName}
onChange={(event) => setUserForm((current) => ({ ...current, lastName: event.target.value }))}
@@ -347,7 +347,7 @@ export function UserManagementPage() {
</label>
</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
type="checkbox"
checked={userForm.isActive}
@@ -356,11 +356,11 @@ export function UserManagementPage() {
User can sign in
</label>
<div className="mt-5">
<p className="text-sm font-semibold text-text">Assigned roles</p>
<div className="mt-3 grid gap-3">
<div className="mt-3">
<p className="section-kicker">ASSIGNED ROLES</p>
<div className="mt-3 grid gap-2">
{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
type="checkbox"
checked={userForm.roleIds.includes(role.id)}
@@ -375,7 +375,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>
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
{selectedUserId === "new" ? "Create user" : "Save user"}
@@ -402,9 +402,9 @@ export function UserManagementPage() {
</select>
</div>
<div className="mt-5 grid gap-4">
<div className="mt-3 grid gap-3">
<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
value={roleForm.name}
onChange={(event) => setRoleForm((current) => ({ ...current, name: event.target.value }))}
@@ -412,7 +412,7 @@ export function UserManagementPage() {
/>
</label>
<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
value={roleForm.description}
onChange={(event) => setRoleForm((current) => ({ ...current, description: event.target.value }))}
@@ -422,11 +422,11 @@ export function UserManagementPage() {
</label>
</div>
<div className="mt-5">
<p className="text-sm font-semibold text-text">Role permissions</p>
<div className="mt-3 grid gap-3 md:grid-cols-2">
<div className="mt-3">
<p className="section-kicker">ROLE PERMISSIONS</p>
<div className="mt-3 grid gap-2 md:grid-cols-2">
{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
type="checkbox"
checked={roleForm.permissionKeys.includes(permission.key)}
@@ -441,9 +441,9 @@ export function UserManagementPage() {
</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) => (
<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="mt-1 text-xs text-muted">{role.userCount} assigned users</p>
<p className="mt-2 text-xs text-muted">{role.permissionKeys.length} permissions</p>
@@ -451,7 +451,7 @@ export function UserManagementPage() {
))}
</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>
<button type="submit" className="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white">
{selectedRoleId === "new" ? "Create role" : "Save role"}
@@ -467,7 +467,7 @@ export function UserManagementPage() {
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<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
value={sessionQuery}
onChange={(event) => setSessionQuery(event.target.value)}
@@ -476,7 +476,7 @@ export function UserManagementPage() {
/>
</label>
<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
value={sessionUserFilter}
onChange={(event) => setSessionUserFilter(event.target.value)}
@@ -491,7 +491,7 @@ export function UserManagementPage() {
</select>
</label>
<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
value={sessionStatusFilter}
onChange={(event) => setSessionStatusFilter(event.target.value as "ALL" | AdminAuthSessionDto["status"])}
@@ -504,7 +504,7 @@ export function UserManagementPage() {
</select>
</label>
<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
value={sessionReviewFilter}
onChange={(event) => setSessionReviewFilter(event.target.value as "ALL" | AdminAuthSessionDto["reviewState"])}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,15 @@
import puppeteer from "puppeteer";
import puppeteer, { PaperFormat } from "puppeteer";
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({
executablePath: env.PUPPETEER_EXECUTABLE_PATH,
headless: true,
@@ -14,7 +21,10 @@ export async function renderPdf(html: string) {
await page.setContent(html, { waitUntil: "networkidle0" });
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,
preferCSSPageSize: true,
});

View File

@@ -152,29 +152,40 @@ function buildShippingLabelPdf(options: {
<html>
<head>
<style>
@page { size: 4in 6in; margin: 8mm; }
body { font-family: ${company.theme.fontFamily}, Arial, sans-serif; color: #111827; font-size: 11px; }
.label { border: 2px solid #111827; border-radius: 12px; padding: 12px; display: flex; flex-direction: column; gap: 12px; min-height: calc(6in - 16mm); box-sizing: border-box; }
.row { display: flex; justify-content: space-between; gap: 12px; }
@page { size: 4in 6in; margin: 0; }
*, *::before, *::after { 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; }
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; }
.brand { border-bottom: 2px solid ${company.theme.primaryColor}; padding-bottom: 10px; }
.brand h1 { margin: 0; font-size: 18px; color: ${company.theme.primaryColor}; }
.block { border: 1px solid #d1d5db; border-radius: 10px; padding: 10px; }
.stack { display: flex; flex-direction: column; gap: 4px; }
.barcode { border: 2px solid #111827; border-radius: 10px; padding: 8px; text-align: center; font-family: monospace; font-size: 18px; letter-spacing: 0.18em; }
.brand { border-bottom: 2px solid ${company.theme.primaryColor}; padding-bottom: 0.09in; }
.brand-row { align-items: flex-start; }
.brand-company { flex: 1; min-width: 0; padding-right: 0.06in; }
.brand h1 { margin: 0; font-size: 16px; line-height: 1.05; color: ${company.theme.primaryColor}; overflow-wrap: anywhere; }
.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; }
.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>
</head>
<body>
<div class="label">
<div class="page">
<div class="label">
<div class="brand">
<div class="row">
<div>
<div class="row brand-row">
<div class="brand-company">
<div class="muted">From</div>
<h1>${escapeHtml(company.companyName)}</h1>
</div>
<div style="text-align:right;">
<div class="shipment-number">
<div class="muted">Shipment</div>
<div class="big">${escapeHtml(shipment.shipmentNumber)}</div>
</div>
@@ -217,7 +228,7 @@ function buildShippingLabelPdf(options: {
</div>
</body>
</html>
`);
`, { width: "4in", height: "6in", margin: { top: "0", right: "0", bottom: "0", left: "0" } });
}
function buildBillOfLadingPdf(options: {

View File

@@ -2,6 +2,7 @@ import type {
GanttLinkDto,
GanttTaskDto,
PlanningReadinessState,
PlanningStationDayLoadDto,
PlanningStationLoadDto,
PlanningTaskActionDto,
PlanningTimelineDto,
@@ -94,6 +95,15 @@ type StationAccumulator = {
workingDays: number[];
};
type StationDayAccumulator = {
stationId: string;
dateKey: string;
plannedMinutes: number;
actualMinutes: number;
operationCount: number;
capacityMinutes: number;
};
function clampProgress(value: number) {
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(
project: PlanningProjectRecord,
projectWorkOrders: PlanningWorkOrderRecord[],
@@ -492,6 +519,7 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
}
const stationAccumulators = new Map<string, StationAccumulator>();
const stationDayAccumulators = new Map<string, StationDayAccumulator>();
for (const workOrder of openWorkOrders) {
const insight = workOrderInsights.get(workOrder.id);
for (const operation of workOrder.operations) {
@@ -525,7 +553,22 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
current.lateCount += 1;
}
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);
}
@@ -537,6 +580,14 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
}
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 tasks: GanttTaskDto[] = [];
@@ -863,5 +914,6 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
})
.slice(0, 12),
stationLoads,
stationDayLoads,
};
}

View File

@@ -95,10 +95,23 @@ export interface PlanningStationLoadDto {
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 {
tasks: GanttTaskDto[];
links: GanttLinkDto[];
summary: PlanningSummaryDto;
exceptions: PlanningExceptionDto[];
stationLoads: PlanningStationLoadDto[];
stationDayLoads: PlanningStationDayLoadDto[];
}

96
test-puppeteer.js Normal file
View 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
View 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.