@pylonsync/create-pylon 0.3.344 → 0.3.346
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-pylon.js +35 -0
- package/package.json +1 -1
- package/templates/crm/AGENTS.md +116 -0
- package/templates/crm/CLAUDE.md +7 -0
- package/templates/crm/README.md +96 -0
- package/templates/crm/app/companies/companies-view.tsx +129 -0
- package/templates/crm/app/companies/page.tsx +17 -0
- package/templates/crm/app/contacts/contacts-view.tsx +139 -0
- package/templates/crm/app/contacts/page.tsx +17 -0
- package/templates/crm/app/deals/[id]/deal-view.tsx +189 -0
- package/templates/crm/app/deals/[id]/page.tsx +23 -0
- package/templates/crm/app/error.tsx +43 -0
- package/templates/crm/app/globals.css +201 -0
- package/templates/crm/app/layout.tsx +31 -0
- package/templates/crm/app/login/auth-form.tsx +122 -0
- package/templates/crm/app/login/page.tsx +38 -0
- package/templates/crm/app/not-found.tsx +29 -0
- package/templates/crm/app/page.tsx +38 -0
- package/templates/crm/app/pipeline-view.tsx +99 -0
- package/templates/crm/app/robots.ts +12 -0
- package/templates/crm/app/sitemap.ts +27 -0
- package/templates/crm/app/workspace.tsx +231 -0
- package/templates/crm/app.ts +171 -0
- package/templates/crm/bunfig +4 -0
- package/templates/crm/components/activity-timeline.tsx +133 -0
- package/templates/crm/components/avatar.tsx +40 -0
- package/templates/crm/components/board-skeleton.tsx +44 -0
- package/templates/crm/components/command-palette.tsx +162 -0
- package/templates/crm/components/data-table.tsx +90 -0
- package/templates/crm/components/deal-card.tsx +91 -0
- package/templates/crm/components/deal-dialog.tsx +169 -0
- package/templates/crm/components/empty-state.tsx +43 -0
- package/templates/crm/components/kbd.tsx +23 -0
- package/templates/crm/components/metrics-bar.tsx +46 -0
- package/templates/crm/components/page-header.tsx +36 -0
- package/templates/crm/components/pipeline-board.tsx +105 -0
- package/templates/crm/components/record-dialog.tsx +126 -0
- package/templates/crm/components/sidebar.tsx +112 -0
- package/templates/crm/components/stage-badge.tsx +33 -0
- package/templates/crm/components/ui/badge.tsx +37 -0
- package/templates/crm/components/ui/button.tsx +56 -0
- package/templates/crm/components/ui/card.tsx +90 -0
- package/templates/crm/components/ui/dialog.tsx +110 -0
- package/templates/crm/components/ui/input.tsx +30 -0
- package/templates/crm/components/ui/label.tsx +21 -0
- package/templates/crm/components/ui/select.tsx +39 -0
- package/templates/crm/components/ui/table.tsx +124 -0
- package/templates/crm/components/ui/textarea.tsx +24 -0
- package/templates/crm/components.json +20 -0
- package/templates/crm/functions/createDeal.ts +59 -0
- package/templates/crm/functions/logActivity.ts +49 -0
- package/templates/crm/functions/moveDeal.ts +53 -0
- package/templates/crm/functions/seedWorkspace.ts +63 -0
- package/templates/crm/gitignore +10 -0
- package/templates/crm/lib/pipeline.ts +221 -0
- package/templates/crm/lib/search.ts +78 -0
- package/templates/crm/lib/seed.ts +130 -0
- package/templates/crm/lib/utils.ts +10 -0
- package/templates/crm/package.json +39 -0
- package/templates/crm/tests/components.test.tsx +245 -0
- package/templates/crm/tests/pipeline.test.ts +219 -0
- package/templates/crm/tests/search.test.ts +83 -0
- package/templates/crm/tests/setup.ts +5 -0
- package/templates/crm/tsconfig.json +33 -0
- package/templates/helpdesk/AGENTS.md +116 -0
- package/templates/helpdesk/CLAUDE.md +7 -0
- package/templates/helpdesk/README.md +90 -0
- package/templates/helpdesk/app/customers/customers-view.tsx +140 -0
- package/templates/helpdesk/app/customers/page.tsx +17 -0
- package/templates/helpdesk/app/error.tsx +43 -0
- package/templates/helpdesk/app/globals.css +201 -0
- package/templates/helpdesk/app/inbox-view.tsx +147 -0
- package/templates/helpdesk/app/layout.tsx +31 -0
- package/templates/helpdesk/app/login/auth-form.tsx +122 -0
- package/templates/helpdesk/app/login/page.tsx +38 -0
- package/templates/helpdesk/app/not-found.tsx +29 -0
- package/templates/helpdesk/app/page.tsx +38 -0
- package/templates/helpdesk/app/robots.ts +12 -0
- package/templates/helpdesk/app/sitemap.ts +27 -0
- package/templates/helpdesk/app/tickets/[id]/page.tsx +23 -0
- package/templates/helpdesk/app/tickets/[id]/ticket-view.tsx +164 -0
- package/templates/helpdesk/app/workspace.tsx +210 -0
- package/templates/helpdesk/app.ts +142 -0
- package/templates/helpdesk/bunfig +4 -0
- package/templates/helpdesk/components/avatar.tsx +40 -0
- package/templates/helpdesk/components/command-palette.tsx +162 -0
- package/templates/helpdesk/components/data-table.tsx +90 -0
- package/templates/helpdesk/components/empty-state.tsx +43 -0
- package/templates/helpdesk/components/kbd.tsx +23 -0
- package/templates/helpdesk/components/new-ticket-dialog.tsx +147 -0
- package/templates/helpdesk/components/page-header.tsx +36 -0
- package/templates/helpdesk/components/priority-badge.tsx +61 -0
- package/templates/helpdesk/components/record-dialog.tsx +126 -0
- package/templates/helpdesk/components/sidebar.tsx +111 -0
- package/templates/helpdesk/components/sla-indicator.tsx +47 -0
- package/templates/helpdesk/components/thread.tsx +143 -0
- package/templates/helpdesk/components/ticket-list.tsx +78 -0
- package/templates/helpdesk/components/ui/badge.tsx +37 -0
- package/templates/helpdesk/components/ui/button.tsx +56 -0
- package/templates/helpdesk/components/ui/card.tsx +90 -0
- package/templates/helpdesk/components/ui/dialog.tsx +110 -0
- package/templates/helpdesk/components/ui/input.tsx +30 -0
- package/templates/helpdesk/components/ui/label.tsx +21 -0
- package/templates/helpdesk/components/ui/select.tsx +39 -0
- package/templates/helpdesk/components/ui/table.tsx +124 -0
- package/templates/helpdesk/components/ui/textarea.tsx +24 -0
- package/templates/helpdesk/components.json +20 -0
- package/templates/helpdesk/functions/createTicket.ts +62 -0
- package/templates/helpdesk/functions/replyToTicket.ts +55 -0
- package/templates/helpdesk/functions/seedWorkspace.ts +52 -0
- package/templates/helpdesk/functions/setTicketState.ts +58 -0
- package/templates/helpdesk/gitignore +10 -0
- package/templates/helpdesk/lib/format.ts +53 -0
- package/templates/helpdesk/lib/search.ts +78 -0
- package/templates/helpdesk/lib/seed.ts +185 -0
- package/templates/helpdesk/lib/tickets.ts +184 -0
- package/templates/helpdesk/lib/utils.ts +10 -0
- package/templates/helpdesk/package.json +39 -0
- package/templates/helpdesk/tests/components.test.tsx +202 -0
- package/templates/helpdesk/tests/search.test.ts +83 -0
- package/templates/helpdesk/tests/setup.ts +5 -0
- package/templates/helpdesk/tests/tickets.test.ts +215 -0
- package/templates/helpdesk/tsconfig.json +33 -0
- package/templates/inventory/AGENTS.md +116 -0
- package/templates/inventory/CLAUDE.md +7 -0
- package/templates/inventory/README.md +103 -0
- package/templates/inventory/app/error.tsx +43 -0
- package/templates/inventory/app/globals.css +201 -0
- package/templates/inventory/app/layout.tsx +31 -0
- package/templates/inventory/app/login/auth-form.tsx +122 -0
- package/templates/inventory/app/login/page.tsx +38 -0
- package/templates/inventory/app/movements/movements-view.tsx +103 -0
- package/templates/inventory/app/movements/page.tsx +17 -0
- package/templates/inventory/app/not-found.tsx +29 -0
- package/templates/inventory/app/page.tsx +34 -0
- package/templates/inventory/app/products/[id]/page.tsx +23 -0
- package/templates/inventory/app/products/[id]/product-view.tsx +178 -0
- package/templates/inventory/app/products-view.tsx +213 -0
- package/templates/inventory/app/robots.ts +12 -0
- package/templates/inventory/app/sitemap.ts +27 -0
- package/templates/inventory/app/workspace.tsx +172 -0
- package/templates/inventory/app.ts +154 -0
- package/templates/inventory/bunfig +4 -0
- package/templates/inventory/components/avatar.tsx +40 -0
- package/templates/inventory/components/command-palette.tsx +162 -0
- package/templates/inventory/components/data-table.tsx +90 -0
- package/templates/inventory/components/empty-state.tsx +43 -0
- package/templates/inventory/components/kbd.tsx +23 -0
- package/templates/inventory/components/movement-dialog.tsx +182 -0
- package/templates/inventory/components/page-header.tsx +36 -0
- package/templates/inventory/components/record-dialog.tsx +126 -0
- package/templates/inventory/components/sidebar.tsx +111 -0
- package/templates/inventory/components/stock-level.tsx +47 -0
- package/templates/inventory/components/summary-bar.tsx +56 -0
- package/templates/inventory/components/ui/badge.tsx +37 -0
- package/templates/inventory/components/ui/button.tsx +56 -0
- package/templates/inventory/components/ui/card.tsx +90 -0
- package/templates/inventory/components/ui/dialog.tsx +110 -0
- package/templates/inventory/components/ui/input.tsx +30 -0
- package/templates/inventory/components/ui/label.tsx +21 -0
- package/templates/inventory/components/ui/select.tsx +39 -0
- package/templates/inventory/components/ui/table.tsx +124 -0
- package/templates/inventory/components/ui/textarea.tsx +24 -0
- package/templates/inventory/components.json +20 -0
- package/templates/inventory/functions/recordMovement.ts +72 -0
- package/templates/inventory/functions/seedWorkspace.ts +40 -0
- package/templates/inventory/gitignore +10 -0
- package/templates/inventory/lib/format.ts +53 -0
- package/templates/inventory/lib/search.ts +78 -0
- package/templates/inventory/lib/seed.ts +102 -0
- package/templates/inventory/lib/stock.ts +207 -0
- package/templates/inventory/lib/utils.ts +10 -0
- package/templates/inventory/package.json +39 -0
- package/templates/inventory/tests/components.test.tsx +155 -0
- package/templates/inventory/tests/search.test.ts +83 -0
- package/templates/inventory/tests/setup.ts +5 -0
- package/templates/inventory/tests/stock.test.ts +255 -0
- package/templates/inventory/tsconfig.json +33 -0
- package/templates/invoices/AGENTS.md +116 -0
- package/templates/invoices/CLAUDE.md +7 -0
- package/templates/invoices/README.md +99 -0
- package/templates/invoices/app/clients/clients-view.tsx +130 -0
- package/templates/invoices/app/clients/page.tsx +17 -0
- package/templates/invoices/app/error.tsx +43 -0
- package/templates/invoices/app/globals.css +201 -0
- package/templates/invoices/app/invoices/[id]/invoice-view.tsx +253 -0
- package/templates/invoices/app/invoices/[id]/page.tsx +23 -0
- package/templates/invoices/app/invoices-view.tsx +183 -0
- package/templates/invoices/app/layout.tsx +31 -0
- package/templates/invoices/app/login/auth-form.tsx +122 -0
- package/templates/invoices/app/login/page.tsx +38 -0
- package/templates/invoices/app/not-found.tsx +29 -0
- package/templates/invoices/app/page.tsx +36 -0
- package/templates/invoices/app/robots.ts +12 -0
- package/templates/invoices/app/sitemap.ts +27 -0
- package/templates/invoices/app/workspace.tsx +174 -0
- package/templates/invoices/app.ts +169 -0
- package/templates/invoices/bunfig +4 -0
- package/templates/invoices/components/avatar.tsx +40 -0
- package/templates/invoices/components/command-palette.tsx +162 -0
- package/templates/invoices/components/data-table.tsx +90 -0
- package/templates/invoices/components/empty-state.tsx +43 -0
- package/templates/invoices/components/kbd.tsx +23 -0
- package/templates/invoices/components/line-items.tsx +172 -0
- package/templates/invoices/components/page-header.tsx +36 -0
- package/templates/invoices/components/payment-dialog.tsx +132 -0
- package/templates/invoices/components/record-dialog.tsx +126 -0
- package/templates/invoices/components/sidebar.tsx +111 -0
- package/templates/invoices/components/status-badge.tsx +35 -0
- package/templates/invoices/components/summary-bar.tsx +65 -0
- package/templates/invoices/components/totals-panel.tsx +70 -0
- package/templates/invoices/components/ui/badge.tsx +37 -0
- package/templates/invoices/components/ui/button.tsx +56 -0
- package/templates/invoices/components/ui/card.tsx +90 -0
- package/templates/invoices/components/ui/dialog.tsx +110 -0
- package/templates/invoices/components/ui/input.tsx +30 -0
- package/templates/invoices/components/ui/label.tsx +21 -0
- package/templates/invoices/components/ui/select.tsx +39 -0
- package/templates/invoices/components/ui/table.tsx +124 -0
- package/templates/invoices/components/ui/textarea.tsx +24 -0
- package/templates/invoices/components.json +20 -0
- package/templates/invoices/functions/createInvoice.ts +49 -0
- package/templates/invoices/functions/recordPayment.ts +72 -0
- package/templates/invoices/functions/seedWorkspace.ts +57 -0
- package/templates/invoices/functions/setInvoiceStatus.ts +35 -0
- package/templates/invoices/gitignore +10 -0
- package/templates/invoices/lib/billing.ts +259 -0
- package/templates/invoices/lib/format.ts +53 -0
- package/templates/invoices/lib/search.ts +78 -0
- package/templates/invoices/lib/seed.ts +190 -0
- package/templates/invoices/lib/utils.ts +10 -0
- package/templates/invoices/package.json +39 -0
- package/templates/invoices/tests/billing.test.ts +283 -0
- package/templates/invoices/tests/components.test.tsx +172 -0
- package/templates/invoices/tests/search.test.ts +83 -0
- package/templates/invoices/tests/setup.ts +5 -0
- package/templates/invoices/tsconfig.json +33 -0
- package/templates/projects/AGENTS.md +116 -0
- package/templates/projects/CLAUDE.md +7 -0
- package/templates/projects/README.md +96 -0
- package/templates/projects/app/clients/clients-view.tsx +126 -0
- package/templates/projects/app/clients/page.tsx +17 -0
- package/templates/projects/app/error.tsx +43 -0
- package/templates/projects/app/globals.css +201 -0
- package/templates/projects/app/layout.tsx +31 -0
- package/templates/projects/app/login/auth-form.tsx +122 -0
- package/templates/projects/app/login/page.tsx +38 -0
- package/templates/projects/app/not-found.tsx +29 -0
- package/templates/projects/app/page.tsx +30 -0
- package/templates/projects/app/projects/[id]/page.tsx +23 -0
- package/templates/projects/app/projects/[id]/project-view.tsx +179 -0
- package/templates/projects/app/projects-view.tsx +181 -0
- package/templates/projects/app/robots.ts +12 -0
- package/templates/projects/app/sitemap.ts +27 -0
- package/templates/projects/app/workspace.tsx +190 -0
- package/templates/projects/app.ts +186 -0
- package/templates/projects/bunfig +4 -0
- package/templates/projects/components/avatar.tsx +40 -0
- package/templates/projects/components/board-skeleton.tsx +37 -0
- package/templates/projects/components/budget-bar.tsx +61 -0
- package/templates/projects/components/command-palette.tsx +162 -0
- package/templates/projects/components/data-table.tsx +90 -0
- package/templates/projects/components/empty-state.tsx +43 -0
- package/templates/projects/components/kbd.tsx +23 -0
- package/templates/projects/components/page-header.tsx +36 -0
- package/templates/projects/components/record-dialog.tsx +126 -0
- package/templates/projects/components/sidebar.tsx +111 -0
- package/templates/projects/components/task-board.tsx +109 -0
- package/templates/projects/components/task-card.tsx +77 -0
- package/templates/projects/components/time-dialog.tsx +114 -0
- package/templates/projects/components/ui/badge.tsx +37 -0
- package/templates/projects/components/ui/button.tsx +56 -0
- package/templates/projects/components/ui/card.tsx +90 -0
- package/templates/projects/components/ui/dialog.tsx +110 -0
- package/templates/projects/components/ui/input.tsx +30 -0
- package/templates/projects/components/ui/label.tsx +21 -0
- package/templates/projects/components/ui/select.tsx +39 -0
- package/templates/projects/components/ui/table.tsx +124 -0
- package/templates/projects/components/ui/textarea.tsx +24 -0
- package/templates/projects/components.json +20 -0
- package/templates/projects/functions/logTime.ts +49 -0
- package/templates/projects/functions/moveTask.ts +40 -0
- package/templates/projects/functions/seedWorkspace.ts +59 -0
- package/templates/projects/gitignore +10 -0
- package/templates/projects/lib/format.ts +53 -0
- package/templates/projects/lib/search.ts +78 -0
- package/templates/projects/lib/seed.ts +140 -0
- package/templates/projects/lib/utils.ts +10 -0
- package/templates/projects/lib/work.ts +233 -0
- package/templates/projects/package.json +39 -0
- package/templates/projects/tests/components.test.tsx +200 -0
- package/templates/projects/tests/search.test.ts +83 -0
- package/templates/projects/tests/setup.ts +5 -0
- package/templates/projects/tests/work.test.ts +238 -0
- package/templates/projects/tsconfig.json +33 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Plus, Trash2 } from "lucide-react";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import { Input } from "@/components/ui/input";
|
|
5
|
+
import {
|
|
6
|
+
lineTotalCents,
|
|
7
|
+
money,
|
|
8
|
+
parseAmount,
|
|
9
|
+
parseQuantity,
|
|
10
|
+
quantity,
|
|
11
|
+
type LineItem,
|
|
12
|
+
} from "@/lib/billing";
|
|
13
|
+
|
|
14
|
+
export interface LineDraft {
|
|
15
|
+
description: string;
|
|
16
|
+
quantityMilli: number;
|
|
17
|
+
unitPriceCents: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The billable lines, and the row that adds one.
|
|
22
|
+
*
|
|
23
|
+
* Editing is deliberately limited to add and remove: an invoice that has been
|
|
24
|
+
* sent is a document someone is paying against, and silently mutating a line
|
|
25
|
+
* after the fact is how the copy in their inbox stops matching yours. Correct a
|
|
26
|
+
* sent invoice by voiding it and issuing another.
|
|
27
|
+
*/
|
|
28
|
+
export function LineItems({
|
|
29
|
+
items,
|
|
30
|
+
editable,
|
|
31
|
+
onAdd,
|
|
32
|
+
onRemove,
|
|
33
|
+
}: {
|
|
34
|
+
items: LineItem[];
|
|
35
|
+
/** False once the invoice leaves draft. */
|
|
36
|
+
editable: boolean;
|
|
37
|
+
onAdd: (draft: LineDraft) => void | Promise<void>;
|
|
38
|
+
onRemove: (id: string) => void | Promise<void>;
|
|
39
|
+
}) {
|
|
40
|
+
const [description, setDescription] = useState("");
|
|
41
|
+
const [qty, setQty] = useState("1");
|
|
42
|
+
const [price, setPrice] = useState("");
|
|
43
|
+
const [busy, setBusy] = useState(false);
|
|
44
|
+
|
|
45
|
+
const parsedQty = parseQuantity(qty);
|
|
46
|
+
const parsedPrice = parseAmount(price);
|
|
47
|
+
const canAdd =
|
|
48
|
+
description.trim().length > 0 &&
|
|
49
|
+
parsedQty !== null &&
|
|
50
|
+
parsedQty > 0 &&
|
|
51
|
+
parsedPrice !== null &&
|
|
52
|
+
!busy;
|
|
53
|
+
|
|
54
|
+
async function add(event: React.FormEvent) {
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
if (!canAdd) return;
|
|
57
|
+
setBusy(true);
|
|
58
|
+
try {
|
|
59
|
+
await onAdd({
|
|
60
|
+
description: description.trim(),
|
|
61
|
+
quantityMilli: parsedQty as number,
|
|
62
|
+
unitPriceCents: parsedPrice as number,
|
|
63
|
+
});
|
|
64
|
+
setDescription("");
|
|
65
|
+
setQty("1");
|
|
66
|
+
setPrice("");
|
|
67
|
+
} finally {
|
|
68
|
+
setBusy(false);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const ordered = [...items].sort(
|
|
73
|
+
(a, b) =>
|
|
74
|
+
(Number((a as { position?: number }).position) || 0) -
|
|
75
|
+
(Number((b as { position?: number }).position) || 0),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="rounded-lg border border-border bg-card">
|
|
80
|
+
<table className="w-full border-collapse text-[13px]">
|
|
81
|
+
<thead>
|
|
82
|
+
<tr className="hairline">
|
|
83
|
+
<th scope="col" className="h-8 px-3 text-left text-[11px] font-medium text-muted-foreground">
|
|
84
|
+
Description
|
|
85
|
+
</th>
|
|
86
|
+
<th scope="col" className="h-8 w-20 px-3 text-right text-[11px] font-medium text-muted-foreground">
|
|
87
|
+
Qty
|
|
88
|
+
</th>
|
|
89
|
+
<th scope="col" className="h-8 w-28 px-3 text-right text-[11px] font-medium text-muted-foreground">
|
|
90
|
+
Unit
|
|
91
|
+
</th>
|
|
92
|
+
<th scope="col" className="h-8 w-28 px-3 text-right text-[11px] font-medium text-muted-foreground">
|
|
93
|
+
Amount
|
|
94
|
+
</th>
|
|
95
|
+
{editable ? <th className="w-10" /> : null}
|
|
96
|
+
</tr>
|
|
97
|
+
</thead>
|
|
98
|
+
<tbody>
|
|
99
|
+
{ordered.length === 0 ? (
|
|
100
|
+
<tr>
|
|
101
|
+
<td
|
|
102
|
+
colSpan={editable ? 5 : 4}
|
|
103
|
+
className="px-3 py-6 text-center text-[12px] text-muted-foreground"
|
|
104
|
+
>
|
|
105
|
+
No lines yet.
|
|
106
|
+
</td>
|
|
107
|
+
</tr>
|
|
108
|
+
) : (
|
|
109
|
+
ordered.map((item) => (
|
|
110
|
+
<tr key={item.id} className="border-b border-border/60 last:border-0">
|
|
111
|
+
<td className="px-3 py-2">{item.description}</td>
|
|
112
|
+
<td className="tabular px-3 py-2 text-right text-muted-foreground">
|
|
113
|
+
{quantity(item.quantityMilli)}
|
|
114
|
+
</td>
|
|
115
|
+
<td className="tabular px-3 py-2 text-right text-muted-foreground">
|
|
116
|
+
{money(item.unitPriceCents)}
|
|
117
|
+
</td>
|
|
118
|
+
<td className="tabular px-3 py-2 text-right">
|
|
119
|
+
{money(lineTotalCents(item))}
|
|
120
|
+
</td>
|
|
121
|
+
{editable ? (
|
|
122
|
+
<td className="px-2 py-2 text-right">
|
|
123
|
+
<Button
|
|
124
|
+
variant="ghost"
|
|
125
|
+
size="icon"
|
|
126
|
+
aria-label={`Remove ${item.description}`}
|
|
127
|
+
onClick={() => onRemove(item.id)}
|
|
128
|
+
className="size-7 text-muted-foreground hover:text-destructive"
|
|
129
|
+
>
|
|
130
|
+
<Trash2 />
|
|
131
|
+
</Button>
|
|
132
|
+
</td>
|
|
133
|
+
) : null}
|
|
134
|
+
</tr>
|
|
135
|
+
))
|
|
136
|
+
)}
|
|
137
|
+
</tbody>
|
|
138
|
+
</table>
|
|
139
|
+
|
|
140
|
+
{editable ? (
|
|
141
|
+
<form onSubmit={add} className="flex items-end gap-2 border-t border-border p-2">
|
|
142
|
+
<Input
|
|
143
|
+
aria-label="Description"
|
|
144
|
+
placeholder="Senior engineering"
|
|
145
|
+
value={description}
|
|
146
|
+
onChange={(event) => setDescription(event.target.value)}
|
|
147
|
+
className="h-8 flex-1"
|
|
148
|
+
/>
|
|
149
|
+
<Input
|
|
150
|
+
aria-label="Quantity"
|
|
151
|
+
inputMode="decimal"
|
|
152
|
+
value={qty}
|
|
153
|
+
onChange={(event) => setQty(event.target.value)}
|
|
154
|
+
className="h-8 w-20 text-right"
|
|
155
|
+
/>
|
|
156
|
+
<Input
|
|
157
|
+
aria-label="Unit price"
|
|
158
|
+
inputMode="decimal"
|
|
159
|
+
placeholder="165.00"
|
|
160
|
+
value={price}
|
|
161
|
+
onChange={(event) => setPrice(event.target.value)}
|
|
162
|
+
className="h-8 w-28 text-right"
|
|
163
|
+
/>
|
|
164
|
+
<Button type="submit" size="sm" disabled={!canAdd} className="h-8">
|
|
165
|
+
<Plus />
|
|
166
|
+
Add
|
|
167
|
+
</Button>
|
|
168
|
+
</form>
|
|
169
|
+
) : null}
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The bar at the top of every view: title on the left, actions on the right.
|
|
6
|
+
* Fixed height so switching pages doesn't shift the content below it.
|
|
7
|
+
*/
|
|
8
|
+
export function PageHeader({
|
|
9
|
+
title,
|
|
10
|
+
count,
|
|
11
|
+
children,
|
|
12
|
+
className,
|
|
13
|
+
}: {
|
|
14
|
+
title: string;
|
|
15
|
+
/** Shown beside the title — the number of rows in view. */
|
|
16
|
+
count?: number;
|
|
17
|
+
children?: React.ReactNode;
|
|
18
|
+
className?: string;
|
|
19
|
+
}) {
|
|
20
|
+
return (
|
|
21
|
+
<header
|
|
22
|
+
className={cn(
|
|
23
|
+
"flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border px-4",
|
|
24
|
+
className,
|
|
25
|
+
)}
|
|
26
|
+
>
|
|
27
|
+
<div className="flex min-w-0 items-baseline gap-2">
|
|
28
|
+
<h1 className="truncate text-[13px] font-semibold">{title}</h1>
|
|
29
|
+
{typeof count === "number" ? (
|
|
30
|
+
<span className="tabular text-[12px] text-muted-foreground">{count}</span>
|
|
31
|
+
) : null}
|
|
32
|
+
</div>
|
|
33
|
+
<div className="flex items-center gap-2">{children}</div>
|
|
34
|
+
</header>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Dialog,
|
|
4
|
+
DialogContent,
|
|
5
|
+
DialogFooter,
|
|
6
|
+
DialogHeader,
|
|
7
|
+
DialogTitle,
|
|
8
|
+
} from "@/components/ui/dialog";
|
|
9
|
+
import { Button } from "@/components/ui/button";
|
|
10
|
+
import { Input } from "@/components/ui/input";
|
|
11
|
+
import { Label } from "@/components/ui/label";
|
|
12
|
+
import { Select } from "@/components/ui/select";
|
|
13
|
+
import { money, parseAmount } from "@/lib/billing";
|
|
14
|
+
|
|
15
|
+
const METHODS = ["bank", "card", "cash", "other"];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Record a payment against an invoice.
|
|
19
|
+
*
|
|
20
|
+
* Defaults to the outstanding balance, because "paid in full" is what happens
|
|
21
|
+
* most of the time and retyping the figure is how a typo gets in. Over-payment
|
|
22
|
+
* is rejected here rather than silently producing a negative balance — a credit
|
|
23
|
+
* is a decision, not a rounding outcome.
|
|
24
|
+
*/
|
|
25
|
+
export function PaymentDialog({
|
|
26
|
+
open,
|
|
27
|
+
balanceCents,
|
|
28
|
+
onOpenChange,
|
|
29
|
+
onRecord,
|
|
30
|
+
}: {
|
|
31
|
+
open: boolean;
|
|
32
|
+
balanceCents: number;
|
|
33
|
+
onOpenChange: (open: boolean) => void;
|
|
34
|
+
onRecord: (amountCents: number, method: string, reference: string) => void | Promise<void>;
|
|
35
|
+
}) {
|
|
36
|
+
const [amount, setAmount] = useState("");
|
|
37
|
+
const [method, setMethod] = useState("bank");
|
|
38
|
+
const [reference, setReference] = useState("");
|
|
39
|
+
const [busy, setBusy] = useState(false);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!open) return;
|
|
43
|
+
setAmount((balanceCents / 100).toFixed(2));
|
|
44
|
+
setMethod("bank");
|
|
45
|
+
setReference("");
|
|
46
|
+
setBusy(false);
|
|
47
|
+
}, [open, balanceCents]);
|
|
48
|
+
|
|
49
|
+
const parsed = parseAmount(amount);
|
|
50
|
+
const tooMuch = parsed !== null && parsed > balanceCents;
|
|
51
|
+
const canSubmit = parsed !== null && parsed > 0 && !tooMuch && !busy;
|
|
52
|
+
|
|
53
|
+
async function submit(event: React.FormEvent) {
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
if (!canSubmit) return;
|
|
56
|
+
setBusy(true);
|
|
57
|
+
try {
|
|
58
|
+
await onRecord(parsed as number, method, reference.trim());
|
|
59
|
+
onOpenChange(false);
|
|
60
|
+
} finally {
|
|
61
|
+
setBusy(false);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
67
|
+
<DialogContent>
|
|
68
|
+
<DialogHeader>
|
|
69
|
+
<DialogTitle>Record payment</DialogTitle>
|
|
70
|
+
</DialogHeader>
|
|
71
|
+
<form onSubmit={submit} className="grid gap-4">
|
|
72
|
+
<div className="space-y-1.5">
|
|
73
|
+
<Label htmlFor="payment-amount">Amount</Label>
|
|
74
|
+
<Input
|
|
75
|
+
id="payment-amount"
|
|
76
|
+
autoFocus
|
|
77
|
+
inputMode="decimal"
|
|
78
|
+
value={amount}
|
|
79
|
+
onChange={(event) => setAmount(event.target.value)}
|
|
80
|
+
/>
|
|
81
|
+
<p className="text-[11px] text-muted-foreground">
|
|
82
|
+
Balance due {money(balanceCents)}
|
|
83
|
+
</p>
|
|
84
|
+
{tooMuch ? (
|
|
85
|
+
<p className="text-[11px] text-destructive">
|
|
86
|
+
More than the balance — record the exact amount received.
|
|
87
|
+
</p>
|
|
88
|
+
) : null}
|
|
89
|
+
{parsed === null && amount.trim() ? (
|
|
90
|
+
<p className="text-[11px] text-destructive">That isn't an amount.</p>
|
|
91
|
+
) : null}
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<div className="grid grid-cols-2 gap-3">
|
|
95
|
+
<div className="space-y-1.5">
|
|
96
|
+
<Label htmlFor="payment-method">Method</Label>
|
|
97
|
+
<Select
|
|
98
|
+
id="payment-method"
|
|
99
|
+
value={method}
|
|
100
|
+
onChange={(event) => setMethod(event.target.value)}
|
|
101
|
+
>
|
|
102
|
+
{METHODS.map((m) => (
|
|
103
|
+
<option key={m} value={m}>
|
|
104
|
+
{m[0].toUpperCase() + m.slice(1)}
|
|
105
|
+
</option>
|
|
106
|
+
))}
|
|
107
|
+
</Select>
|
|
108
|
+
</div>
|
|
109
|
+
<div className="space-y-1.5">
|
|
110
|
+
<Label htmlFor="payment-reference">Reference</Label>
|
|
111
|
+
<Input
|
|
112
|
+
id="payment-reference"
|
|
113
|
+
placeholder="Optional"
|
|
114
|
+
value={reference}
|
|
115
|
+
onChange={(event) => setReference(event.target.value)}
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<DialogFooter>
|
|
121
|
+
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
|
122
|
+
Cancel
|
|
123
|
+
</Button>
|
|
124
|
+
<Button type="submit" disabled={!canSubmit}>
|
|
125
|
+
{busy ? "Recording…" : "Record payment"}
|
|
126
|
+
</Button>
|
|
127
|
+
</DialogFooter>
|
|
128
|
+
</form>
|
|
129
|
+
</DialogContent>
|
|
130
|
+
</Dialog>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Dialog,
|
|
4
|
+
DialogContent,
|
|
5
|
+
DialogFooter,
|
|
6
|
+
DialogHeader,
|
|
7
|
+
DialogTitle,
|
|
8
|
+
} from "@/components/ui/dialog";
|
|
9
|
+
import { Button } from "@/components/ui/button";
|
|
10
|
+
import { Input } from "@/components/ui/input";
|
|
11
|
+
import { Label } from "@/components/ui/label";
|
|
12
|
+
import { Select } from "@/components/ui/select";
|
|
13
|
+
|
|
14
|
+
export interface FieldSpec {
|
|
15
|
+
name: string;
|
|
16
|
+
label: string;
|
|
17
|
+
type?: "text" | "email" | "tel";
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
required?: boolean;
|
|
20
|
+
/** Renders a picker instead of a text input. */
|
|
21
|
+
options?: Array<{ value: string; label: string }>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A create dialog driven by a field list — used for companies and contacts,
|
|
26
|
+
* which differ only in their fields. Deals get their own dialog because value,
|
|
27
|
+
* stage and close date need real layout rather than a stack of text inputs.
|
|
28
|
+
*/
|
|
29
|
+
export function RecordDialog({
|
|
30
|
+
open,
|
|
31
|
+
title,
|
|
32
|
+
fields,
|
|
33
|
+
submitLabel,
|
|
34
|
+
onOpenChange,
|
|
35
|
+
onCreate,
|
|
36
|
+
}: {
|
|
37
|
+
open: boolean;
|
|
38
|
+
title: string;
|
|
39
|
+
fields: FieldSpec[];
|
|
40
|
+
submitLabel: string;
|
|
41
|
+
onOpenChange: (open: boolean) => void;
|
|
42
|
+
onCreate: (values: Record<string, string>) => void | Promise<void>;
|
|
43
|
+
}) {
|
|
44
|
+
const [values, setValues] = useState<Record<string, string>>({});
|
|
45
|
+
const [busy, setBusy] = useState(false);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!open) return;
|
|
49
|
+
setValues({});
|
|
50
|
+
setBusy(false);
|
|
51
|
+
}, [open]);
|
|
52
|
+
|
|
53
|
+
const missing = fields.some(
|
|
54
|
+
(field) => field.required && !(values[field.name] ?? "").trim(),
|
|
55
|
+
);
|
|
56
|
+
const canSubmit = !missing && !busy;
|
|
57
|
+
|
|
58
|
+
async function submit(event: React.FormEvent) {
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
if (!canSubmit) return;
|
|
61
|
+
setBusy(true);
|
|
62
|
+
try {
|
|
63
|
+
const trimmed: Record<string, string> = {};
|
|
64
|
+
for (const [key, value] of Object.entries(values)) {
|
|
65
|
+
const clean = value.trim();
|
|
66
|
+
if (clean) trimmed[key] = clean;
|
|
67
|
+
}
|
|
68
|
+
await onCreate(trimmed);
|
|
69
|
+
onOpenChange(false);
|
|
70
|
+
} finally {
|
|
71
|
+
setBusy(false);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
77
|
+
<DialogContent>
|
|
78
|
+
<DialogHeader>
|
|
79
|
+
<DialogTitle>{title}</DialogTitle>
|
|
80
|
+
</DialogHeader>
|
|
81
|
+
<form onSubmit={submit} className="grid gap-4">
|
|
82
|
+
{fields.map((field, index) => (
|
|
83
|
+
<div key={field.name} className="space-y-1.5">
|
|
84
|
+
<Label htmlFor={`record-${field.name}`}>{field.label}</Label>
|
|
85
|
+
{field.options ? (
|
|
86
|
+
<Select
|
|
87
|
+
id={`record-${field.name}`}
|
|
88
|
+
value={values[field.name] ?? ""}
|
|
89
|
+
onChange={(event) =>
|
|
90
|
+
setValues((v) => ({ ...v, [field.name]: event.target.value }))
|
|
91
|
+
}
|
|
92
|
+
>
|
|
93
|
+
<option value="">—</option>
|
|
94
|
+
{field.options.map((option) => (
|
|
95
|
+
<option key={option.value} value={option.value}>
|
|
96
|
+
{option.label}
|
|
97
|
+
</option>
|
|
98
|
+
))}
|
|
99
|
+
</Select>
|
|
100
|
+
) : (
|
|
101
|
+
<Input
|
|
102
|
+
id={`record-${field.name}`}
|
|
103
|
+
autoFocus={index === 0}
|
|
104
|
+
type={field.type ?? "text"}
|
|
105
|
+
placeholder={field.placeholder}
|
|
106
|
+
value={values[field.name] ?? ""}
|
|
107
|
+
onChange={(event) =>
|
|
108
|
+
setValues((v) => ({ ...v, [field.name]: event.target.value }))
|
|
109
|
+
}
|
|
110
|
+
/>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
))}
|
|
114
|
+
<DialogFooter>
|
|
115
|
+
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
|
116
|
+
Cancel
|
|
117
|
+
</Button>
|
|
118
|
+
<Button type="submit" disabled={!canSubmit}>
|
|
119
|
+
{busy ? "Saving…" : submitLabel}
|
|
120
|
+
</Button>
|
|
121
|
+
</DialogFooter>
|
|
122
|
+
</form>
|
|
123
|
+
</DialogContent>
|
|
124
|
+
</Dialog>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link } from "@pylonsync/react";
|
|
3
|
+
import { Command, FileText, LogOut, Users } from "lucide-react";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
import { Avatar } from "@/components/avatar";
|
|
6
|
+
import { Kbd } from "@/components/kbd";
|
|
7
|
+
|
|
8
|
+
export interface NavItem {
|
|
9
|
+
href: string;
|
|
10
|
+
label: string;
|
|
11
|
+
icon: React.ReactNode;
|
|
12
|
+
count?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const NAV: Array<Omit<NavItem, "count">> = [
|
|
16
|
+
{ href: "/", label: "Invoices", icon: <FileText /> },
|
|
17
|
+
{ href: "/clients", label: "Clients", icon: <Users /> },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The fixed left rail. Presentational: the active path, the counts, and the
|
|
22
|
+
* sign-out handler all arrive as props, so it renders identically in a test.
|
|
23
|
+
*/
|
|
24
|
+
export function Sidebar({
|
|
25
|
+
workspace,
|
|
26
|
+
email,
|
|
27
|
+
pathname,
|
|
28
|
+
counts,
|
|
29
|
+
onOpenCommand,
|
|
30
|
+
onSignOut,
|
|
31
|
+
}: {
|
|
32
|
+
workspace: string;
|
|
33
|
+
email: string;
|
|
34
|
+
pathname: string;
|
|
35
|
+
counts?: Record<string, number>;
|
|
36
|
+
onOpenCommand: () => void;
|
|
37
|
+
onSignOut: () => void;
|
|
38
|
+
}) {
|
|
39
|
+
return (
|
|
40
|
+
<aside className="flex w-[224px] shrink-0 flex-col border-r border-border bg-surface-1">
|
|
41
|
+
<div className="flex h-12 items-center gap-2 px-3">
|
|
42
|
+
<span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-primary text-[11px] font-bold text-primary-foreground">
|
|
43
|
+
{workspace.slice(0, 1).toUpperCase()}
|
|
44
|
+
</span>
|
|
45
|
+
<span className="truncate text-[13px] font-semibold">{workspace}</span>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div className="px-2 pb-2">
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
onClick={onOpenCommand}
|
|
52
|
+
className={cn(
|
|
53
|
+
"flex w-full items-center gap-2 rounded-md border border-border bg-background px-2 py-1.5",
|
|
54
|
+
"text-[12px] text-muted-foreground transition-colors hover:border-ring/40 hover:text-foreground",
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
<Command className="size-3.5" />
|
|
58
|
+
<span className="flex-1 text-left">Search…</span>
|
|
59
|
+
<Kbd>⌘K</Kbd>
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<nav className="flex-1 space-y-0.5 px-2" aria-label="Main">
|
|
64
|
+
{NAV.map((item) => {
|
|
65
|
+
const active =
|
|
66
|
+
item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
|
67
|
+
return (
|
|
68
|
+
<Link
|
|
69
|
+
key={item.href}
|
|
70
|
+
href={item.href}
|
|
71
|
+
aria-current={active ? "page" : undefined}
|
|
72
|
+
className={cn(
|
|
73
|
+
"flex h-7 items-center gap-2 rounded-md px-2 text-[13px] transition-colors",
|
|
74
|
+
"[&_svg]:size-3.5 [&_svg]:shrink-0",
|
|
75
|
+
active
|
|
76
|
+
? "bg-surface-2 font-medium text-foreground"
|
|
77
|
+
: "text-muted-foreground hover:bg-surface-2/60 hover:text-foreground",
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
{item.icon}
|
|
81
|
+
<span className="flex-1 truncate">{item.label}</span>
|
|
82
|
+
{counts?.[item.href] ? (
|
|
83
|
+
<span className="tabular text-[11px] text-muted-foreground">
|
|
84
|
+
{counts[item.href]}
|
|
85
|
+
</span>
|
|
86
|
+
) : null}
|
|
87
|
+
</Link>
|
|
88
|
+
);
|
|
89
|
+
})}
|
|
90
|
+
</nav>
|
|
91
|
+
|
|
92
|
+
<div className="border-t border-border p-2">
|
|
93
|
+
<div className="flex items-center gap-2 rounded-md px-1.5 py-1">
|
|
94
|
+
<Avatar name={email} size="sm" />
|
|
95
|
+
<span className="min-w-0 flex-1 truncate text-[12px] text-muted-foreground">
|
|
96
|
+
{email}
|
|
97
|
+
</span>
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
onClick={onSignOut}
|
|
101
|
+
title="Sign out"
|
|
102
|
+
aria-label="Sign out"
|
|
103
|
+
className="rounded p-1 text-muted-foreground transition-colors hover:bg-surface-2 hover:text-foreground"
|
|
104
|
+
>
|
|
105
|
+
<LogOut className="size-3.5" />
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</aside>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
import { statusById } from "@/lib/billing";
|
|
4
|
+
|
|
5
|
+
// "overdue" isn't a stored status — it's derived in lib/billing.ts — but it's
|
|
6
|
+
// the one the eye needs to find first, so it gets the only alarming colour.
|
|
7
|
+
const STYLE: Record<string, string> = {
|
|
8
|
+
draft: "border-border text-muted-foreground",
|
|
9
|
+
sent: "border-stage-qualified/40 text-stage-qualified",
|
|
10
|
+
overdue: "border-destructive/50 text-destructive",
|
|
11
|
+
paid: "border-stage-won/40 text-stage-won",
|
|
12
|
+
void: "border-border text-muted-foreground line-through",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const LABEL: Record<string, string> = { overdue: "Overdue" };
|
|
16
|
+
|
|
17
|
+
export function StatusBadge({
|
|
18
|
+
status,
|
|
19
|
+
className,
|
|
20
|
+
}: {
|
|
21
|
+
status: string;
|
|
22
|
+
className?: string;
|
|
23
|
+
}) {
|
|
24
|
+
return (
|
|
25
|
+
<span
|
|
26
|
+
className={cn(
|
|
27
|
+
"inline-flex h-5 items-center rounded border px-1.5 text-[11px] font-medium whitespace-nowrap",
|
|
28
|
+
STYLE[status] ?? "border-border text-muted-foreground",
|
|
29
|
+
className,
|
|
30
|
+
)}
|
|
31
|
+
>
|
|
32
|
+
{LABEL[status] ?? statusById(status)?.label ?? status}
|
|
33
|
+
</span>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { money, summarize, type Invoice, type LineItem, type Payment } from "@/lib/billing";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The four numbers a small business checks daily. All derived from the line
|
|
6
|
+
* items and payments — see lib/billing.ts, where the arithmetic is unit-tested.
|
|
7
|
+
*/
|
|
8
|
+
export function SummaryBar({
|
|
9
|
+
invoices,
|
|
10
|
+
items,
|
|
11
|
+
payments,
|
|
12
|
+
now,
|
|
13
|
+
}: {
|
|
14
|
+
invoices: Invoice[];
|
|
15
|
+
items: LineItem[];
|
|
16
|
+
payments: Payment[];
|
|
17
|
+
/** Injectable so a test gets a deterministic "overdue". */
|
|
18
|
+
now?: number;
|
|
19
|
+
}) {
|
|
20
|
+
const s = summarize(invoices, items, payments, now);
|
|
21
|
+
return (
|
|
22
|
+
<dl className="grid grid-cols-2 gap-px border-b border-border bg-border sm:grid-cols-4">
|
|
23
|
+
<Metric label="Outstanding" value={money(s.outstandingCents)} hint="sent, unpaid" />
|
|
24
|
+
<Metric
|
|
25
|
+
label="Overdue"
|
|
26
|
+
value={money(s.overdueCents)}
|
|
27
|
+
hint={`${s.overdueCount} invoice${s.overdueCount === 1 ? "" : "s"}`}
|
|
28
|
+
alarming={s.overdueCents > 0}
|
|
29
|
+
/>
|
|
30
|
+
<Metric label="Collected" value={money(s.paidCents)} hint="all time" />
|
|
31
|
+
<Metric
|
|
32
|
+
label="Drafts"
|
|
33
|
+
value={String(s.draftCount)}
|
|
34
|
+
hint="not yet sent"
|
|
35
|
+
/>
|
|
36
|
+
</dl>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function Metric({
|
|
41
|
+
label,
|
|
42
|
+
value,
|
|
43
|
+
hint,
|
|
44
|
+
alarming,
|
|
45
|
+
}: {
|
|
46
|
+
label: string;
|
|
47
|
+
value: string;
|
|
48
|
+
hint: string;
|
|
49
|
+
alarming?: boolean;
|
|
50
|
+
}) {
|
|
51
|
+
return (
|
|
52
|
+
<div className="bg-background px-4 py-3">
|
|
53
|
+
<dt className="text-[11px] text-muted-foreground">{label}</dt>
|
|
54
|
+
<dd
|
|
55
|
+
className={
|
|
56
|
+
"tabular mt-0.5 text-[18px] font-semibold tracking-tight" +
|
|
57
|
+
(alarming ? " text-destructive" : "")
|
|
58
|
+
}
|
|
59
|
+
>
|
|
60
|
+
{value}
|
|
61
|
+
</dd>
|
|
62
|
+
<p className="mt-0.5 text-[11px] text-muted-foreground">{hint}</p>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|