@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,133 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { CalendarDays, Mail, PhoneCall, StickyNote } from "lucide-react";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
import { Avatar } from "@/components/avatar";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Select } from "@/components/ui/select";
|
|
7
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
8
|
+
import { ACTIVITY_KINDS, relativeTime, type ActivityKind } from "@/lib/pipeline";
|
|
9
|
+
|
|
10
|
+
export interface ActivityRow {
|
|
11
|
+
id: string;
|
|
12
|
+
kind: string;
|
|
13
|
+
body: string;
|
|
14
|
+
ownerId?: string | null;
|
|
15
|
+
createdAt?: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ICON: Record<string, React.ReactNode> = {
|
|
19
|
+
note: <StickyNote />,
|
|
20
|
+
call: <PhoneCall />,
|
|
21
|
+
email: <Mail />,
|
|
22
|
+
meeting: <CalendarDays />,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What happened on this deal, newest first, with the composer on top so logging
|
|
27
|
+
* a call takes one click from landing on the page.
|
|
28
|
+
*/
|
|
29
|
+
export function ActivityTimeline({
|
|
30
|
+
activities,
|
|
31
|
+
ownerName,
|
|
32
|
+
onLog,
|
|
33
|
+
}: {
|
|
34
|
+
activities: ActivityRow[];
|
|
35
|
+
ownerName: (id: string | null | undefined) => string | null;
|
|
36
|
+
onLog: (kind: ActivityKind, body: string) => void | Promise<void>;
|
|
37
|
+
}) {
|
|
38
|
+
const [kind, setKind] = useState<ActivityKind>("note");
|
|
39
|
+
const [body, setBody] = useState("");
|
|
40
|
+
const [busy, setBusy] = useState(false);
|
|
41
|
+
|
|
42
|
+
const ordered = [...activities].sort((a, b) =>
|
|
43
|
+
(b.createdAt ?? "").localeCompare(a.createdAt ?? ""),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
async function submit(event: React.FormEvent) {
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
const text = body.trim();
|
|
49
|
+
if (!text || busy) return;
|
|
50
|
+
setBusy(true);
|
|
51
|
+
try {
|
|
52
|
+
await onLog(kind, text);
|
|
53
|
+
setBody("");
|
|
54
|
+
} finally {
|
|
55
|
+
setBusy(false);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="space-y-4">
|
|
61
|
+
<form onSubmit={submit} className="rounded-lg border border-border bg-card p-2.5">
|
|
62
|
+
<Textarea
|
|
63
|
+
value={body}
|
|
64
|
+
rows={2}
|
|
65
|
+
placeholder="Log a call, note, or next step…"
|
|
66
|
+
aria-label="Activity"
|
|
67
|
+
onChange={(event) => setBody(event.target.value)}
|
|
68
|
+
onKeyDown={(event) => {
|
|
69
|
+
// ⌘/Ctrl+Enter submits — the composer is small and always focused
|
|
70
|
+
// when you're typing into it.
|
|
71
|
+
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
|
72
|
+
submit(event);
|
|
73
|
+
}
|
|
74
|
+
}}
|
|
75
|
+
className="min-h-16 resize-none border-0 bg-transparent p-0 shadow-none focus-visible:ring-0"
|
|
76
|
+
/>
|
|
77
|
+
<div className="mt-2 flex items-center justify-between gap-2">
|
|
78
|
+
<div className="w-32">
|
|
79
|
+
<Select
|
|
80
|
+
aria-label="Activity type"
|
|
81
|
+
value={kind}
|
|
82
|
+
onChange={(event) => setKind(event.target.value as ActivityKind)}
|
|
83
|
+
className="h-7 text-[12px]"
|
|
84
|
+
>
|
|
85
|
+
{ACTIVITY_KINDS.map((value) => (
|
|
86
|
+
<option key={value} value={value}>
|
|
87
|
+
{value[0].toUpperCase() + value.slice(1)}
|
|
88
|
+
</option>
|
|
89
|
+
))}
|
|
90
|
+
</Select>
|
|
91
|
+
</div>
|
|
92
|
+
<Button type="submit" size="sm" disabled={!body.trim() || busy}>
|
|
93
|
+
{busy ? "Saving…" : "Log"}
|
|
94
|
+
</Button>
|
|
95
|
+
</div>
|
|
96
|
+
</form>
|
|
97
|
+
|
|
98
|
+
{ordered.length === 0 ? (
|
|
99
|
+
<p className="px-1 py-6 text-center text-[12px] text-muted-foreground">
|
|
100
|
+
No activity yet. Log the first call or note above.
|
|
101
|
+
</p>
|
|
102
|
+
) : (
|
|
103
|
+
<ol className="space-y-3">
|
|
104
|
+
{ordered.map((activity) => (
|
|
105
|
+
<li key={activity.id} className="flex gap-2.5">
|
|
106
|
+
<div
|
|
107
|
+
className={cn(
|
|
108
|
+
"mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-md",
|
|
109
|
+
"bg-surface-2 text-muted-foreground [&_svg]:size-3",
|
|
110
|
+
)}
|
|
111
|
+
>
|
|
112
|
+
{ICON[activity.kind] ?? ICON.note}
|
|
113
|
+
</div>
|
|
114
|
+
<div className="min-w-0 flex-1">
|
|
115
|
+
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
|
116
|
+
<Avatar name={ownerName(activity.ownerId) ?? "?"} size="sm" />
|
|
117
|
+
<span className="capitalize">{activity.kind}</span>
|
|
118
|
+
<span aria-hidden="true">·</span>
|
|
119
|
+
<time dateTime={activity.createdAt ?? undefined}>
|
|
120
|
+
{relativeTime(activity.createdAt)}
|
|
121
|
+
</time>
|
|
122
|
+
</div>
|
|
123
|
+
<p className="mt-1 whitespace-pre-wrap text-[13px] leading-5">
|
|
124
|
+
{activity.body}
|
|
125
|
+
</p>
|
|
126
|
+
</div>
|
|
127
|
+
</li>
|
|
128
|
+
))}
|
|
129
|
+
</ol>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
import { accentIndex, initials } from "@/lib/pipeline";
|
|
4
|
+
|
|
5
|
+
// Deterministic accent per record — the same company keeps its colour across
|
|
6
|
+
// sessions and machines without storing one. Hues are muted so a dense list
|
|
7
|
+
// doesn't turn into confetti.
|
|
8
|
+
const ACCENTS = [
|
|
9
|
+
"bg-indigo-500/15 text-indigo-500 dark:text-indigo-300",
|
|
10
|
+
"bg-emerald-500/15 text-emerald-600 dark:text-emerald-300",
|
|
11
|
+
"bg-amber-500/15 text-amber-600 dark:text-amber-300",
|
|
12
|
+
"bg-rose-500/15 text-rose-500 dark:text-rose-300",
|
|
13
|
+
"bg-sky-500/15 text-sky-600 dark:text-sky-300",
|
|
14
|
+
"bg-violet-500/15 text-violet-500 dark:text-violet-300",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function Avatar({
|
|
18
|
+
name,
|
|
19
|
+
size = "md",
|
|
20
|
+
className,
|
|
21
|
+
}: {
|
|
22
|
+
name: string | null | undefined;
|
|
23
|
+
size?: "sm" | "md";
|
|
24
|
+
className?: string;
|
|
25
|
+
}) {
|
|
26
|
+
const label = initials(name);
|
|
27
|
+
return (
|
|
28
|
+
<span
|
|
29
|
+
aria-hidden="true"
|
|
30
|
+
className={cn(
|
|
31
|
+
"inline-flex shrink-0 select-none items-center justify-center rounded-full font-medium",
|
|
32
|
+
size === "sm" ? "size-5 text-[9px]" : "size-6 text-[10px]",
|
|
33
|
+
ACCENTS[accentIndex(name ?? "?", ACCENTS.length)],
|
|
34
|
+
className,
|
|
35
|
+
)}
|
|
36
|
+
>
|
|
37
|
+
{label}
|
|
38
|
+
</span>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { BOARD_STAGES } from "@/lib/pipeline";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The board's shape while the replica hydrates.
|
|
6
|
+
*
|
|
7
|
+
* The shell is server-rendered but the deals arrive over sync, so there's a
|
|
8
|
+
* beat before the first row lands. Showing the real column structure with
|
|
9
|
+
* placeholder cards keeps that beat from reading as "you have no deals" — and
|
|
10
|
+
* because the columns are already in place, nothing jumps when the data
|
|
11
|
+
* arrives.
|
|
12
|
+
*/
|
|
13
|
+
export function BoardSkeleton() {
|
|
14
|
+
return (
|
|
15
|
+
<div className="flex h-full gap-3 overflow-hidden p-4" aria-hidden="true">
|
|
16
|
+
{BOARD_STAGES.map((stage, column) => (
|
|
17
|
+
<section
|
|
18
|
+
key={stage.id}
|
|
19
|
+
className="flex w-[268px] shrink-0 flex-col rounded-xl border border-border bg-surface-1/50"
|
|
20
|
+
>
|
|
21
|
+
<header className="flex items-center gap-2 px-3 py-2.5">
|
|
22
|
+
<span className="text-[12px] font-medium text-muted-foreground">
|
|
23
|
+
{stage.label}
|
|
24
|
+
</span>
|
|
25
|
+
</header>
|
|
26
|
+
<div className="flex flex-col gap-2 px-2 pb-2">
|
|
27
|
+
{/* Fewer cards further down the funnel, so the placeholder reads
|
|
28
|
+
like a pipeline rather than a grid. */}
|
|
29
|
+
{Array.from({ length: Math.max(1, 3 - column) }).map((_, card) => (
|
|
30
|
+
<div
|
|
31
|
+
key={card}
|
|
32
|
+
className="animate-pulse rounded-lg border border-border bg-card p-2.5"
|
|
33
|
+
>
|
|
34
|
+
<div className="h-3 w-3/4 rounded bg-muted" />
|
|
35
|
+
<div className="mt-2 h-2.5 w-1/2 rounded bg-muted" />
|
|
36
|
+
<div className="mt-3 h-4 w-4 rounded-full bg-muted" />
|
|
37
|
+
</div>
|
|
38
|
+
))}
|
|
39
|
+
</div>
|
|
40
|
+
</section>
|
|
41
|
+
))}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Building2, Plus, Search, User } from "lucide-react";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
import { Kbd } from "@/components/kbd";
|
|
5
|
+
import { moveSelection, searchItems, type SearchItem } from "@/lib/search";
|
|
6
|
+
|
|
7
|
+
const TYPE_ICON: Record<SearchItem["type"], React.ReactNode> = {
|
|
8
|
+
deal: <Search />,
|
|
9
|
+
company: <Building2 />,
|
|
10
|
+
contact: <User />,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export interface CommandAction {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
run: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* ⌘K. Searches the synced replica in memory, so results land as you type with
|
|
21
|
+
* no request per keystroke — see lib/search.ts for the ranking.
|
|
22
|
+
*
|
|
23
|
+
* Presentational: items and actions come in as props, selection is delegated
|
|
24
|
+
* through onSelect. Keyboard handling lives here because it belongs to the
|
|
25
|
+
* widget, not to the page that opened it.
|
|
26
|
+
*/
|
|
27
|
+
export function CommandPalette({
|
|
28
|
+
open,
|
|
29
|
+
items,
|
|
30
|
+
actions = [],
|
|
31
|
+
onClose,
|
|
32
|
+
onSelect,
|
|
33
|
+
}: {
|
|
34
|
+
open: boolean;
|
|
35
|
+
items: SearchItem[];
|
|
36
|
+
actions?: CommandAction[];
|
|
37
|
+
onClose: () => void;
|
|
38
|
+
onSelect: (item: SearchItem) => void;
|
|
39
|
+
}) {
|
|
40
|
+
const [query, setQuery] = useState("");
|
|
41
|
+
const [index, setIndex] = useState(0);
|
|
42
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
43
|
+
|
|
44
|
+
const results = useMemo(() => searchItems(items, query), [items, query]);
|
|
45
|
+
const shownActions = query.trim() ? [] : actions;
|
|
46
|
+
const rows = useMemo(
|
|
47
|
+
() => [
|
|
48
|
+
...shownActions.map((a) => ({ kind: "action" as const, action: a })),
|
|
49
|
+
...results.map((item) => ({ kind: "item" as const, item })),
|
|
50
|
+
],
|
|
51
|
+
[shownActions, results],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!open) return;
|
|
56
|
+
setQuery("");
|
|
57
|
+
setIndex(0);
|
|
58
|
+
// Focus after paint so the dialog is mounted and the caret lands.
|
|
59
|
+
const id = requestAnimationFrame(() => inputRef.current?.focus());
|
|
60
|
+
return () => cancelAnimationFrame(id);
|
|
61
|
+
}, [open]);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
setIndex(0);
|
|
65
|
+
}, [query]);
|
|
66
|
+
|
|
67
|
+
if (!open) return null;
|
|
68
|
+
|
|
69
|
+
function commit(at: number) {
|
|
70
|
+
const row = rows[at];
|
|
71
|
+
if (!row) return;
|
|
72
|
+
if (row.kind === "action") row.action.run();
|
|
73
|
+
else onSelect(row.item);
|
|
74
|
+
onClose();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div
|
|
79
|
+
className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[12vh]"
|
|
80
|
+
// Clicking the backdrop dismisses; clicks inside the panel must not.
|
|
81
|
+
onMouseDown={(event) => {
|
|
82
|
+
if (event.target === event.currentTarget) onClose();
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
<div
|
|
86
|
+
role="dialog"
|
|
87
|
+
aria-modal="true"
|
|
88
|
+
aria-label="Search"
|
|
89
|
+
className="w-full max-w-lg overflow-hidden rounded-xl border border-border bg-popover shadow-2xl"
|
|
90
|
+
onKeyDown={(event) => {
|
|
91
|
+
if (event.key === "Escape") {
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
onClose();
|
|
94
|
+
} else if (event.key === "ArrowDown") {
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
setIndex((i) => moveSelection(i, 1, rows.length));
|
|
97
|
+
} else if (event.key === "ArrowUp") {
|
|
98
|
+
event.preventDefault();
|
|
99
|
+
setIndex((i) => moveSelection(i, -1, rows.length));
|
|
100
|
+
} else if (event.key === "Enter") {
|
|
101
|
+
event.preventDefault();
|
|
102
|
+
commit(index);
|
|
103
|
+
}
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
<div className="flex items-center gap-2 border-b border-border px-3">
|
|
107
|
+
<Search className="size-4 shrink-0 text-muted-foreground" />
|
|
108
|
+
<input
|
|
109
|
+
ref={inputRef}
|
|
110
|
+
value={query}
|
|
111
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
112
|
+
placeholder="Search deals, companies, contacts…"
|
|
113
|
+
aria-label="Search deals, companies and contacts"
|
|
114
|
+
className="h-11 flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground"
|
|
115
|
+
/>
|
|
116
|
+
<Kbd>esc</Kbd>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<div className="max-h-80 overflow-y-auto p-1.5">
|
|
120
|
+
{rows.length === 0 ? (
|
|
121
|
+
<p className="px-3 py-6 text-center text-[12px] text-muted-foreground">
|
|
122
|
+
No matches for “{query}”
|
|
123
|
+
</p>
|
|
124
|
+
) : (
|
|
125
|
+
rows.map((row, at) => (
|
|
126
|
+
<button
|
|
127
|
+
key={row.kind === "action" ? row.action.id : row.item.id}
|
|
128
|
+
type="button"
|
|
129
|
+
// Pointer hover moves selection so mouse and keyboard agree.
|
|
130
|
+
onMouseMove={() => setIndex(at)}
|
|
131
|
+
onClick={() => commit(at)}
|
|
132
|
+
aria-selected={at === index}
|
|
133
|
+
className={cn(
|
|
134
|
+
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left text-[13px] transition-colors",
|
|
135
|
+
"[&_svg]:size-3.5 [&_svg]:shrink-0 [&_svg]:text-muted-foreground",
|
|
136
|
+
at === index ? "bg-surface-2" : "hover:bg-surface-2/60",
|
|
137
|
+
)}
|
|
138
|
+
>
|
|
139
|
+
{row.kind === "action" ? (
|
|
140
|
+
<>
|
|
141
|
+
<Plus />
|
|
142
|
+
<span className="flex-1 truncate">{row.action.label}</span>
|
|
143
|
+
</>
|
|
144
|
+
) : (
|
|
145
|
+
<>
|
|
146
|
+
{TYPE_ICON[row.item.type]}
|
|
147
|
+
<span className="flex-1 truncate">{row.item.title}</span>
|
|
148
|
+
{row.item.subtitle ? (
|
|
149
|
+
<span className="truncate text-[12px] text-muted-foreground">
|
|
150
|
+
{row.item.subtitle}
|
|
151
|
+
</span>
|
|
152
|
+
) : null}
|
|
153
|
+
</>
|
|
154
|
+
)}
|
|
155
|
+
</button>
|
|
156
|
+
))
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
export interface ColumnDef<Row> {
|
|
5
|
+
key: string;
|
|
6
|
+
header: string;
|
|
7
|
+
/** Cell content. Return a string and it's rendered as-is. */
|
|
8
|
+
cell: (row: Row) => React.ReactNode;
|
|
9
|
+
/** Right-align and tabular-figure a numeric column. */
|
|
10
|
+
numeric?: boolean;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A dense list view. Rows are 36px so a screenful is a screenful of data, and
|
|
16
|
+
* the header sticks while the body scrolls.
|
|
17
|
+
*
|
|
18
|
+
* Generic and presentational — every view here uses the same one, which is why
|
|
19
|
+
* companies and contacts look like the same product.
|
|
20
|
+
*/
|
|
21
|
+
export function DataTable<Row extends { id: string }>({
|
|
22
|
+
rows,
|
|
23
|
+
columns,
|
|
24
|
+
onRowClick,
|
|
25
|
+
empty,
|
|
26
|
+
}: {
|
|
27
|
+
rows: Row[];
|
|
28
|
+
columns: ColumnDef<Row>[];
|
|
29
|
+
onRowClick?: (row: Row) => void;
|
|
30
|
+
empty?: React.ReactNode;
|
|
31
|
+
}) {
|
|
32
|
+
if (rows.length === 0 && empty) return <>{empty}</>;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
36
|
+
<table className="w-full border-collapse text-[13px]">
|
|
37
|
+
<thead className="sticky top-0 z-10 bg-background">
|
|
38
|
+
<tr className="hairline">
|
|
39
|
+
{columns.map((column) => (
|
|
40
|
+
<th
|
|
41
|
+
key={column.key}
|
|
42
|
+
scope="col"
|
|
43
|
+
className={cn(
|
|
44
|
+
"h-8 px-4 text-left text-[11px] font-medium text-muted-foreground",
|
|
45
|
+
column.numeric && "text-right",
|
|
46
|
+
column.className,
|
|
47
|
+
)}
|
|
48
|
+
>
|
|
49
|
+
{column.header}
|
|
50
|
+
</th>
|
|
51
|
+
))}
|
|
52
|
+
</tr>
|
|
53
|
+
</thead>
|
|
54
|
+
<tbody>
|
|
55
|
+
{rows.map((row) => (
|
|
56
|
+
<tr
|
|
57
|
+
key={row.id}
|
|
58
|
+
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
59
|
+
tabIndex={onRowClick ? 0 : undefined}
|
|
60
|
+
onKeyDown={
|
|
61
|
+
onRowClick
|
|
62
|
+
? (event) => {
|
|
63
|
+
if (event.key === "Enter") onRowClick(row);
|
|
64
|
+
}
|
|
65
|
+
: undefined
|
|
66
|
+
}
|
|
67
|
+
className={cn(
|
|
68
|
+
"border-b border-border/60 transition-colors",
|
|
69
|
+
onRowClick && "cursor-pointer hover:bg-surface-1",
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
{columns.map((column) => (
|
|
73
|
+
<td
|
|
74
|
+
key={column.key}
|
|
75
|
+
className={cn(
|
|
76
|
+
"h-9 max-w-0 truncate px-4",
|
|
77
|
+
column.numeric && "tabular text-right",
|
|
78
|
+
column.className,
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
{column.cell(row)}
|
|
82
|
+
</td>
|
|
83
|
+
))}
|
|
84
|
+
</tr>
|
|
85
|
+
))}
|
|
86
|
+
</tbody>
|
|
87
|
+
</table>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
import { Avatar } from "@/components/avatar";
|
|
4
|
+
import { daysUntil, money, type Deal } from "@/lib/pipeline";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One deal on the board. Draggable, but never the source of truth for where it
|
|
8
|
+
* sits — the parent decides that from the synced row, so a teammate's move
|
|
9
|
+
* lands here through the same path as your own.
|
|
10
|
+
*/
|
|
11
|
+
export function DealCard({
|
|
12
|
+
deal,
|
|
13
|
+
company,
|
|
14
|
+
owner,
|
|
15
|
+
onOpen,
|
|
16
|
+
onDragStart,
|
|
17
|
+
onDragEnd,
|
|
18
|
+
dragging,
|
|
19
|
+
}: {
|
|
20
|
+
deal: Deal;
|
|
21
|
+
company?: string | null;
|
|
22
|
+
owner?: string | null;
|
|
23
|
+
onOpen: (id: string) => void;
|
|
24
|
+
onDragStart: (id: string) => void;
|
|
25
|
+
onDragEnd: () => void;
|
|
26
|
+
dragging?: boolean;
|
|
27
|
+
}) {
|
|
28
|
+
const days = daysUntil(deal.closeDate);
|
|
29
|
+
// Only surface a date when it's actionable: overdue, or inside a week.
|
|
30
|
+
const overdue = days !== null && days < 0;
|
|
31
|
+
const soon = days !== null && days >= 0 && days <= 7;
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<article
|
|
35
|
+
draggable
|
|
36
|
+
onDragStart={(event) => {
|
|
37
|
+
// setData is required for Firefox to start a drag at all.
|
|
38
|
+
event.dataTransfer.setData("text/plain", deal.id);
|
|
39
|
+
event.dataTransfer.effectAllowed = "move";
|
|
40
|
+
onDragStart(deal.id);
|
|
41
|
+
}}
|
|
42
|
+
onDragEnd={onDragEnd}
|
|
43
|
+
onClick={() => onOpen(deal.id)}
|
|
44
|
+
onKeyDown={(event) => {
|
|
45
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
46
|
+
event.preventDefault();
|
|
47
|
+
onOpen(deal.id);
|
|
48
|
+
}
|
|
49
|
+
}}
|
|
50
|
+
role="button"
|
|
51
|
+
tabIndex={0}
|
|
52
|
+
aria-label={`${deal.title}${company ? `, ${company}` : ""}`}
|
|
53
|
+
className={cn(
|
|
54
|
+
"group cursor-pointer rounded-lg border border-border bg-card p-2.5 transition-all",
|
|
55
|
+
"hover:border-ring/40 focus-visible:border-ring",
|
|
56
|
+
dragging && "opacity-40",
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
<div className="flex items-start justify-between gap-2">
|
|
60
|
+
<p className="line-clamp-2 flex-1 text-[13px] font-medium leading-snug">
|
|
61
|
+
{deal.title}
|
|
62
|
+
</p>
|
|
63
|
+
<span className="tabular shrink-0 text-[12px] font-medium text-muted-foreground">
|
|
64
|
+
{money(deal.value)}
|
|
65
|
+
</span>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{company ? (
|
|
69
|
+
<p className="mt-1 truncate text-[12px] text-muted-foreground">{company}</p>
|
|
70
|
+
) : null}
|
|
71
|
+
|
|
72
|
+
<div className="mt-2.5 flex items-center justify-between gap-2">
|
|
73
|
+
{owner ? <Avatar name={owner} size="sm" /> : <span />}
|
|
74
|
+
{overdue || soon ? (
|
|
75
|
+
<span
|
|
76
|
+
className={cn(
|
|
77
|
+
"tabular text-[11px]",
|
|
78
|
+
overdue ? "text-destructive" : "text-muted-foreground",
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
{overdue
|
|
82
|
+
? `${Math.abs(days as number)}d overdue`
|
|
83
|
+
: days === 0
|
|
84
|
+
? "Today"
|
|
85
|
+
: `${days}d`}
|
|
86
|
+
</span>
|
|
87
|
+
) : null}
|
|
88
|
+
</div>
|
|
89
|
+
</article>
|
|
90
|
+
);
|
|
91
|
+
}
|