@pylonsync/create-pylon 0.3.344 → 0.3.345
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,72 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { totals, type LineItem, type Payment } from "../lib/billing";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Record money received against an invoice.
|
|
6
|
+
*
|
|
7
|
+
* A mutation rather than a bare insert because a payment can settle the
|
|
8
|
+
* invoice, and the status flip has to happen in the same transaction — a
|
|
9
|
+
* payment that lands without the status changing leaves someone chasing an
|
|
10
|
+
* invoice that's already been paid.
|
|
11
|
+
*
|
|
12
|
+
* The balance is recomputed SERVER-side from the line items and existing
|
|
13
|
+
* payments. Trusting a client-sent balance would let a stale tab overpay or
|
|
14
|
+
* mark an invoice settled that isn't.
|
|
15
|
+
*/
|
|
16
|
+
export default mutation<
|
|
17
|
+
{ invoiceId: string; amountCents: number; method?: string; reference?: string },
|
|
18
|
+
{ ok: true; settled: boolean }
|
|
19
|
+
>({
|
|
20
|
+
auth: "user",
|
|
21
|
+
args: {
|
|
22
|
+
invoiceId: v.id("Invoice"),
|
|
23
|
+
amountCents: v.number(),
|
|
24
|
+
method: v.optional(v.string()),
|
|
25
|
+
reference: v.optional(v.string()),
|
|
26
|
+
},
|
|
27
|
+
async handler(ctx, args) {
|
|
28
|
+
const amountCents = Math.round(Number(args.amountCents) || 0);
|
|
29
|
+
if (amountCents <= 0) {
|
|
30
|
+
throw ctx.error("INVALID_ARGS", "A payment must be a positive amount.");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const invoice = await ctx.db.get("Invoice", args.invoiceId);
|
|
34
|
+
if (!invoice) throw ctx.error("NOT_FOUND", "Invoice not found.");
|
|
35
|
+
if (invoice.status === "void") {
|
|
36
|
+
throw ctx.error("INVALID_ARGS", "That invoice is void.");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const items = (await ctx.db.query("LineItem", {
|
|
40
|
+
invoiceId: args.invoiceId,
|
|
41
|
+
})) as unknown as LineItem[];
|
|
42
|
+
const payments = (await ctx.db.query("Payment", {
|
|
43
|
+
invoiceId: args.invoiceId,
|
|
44
|
+
})) as unknown as Payment[];
|
|
45
|
+
|
|
46
|
+
const before = totals(invoice as never, items, payments);
|
|
47
|
+
if (amountCents > before.balanceCents) {
|
|
48
|
+
throw ctx.error(
|
|
49
|
+
"INVALID_ARGS",
|
|
50
|
+
"That is more than the outstanding balance.",
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const now = new Date().toISOString();
|
|
55
|
+
await ctx.db.insert("Payment", {
|
|
56
|
+
invoiceId: args.invoiceId,
|
|
57
|
+
amountCents,
|
|
58
|
+
method: args.method ?? "other",
|
|
59
|
+
reference: args.reference || null,
|
|
60
|
+
paidAt: now,
|
|
61
|
+
recordedBy: ctx.auth.userId,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const settled = amountCents >= before.balanceCents;
|
|
65
|
+
await ctx.db.update("Invoice", args.invoiceId, {
|
|
66
|
+
updatedAt: now,
|
|
67
|
+
...(settled ? { status: "paid" } : {}),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return { ok: true, settled } as const;
|
|
71
|
+
},
|
|
72
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { mutation } from "@pylonsync/functions";
|
|
2
|
+
import { shapeSeed } from "../lib/seed";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fill a brand-new billing workspace once.
|
|
6
|
+
*
|
|
7
|
+
* An empty invoice list shows nothing about totals, ageing, or partial payment.
|
|
8
|
+
* The client calls this after sign-in; it returns immediately if any invoice
|
|
9
|
+
* exists, so it's safe on every load and can never duplicate the fixtures or
|
|
10
|
+
* touch real books.
|
|
11
|
+
*
|
|
12
|
+
* Delete this function and lib/seed.ts once you're billing real clients.
|
|
13
|
+
*/
|
|
14
|
+
export default mutation<Record<string, never>, { seeded: boolean }>({
|
|
15
|
+
auth: "user",
|
|
16
|
+
args: {},
|
|
17
|
+
async handler(ctx) {
|
|
18
|
+
const existing = await ctx.db.query("Invoice", { $limit: 1 });
|
|
19
|
+
if (existing.length > 0) return { seeded: false };
|
|
20
|
+
|
|
21
|
+
const seed = shapeSeed();
|
|
22
|
+
const me = ctx.auth.userId;
|
|
23
|
+
|
|
24
|
+
const clientIds = new Map<string, string>();
|
|
25
|
+
for (const client of seed.clients) {
|
|
26
|
+
const id = await ctx.db.insert("Client", client.row);
|
|
27
|
+
clientIds.set(client.key, id as string);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const invoiceIds = new Map<string, string>();
|
|
31
|
+
for (const invoice of seed.invoices) {
|
|
32
|
+
const id = await ctx.db.insert("Invoice", {
|
|
33
|
+
...invoice.row,
|
|
34
|
+
clientId: clientIds.get(invoice.client) ?? null,
|
|
35
|
+
ownerId: me,
|
|
36
|
+
});
|
|
37
|
+
invoiceIds.set(invoice.key, id as string);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const line of seed.lines) {
|
|
41
|
+
await ctx.db.insert("LineItem", {
|
|
42
|
+
...line.row,
|
|
43
|
+
invoiceId: invoiceIds.get(line.invoice) ?? null,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const payment of seed.payments) {
|
|
48
|
+
await ctx.db.insert("Payment", {
|
|
49
|
+
...payment.row,
|
|
50
|
+
invoiceId: invoiceIds.get(payment.invoice) ?? null,
|
|
51
|
+
recordedBy: me,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { seeded: true };
|
|
56
|
+
},
|
|
57
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { isValidStatus } from "../lib/billing";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Move an invoice between draft, sent, paid, and void.
|
|
6
|
+
*
|
|
7
|
+
* "overdue" is deliberately NOT settable — it's derived from the due date and
|
|
8
|
+
* the balance (lib/billing.ts), so there is no column to fall out of date and
|
|
9
|
+
* no nightly job to run.
|
|
10
|
+
*
|
|
11
|
+
* Sending stamps the issue date if it's missing, because "sent" with no issue
|
|
12
|
+
* date is a document that can't be aged.
|
|
13
|
+
*/
|
|
14
|
+
export default mutation<{ invoiceId: string; status: string }, { ok: true }>({
|
|
15
|
+
auth: "user",
|
|
16
|
+
args: { invoiceId: v.id("Invoice"), status: v.string() },
|
|
17
|
+
async handler(ctx, args) {
|
|
18
|
+
if (!isValidStatus(args.status)) {
|
|
19
|
+
throw ctx.error("INVALID_ARGS", `Unknown status "${args.status}".`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const invoice = await ctx.db.get("Invoice", args.invoiceId);
|
|
23
|
+
if (!invoice) throw ctx.error("NOT_FOUND", "Invoice not found.");
|
|
24
|
+
|
|
25
|
+
const now = new Date().toISOString();
|
|
26
|
+
const patch: Record<string, unknown> = {
|
|
27
|
+
status: args.status,
|
|
28
|
+
updatedAt: now,
|
|
29
|
+
};
|
|
30
|
+
if (args.status === "sent" && !invoice.issueDate) patch.issueDate = now;
|
|
31
|
+
|
|
32
|
+
await ctx.db.update("Invoice", args.invoiceId, patch);
|
|
33
|
+
return { ok: true } as const;
|
|
34
|
+
},
|
|
35
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// Invoice arithmetic and status.
|
|
2
|
+
//
|
|
3
|
+
// MONEY IS INTEGER CENTS everywhere in this app. Floating-point dollars are how
|
|
4
|
+
// an invoice ends up off by a penny after tax, and a penny on an invoice is a
|
|
5
|
+
// support ticket. Values are converted at the edges — `parseAmount` on input,
|
|
6
|
+
// `money` on output — and never in between.
|
|
7
|
+
//
|
|
8
|
+
// Pure: no React, no `db`. The totals a customer is billed from are the last
|
|
9
|
+
// thing that should need a running server to verify.
|
|
10
|
+
|
|
11
|
+
export interface Status {
|
|
12
|
+
id: string;
|
|
13
|
+
label: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const STATUSES: Status[] = [
|
|
17
|
+
{ id: "draft", label: "Draft" },
|
|
18
|
+
{ id: "sent", label: "Sent" },
|
|
19
|
+
{ id: "paid", label: "Paid" },
|
|
20
|
+
{ id: "void", label: "Void" },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export interface LineItem {
|
|
24
|
+
id: string;
|
|
25
|
+
invoiceId: string;
|
|
26
|
+
description: string;
|
|
27
|
+
/** Thousandths, so 1.5 hours is 1500 — no float quantities either. */
|
|
28
|
+
quantityMilli: number;
|
|
29
|
+
unitPriceCents: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Payment {
|
|
33
|
+
id: string;
|
|
34
|
+
invoiceId: string;
|
|
35
|
+
amountCents: number;
|
|
36
|
+
paidAt?: string | null;
|
|
37
|
+
method?: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Invoice {
|
|
41
|
+
id: string;
|
|
42
|
+
number: string;
|
|
43
|
+
clientId?: string | null;
|
|
44
|
+
status: string;
|
|
45
|
+
/** Basis points: 875 = 8.75%. Integer, for the same reason as cents. */
|
|
46
|
+
taxRateBps?: number | null;
|
|
47
|
+
issueDate?: string | null;
|
|
48
|
+
dueDate?: string | null;
|
|
49
|
+
notes?: string | null;
|
|
50
|
+
createdAt?: string | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function statusById(id: string): Status | undefined {
|
|
54
|
+
return STATUSES.find((s) => s.id === id);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isValidStatus(id: string): boolean {
|
|
58
|
+
return STATUSES.some((s) => s.id === id);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface Totals {
|
|
62
|
+
subtotalCents: number;
|
|
63
|
+
taxCents: number;
|
|
64
|
+
totalCents: number;
|
|
65
|
+
paidCents: number;
|
|
66
|
+
balanceCents: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What an invoice is worth, what's been paid, and what's left.
|
|
71
|
+
*
|
|
72
|
+
* Tax is computed on the rounded subtotal rather than per line: that's what a
|
|
73
|
+
* customer can reproduce from the printed invoice, and per-line rounding drifts
|
|
74
|
+
* from the visible total.
|
|
75
|
+
*/
|
|
76
|
+
export function totals(
|
|
77
|
+
invoice: Invoice,
|
|
78
|
+
items: LineItem[],
|
|
79
|
+
payments: Payment[],
|
|
80
|
+
): Totals {
|
|
81
|
+
const lines = items.filter((item) => item.invoiceId === invoice.id);
|
|
82
|
+
const subtotalCents = lines.reduce((sum, item) => sum + lineTotalCents(item), 0);
|
|
83
|
+
|
|
84
|
+
const bps = Math.max(0, Number(invoice.taxRateBps) || 0);
|
|
85
|
+
const taxCents = Math.round((subtotalCents * bps) / 10_000);
|
|
86
|
+
const totalCents = subtotalCents + taxCents;
|
|
87
|
+
|
|
88
|
+
const paidCents = payments
|
|
89
|
+
.filter((payment) => payment.invoiceId === invoice.id)
|
|
90
|
+
.reduce((sum, payment) => sum + (Number(payment.amountCents) || 0), 0);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
subtotalCents,
|
|
94
|
+
taxCents,
|
|
95
|
+
totalCents,
|
|
96
|
+
paidCents,
|
|
97
|
+
// Never negative: an overpayment is a credit to handle deliberately, not a
|
|
98
|
+
// negative balance that quietly offsets the next invoice.
|
|
99
|
+
balanceCents: Math.max(0, totalCents - paidCents),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One line, rounded once. quantityMilli is thousandths of a unit. */
|
|
104
|
+
export function lineTotalCents(item: {
|
|
105
|
+
quantityMilli: number;
|
|
106
|
+
unitPriceCents: number;
|
|
107
|
+
}): number {
|
|
108
|
+
const qty = Number(item.quantityMilli) || 0;
|
|
109
|
+
const unit = Number(item.unitPriceCents) || 0;
|
|
110
|
+
return Math.round((qty * unit) / 1000);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Overdue is DERIVED, not a stored status — an invoice becomes overdue by the
|
|
115
|
+
* clock passing, and no job runs at midnight to flip a column. A sent invoice
|
|
116
|
+
* with a due date in the past and a balance outstanding is overdue; anything
|
|
117
|
+
* paid, void, or still a draft is not.
|
|
118
|
+
*/
|
|
119
|
+
export function isOverdue(
|
|
120
|
+
invoice: Invoice,
|
|
121
|
+
balanceCents: number,
|
|
122
|
+
now: number = Date.now(),
|
|
123
|
+
): boolean {
|
|
124
|
+
if (invoice.status !== "sent") return false;
|
|
125
|
+
if (balanceCents <= 0) return false;
|
|
126
|
+
const due = Date.parse(invoice.dueDate ?? "");
|
|
127
|
+
if (!Number.isFinite(due)) return false;
|
|
128
|
+
return now > endOfDay(due);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** What to show in the status column, including the derived Overdue. */
|
|
132
|
+
export function displayStatus(
|
|
133
|
+
invoice: Invoice,
|
|
134
|
+
balanceCents: number,
|
|
135
|
+
now: number = Date.now(),
|
|
136
|
+
): string {
|
|
137
|
+
if (isOverdue(invoice, balanceCents, now)) return "overdue";
|
|
138
|
+
// A sent invoice that's been fully paid reads as paid even before someone
|
|
139
|
+
// remembers to change the field.
|
|
140
|
+
if (invoice.status === "sent" && balanceCents <= 0) return "paid";
|
|
141
|
+
return invoice.status;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function daysOverdue(
|
|
145
|
+
invoice: Invoice,
|
|
146
|
+
now: number = Date.now(),
|
|
147
|
+
): number | null {
|
|
148
|
+
const due = Date.parse(invoice.dueDate ?? "");
|
|
149
|
+
if (!Number.isFinite(due)) return null;
|
|
150
|
+
return Math.floor((startOfDay(now) - startOfDay(due)) / 86_400_000);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface Summary {
|
|
154
|
+
outstandingCents: number;
|
|
155
|
+
overdueCents: number;
|
|
156
|
+
paidCents: number;
|
|
157
|
+
draftCount: number;
|
|
158
|
+
overdueCount: number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The numbers on the list header: what's owed, what's late, what's landed. */
|
|
162
|
+
export function summarize(
|
|
163
|
+
invoices: Invoice[],
|
|
164
|
+
items: LineItem[],
|
|
165
|
+
payments: Payment[],
|
|
166
|
+
now: number = Date.now(),
|
|
167
|
+
): Summary {
|
|
168
|
+
const summary: Summary = {
|
|
169
|
+
outstandingCents: 0,
|
|
170
|
+
overdueCents: 0,
|
|
171
|
+
paidCents: 0,
|
|
172
|
+
draftCount: 0,
|
|
173
|
+
overdueCount: 0,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
for (const invoice of invoices) {
|
|
177
|
+
const t = totals(invoice, items, payments);
|
|
178
|
+
summary.paidCents += t.paidCents;
|
|
179
|
+
if (invoice.status === "draft") summary.draftCount += 1;
|
|
180
|
+
if (invoice.status === "sent") {
|
|
181
|
+
summary.outstandingCents += t.balanceCents;
|
|
182
|
+
if (isOverdue(invoice, t.balanceCents, now)) {
|
|
183
|
+
summary.overdueCents += t.balanceCents;
|
|
184
|
+
summary.overdueCount += 1;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return summary;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** "$1,240.00" — invoices show exact amounts, never a compacted $1.2K. */
|
|
192
|
+
export function money(cents: number | null | undefined): string {
|
|
193
|
+
const value = Number(cents) || 0;
|
|
194
|
+
const sign = value < 0 ? "-" : "";
|
|
195
|
+
const abs = Math.abs(value);
|
|
196
|
+
const dollars = Math.floor(abs / 100).toLocaleString("en-US");
|
|
197
|
+
const rest = String(abs % 100).padStart(2, "0");
|
|
198
|
+
return `${sign}$${dollars}.${rest}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** "1.5" from 1500 thousandths, without a trailing ".000". */
|
|
202
|
+
export function quantity(milli: number | null | undefined): string {
|
|
203
|
+
const value = (Number(milli) || 0) / 1000;
|
|
204
|
+
return String(Number(value.toFixed(3)));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Parse a typed amount into cents. Accepts "1,240.50", "$1240.5", "1240".
|
|
209
|
+
* Returns null for anything it can't read, so a bad keystroke can't silently
|
|
210
|
+
* become $0.00 on a bill.
|
|
211
|
+
*/
|
|
212
|
+
export function parseAmount(input: string): number | null {
|
|
213
|
+
const cleaned = input.replace(/[$,\s]/g, "");
|
|
214
|
+
if (!cleaned || !/^-?\d*\.?\d*$/.test(cleaned) || cleaned === "." || cleaned === "-") {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
const value = Number(cleaned);
|
|
218
|
+
if (!Number.isFinite(value)) return null;
|
|
219
|
+
return Math.round(value * 100);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Parse a typed quantity into thousandths. */
|
|
223
|
+
export function parseQuantity(input: string): number | null {
|
|
224
|
+
const cleaned = input.replace(/[,\s]/g, "");
|
|
225
|
+
if (!cleaned || !/^\d*\.?\d*$/.test(cleaned) || cleaned === ".") return null;
|
|
226
|
+
const value = Number(cleaned);
|
|
227
|
+
if (!Number.isFinite(value)) return null;
|
|
228
|
+
return Math.round(value * 1000);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The next invoice number, continuing whatever series is already there.
|
|
233
|
+
*
|
|
234
|
+
* Derived from the existing numbers rather than a stored counter: a counter in
|
|
235
|
+
* a synced replica is a race, and gaps in an invoice series get asked about by
|
|
236
|
+
* accountants.
|
|
237
|
+
*/
|
|
238
|
+
export function nextNumber(existing: string[], year: number): string {
|
|
239
|
+
const prefix = `INV-${year}-`;
|
|
240
|
+
let highest = 0;
|
|
241
|
+
for (const number of existing) {
|
|
242
|
+
if (!number.startsWith(prefix)) continue;
|
|
243
|
+
const suffix = Number(number.slice(prefix.length));
|
|
244
|
+
if (Number.isFinite(suffix) && suffix > highest) highest = suffix;
|
|
245
|
+
}
|
|
246
|
+
return `${prefix}${String(highest + 1).padStart(4, "0")}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function startOfDay(ms: number): number {
|
|
250
|
+
const date = new Date(ms);
|
|
251
|
+
date.setHours(0, 0, 0, 0);
|
|
252
|
+
return date.getTime();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function endOfDay(ms: number): number {
|
|
256
|
+
const date = new Date(ms);
|
|
257
|
+
date.setHours(23, 59, 59, 999);
|
|
258
|
+
return date.getTime();
|
|
259
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Small display helpers shared across views. Pure, so the timeline and the
|
|
2
|
+
// queue can't drift on how they render the same instant.
|
|
3
|
+
|
|
4
|
+
/** Initials for an avatar: "Dana Whitfield" → "DW", "jordan@x.com" → "JO". */
|
|
5
|
+
export function initials(nameOrEmail: string | null | undefined): string {
|
|
6
|
+
const raw = (nameOrEmail ?? "").trim();
|
|
7
|
+
if (!raw) return "?";
|
|
8
|
+
const name = raw.includes("@") ? raw.split("@")[0] : raw;
|
|
9
|
+
const parts = name.split(/[\s._-]+/).filter(Boolean);
|
|
10
|
+
if (parts.length === 0) return "?";
|
|
11
|
+
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
|
12
|
+
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A stable accent per record, so the same person keeps their colour. */
|
|
16
|
+
export function accentIndex(seed: string, buckets = 6): number {
|
|
17
|
+
let hash = 0;
|
|
18
|
+
for (let i = 0; i < seed.length; i += 1) {
|
|
19
|
+
hash = (hash * 31 + seed.charCodeAt(i)) | 0;
|
|
20
|
+
}
|
|
21
|
+
return Math.abs(hash) % buckets;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** "just now" / "4h ago" / "Mar 3". */
|
|
25
|
+
export function relativeTime(
|
|
26
|
+
iso: string | null | undefined,
|
|
27
|
+
now: number = Date.now(),
|
|
28
|
+
): string {
|
|
29
|
+
if (!iso) return "";
|
|
30
|
+
const then = Date.parse(iso);
|
|
31
|
+
if (!Number.isFinite(then)) return "";
|
|
32
|
+
const seconds = Math.round((now - then) / 1000);
|
|
33
|
+
if (seconds < 45) return "just now";
|
|
34
|
+
const minutes = Math.round(seconds / 60);
|
|
35
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
36
|
+
const hours = Math.round(minutes / 60);
|
|
37
|
+
if (hours < 24) return `${hours}h ago`;
|
|
38
|
+
const days = Math.round(hours / 24);
|
|
39
|
+
if (days < 7) return `${days}d ago`;
|
|
40
|
+
return new Date(then).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** "3h 20m" / "45m" / "2d" — a duration an agent reads at a glance. */
|
|
44
|
+
export function duration(minutes: number): string {
|
|
45
|
+
const abs = Math.abs(Math.round(minutes));
|
|
46
|
+
if (abs < 60) return `${abs}m`;
|
|
47
|
+
if (abs < 1440) {
|
|
48
|
+
const hours = Math.floor(abs / 60);
|
|
49
|
+
const rest = abs % 60;
|
|
50
|
+
return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`;
|
|
51
|
+
}
|
|
52
|
+
return `${Math.floor(abs / 1440)}d`;
|
|
53
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Client-side search powering the ⌘K palette.
|
|
2
|
+
//
|
|
3
|
+
// A small team's CRM fits in the synced replica, so search runs locally against
|
|
4
|
+
// data already in memory: results appear as you type, with no request per
|
|
5
|
+
// keystroke. Swap in `db.useSearch` (Pylon's FTS) when a workspace outgrows
|
|
6
|
+
// that — the component boundary doesn't change, only this file.
|
|
7
|
+
|
|
8
|
+
export interface SearchItem {
|
|
9
|
+
id: string;
|
|
10
|
+
type: "deal" | "company" | "contact";
|
|
11
|
+
title: string;
|
|
12
|
+
/** Second line: the company for a contact, the domain for a company. */
|
|
13
|
+
subtitle?: string;
|
|
14
|
+
href: string;
|
|
15
|
+
/** Extra text matched but not displayed — an email, say. */
|
|
16
|
+
keywords?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Scored extends SearchItem {
|
|
20
|
+
score: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Rank items against a query. Prefix matches beat word-boundary matches, which
|
|
25
|
+
* beat substring matches; ties break on the shorter title, so "Acme" ranks
|
|
26
|
+
* above "Acme Corporation Holdings" for the query "acme".
|
|
27
|
+
*/
|
|
28
|
+
export function searchItems(
|
|
29
|
+
items: SearchItem[],
|
|
30
|
+
query: string,
|
|
31
|
+
limit = 8,
|
|
32
|
+
): SearchItem[] {
|
|
33
|
+
const q = query.trim().toLowerCase();
|
|
34
|
+
if (!q) return items.slice(0, limit);
|
|
35
|
+
|
|
36
|
+
const scored: Scored[] = [];
|
|
37
|
+
for (const item of items) {
|
|
38
|
+
const score = scoreItem(item, q);
|
|
39
|
+
if (score > 0) scored.push({ ...item, score });
|
|
40
|
+
}
|
|
41
|
+
scored.sort(
|
|
42
|
+
(a, b) => b.score - a.score || a.title.length - b.title.length ||
|
|
43
|
+
a.title.localeCompare(b.title),
|
|
44
|
+
);
|
|
45
|
+
return scored.slice(0, limit);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function scoreItem(item: SearchItem, q: string): number {
|
|
49
|
+
const title = item.title.toLowerCase();
|
|
50
|
+
if (title.startsWith(q)) return 100;
|
|
51
|
+
if (wordStart(title, q)) return 80;
|
|
52
|
+
if (title.includes(q)) return 60;
|
|
53
|
+
|
|
54
|
+
const haystack = `${item.subtitle ?? ""} ${item.keywords ?? ""}`.toLowerCase();
|
|
55
|
+
if (wordStart(haystack, q)) return 40;
|
|
56
|
+
if (haystack.includes(q)) return 20;
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function wordStart(haystack: string, needle: string): boolean {
|
|
61
|
+
let from = 0;
|
|
62
|
+
for (;;) {
|
|
63
|
+
const at = haystack.indexOf(needle, from);
|
|
64
|
+
if (at === -1) return false;
|
|
65
|
+
if (at === 0 || /[\s._@/-]/.test(haystack[at - 1])) return true;
|
|
66
|
+
from = at + 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Move through a list with arrow keys, wrapping at both ends. */
|
|
71
|
+
export function moveSelection(
|
|
72
|
+
current: number,
|
|
73
|
+
delta: number,
|
|
74
|
+
length: number,
|
|
75
|
+
): number {
|
|
76
|
+
if (length === 0) return 0;
|
|
77
|
+
return (current + delta + length) % length;
|
|
78
|
+
}
|