finance
This commit is contained in:
@@ -6,6 +6,7 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
|
||||
|
||||
### Added
|
||||
|
||||
- Finance module with customer-payment posting against sales orders, finance costing assumptions, sales-order cash/spend ledger rollups, manufacturing cost snapshots, and CapEx tracking for equipment, tooling, and consumables
|
||||
- Inventory-backed shipment picking from shipment detail pages, including sales-order line remaining-quantity visibility, warehouse/location source selection, issued-stock posting, and shipment pick history
|
||||
- Project cockpit section on project detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and project cost snapshot rollups, plus direct launch paths into prefilled work-order and purchase-order follow-through and a chronological project activity timeline
|
||||
- Planning workbench replacing the old one-note planning screen with mode switching, dense exception rail, heatmap load view, agenda view, and focus drawer
|
||||
|
||||
21
README.md
21
README.md
@@ -23,6 +23,7 @@ Current foundation scope includes:
|
||||
- purchase orders with searchable vendor and SKU entry, restricted to purchasable inventory items
|
||||
- purchase-order revision history and revision comparison across commercial and receipt changes
|
||||
- purchase receiving with warehouse/location posting and receipt history against purchase orders
|
||||
- finance with sales-order-linked customer payments, live purchasing/manufacturing spend rollups, costing assumptions, and CapEx tracking for equipment, tooling, and consumables
|
||||
- branded quote, sales-order, and purchase-order PDFs through the shared backend document pipeline
|
||||
- purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and backup files
|
||||
- shipping shipments linked to sales orders with inventory-backed picking, stock issue posting, packing slips, shipping labels, bills of lading, and logistics attachments
|
||||
@@ -89,6 +90,24 @@ Navigation direction:
|
||||
- new modules should add a clear, domain-appropriate SVG icon when they are added to the shell
|
||||
- icons should stay lightweight, theme-aware, and dependency-free unless there is a strong reason to introduce a shared icon package
|
||||
|
||||
## Finance Direction
|
||||
|
||||
Finance is now a first-class domain for commercial cash tracking and capital planning rather than a hidden report stitched together from sales and purchasing screens. The current slice ships sales-order-linked payment posting, labor/overhead costing assumptions, cross-linked revenue versus purchasing/manufacturing spend rollups, and CapEx tracking for equipment, tooling, and consumables with optional purchase-order linkage.
|
||||
|
||||
Current interactions:
|
||||
|
||||
- Sales: customer receipts post against sales orders and update finance-ledger visibility for booked revenue, payments received, and open A/R
|
||||
- Purchasing: linked PO lines contribute committed and received spend visibility to the sales-order finance ledger
|
||||
- Manufacturing: issued material and recorded labor drive derived manufacturing/assembly cost rollups using finance-side labor and overhead assumptions
|
||||
- Dashboard direction: finance should later contribute margin, cash, CapEx, and payment-risk widgets without replacing the operational dashboard
|
||||
|
||||
Next expansion areas:
|
||||
|
||||
- AP-side disbursements, invoice matching, and vendor payment workflows
|
||||
- More granular manufacturing costing with crew rates, burden rules, and variance reporting
|
||||
- Project-level P&L and earned-value style rollups across commercial, supply, and execution
|
||||
- Accounting export/integration once the internal finance operating model is deeper
|
||||
|
||||
## Projects Direction
|
||||
|
||||
Projects are now the long-running program and delivery layer for cross-module execution. The current slice ships project records with customer linkage, owner assignment, priority, due dates, milestones, project-side milestone/work-order rollups, cockpit-style commercial/supply/execution/delivery/purchasing visibility, readiness-risk scoring, a cost snapshot from linked purchasing and manufacturing data, direct launch paths into prefilled purchasing/manufacturing follow-through, an activity timeline across linked execution records, notes, commercial document links, shipment links, attachments, and dashboard visibility.
|
||||
@@ -394,7 +413,7 @@ Current follow-up direction:
|
||||
## UI Notes
|
||||
|
||||
- Dark mode persistence is handled through the frontend theme provider and should remain stable across page navigation.
|
||||
- The shell layout is tuned for wider desktop use than the original foundation build, and now exposes Dashboard, CRM, inventory, sales, purchasing, shipping, projects, manufacturing, settings, and planning modules from the same app shell.
|
||||
- The shell layout is tuned for wider desktop use than the original foundation build, and now exposes Dashboard, CRM, inventory, sales, purchasing, finance, shipping, projects, manufacturing, settings, and planning modules from the same app shell.
|
||||
- The active module screens now follow a tighter density baseline for forms, tables, and detail cards.
|
||||
- The dashboard should continue evolving as a modular metric board for future purchasing, shipping, planning, and audit data.
|
||||
- The client now ships with route-level lazy loading and vendor chunking, so future frontend work should preserve that split instead of re-centralizing module imports in `main.tsx`.
|
||||
|
||||
@@ -64,6 +64,14 @@ This file tracks work that still needs to be completed. Shipped phase history an
|
||||
- Better totals breakdown visibility on list pages and detail pages
|
||||
- Faster document cloning and quote-to-order style conversions across document types
|
||||
|
||||
### Finance
|
||||
|
||||
- Expand from customer receipts into AP disbursements, invoice matching, and vendor-payment control
|
||||
- Add project-level P&L, cash posture, and earned-value style rollups across sales, purchasing, manufacturing, and shipping
|
||||
- Deepen manufacturing costing with crew rates, burden rules, and variance reporting instead of only the current labor/overhead assumptions
|
||||
- Add accounting export or integration surfaces once the internal finance workflows mature
|
||||
- Add richer dashboard widgets for margin pressure, open receivables, CapEx exposure, and payment coverage risk
|
||||
|
||||
### Shipping and logistics
|
||||
|
||||
- Partial shipment workflow and split-shipment visibility
|
||||
|
||||
@@ -28,6 +28,7 @@ This file tracks roadmap phases, slices, and major foundations that have already
|
||||
- Purchase orders with vendor lookup, item lines, totals, and quick status actions
|
||||
- Purchase-order line selection restricted to inventory items flagged as purchasable
|
||||
- Purchase receiving foundation with warehouse/location posting, receipt history, and per-line received quantity tracking
|
||||
- Finance module with sales-order-linked customer payments, live spend/margin rollups across linked purchase orders and manufacturing, finance costing assumptions, and CapEx tracking for equipment, tooling, and consumables
|
||||
- Branded sales quote, sales order, and purchase-order PDF templates through the shared Puppeteer pipeline
|
||||
- Shipping shipment records linked to sales orders
|
||||
- Inventory-backed shipment picking with stock issue posting from warehouse locations and shipment-side pick history
|
||||
|
||||
@@ -14,6 +14,7 @@ const links = [
|
||||
{ to: "/sales/quotes", label: "Quotes", icon: <QuoteIcon /> },
|
||||
{ to: "/sales/orders", label: "Sales Orders", icon: <SalesOrderIcon /> },
|
||||
{ to: "/purchasing/orders", label: "Purchase Orders", icon: <PurchaseOrderIcon /> },
|
||||
{ to: "/finance", label: "Finance", icon: <FinanceIcon /> },
|
||||
{ to: "/shipping/shipments", label: "Shipments", icon: <ShipmentIcon /> },
|
||||
{ to: "/projects", label: "Projects", icon: <ProjectsIcon /> },
|
||||
{ to: "/manufacturing/work-orders", label: "Manufacturing", icon: <ManufacturingIcon /> },
|
||||
@@ -146,6 +147,18 @@ function ShipmentIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function FinanceIcon() {
|
||||
return (
|
||||
<NavIcon>
|
||||
<path d="M4 18h16" />
|
||||
<path d="M7 15V9" />
|
||||
<path d="M12 15V6" />
|
||||
<path d="M17 15v-4" />
|
||||
<path d="M5 6h14" />
|
||||
</NavIcon>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkbenchIcon() {
|
||||
return (
|
||||
<NavIcon>
|
||||
|
||||
@@ -14,6 +14,13 @@ import type {
|
||||
ApiResponse,
|
||||
CompanyProfileDto,
|
||||
CompanyProfileInput,
|
||||
FinanceCapexDto,
|
||||
FinanceCapexInput,
|
||||
FinanceCustomerPaymentDto,
|
||||
FinanceCustomerPaymentInput,
|
||||
FinanceDashboardDto,
|
||||
FinanceProfileDto,
|
||||
FinanceProfileInput,
|
||||
FileAttachmentDto,
|
||||
PlanningTimelineDto,
|
||||
LoginRequest,
|
||||
@@ -287,6 +294,21 @@ export const api = {
|
||||
token
|
||||
);
|
||||
},
|
||||
getFinanceDashboard(token: string) {
|
||||
return request<FinanceDashboardDto>("/api/v1/finance/overview", undefined, token);
|
||||
},
|
||||
updateFinanceProfile(token: string, payload: FinanceProfileInput) {
|
||||
return request<FinanceProfileDto>("/api/v1/finance/profile", { method: "PUT", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
createFinancePayment(token: string, payload: FinanceCustomerPaymentInput) {
|
||||
return request<FinanceCustomerPaymentDto>("/api/v1/finance/payments", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
createCapexEntry(token: string, payload: FinanceCapexInput) {
|
||||
return request<FinanceCapexDto>("/api/v1/finance/capex", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
updateCapexEntry(token: string, capexId: string, payload: FinanceCapexInput) {
|
||||
return request<FinanceCapexDto>(`/api/v1/finance/capex/${capexId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
getCustomers(
|
||||
token: string,
|
||||
filters?: {
|
||||
|
||||
@@ -101,6 +101,9 @@ const ShipmentDetailPage = React.lazy(() =>
|
||||
const ShipmentFormPage = React.lazy(() =>
|
||||
import("./modules/shipping/ShipmentFormPage").then((module) => ({ default: module.ShipmentFormPage }))
|
||||
);
|
||||
const FinancePage = React.lazy(() =>
|
||||
import("./modules/finance/FinancePage").then((module) => ({ default: module.FinancePage }))
|
||||
);
|
||||
const WorkbenchPage = React.lazy(() =>
|
||||
import("./modules/workbench/WorkbenchPage").then((module) => ({ default: module.WorkbenchPage }))
|
||||
);
|
||||
@@ -201,6 +204,10 @@ const router = createBrowserRouter([
|
||||
{ path: "/shipping/shipments/:shipmentId", element: lazyElement(<ShipmentDetailPage />) },
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.financeRead]} />,
|
||||
children: [{ path: "/finance", element: lazyElement(<FinancePage />) }],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.crmWrite]} />,
|
||||
children: [
|
||||
|
||||
481
client/src/modules/finance/FinancePage.tsx
Normal file
481
client/src/modules/finance/FinancePage.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import type {
|
||||
CapexCategory,
|
||||
CapexStatus,
|
||||
FinanceCapexInput,
|
||||
FinanceCustomerPaymentInput,
|
||||
FinanceDashboardDto,
|
||||
FinancePaymentMethod,
|
||||
FinancePaymentType,
|
||||
FinanceProfileInput,
|
||||
} from "@mrp/shared";
|
||||
import { capexCategories, capexStatuses, financePaymentMethods, financePaymentTypes } from "@mrp/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
|
||||
function formatCurrency(value: number, currencyCode = "USD") {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: currencyCode,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatPercent(value: number) {
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function FinancePage() {
|
||||
const { token, user } = useAuth();
|
||||
const canManage = user?.permissions.includes(permissions.financeWrite) ?? false;
|
||||
const [dashboard, setDashboard] = useState<FinanceDashboardDto | null>(null);
|
||||
const [salesOrders, setSalesOrders] = useState<Awaited<ReturnType<typeof api.getSalesOrders>>>([]);
|
||||
const [purchaseOrders, setPurchaseOrders] = useState<Awaited<ReturnType<typeof api.getPurchaseOrders>>>([]);
|
||||
const [vendors, setVendors] = useState<Awaited<ReturnType<typeof api.getPurchaseVendors>>>([]);
|
||||
const [status, setStatus] = useState("Loading finance workbench...");
|
||||
const [isSavingProfile, setIsSavingProfile] = useState(false);
|
||||
const [isPostingPayment, setIsPostingPayment] = useState(false);
|
||||
const [isSavingCapex, setIsSavingCapex] = useState(false);
|
||||
const [editingCapexId, setEditingCapexId] = useState<string | null>(null);
|
||||
const [profileForm, setProfileForm] = useState<FinanceProfileInput>({
|
||||
currencyCode: "USD",
|
||||
standardLaborRatePerHour: 45,
|
||||
overheadRatePerHour: 18,
|
||||
});
|
||||
const [paymentForm, setPaymentForm] = useState<FinanceCustomerPaymentInput>({
|
||||
salesOrderId: "",
|
||||
paymentType: "DEPOSIT",
|
||||
paymentMethod: "ACH",
|
||||
paymentDate: new Date().toISOString(),
|
||||
amount: 0,
|
||||
reference: "",
|
||||
notes: "",
|
||||
});
|
||||
const [capexForm, setCapexForm] = useState<FinanceCapexInput>({
|
||||
title: "",
|
||||
category: "EQUIPMENT",
|
||||
status: "PLANNED",
|
||||
vendorId: null,
|
||||
purchaseOrderId: null,
|
||||
plannedAmount: 0,
|
||||
actualAmount: 0,
|
||||
requestDate: new Date().toISOString(),
|
||||
targetInServiceDate: null,
|
||||
purchasedAt: null,
|
||||
notes: "",
|
||||
});
|
||||
|
||||
async function loadFinance(activeToken: string) {
|
||||
const [nextDashboard, nextSalesOrders, nextPurchaseOrders, nextVendors] = await Promise.all([
|
||||
api.getFinanceDashboard(activeToken),
|
||||
api.getSalesOrders(activeToken),
|
||||
api.getPurchaseOrders(activeToken),
|
||||
api.getPurchaseVendors(activeToken),
|
||||
]);
|
||||
setDashboard(nextDashboard);
|
||||
setSalesOrders(nextSalesOrders);
|
||||
setPurchaseOrders(nextPurchaseOrders);
|
||||
setVendors(nextVendors);
|
||||
setProfileForm({
|
||||
currencyCode: nextDashboard.profile.currencyCode,
|
||||
standardLaborRatePerHour: nextDashboard.profile.standardLaborRatePerHour,
|
||||
overheadRatePerHour: nextDashboard.profile.overheadRatePerHour,
|
||||
});
|
||||
setPaymentForm((current) => ({
|
||||
...current,
|
||||
salesOrderId: current.salesOrderId || nextSalesOrders[0]?.id || "",
|
||||
}));
|
||||
setStatus("Finance workbench loaded.");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadFinance(token).catch((error: unknown) => {
|
||||
setStatus(error instanceof ApiError ? error.message : "Unable to load finance workbench.");
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
function resetCapexForm() {
|
||||
setEditingCapexId(null);
|
||||
setCapexForm({
|
||||
title: "",
|
||||
category: "EQUIPMENT",
|
||||
status: "PLANNED",
|
||||
vendorId: null,
|
||||
purchaseOrderId: null,
|
||||
plannedAmount: 0,
|
||||
actualAmount: 0,
|
||||
requestDate: new Date().toISOString(),
|
||||
targetInServiceDate: null,
|
||||
purchasedAt: null,
|
||||
notes: "",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSaveProfile() {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingProfile(true);
|
||||
setStatus("Saving finance assumptions...");
|
||||
try {
|
||||
const nextProfile = await api.updateFinanceProfile(token, profileForm);
|
||||
setDashboard((current) => (current ? { ...current, profile: nextProfile } : current));
|
||||
setStatus("Finance assumptions updated.");
|
||||
} catch (error: unknown) {
|
||||
setStatus(error instanceof ApiError ? error.message : "Unable to save finance assumptions.");
|
||||
} finally {
|
||||
setIsSavingProfile(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePostPayment() {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPostingPayment(true);
|
||||
setStatus("Posting customer payment...");
|
||||
try {
|
||||
await api.createFinancePayment(token, paymentForm);
|
||||
await loadFinance(token);
|
||||
setPaymentForm((current) => ({
|
||||
...current,
|
||||
amount: 0,
|
||||
reference: "",
|
||||
notes: "",
|
||||
paymentDate: new Date().toISOString(),
|
||||
}));
|
||||
setStatus("Customer payment posted.");
|
||||
} catch (error: unknown) {
|
||||
setStatus(error instanceof ApiError ? error.message : "Unable to post customer payment.");
|
||||
} finally {
|
||||
setIsPostingPayment(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveCapex() {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingCapex(true);
|
||||
setStatus(editingCapexId ? "Updating CapEx entry..." : "Creating CapEx entry...");
|
||||
try {
|
||||
if (editingCapexId) {
|
||||
await api.updateCapexEntry(token, editingCapexId, capexForm);
|
||||
} else {
|
||||
await api.createCapexEntry(token, capexForm);
|
||||
}
|
||||
await loadFinance(token);
|
||||
resetCapexForm();
|
||||
setStatus(editingCapexId ? "CapEx entry updated." : "CapEx entry created.");
|
||||
} catch (error: unknown) {
|
||||
setStatus(error instanceof ApiError ? error.message : "Unable to save CapEx entry.");
|
||||
} finally {
|
||||
setIsSavingCapex(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!dashboard) {
|
||||
return <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||
}
|
||||
|
||||
const { profile, summary, salesOrderLedgers, payments, capex } = dashboard;
|
||||
const currencyCode = profile.currencyCode || "USD";
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Finance</p>
|
||||
<h2 className="mt-2 text-2xl font-bold text-text">Cash, spend, and CapEx control</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm text-muted">
|
||||
Track customer payments against sales orders, compare them to linked purchasing and manufacturing spend, and manage capital purchases for equipment, tooling, and consumables.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3 text-sm text-muted">
|
||||
Live snapshot generated {new Date(dashboard.generatedAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-3 xl:grid-cols-6">
|
||||
{[
|
||||
{ label: "Booked Revenue", value: formatCurrency(summary.bookedRevenue, currencyCode) },
|
||||
{ label: "Payments In", value: formatCurrency(summary.paymentsReceived, currencyCode) },
|
||||
{ label: "A/R Open", value: formatCurrency(summary.accountsReceivableOpen, currencyCode) },
|
||||
{ label: "PO Spend", value: formatCurrency(summary.linkedPurchaseReceivedValue, currencyCode) },
|
||||
{ label: "Mfg Cost", value: formatCurrency(summary.manufacturingTotalCost, currencyCode) },
|
||||
{ label: "CapEx Actual", value: formatCurrency(summary.capexActual, currencyCode) },
|
||||
].map((card) => (
|
||||
<article key={card.label} 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">{card.label}</p>
|
||||
<div className="mt-2 text-xl font-extrabold text-text">{card.value}</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
|
||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Sales Order Ledger</p>
|
||||
<p className="mt-2 text-sm text-muted">Revenue, receipts, purchasing, and manufacturing cost by order.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-line/60 text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-[0.16em] text-muted">
|
||||
<th className="pb-3 pr-3 font-semibold">Order</th>
|
||||
<th className="pb-3 pr-3 font-semibold">Revenue</th>
|
||||
<th className="pb-3 pr-3 font-semibold">Payments</th>
|
||||
<th className="pb-3 pr-3 font-semibold">PO</th>
|
||||
<th className="pb-3 pr-3 font-semibold">Manufacturing</th>
|
||||
<th className="pb-3 pr-3 font-semibold">Spend</th>
|
||||
<th className="pb-3 font-semibold">Margin</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/50">
|
||||
{salesOrderLedgers.map((ledger) => (
|
||||
<tr key={ledger.salesOrderId}>
|
||||
<td className="py-3 pr-3 align-top">
|
||||
<Link to={`/sales/orders/${ledger.salesOrderId}`} className="font-semibold text-brand hover:underline">
|
||||
{ledger.salesOrderNumber}
|
||||
</Link>
|
||||
<div className="text-xs text-muted">{ledger.customerName}</div>
|
||||
<div className="text-xs text-muted">
|
||||
{ledger.linkedPurchaseOrderCount} PO / {ledger.linkedWorkOrderCount} WO
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-3 align-top text-text">{formatCurrency(ledger.revenueTotal, currencyCode)}</td>
|
||||
<td className="py-3 pr-3 align-top">
|
||||
<div className="text-text">{formatCurrency(ledger.paymentsReceived, currencyCode)}</div>
|
||||
<div className="text-xs text-muted">A/R {formatCurrency(ledger.accountsReceivableOpen, currencyCode)}</div>
|
||||
</td>
|
||||
<td className="py-3 pr-3 align-top">
|
||||
<div className="text-text">{formatCurrency(ledger.linkedPurchaseReceivedValue, currencyCode)}</div>
|
||||
<div className="text-xs text-muted">Committed {formatCurrency(ledger.linkedPurchaseCommitted, currencyCode)}</div>
|
||||
</td>
|
||||
<td className="py-3 pr-3 align-top">
|
||||
<div className="text-text">{formatCurrency(ledger.manufacturingTotalCost, currencyCode)}</div>
|
||||
<div className="text-xs text-muted">
|
||||
Mat {formatCurrency(ledger.manufacturingMaterialCost, currencyCode)} / Lab+OH {formatCurrency(ledger.manufacturingLaborCost + ledger.manufacturingOverheadCost, currencyCode)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-3 align-top">
|
||||
<div className="text-text">{formatCurrency(ledger.totalRecognizedSpend, currencyCode)}</div>
|
||||
<div className="text-xs text-muted">Coverage {formatPercent(ledger.paymentCoveragePercent)}</div>
|
||||
</td>
|
||||
<td className="py-3 align-top">
|
||||
<div className={`font-semibold ${ledger.grossMarginEstimate >= 0 ? "text-emerald-700 dark:text-emerald-300" : "text-rose-700 dark:text-rose-300"}`}>
|
||||
{formatCurrency(ledger.grossMarginEstimate, currencyCode)}
|
||||
</div>
|
||||
<div className="text-xs text-muted">{formatPercent(ledger.grossMarginPercent)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div className="space-y-3">
|
||||
<article className="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">Costing Assumptions</p>
|
||||
<div className="mt-4 grid gap-3">
|
||||
<label className="text-sm text-text">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Currency</span>
|
||||
<input value={profileForm.currencyCode} onChange={(event) => setProfileForm((current) => ({ ...current, currencyCode: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 outline-none" />
|
||||
</label>
|
||||
<label className="text-sm text-text">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Labor Rate / Hour</span>
|
||||
<input type="number" step="0.01" min={0} value={profileForm.standardLaborRatePerHour} onChange={(event) => setProfileForm((current) => ({ ...current, standardLaborRatePerHour: Number(event.target.value) }))} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 outline-none" />
|
||||
</label>
|
||||
<label className="text-sm text-text">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Overhead / Labor Hour</span>
|
||||
<input type="number" step="0.01" min={0} value={profileForm.overheadRatePerHour} onChange={(event) => setProfileForm((current) => ({ ...current, overheadRatePerHour: Number(event.target.value) }))} className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 outline-none" />
|
||||
</label>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<button type="button" onClick={() => void handleSaveProfile()} disabled={isSavingProfile} className="mt-4 inline-flex rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">
|
||||
{isSavingProfile ? "Saving..." : "Save assumptions"}
|
||||
</button>
|
||||
) : null}
|
||||
</article>
|
||||
|
||||
<article className="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">Post Payment</p>
|
||||
<div className="mt-4 grid gap-3">
|
||||
<select value={paymentForm.salesOrderId} onChange={(event) => setPaymentForm((current) => ({ ...current, salesOrderId: event.target.value }))} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
||||
{salesOrders.map((order) => (
|
||||
<option key={order.id} value={order.id}>
|
||||
{order.documentNumber} / {order.customerName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<select value={paymentForm.paymentType} onChange={(event) => setPaymentForm((current) => ({ ...current, paymentType: event.target.value as FinancePaymentType }))} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
||||
{financePaymentTypes.map((type) => <option key={type} value={type}>{type}</option>)}
|
||||
</select>
|
||||
<select value={paymentForm.paymentMethod} onChange={(event) => setPaymentForm((current) => ({ ...current, paymentMethod: event.target.value as FinancePaymentMethod }))} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
||||
{financePaymentMethods.map((method) => <option key={method} value={method}>{method}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<input type="datetime-local" value={paymentForm.paymentDate.slice(0, 16)} onChange={(event) => setPaymentForm((current) => ({ ...current, paymentDate: new Date(event.target.value).toISOString() }))} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none" />
|
||||
<input type="number" min={0} step="0.01" value={paymentForm.amount} onChange={(event) => setPaymentForm((current) => ({ ...current, amount: Number(event.target.value) }))} placeholder="Amount" className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none" />
|
||||
</div>
|
||||
<input value={paymentForm.reference} onChange={(event) => setPaymentForm((current) => ({ ...current, reference: event.target.value }))} placeholder="Reference / remittance" className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none" />
|
||||
<textarea value={paymentForm.notes} onChange={(event) => setPaymentForm((current) => ({ ...current, notes: event.target.value }))} placeholder="Notes" rows={3} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none" />
|
||||
</div>
|
||||
{canManage ? (
|
||||
<button type="button" onClick={() => void handlePostPayment()} disabled={isPostingPayment || !paymentForm.salesOrderId || paymentForm.amount <= 0} className="mt-4 inline-flex rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">
|
||||
{isPostingPayment ? "Posting..." : "Post payment"}
|
||||
</button>
|
||||
) : null}
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Recent Payments</p>
|
||||
<p className="mt-2 text-sm text-muted">Posted receipts linked directly to sales orders.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 space-y-3">
|
||||
{payments.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">No payments posted yet.</div>
|
||||
) : (
|
||||
payments.map((payment) => (
|
||||
<div key={payment.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<Link to={`/sales/orders/${payment.salesOrderId}`} className="font-semibold text-brand hover:underline">
|
||||
{payment.salesOrderNumber}
|
||||
</Link>
|
||||
<div className="text-xs text-muted">{payment.customerName}</div>
|
||||
<div className="mt-1 text-sm text-text">{payment.paymentType} via {payment.paymentMethod}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-semibold text-text">{formatCurrency(payment.amount, currencyCode)}</div>
|
||||
<div className="text-xs text-muted">{new Date(payment.paymentDate).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted">{payment.reference || "No reference"} / {payment.createdByName}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">CapEx Tracker</p>
|
||||
<p className="mt-2 text-sm text-muted">Manage equipment, tooling, and consumable capital plans with optional PO linkage.</p>
|
||||
</div>
|
||||
{editingCapexId ? (
|
||||
<button type="button" onClick={resetCapexForm} className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||
Clear edit
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 lg:grid-cols-2">
|
||||
<input value={capexForm.title} onChange={(event) => setCapexForm((current) => ({ ...current, title: event.target.value }))} placeholder="CapEx title" className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none" />
|
||||
<select value={capexForm.category} onChange={(event) => setCapexForm((current) => ({ ...current, category: event.target.value as CapexCategory }))} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
||||
{capexCategories.map((category) => <option key={category} value={category}>{category}</option>)}
|
||||
</select>
|
||||
<select value={capexForm.status} onChange={(event) => setCapexForm((current) => ({ ...current, status: event.target.value as CapexStatus }))} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
||||
{capexStatuses.map((capexStatus) => <option key={capexStatus} value={capexStatus}>{capexStatus}</option>)}
|
||||
</select>
|
||||
<select value={capexForm.vendorId ?? ""} onChange={(event) => setCapexForm((current) => ({ ...current, vendorId: event.target.value || null }))} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
||||
<option value="">No vendor linked</option>
|
||||
{vendors.map((vendor) => <option key={vendor.id} value={vendor.id}>{vendor.name}</option>)}
|
||||
</select>
|
||||
<select value={capexForm.purchaseOrderId ?? ""} onChange={(event) => setCapexForm((current) => ({ ...current, purchaseOrderId: event.target.value || null }))} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none">
|
||||
<option value="">No purchase order linked</option>
|
||||
{purchaseOrders.map((order) => <option key={order.id} value={order.id}>{order.documentNumber} / {order.vendorName}</option>)}
|
||||
</select>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<input type="number" min={0} step="0.01" value={capexForm.plannedAmount} onChange={(event) => setCapexForm((current) => ({ ...current, plannedAmount: Number(event.target.value) }))} placeholder="Planned amount" className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none" />
|
||||
<input type="number" min={0} step="0.01" value={capexForm.actualAmount} onChange={(event) => setCapexForm((current) => ({ ...current, actualAmount: Number(event.target.value) }))} placeholder="Actual amount" className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none" />
|
||||
</div>
|
||||
<input type="date" value={capexForm.requestDate.slice(0, 10)} onChange={(event) => setCapexForm((current) => ({ ...current, requestDate: new Date(`${event.target.value}T00:00:00`).toISOString() }))} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none" />
|
||||
<input type="date" value={capexForm.targetInServiceDate ? capexForm.targetInServiceDate.slice(0, 10) : ""} onChange={(event) => setCapexForm((current) => ({ ...current, targetInServiceDate: event.target.value ? new Date(`${event.target.value}T00:00:00`).toISOString() : null }))} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none" />
|
||||
<input type="date" value={capexForm.purchasedAt ? capexForm.purchasedAt.slice(0, 10) : ""} onChange={(event) => setCapexForm((current) => ({ ...current, purchasedAt: event.target.value ? new Date(`${event.target.value}T00:00:00`).toISOString() : null }))} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none" />
|
||||
<textarea value={capexForm.notes} onChange={(event) => setCapexForm((current) => ({ ...current, notes: event.target.value }))} placeholder="Business justification, install notes, or sourcing detail" rows={3} className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none lg:col-span-2" />
|
||||
</div>
|
||||
|
||||
{canManage ? (
|
||||
<button type="button" onClick={() => void handleSaveCapex()} disabled={isSavingCapex || !capexForm.title.trim()} className="mt-4 inline-flex rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">
|
||||
{isSavingCapex ? "Saving..." : editingCapexId ? "Update CapEx" : "Create CapEx"}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{capex.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">No CapEx entries yet.</div>
|
||||
) : (
|
||||
capex.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingCapexId(entry.id);
|
||||
setCapexForm({
|
||||
title: entry.title,
|
||||
category: entry.category,
|
||||
status: entry.status,
|
||||
vendorId: entry.vendorId,
|
||||
purchaseOrderId: entry.purchaseOrderId,
|
||||
plannedAmount: entry.plannedAmount,
|
||||
actualAmount: entry.actualAmount,
|
||||
requestDate: entry.requestDate,
|
||||
targetInServiceDate: entry.targetInServiceDate,
|
||||
purchasedAt: entry.purchasedAt,
|
||||
notes: entry.notes,
|
||||
});
|
||||
}}
|
||||
className="block w-full rounded-[18px] border border-line/70 bg-page/60 p-3 text-left transition hover:bg-page/80"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-semibold text-text">{entry.title}</div>
|
||||
<div className="text-xs text-muted">
|
||||
{entry.category} / {entry.status}
|
||||
{entry.vendorName ? ` / ${entry.vendorName}` : ""}
|
||||
{entry.purchaseOrderNumber ? ` / ${entry.purchaseOrderNumber}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-semibold text-text">{formatCurrency(entry.actualAmount || entry.plannedAmount, currencyCode)}</div>
|
||||
<div className="text-xs text-muted">Plan {formatCurrency(entry.plannedAmount, currencyCode)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">
|
||||
{status}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "FinanceProfile" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"currencyCode" TEXT NOT NULL DEFAULT 'USD',
|
||||
"standardLaborRatePerHour" REAL NOT NULL DEFAULT 45,
|
||||
"overheadRatePerHour" REAL NOT NULL DEFAULT 18,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FinanceCustomerPayment" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"salesOrderId" TEXT NOT NULL,
|
||||
"paymentType" TEXT NOT NULL,
|
||||
"paymentMethod" TEXT NOT NULL,
|
||||
"paymentDate" DATETIME NOT NULL,
|
||||
"amount" REAL NOT NULL,
|
||||
"reference" TEXT NOT NULL,
|
||||
"notes" TEXT NOT NULL,
|
||||
"createdById" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "FinanceCustomerPayment_salesOrderId_fkey" FOREIGN KEY ("salesOrderId") REFERENCES "SalesOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "FinanceCustomerPayment_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FinanceManufacturingCostSnapshot" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"workOrderId" TEXT NOT NULL,
|
||||
"materialCost" REAL NOT NULL DEFAULT 0,
|
||||
"laborCost" REAL NOT NULL DEFAULT 0,
|
||||
"overheadCost" REAL NOT NULL DEFAULT 0,
|
||||
"totalCost" REAL NOT NULL DEFAULT 0,
|
||||
"materialIssueCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"laborEntryCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"calculatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "FinanceManufacturingCostSnapshot_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CapexEntry" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"title" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"vendorId" TEXT,
|
||||
"purchaseOrderId" TEXT,
|
||||
"plannedAmount" REAL NOT NULL,
|
||||
"actualAmount" REAL NOT NULL,
|
||||
"requestDate" DATETIME NOT NULL,
|
||||
"targetInServiceDate" DATETIME,
|
||||
"purchasedAt" DATETIME,
|
||||
"notes" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "CapexEntry_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "CapexEntry_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FinanceCustomerPayment_salesOrderId_paymentDate_idx" ON "FinanceCustomerPayment"("salesOrderId", "paymentDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FinanceCustomerPayment_createdAt_idx" ON "FinanceCustomerPayment"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "FinanceManufacturingCostSnapshot_workOrderId_key" ON "FinanceManufacturingCostSnapshot"("workOrderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FinanceManufacturingCostSnapshot_calculatedAt_idx" ON "FinanceManufacturingCostSnapshot"("calculatedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CapexEntry_status_requestDate_idx" ON "CapexEntry"("status", "requestDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CapexEntry_vendorId_createdAt_idx" ON "CapexEntry"("vendorId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CapexEntry_purchaseOrderId_createdAt_idx" ON "CapexEntry"("purchaseOrderId", "createdAt");
|
||||
@@ -29,6 +29,7 @@ model User {
|
||||
workOrderOperationLaborEntries WorkOrderOperationLaborEntry[]
|
||||
assignedWorkOrderOperations WorkOrderOperation[]
|
||||
shipmentPicks ShipmentPick[]
|
||||
financeCustomerPayments FinanceCustomerPayment[]
|
||||
approvedSalesQuotes SalesQuote[] @relation("SalesQuoteApprovedBy")
|
||||
approvedSalesOrders SalesOrder[] @relation("SalesOrderApprovedBy")
|
||||
salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy")
|
||||
@@ -401,6 +402,7 @@ model Vendor {
|
||||
contactEntries CrmContactEntry[]
|
||||
contacts CrmContact[]
|
||||
purchaseOrders PurchaseOrder[]
|
||||
capexEntries CapexEntry[]
|
||||
preferredSupplyItems InventoryItem[]
|
||||
}
|
||||
|
||||
@@ -496,6 +498,7 @@ model SalesOrder {
|
||||
revisions SalesOrderRevision[]
|
||||
workOrders WorkOrder[]
|
||||
purchaseOrderLines PurchaseOrderLine[]
|
||||
customerPayments FinanceCustomerPayment[]
|
||||
}
|
||||
|
||||
model SalesOrderLine {
|
||||
@@ -665,6 +668,7 @@ model WorkOrder {
|
||||
materialIssues WorkOrderMaterialIssue[]
|
||||
completions WorkOrderCompletion[]
|
||||
reservations InventoryReservation[]
|
||||
financeCostSnapshot FinanceManufacturingCostSnapshot?
|
||||
|
||||
@@index([itemId, createdAt])
|
||||
@@index([projectId, dueDate])
|
||||
@@ -788,6 +792,74 @@ model WorkOrderCompletion {
|
||||
@@index([workOrderId, createdAt])
|
||||
}
|
||||
|
||||
model FinanceProfile {
|
||||
id String @id @default(cuid())
|
||||
currencyCode String @default("USD")
|
||||
standardLaborRatePerHour Float @default(45)
|
||||
overheadRatePerHour Float @default(18)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model FinanceCustomerPayment {
|
||||
id String @id @default(cuid())
|
||||
salesOrderId String
|
||||
paymentType String
|
||||
paymentMethod String
|
||||
paymentDate DateTime
|
||||
amount Float
|
||||
reference String
|
||||
notes String
|
||||
createdById String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
salesOrder SalesOrder @relation(fields: [salesOrderId], references: [id], onDelete: Cascade)
|
||||
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([salesOrderId, paymentDate])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model FinanceManufacturingCostSnapshot {
|
||||
id String @id @default(cuid())
|
||||
workOrderId String @unique
|
||||
materialCost Float @default(0)
|
||||
laborCost Float @default(0)
|
||||
overheadCost Float @default(0)
|
||||
totalCost Float @default(0)
|
||||
materialIssueCount Int @default(0)
|
||||
laborEntryCount Int @default(0)
|
||||
calculatedAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([calculatedAt])
|
||||
}
|
||||
|
||||
model CapexEntry {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
category String
|
||||
status String
|
||||
vendorId String?
|
||||
purchaseOrderId String?
|
||||
plannedAmount Float
|
||||
actualAmount Float
|
||||
requestDate DateTime
|
||||
targetInServiceDate DateTime?
|
||||
purchasedAt DateTime?
|
||||
notes String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
vendor Vendor? @relation(fields: [vendorId], references: [id], onDelete: SetNull)
|
||||
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([status, requestDate])
|
||||
@@index([vendorId, createdAt])
|
||||
@@index([purchaseOrderId, createdAt])
|
||||
}
|
||||
|
||||
model PurchaseOrder {
|
||||
id String @id @default(cuid())
|
||||
documentNumber String @unique
|
||||
@@ -803,6 +875,7 @@ model PurchaseOrder {
|
||||
lines PurchaseOrderLine[]
|
||||
receipts PurchaseReceipt[]
|
||||
revisions PurchaseOrderRevision[]
|
||||
capexEntries CapexEntry[]
|
||||
}
|
||||
|
||||
model PurchaseOrderLine {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { authRouter } from "./modules/auth/router.js";
|
||||
import { crmRouter } from "./modules/crm/router.js";
|
||||
import { documentsRouter } from "./modules/documents/router.js";
|
||||
import { filesRouter } from "./modules/files/router.js";
|
||||
import { financeRouter } from "./modules/finance/router.js";
|
||||
import { ganttRouter } from "./modules/gantt/router.js";
|
||||
import { inventoryRouter } from "./modules/inventory/router.js";
|
||||
import { manufacturingRouter } from "./modules/manufacturing/router.js";
|
||||
@@ -97,6 +98,7 @@ export function createApp() {
|
||||
app.use("/api/v1/admin", adminRouter);
|
||||
app.use("/api/v1", settingsRouter);
|
||||
app.use("/api/v1/files", filesRouter);
|
||||
app.use("/api/v1/finance", financeRouter);
|
||||
app.use("/api/v1/crm", crmRouter);
|
||||
app.use("/api/v1/inventory", inventoryRouter);
|
||||
app.use("/api/v1/manufacturing", manufacturingRouter);
|
||||
|
||||
@@ -17,6 +17,8 @@ const permissionDescriptions: Record<PermissionKey, string> = {
|
||||
[permissions.manufacturingWrite]: "Manage manufacturing work orders and execution data",
|
||||
[permissions.filesRead]: "View attached files",
|
||||
[permissions.filesWrite]: "Upload and manage attached files",
|
||||
[permissions.financeRead]: "View finance rollups, payments, and capital plans",
|
||||
[permissions.financeWrite]: "Manage finance rollups, payments, and capital plans",
|
||||
[permissions.ganttRead]: "View planning workbench",
|
||||
[permissions.salesRead]: "View sales data",
|
||||
[permissions.salesWrite]: "Manage quotes and sales orders",
|
||||
@@ -122,4 +124,11 @@ export async function bootstrapAppData() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const existingFinanceProfile = await prisma.financeProfile.findFirst();
|
||||
if (!existingFinanceProfile) {
|
||||
await prisma.financeProfile.create({
|
||||
data: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
104
server/src/modules/finance/router.ts
Normal file
104
server/src/modules/finance/router.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import { capexCategories, capexStatuses, financePaymentMethods, financePaymentTypes } from "@mrp/shared/dist/finance/types.js";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { fail, ok } from "../../lib/http.js";
|
||||
import { requirePermissions } from "../../lib/rbac.js";
|
||||
import { createCapexEntry, createCustomerPayment, getFinanceDashboard, updateCapexEntry, updateFinanceProfile } from "./service.js";
|
||||
|
||||
const financeProfileSchema = z.object({
|
||||
currencyCode: z.string().trim().min(3).max(8),
|
||||
standardLaborRatePerHour: z.number().nonnegative(),
|
||||
overheadRatePerHour: z.number().nonnegative(),
|
||||
});
|
||||
|
||||
const financePaymentSchema = z.object({
|
||||
salesOrderId: z.string().trim().min(1),
|
||||
paymentType: z.enum(financePaymentTypes),
|
||||
paymentMethod: z.enum(financePaymentMethods),
|
||||
paymentDate: z.string().datetime(),
|
||||
amount: z.number().positive(),
|
||||
reference: z.string(),
|
||||
notes: z.string(),
|
||||
});
|
||||
|
||||
const capexSchema = z.object({
|
||||
title: z.string().trim().min(1),
|
||||
category: z.enum(capexCategories),
|
||||
status: z.enum(capexStatuses),
|
||||
vendorId: z.string().trim().min(1).nullable(),
|
||||
purchaseOrderId: z.string().trim().min(1).nullable(),
|
||||
plannedAmount: z.number().nonnegative(),
|
||||
actualAmount: z.number().nonnegative(),
|
||||
requestDate: z.string().datetime(),
|
||||
targetInServiceDate: z.string().datetime().nullable(),
|
||||
purchasedAt: z.string().datetime().nullable(),
|
||||
notes: z.string(),
|
||||
});
|
||||
|
||||
function getRouteParam(value: unknown) {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
export const financeRouter = Router();
|
||||
|
||||
financeRouter.get("/overview", requirePermissions([permissions.financeRead]), async (_request, response) => {
|
||||
return ok(response, await getFinanceDashboard());
|
||||
});
|
||||
|
||||
financeRouter.put("/profile", requirePermissions([permissions.financeWrite]), async (request, response) => {
|
||||
const parsed = financeProfileSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Finance profile payload is invalid.");
|
||||
}
|
||||
|
||||
return ok(response, await updateFinanceProfile(parsed.data, request.authUser?.id));
|
||||
});
|
||||
|
||||
financeRouter.post("/payments", requirePermissions([permissions.financeWrite]), async (request, response) => {
|
||||
const parsed = financePaymentSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Finance payment payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await createCustomerPayment(parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
return ok(response, result.payment, 201);
|
||||
});
|
||||
|
||||
financeRouter.post("/capex", requirePermissions([permissions.financeWrite]), async (request, response) => {
|
||||
const parsed = capexSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "CapEx payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await createCapexEntry(parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
return ok(response, result.capex, 201);
|
||||
});
|
||||
|
||||
financeRouter.put("/capex/:capexId", requirePermissions([permissions.financeWrite]), async (request, response) => {
|
||||
const capexId = getRouteParam(request.params.capexId);
|
||||
if (!capexId) {
|
||||
return fail(response, 400, "INVALID_INPUT", "CapEx id is invalid.");
|
||||
}
|
||||
|
||||
const parsed = capexSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "CapEx payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await updateCapexEntry(capexId, parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
return ok(response, result.capex);
|
||||
});
|
||||
619
server/src/modules/finance/service.ts
Normal file
619
server/src/modules/finance/service.ts
Normal file
@@ -0,0 +1,619 @@
|
||||
import type {
|
||||
FinanceCapexDto,
|
||||
FinanceCapexInput,
|
||||
FinanceCustomerPaymentDto,
|
||||
FinanceCustomerPaymentInput,
|
||||
FinanceDashboardDto,
|
||||
FinanceProfileDto,
|
||||
FinanceProfileInput,
|
||||
FinanceSalesOrderLedgerDto,
|
||||
FinanceSummaryDto,
|
||||
} from "@mrp/shared";
|
||||
|
||||
import { logAuditEvent } from "../../lib/audit.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
function iso(value: Date | null) {
|
||||
return value ? value.toISOString() : null;
|
||||
}
|
||||
|
||||
function mapProfile(record: {
|
||||
id: string;
|
||||
currencyCode: string;
|
||||
standardLaborRatePerHour: number;
|
||||
overheadRatePerHour: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): FinanceProfileDto {
|
||||
return {
|
||||
id: record.id,
|
||||
currencyCode: record.currencyCode,
|
||||
standardLaborRatePerHour: record.standardLaborRatePerHour,
|
||||
overheadRatePerHour: record.overheadRatePerHour,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function mapPayment(record: {
|
||||
id: string;
|
||||
paymentType: string;
|
||||
paymentMethod: string;
|
||||
paymentDate: Date;
|
||||
amount: number;
|
||||
reference: string;
|
||||
notes: string;
|
||||
createdAt: Date;
|
||||
salesOrder: {
|
||||
id: string;
|
||||
documentNumber: string;
|
||||
customer: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
createdBy: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
} | null;
|
||||
}): FinanceCustomerPaymentDto {
|
||||
return {
|
||||
id: record.id,
|
||||
salesOrderId: record.salesOrder.id,
|
||||
salesOrderNumber: record.salesOrder.documentNumber,
|
||||
customerId: record.salesOrder.customer.id,
|
||||
customerName: record.salesOrder.customer.name,
|
||||
paymentType: record.paymentType as FinanceCustomerPaymentDto["paymentType"],
|
||||
paymentMethod: record.paymentMethod as FinanceCustomerPaymentDto["paymentMethod"],
|
||||
paymentDate: record.paymentDate.toISOString(),
|
||||
amount: record.amount,
|
||||
reference: record.reference,
|
||||
notes: record.notes,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
createdByName: record.createdBy ? `${record.createdBy.firstName} ${record.createdBy.lastName}`.trim() : "System",
|
||||
};
|
||||
}
|
||||
|
||||
function mapCapex(record: {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
status: string;
|
||||
plannedAmount: number;
|
||||
actualAmount: number;
|
||||
requestDate: Date;
|
||||
targetInServiceDate: Date | null;
|
||||
purchasedAt: Date | null;
|
||||
notes: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
vendor: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
purchaseOrder: {
|
||||
id: string;
|
||||
documentNumber: string;
|
||||
} | null;
|
||||
}): FinanceCapexDto {
|
||||
return {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
category: record.category as FinanceCapexDto["category"],
|
||||
status: record.status as FinanceCapexDto["status"],
|
||||
vendorId: record.vendor?.id ?? null,
|
||||
vendorName: record.vendor?.name ?? null,
|
||||
purchaseOrderId: record.purchaseOrder?.id ?? null,
|
||||
purchaseOrderNumber: record.purchaseOrder?.documentNumber ?? null,
|
||||
plannedAmount: record.plannedAmount,
|
||||
actualAmount: record.actualAmount,
|
||||
requestDate: record.requestDate.toISOString(),
|
||||
targetInServiceDate: iso(record.targetInServiceDate),
|
||||
purchasedAt: iso(record.purchasedAt),
|
||||
notes: record.notes,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function getOrCreateProfile() {
|
||||
const existing = await prisma.financeProfile.findFirst({
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
return prisma.financeProfile.create({
|
||||
data: {},
|
||||
});
|
||||
}
|
||||
|
||||
async function computeWorkOrderCostSnapshot(
|
||||
workOrder: {
|
||||
id: string;
|
||||
materialIssues: Array<{
|
||||
quantity: number;
|
||||
componentItem: {
|
||||
defaultCost: number | null;
|
||||
};
|
||||
}>;
|
||||
operations: Array<{
|
||||
laborEntries: Array<{
|
||||
minutes: number;
|
||||
}>;
|
||||
}>;
|
||||
},
|
||||
profile: {
|
||||
standardLaborRatePerHour: number;
|
||||
overheadRatePerHour: number;
|
||||
}
|
||||
) {
|
||||
const materialCost = workOrder.materialIssues.reduce((sum, issue) => sum + issue.quantity * (issue.componentItem.defaultCost ?? 0), 0);
|
||||
const laborMinutes = workOrder.operations.reduce(
|
||||
(sum, operation) => sum + operation.laborEntries.reduce((entrySum, entry) => entrySum + entry.minutes, 0),
|
||||
0
|
||||
);
|
||||
const laborHours = laborMinutes / 60;
|
||||
const laborCost = laborHours * profile.standardLaborRatePerHour;
|
||||
const overheadCost = laborHours * profile.overheadRatePerHour;
|
||||
const totalCost = materialCost + laborCost + overheadCost;
|
||||
|
||||
await prisma.financeManufacturingCostSnapshot.upsert({
|
||||
where: { workOrderId: workOrder.id },
|
||||
update: {
|
||||
materialCost,
|
||||
laborCost,
|
||||
overheadCost,
|
||||
totalCost,
|
||||
materialIssueCount: workOrder.materialIssues.length,
|
||||
laborEntryCount: workOrder.operations.reduce((sum, operation) => sum + operation.laborEntries.length, 0),
|
||||
calculatedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
workOrderId: workOrder.id,
|
||||
materialCost,
|
||||
laborCost,
|
||||
overheadCost,
|
||||
totalCost,
|
||||
materialIssueCount: workOrder.materialIssues.length,
|
||||
laborEntryCount: workOrder.operations.reduce((sum, operation) => sum + operation.laborEntries.length, 0),
|
||||
calculatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
materialCost,
|
||||
laborCost,
|
||||
overheadCost,
|
||||
totalCost,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getFinanceDashboard(): Promise<FinanceDashboardDto> {
|
||||
const profile = await getOrCreateProfile();
|
||||
|
||||
const [orders, payments, capex] = await Promise.all([
|
||||
prisma.salesOrder.findMany({
|
||||
include: {
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
lines: {
|
||||
select: {
|
||||
quantity: true,
|
||||
unitPrice: true,
|
||||
},
|
||||
},
|
||||
customerPayments: {
|
||||
select: {
|
||||
amount: true,
|
||||
},
|
||||
},
|
||||
purchaseOrderLines: {
|
||||
include: {
|
||||
purchaseOrder: {
|
||||
include: {
|
||||
receipts: {
|
||||
include: {
|
||||
lines: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workOrders: {
|
||||
include: {
|
||||
materialIssues: {
|
||||
include: {
|
||||
componentItem: {
|
||||
select: {
|
||||
defaultCost: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
operations: {
|
||||
include: {
|
||||
laborEntries: {
|
||||
select: {
|
||||
minutes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }],
|
||||
}),
|
||||
prisma.financeCustomerPayment.findMany({
|
||||
include: {
|
||||
salesOrder: {
|
||||
include: {
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
createdBy: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ paymentDate: "desc" }, { createdAt: "desc" }],
|
||||
take: 40,
|
||||
}),
|
||||
prisma.capexEntry.findMany({
|
||||
include: {
|
||||
vendor: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
purchaseOrder: {
|
||||
select: {
|
||||
id: true,
|
||||
documentNumber: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ requestDate: "desc" }, { createdAt: "desc" }],
|
||||
}),
|
||||
]);
|
||||
|
||||
const salesOrderLedgers: FinanceSalesOrderLedgerDto[] = [];
|
||||
|
||||
for (const order of orders) {
|
||||
const revenueTotal = order.lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0);
|
||||
const paymentsReceived = order.customerPayments.reduce((sum, payment) => sum + payment.amount, 0);
|
||||
|
||||
const linkedPurchaseCommitted = order.purchaseOrderLines.reduce((sum, line) => sum + line.quantity * line.unitCost, 0);
|
||||
const linkedPurchaseReceivedValue = order.purchaseOrderLines.reduce((sum, line) => {
|
||||
const receivedQuantity = line.purchaseOrder.receipts.reduce((receiptSum, receipt) => {
|
||||
const matchingQuantity = receipt.lines
|
||||
.filter((receiptLine) => receiptLine.purchaseOrderLineId === line.id)
|
||||
.reduce((lineSum, receiptLine) => lineSum + receiptLine.quantity, 0);
|
||||
return receiptSum + matchingQuantity;
|
||||
}, 0);
|
||||
|
||||
return sum + receivedQuantity * line.unitCost;
|
||||
}, 0);
|
||||
|
||||
let manufacturingMaterialCost = 0;
|
||||
let manufacturingLaborCost = 0;
|
||||
let manufacturingOverheadCost = 0;
|
||||
|
||||
for (const workOrder of order.workOrders) {
|
||||
const snapshot = await computeWorkOrderCostSnapshot(workOrder, profile);
|
||||
manufacturingMaterialCost += snapshot.materialCost;
|
||||
manufacturingLaborCost += snapshot.laborCost;
|
||||
manufacturingOverheadCost += snapshot.overheadCost;
|
||||
}
|
||||
|
||||
const manufacturingTotalCost = manufacturingMaterialCost + manufacturingLaborCost + manufacturingOverheadCost;
|
||||
const totalRecognizedSpend = linkedPurchaseReceivedValue + manufacturingTotalCost;
|
||||
const grossMarginEstimate = revenueTotal - totalRecognizedSpend;
|
||||
const grossMarginPercent = revenueTotal > 0 ? (grossMarginEstimate / revenueTotal) * 100 : 0;
|
||||
const accountsReceivableOpen = Math.max(revenueTotal - paymentsReceived, 0);
|
||||
const paymentCoveragePercent = totalRecognizedSpend > 0 ? (paymentsReceived / totalRecognizedSpend) * 100 : 0;
|
||||
|
||||
salesOrderLedgers.push({
|
||||
salesOrderId: order.id,
|
||||
salesOrderNumber: order.documentNumber,
|
||||
customerId: order.customer.id,
|
||||
customerName: order.customer.name,
|
||||
status: order.status,
|
||||
issueDate: order.issueDate.toISOString(),
|
||||
revenueTotal,
|
||||
paymentsReceived,
|
||||
accountsReceivableOpen,
|
||||
linkedPurchaseCommitted,
|
||||
linkedPurchaseReceivedValue,
|
||||
manufacturingMaterialCost,
|
||||
manufacturingLaborCost,
|
||||
manufacturingOverheadCost,
|
||||
manufacturingTotalCost,
|
||||
totalRecognizedSpend,
|
||||
grossMarginEstimate,
|
||||
grossMarginPercent,
|
||||
paymentCoveragePercent,
|
||||
linkedPurchaseOrderCount: new Set(order.purchaseOrderLines.map((line) => line.purchaseOrderId)).size,
|
||||
linkedWorkOrderCount: order.workOrders.length,
|
||||
});
|
||||
}
|
||||
|
||||
const summary: FinanceSummaryDto = {
|
||||
bookedRevenue: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.revenueTotal, 0),
|
||||
paymentsReceived: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.paymentsReceived, 0),
|
||||
accountsReceivableOpen: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.accountsReceivableOpen, 0),
|
||||
linkedPurchaseCommitted: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.linkedPurchaseCommitted, 0),
|
||||
linkedPurchaseReceivedValue: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.linkedPurchaseReceivedValue, 0),
|
||||
manufacturingMaterialCost: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.manufacturingMaterialCost, 0),
|
||||
manufacturingLaborCost: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.manufacturingLaborCost, 0),
|
||||
manufacturingOverheadCost: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.manufacturingOverheadCost, 0),
|
||||
manufacturingTotalCost: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.manufacturingTotalCost, 0),
|
||||
capexPlanned: capex.reduce((sum, entry) => sum + entry.plannedAmount, 0),
|
||||
capexActual: capex.reduce((sum, entry) => sum + entry.actualAmount, 0),
|
||||
grossMarginEstimate: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.grossMarginEstimate, 0),
|
||||
};
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
profile: mapProfile(profile),
|
||||
summary,
|
||||
salesOrderLedgers,
|
||||
payments: payments.map(mapPayment),
|
||||
capex: capex.map(mapCapex),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateFinanceProfile(payload: FinanceProfileInput, actorId?: string | null) {
|
||||
const profile = await getOrCreateProfile();
|
||||
const updated = await prisma.financeProfile.update({
|
||||
where: { id: profile.id },
|
||||
data: {
|
||||
currencyCode: payload.currencyCode.trim().toUpperCase(),
|
||||
standardLaborRatePerHour: payload.standardLaborRatePerHour,
|
||||
overheadRatePerHour: payload.overheadRatePerHour,
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "finance-profile",
|
||||
entityId: updated.id,
|
||||
action: "updated",
|
||||
summary: "Updated finance costing assumptions.",
|
||||
metadata: {
|
||||
currencyCode: updated.currencyCode,
|
||||
standardLaborRatePerHour: updated.standardLaborRatePerHour,
|
||||
overheadRatePerHour: updated.overheadRatePerHour,
|
||||
},
|
||||
});
|
||||
|
||||
return mapProfile(updated);
|
||||
}
|
||||
|
||||
export async function createCustomerPayment(payload: FinanceCustomerPaymentInput, actorId?: string | null) {
|
||||
const order = await prisma.salesOrder.findUnique({
|
||||
where: { id: payload.salesOrderId },
|
||||
include: {
|
||||
customer: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!order) {
|
||||
return { ok: false as const, reason: "Sales order was not found." };
|
||||
}
|
||||
|
||||
const payment = await prisma.financeCustomerPayment.create({
|
||||
data: {
|
||||
salesOrderId: payload.salesOrderId,
|
||||
paymentType: payload.paymentType,
|
||||
paymentMethod: payload.paymentMethod,
|
||||
paymentDate: new Date(payload.paymentDate),
|
||||
amount: payload.amount,
|
||||
reference: payload.reference.trim(),
|
||||
notes: payload.notes,
|
||||
createdById: actorId ?? null,
|
||||
},
|
||||
include: {
|
||||
salesOrder: {
|
||||
include: {
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
createdBy: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "finance-payment",
|
||||
entityId: payment.id,
|
||||
action: "created",
|
||||
summary: `Posted customer payment against ${order.documentNumber}.`,
|
||||
metadata: {
|
||||
salesOrderId: order.id,
|
||||
salesOrderNumber: order.documentNumber,
|
||||
amount: payment.amount,
|
||||
paymentType: payment.paymentType,
|
||||
paymentMethod: payment.paymentMethod,
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true as const, payment: mapPayment(payment) };
|
||||
}
|
||||
|
||||
export async function createCapexEntry(payload: FinanceCapexInput, actorId?: string | null) {
|
||||
if (payload.vendorId) {
|
||||
const vendor = await prisma.vendor.findUnique({
|
||||
where: { id: payload.vendorId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!vendor) {
|
||||
return { ok: false as const, reason: "Selected vendor was not found." };
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.purchaseOrderId) {
|
||||
const purchaseOrder = await prisma.purchaseOrder.findUnique({
|
||||
where: { id: payload.purchaseOrderId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!purchaseOrder) {
|
||||
return { ok: false as const, reason: "Selected purchase order was not found." };
|
||||
}
|
||||
}
|
||||
|
||||
const created = await prisma.capexEntry.create({
|
||||
data: {
|
||||
title: payload.title.trim(),
|
||||
category: payload.category,
|
||||
status: payload.status,
|
||||
vendorId: payload.vendorId,
|
||||
purchaseOrderId: payload.purchaseOrderId,
|
||||
plannedAmount: payload.plannedAmount,
|
||||
actualAmount: payload.actualAmount,
|
||||
requestDate: new Date(payload.requestDate),
|
||||
targetInServiceDate: payload.targetInServiceDate ? new Date(payload.targetInServiceDate) : null,
|
||||
purchasedAt: payload.purchasedAt ? new Date(payload.purchasedAt) : null,
|
||||
notes: payload.notes,
|
||||
},
|
||||
include: {
|
||||
vendor: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
purchaseOrder: {
|
||||
select: {
|
||||
id: true,
|
||||
documentNumber: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "capex-entry",
|
||||
entityId: created.id,
|
||||
action: "created",
|
||||
summary: `Created CapEx entry ${created.title}.`,
|
||||
metadata: {
|
||||
title: created.title,
|
||||
category: created.category,
|
||||
status: created.status,
|
||||
plannedAmount: created.plannedAmount,
|
||||
actualAmount: created.actualAmount,
|
||||
purchaseOrderId: created.purchaseOrder?.id ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true as const, capex: mapCapex(created) };
|
||||
}
|
||||
|
||||
export async function updateCapexEntry(capexId: string, payload: FinanceCapexInput, actorId?: string | null) {
|
||||
const existing = await prisma.capexEntry.findUnique({
|
||||
where: { id: capexId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) {
|
||||
return { ok: false as const, reason: "CapEx entry was not found." };
|
||||
}
|
||||
|
||||
if (payload.vendorId) {
|
||||
const vendor = await prisma.vendor.findUnique({
|
||||
where: { id: payload.vendorId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!vendor) {
|
||||
return { ok: false as const, reason: "Selected vendor was not found." };
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.purchaseOrderId) {
|
||||
const purchaseOrder = await prisma.purchaseOrder.findUnique({
|
||||
where: { id: payload.purchaseOrderId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!purchaseOrder) {
|
||||
return { ok: false as const, reason: "Selected purchase order was not found." };
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.capexEntry.update({
|
||||
where: { id: capexId },
|
||||
data: {
|
||||
title: payload.title.trim(),
|
||||
category: payload.category,
|
||||
status: payload.status,
|
||||
vendorId: payload.vendorId,
|
||||
purchaseOrderId: payload.purchaseOrderId,
|
||||
plannedAmount: payload.plannedAmount,
|
||||
actualAmount: payload.actualAmount,
|
||||
requestDate: new Date(payload.requestDate),
|
||||
targetInServiceDate: payload.targetInServiceDate ? new Date(payload.targetInServiceDate) : null,
|
||||
purchasedAt: payload.purchasedAt ? new Date(payload.purchasedAt) : null,
|
||||
notes: payload.notes,
|
||||
},
|
||||
include: {
|
||||
vendor: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
purchaseOrder: {
|
||||
select: {
|
||||
id: true,
|
||||
documentNumber: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "capex-entry",
|
||||
entityId: updated.id,
|
||||
action: "updated",
|
||||
summary: `Updated CapEx entry ${updated.title}.`,
|
||||
metadata: {
|
||||
title: updated.title,
|
||||
category: updated.category,
|
||||
status: updated.status,
|
||||
plannedAmount: updated.plannedAmount,
|
||||
actualAmount: updated.actualAmount,
|
||||
purchaseOrderId: updated.purchaseOrder?.id ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true as const, capex: mapCapex(updated) };
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export const permissions = {
|
||||
manufacturingWrite: "manufacturing.write",
|
||||
filesRead: "files.read",
|
||||
filesWrite: "files.write",
|
||||
financeRead: "finance.read",
|
||||
financeWrite: "finance.write",
|
||||
ganttRead: "gantt.read",
|
||||
salesRead: "sales.read",
|
||||
salesWrite: "sales.write",
|
||||
|
||||
131
shared/src/finance/types.ts
Normal file
131
shared/src/finance/types.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
export const financePaymentTypes = ["DEPOSIT", "PROGRESS", "FINAL", "ADJUSTMENT"] as const;
|
||||
export const financePaymentMethods = ["ACH", "WIRE", "CHECK", "CARD", "CASH", "OTHER"] as const;
|
||||
export const capexCategories = ["EQUIPMENT", "TOOLING", "CONSUMABLE"] as const;
|
||||
export const capexStatuses = ["PLANNED", "APPROVED", "ORDERED", "IN_SERVICE", "CLOSED", "CANCELLED"] as const;
|
||||
|
||||
export type FinancePaymentType = (typeof financePaymentTypes)[number];
|
||||
export type FinancePaymentMethod = (typeof financePaymentMethods)[number];
|
||||
export type CapexCategory = (typeof capexCategories)[number];
|
||||
export type CapexStatus = (typeof capexStatuses)[number];
|
||||
|
||||
export interface FinanceProfileDto {
|
||||
id: string;
|
||||
currencyCode: string;
|
||||
standardLaborRatePerHour: number;
|
||||
overheadRatePerHour: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface FinanceProfileInput {
|
||||
currencyCode: string;
|
||||
standardLaborRatePerHour: number;
|
||||
overheadRatePerHour: number;
|
||||
}
|
||||
|
||||
export interface FinanceCustomerPaymentDto {
|
||||
id: string;
|
||||
salesOrderId: string;
|
||||
salesOrderNumber: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
paymentType: FinancePaymentType;
|
||||
paymentMethod: FinancePaymentMethod;
|
||||
paymentDate: string;
|
||||
amount: number;
|
||||
reference: string;
|
||||
notes: string;
|
||||
createdAt: string;
|
||||
createdByName: string;
|
||||
}
|
||||
|
||||
export interface FinanceCustomerPaymentInput {
|
||||
salesOrderId: string;
|
||||
paymentType: FinancePaymentType;
|
||||
paymentMethod: FinancePaymentMethod;
|
||||
paymentDate: string;
|
||||
amount: number;
|
||||
reference: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface FinanceCapexDto {
|
||||
id: string;
|
||||
title: string;
|
||||
category: CapexCategory;
|
||||
status: CapexStatus;
|
||||
vendorId: string | null;
|
||||
vendorName: string | null;
|
||||
purchaseOrderId: string | null;
|
||||
purchaseOrderNumber: string | null;
|
||||
plannedAmount: number;
|
||||
actualAmount: number;
|
||||
requestDate: string;
|
||||
targetInServiceDate: string | null;
|
||||
purchasedAt: string | null;
|
||||
notes: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface FinanceCapexInput {
|
||||
title: string;
|
||||
category: CapexCategory;
|
||||
status: CapexStatus;
|
||||
vendorId: string | null;
|
||||
purchaseOrderId: string | null;
|
||||
plannedAmount: number;
|
||||
actualAmount: number;
|
||||
requestDate: string;
|
||||
targetInServiceDate: string | null;
|
||||
purchasedAt: string | null;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface FinanceSalesOrderLedgerDto {
|
||||
salesOrderId: string;
|
||||
salesOrderNumber: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
status: string;
|
||||
issueDate: string;
|
||||
revenueTotal: number;
|
||||
paymentsReceived: number;
|
||||
accountsReceivableOpen: number;
|
||||
linkedPurchaseCommitted: number;
|
||||
linkedPurchaseReceivedValue: number;
|
||||
manufacturingMaterialCost: number;
|
||||
manufacturingLaborCost: number;
|
||||
manufacturingOverheadCost: number;
|
||||
manufacturingTotalCost: number;
|
||||
totalRecognizedSpend: number;
|
||||
grossMarginEstimate: number;
|
||||
grossMarginPercent: number;
|
||||
paymentCoveragePercent: number;
|
||||
linkedPurchaseOrderCount: number;
|
||||
linkedWorkOrderCount: number;
|
||||
}
|
||||
|
||||
export interface FinanceSummaryDto {
|
||||
bookedRevenue: number;
|
||||
paymentsReceived: number;
|
||||
accountsReceivableOpen: number;
|
||||
linkedPurchaseCommitted: number;
|
||||
linkedPurchaseReceivedValue: number;
|
||||
manufacturingMaterialCost: number;
|
||||
manufacturingLaborCost: number;
|
||||
manufacturingOverheadCost: number;
|
||||
manufacturingTotalCost: number;
|
||||
capexPlanned: number;
|
||||
capexActual: number;
|
||||
grossMarginEstimate: number;
|
||||
}
|
||||
|
||||
export interface FinanceDashboardDto {
|
||||
generatedAt: string;
|
||||
profile: FinanceProfileDto;
|
||||
summary: FinanceSummaryDto;
|
||||
salesOrderLedgers: FinanceSalesOrderLedgerDto[];
|
||||
payments: FinanceCustomerPaymentDto[];
|
||||
capex: FinanceCapexDto[];
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export * from "./common/api.js";
|
||||
export * from "./company/types.js";
|
||||
export * from "./crm/types.js";
|
||||
export * from "./files/types.js";
|
||||
export * from "./finance/types.js";
|
||||
export * from "./gantt/types.js";
|
||||
export * from "./inventory/types.js";
|
||||
export * from "./manufacturing/types.js";
|
||||
|
||||
Reference in New Issue
Block a user