@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,169 @@
|
|
|
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 { BOARD_STAGES } from "@/lib/pipeline";
|
|
14
|
+
|
|
15
|
+
export interface DealDraft {
|
|
16
|
+
title: string;
|
|
17
|
+
companyId: string;
|
|
18
|
+
value: number;
|
|
19
|
+
stage: string;
|
|
20
|
+
closeDate: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* New deal. A dialog rather than an inline row: creating is deliberate and
|
|
25
|
+
* occasional, and a permanent form above the board would push the pipeline —
|
|
26
|
+
* the thing you came to look at — off the screen.
|
|
27
|
+
*/
|
|
28
|
+
export function DealDialog({
|
|
29
|
+
open,
|
|
30
|
+
companies,
|
|
31
|
+
defaultStage = "lead",
|
|
32
|
+
onOpenChange,
|
|
33
|
+
onCreate,
|
|
34
|
+
}: {
|
|
35
|
+
open: boolean;
|
|
36
|
+
companies: Array<{ id: string; name: string }>;
|
|
37
|
+
defaultStage?: string;
|
|
38
|
+
onOpenChange: (open: boolean) => void;
|
|
39
|
+
onCreate: (draft: DealDraft) => void | Promise<void>;
|
|
40
|
+
}) {
|
|
41
|
+
const [title, setTitle] = useState("");
|
|
42
|
+
const [companyId, setCompanyId] = useState("");
|
|
43
|
+
const [value, setValue] = useState("");
|
|
44
|
+
const [stage, setStage] = useState(defaultStage);
|
|
45
|
+
const [closeDate, setCloseDate] = useState("");
|
|
46
|
+
const [busy, setBusy] = useState(false);
|
|
47
|
+
|
|
48
|
+
// Reset on each open so a cancelled draft doesn't reappear next time.
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!open) return;
|
|
51
|
+
setTitle("");
|
|
52
|
+
setCompanyId("");
|
|
53
|
+
setValue("");
|
|
54
|
+
setStage(defaultStage);
|
|
55
|
+
setCloseDate("");
|
|
56
|
+
setBusy(false);
|
|
57
|
+
}, [open, defaultStage]);
|
|
58
|
+
|
|
59
|
+
const canSubmit = title.trim().length > 0 && !busy;
|
|
60
|
+
|
|
61
|
+
async function submit(event: React.FormEvent) {
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
if (!canSubmit) return;
|
|
64
|
+
setBusy(true);
|
|
65
|
+
try {
|
|
66
|
+
await onCreate({
|
|
67
|
+
title: title.trim(),
|
|
68
|
+
companyId,
|
|
69
|
+
value: Number(value) || 0,
|
|
70
|
+
stage,
|
|
71
|
+
closeDate,
|
|
72
|
+
});
|
|
73
|
+
onOpenChange(false);
|
|
74
|
+
} finally {
|
|
75
|
+
setBusy(false);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
81
|
+
<DialogContent>
|
|
82
|
+
<DialogHeader>
|
|
83
|
+
<DialogTitle>New deal</DialogTitle>
|
|
84
|
+
</DialogHeader>
|
|
85
|
+
<form onSubmit={submit} className="grid gap-4">
|
|
86
|
+
<div className="space-y-1.5">
|
|
87
|
+
<Label htmlFor="deal-title">Title</Label>
|
|
88
|
+
<Input
|
|
89
|
+
id="deal-title"
|
|
90
|
+
autoFocus
|
|
91
|
+
value={title}
|
|
92
|
+
placeholder="Fleet dispatch rollout"
|
|
93
|
+
onChange={(event) => setTitle(event.target.value)}
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div className="space-y-1.5">
|
|
98
|
+
<Label htmlFor="deal-company">Company</Label>
|
|
99
|
+
<Select
|
|
100
|
+
id="deal-company"
|
|
101
|
+
value={companyId}
|
|
102
|
+
onChange={(event) => setCompanyId(event.target.value)}
|
|
103
|
+
>
|
|
104
|
+
<option value="">—</option>
|
|
105
|
+
{companies.map((company) => (
|
|
106
|
+
<option key={company.id} value={company.id}>
|
|
107
|
+
{company.name}
|
|
108
|
+
</option>
|
|
109
|
+
))}
|
|
110
|
+
</Select>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<div className="grid grid-cols-2 gap-3">
|
|
114
|
+
<div className="space-y-1.5">
|
|
115
|
+
<Label htmlFor="deal-value">Value</Label>
|
|
116
|
+
<Input
|
|
117
|
+
id="deal-value"
|
|
118
|
+
type="number"
|
|
119
|
+
min="0"
|
|
120
|
+
step="100"
|
|
121
|
+
inputMode="numeric"
|
|
122
|
+
value={value}
|
|
123
|
+
placeholder="10000"
|
|
124
|
+
onChange={(event) => setValue(event.target.value)}
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
127
|
+
<div className="space-y-1.5">
|
|
128
|
+
<Label htmlFor="deal-stage">Stage</Label>
|
|
129
|
+
<Select
|
|
130
|
+
id="deal-stage"
|
|
131
|
+
value={stage}
|
|
132
|
+
onChange={(event) => setStage(event.target.value)}
|
|
133
|
+
>
|
|
134
|
+
{BOARD_STAGES.map((s) => (
|
|
135
|
+
<option key={s.id} value={s.id}>
|
|
136
|
+
{s.label}
|
|
137
|
+
</option>
|
|
138
|
+
))}
|
|
139
|
+
</Select>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<div className="space-y-1.5">
|
|
144
|
+
<Label htmlFor="deal-close">Expected close</Label>
|
|
145
|
+
<Input
|
|
146
|
+
id="deal-close"
|
|
147
|
+
type="date"
|
|
148
|
+
value={closeDate}
|
|
149
|
+
onChange={(event) => setCloseDate(event.target.value)}
|
|
150
|
+
/>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<DialogFooter>
|
|
154
|
+
<Button
|
|
155
|
+
type="button"
|
|
156
|
+
variant="ghost"
|
|
157
|
+
onClick={() => onOpenChange(false)}
|
|
158
|
+
>
|
|
159
|
+
Cancel
|
|
160
|
+
</Button>
|
|
161
|
+
<Button type="submit" disabled={!canSubmit}>
|
|
162
|
+
{busy ? "Creating…" : "Create deal"}
|
|
163
|
+
</Button>
|
|
164
|
+
</DialogFooter>
|
|
165
|
+
</form>
|
|
166
|
+
</DialogContent>
|
|
167
|
+
</Dialog>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* An empty state that says what this screen is for and offers the one action
|
|
6
|
+
* that fills it. "No records found" tells a new user nothing and gives them
|
|
7
|
+
* nowhere to go.
|
|
8
|
+
*/
|
|
9
|
+
export function EmptyState({
|
|
10
|
+
icon,
|
|
11
|
+
title,
|
|
12
|
+
description,
|
|
13
|
+
action,
|
|
14
|
+
className,
|
|
15
|
+
}: {
|
|
16
|
+
icon?: React.ReactNode;
|
|
17
|
+
title: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
action?: React.ReactNode;
|
|
20
|
+
className?: string;
|
|
21
|
+
}) {
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
className={cn(
|
|
25
|
+
"flex flex-col items-center justify-center px-6 py-16 text-center",
|
|
26
|
+
className,
|
|
27
|
+
)}
|
|
28
|
+
>
|
|
29
|
+
{icon ? (
|
|
30
|
+
<div className="mb-3 flex size-9 items-center justify-center rounded-lg bg-surface-2 text-muted-foreground [&_svg]:size-4">
|
|
31
|
+
{icon}
|
|
32
|
+
</div>
|
|
33
|
+
) : null}
|
|
34
|
+
<p className="text-[13px] font-medium">{title}</p>
|
|
35
|
+
{description ? (
|
|
36
|
+
<p className="mt-1 max-w-sm text-[12px] leading-5 text-muted-foreground">
|
|
37
|
+
{description}
|
|
38
|
+
</p>
|
|
39
|
+
) : null}
|
|
40
|
+
{action ? <div className="mt-4">{action}</div> : null}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
/** A keyboard hint. Present in the UI because the shortcuts are real. */
|
|
5
|
+
export function Kbd({
|
|
6
|
+
children,
|
|
7
|
+
className,
|
|
8
|
+
}: {
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
className?: string;
|
|
11
|
+
}) {
|
|
12
|
+
return (
|
|
13
|
+
<kbd
|
|
14
|
+
className={cn(
|
|
15
|
+
"inline-flex h-5 min-w-5 items-center justify-center rounded border border-border bg-surface-2 px-1.5",
|
|
16
|
+
"font-sans text-[10px] font-medium text-muted-foreground",
|
|
17
|
+
className,
|
|
18
|
+
)}
|
|
19
|
+
>
|
|
20
|
+
{children}
|
|
21
|
+
</kbd>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { metrics, money, percent, type Deal } from "@/lib/pipeline";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The four numbers a sales lead actually looks at. Derived, never stored — see
|
|
6
|
+
* lib/pipeline.ts, where the arithmetic is unit-tested.
|
|
7
|
+
*/
|
|
8
|
+
export function MetricsBar({ deals }: { deals: Deal[] }) {
|
|
9
|
+
const m = metrics(deals);
|
|
10
|
+
return (
|
|
11
|
+
<dl className="grid grid-cols-2 gap-px border-b border-border bg-border sm:grid-cols-4">
|
|
12
|
+
<Metric
|
|
13
|
+
label="Open pipeline"
|
|
14
|
+
value={money(m.open)}
|
|
15
|
+
hint={`${m.openCount} deal${m.openCount === 1 ? "" : "s"}`}
|
|
16
|
+
/>
|
|
17
|
+
<Metric
|
|
18
|
+
label="Weighted"
|
|
19
|
+
value={money(m.weighted)}
|
|
20
|
+
hint="by stage probability"
|
|
21
|
+
/>
|
|
22
|
+
<Metric label="Won" value={money(m.won)} hint="closed" />
|
|
23
|
+
<Metric label="Win rate" value={percent(m.winRate)} hint="of closed deals" />
|
|
24
|
+
</dl>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function Metric({
|
|
29
|
+
label,
|
|
30
|
+
value,
|
|
31
|
+
hint,
|
|
32
|
+
}: {
|
|
33
|
+
label: string;
|
|
34
|
+
value: string;
|
|
35
|
+
hint: string;
|
|
36
|
+
}) {
|
|
37
|
+
return (
|
|
38
|
+
<div className="bg-background px-4 py-3">
|
|
39
|
+
<dt className="text-[11px] text-muted-foreground">{label}</dt>
|
|
40
|
+
<dd className="tabular mt-0.5 text-[18px] font-semibold tracking-tight">
|
|
41
|
+
{value}
|
|
42
|
+
</dd>
|
|
43
|
+
<p className="mt-0.5 text-[11px] text-muted-foreground">{hint}</p>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -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,105 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
import { DealCard } from "@/components/deal-card";
|
|
4
|
+
import { BOARD_STAGES, groupByStage, money, type Deal } from "@/lib/pipeline";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The pipeline, as columns you can drag deals between.
|
|
8
|
+
*
|
|
9
|
+
* Presentational on purpose: it renders whatever deals it's handed and reports
|
|
10
|
+
* a move through `onMove`. The container writes to `db`, the write syncs, and
|
|
11
|
+
* the new rows arrive back here — which is also how a teammate's move appears,
|
|
12
|
+
* so there is exactly one code path for "a deal changed stage".
|
|
13
|
+
*/
|
|
14
|
+
export function PipelineBoard({
|
|
15
|
+
deals,
|
|
16
|
+
companyName,
|
|
17
|
+
ownerName,
|
|
18
|
+
onMove,
|
|
19
|
+
onOpen,
|
|
20
|
+
}: {
|
|
21
|
+
deals: Deal[];
|
|
22
|
+
companyName: (id: string | null | undefined) => string | null;
|
|
23
|
+
ownerName: (id: string | null | undefined) => string | null;
|
|
24
|
+
onMove: (dealId: string, stage: string) => void;
|
|
25
|
+
onOpen: (dealId: string) => void;
|
|
26
|
+
}) {
|
|
27
|
+
const [dragging, setDragging] = useState<string | null>(null);
|
|
28
|
+
const [over, setOver] = useState<string | null>(null);
|
|
29
|
+
const columns = groupByStage(deals, BOARD_STAGES);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="flex h-full gap-3 overflow-x-auto p-4">
|
|
33
|
+
{columns.map((column) => {
|
|
34
|
+
const isTarget = over === column.stage.id;
|
|
35
|
+
return (
|
|
36
|
+
<section
|
|
37
|
+
key={column.stage.id}
|
|
38
|
+
onDragOver={(event) => {
|
|
39
|
+
// Preventing default is what marks this a valid drop target.
|
|
40
|
+
event.preventDefault();
|
|
41
|
+
event.dataTransfer.dropEffect = "move";
|
|
42
|
+
if (over !== column.stage.id) setOver(column.stage.id);
|
|
43
|
+
}}
|
|
44
|
+
onDragLeave={(event) => {
|
|
45
|
+
// Ignore bubbling from children, or the highlight flickers.
|
|
46
|
+
if (event.currentTarget.contains(event.relatedTarget as Node)) return;
|
|
47
|
+
setOver((current) => (current === column.stage.id ? null : current));
|
|
48
|
+
}}
|
|
49
|
+
onDrop={(event) => {
|
|
50
|
+
event.preventDefault();
|
|
51
|
+
const id = event.dataTransfer.getData("text/plain") || dragging;
|
|
52
|
+
setOver(null);
|
|
53
|
+
setDragging(null);
|
|
54
|
+
if (!id) return;
|
|
55
|
+
const deal = deals.find((d) => d.id === id);
|
|
56
|
+
// A drop back on the same column is a no-op, not a write.
|
|
57
|
+
if (!deal || deal.stage === column.stage.id) return;
|
|
58
|
+
onMove(id, column.stage.id);
|
|
59
|
+
}}
|
|
60
|
+
className={cn(
|
|
61
|
+
"flex w-[268px] shrink-0 flex-col rounded-xl border transition-colors",
|
|
62
|
+
isTarget
|
|
63
|
+
? "border-ring/60 bg-surface-2/60"
|
|
64
|
+
: "border-border bg-surface-1/50",
|
|
65
|
+
)}
|
|
66
|
+
aria-label={column.stage.label}
|
|
67
|
+
>
|
|
68
|
+
<header className="flex items-center gap-2 px-3 py-2.5">
|
|
69
|
+
<h2 className="text-[12px] font-medium">{column.stage.label}</h2>
|
|
70
|
+
<span className="tabular text-[11px] text-muted-foreground">
|
|
71
|
+
{column.deals.length}
|
|
72
|
+
</span>
|
|
73
|
+
<span className="tabular ml-auto text-[11px] text-muted-foreground">
|
|
74
|
+
{money(column.total)}
|
|
75
|
+
</span>
|
|
76
|
+
</header>
|
|
77
|
+
|
|
78
|
+
<div className="flex min-h-24 flex-1 flex-col gap-2 overflow-y-auto px-2 pb-2">
|
|
79
|
+
{column.deals.map((deal) => (
|
|
80
|
+
<DealCard
|
|
81
|
+
key={deal.id}
|
|
82
|
+
deal={deal}
|
|
83
|
+
company={companyName(deal.companyId)}
|
|
84
|
+
owner={ownerName(deal.ownerId)}
|
|
85
|
+
dragging={dragging === deal.id}
|
|
86
|
+
onOpen={onOpen}
|
|
87
|
+
onDragStart={setDragging}
|
|
88
|
+
onDragEnd={() => {
|
|
89
|
+
setDragging(null);
|
|
90
|
+
setOver(null);
|
|
91
|
+
}}
|
|
92
|
+
/>
|
|
93
|
+
))}
|
|
94
|
+
{column.deals.length === 0 ? (
|
|
95
|
+
<p className="px-1 py-6 text-center text-[11px] text-muted-foreground">
|
|
96
|
+
{isTarget ? "Drop here" : "Nothing here"}
|
|
97
|
+
</p>
|
|
98
|
+
) : null}
|
|
99
|
+
</div>
|
|
100
|
+
</section>
|
|
101
|
+
);
|
|
102
|
+
})}
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -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,112 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link } from "@pylonsync/react";
|
|
3
|
+
import { Building2, Command, LayoutGrid, 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: "Pipeline", icon: <LayoutGrid /> },
|
|
17
|
+
{ href: "/companies", label: "Companies", icon: <Building2 /> },
|
|
18
|
+
{ href: "/contacts", label: "Contacts", icon: <Users /> },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The fixed left rail. Presentational: the active path, the counts, and the
|
|
23
|
+
* sign-out handler all arrive as props, so it renders identically in a test.
|
|
24
|
+
*/
|
|
25
|
+
export function Sidebar({
|
|
26
|
+
workspace,
|
|
27
|
+
email,
|
|
28
|
+
pathname,
|
|
29
|
+
counts,
|
|
30
|
+
onOpenCommand,
|
|
31
|
+
onSignOut,
|
|
32
|
+
}: {
|
|
33
|
+
workspace: string;
|
|
34
|
+
email: string;
|
|
35
|
+
pathname: string;
|
|
36
|
+
counts?: Record<string, number>;
|
|
37
|
+
onOpenCommand: () => void;
|
|
38
|
+
onSignOut: () => void;
|
|
39
|
+
}) {
|
|
40
|
+
return (
|
|
41
|
+
<aside className="flex w-[224px] shrink-0 flex-col border-r border-border bg-surface-1">
|
|
42
|
+
<div className="flex h-12 items-center gap-2 px-3">
|
|
43
|
+
<span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-primary text-[11px] font-bold text-primary-foreground">
|
|
44
|
+
{workspace.slice(0, 1).toUpperCase()}
|
|
45
|
+
</span>
|
|
46
|
+
<span className="truncate text-[13px] font-semibold">{workspace}</span>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div className="px-2 pb-2">
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
onClick={onOpenCommand}
|
|
53
|
+
className={cn(
|
|
54
|
+
"flex w-full items-center gap-2 rounded-md border border-border bg-background px-2 py-1.5",
|
|
55
|
+
"text-[12px] text-muted-foreground transition-colors hover:border-ring/40 hover:text-foreground",
|
|
56
|
+
)}
|
|
57
|
+
>
|
|
58
|
+
<Command className="size-3.5" />
|
|
59
|
+
<span className="flex-1 text-left">Search…</span>
|
|
60
|
+
<Kbd>⌘K</Kbd>
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<nav className="flex-1 space-y-0.5 px-2" aria-label="Main">
|
|
65
|
+
{NAV.map((item) => {
|
|
66
|
+
const active =
|
|
67
|
+
item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
|
68
|
+
return (
|
|
69
|
+
<Link
|
|
70
|
+
key={item.href}
|
|
71
|
+
href={item.href}
|
|
72
|
+
aria-current={active ? "page" : undefined}
|
|
73
|
+
className={cn(
|
|
74
|
+
"flex h-7 items-center gap-2 rounded-md px-2 text-[13px] transition-colors",
|
|
75
|
+
"[&_svg]:size-3.5 [&_svg]:shrink-0",
|
|
76
|
+
active
|
|
77
|
+
? "bg-surface-2 font-medium text-foreground"
|
|
78
|
+
: "text-muted-foreground hover:bg-surface-2/60 hover:text-foreground",
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
{item.icon}
|
|
82
|
+
<span className="flex-1 truncate">{item.label}</span>
|
|
83
|
+
{counts?.[item.href] ? (
|
|
84
|
+
<span className="tabular text-[11px] text-muted-foreground">
|
|
85
|
+
{counts[item.href]}
|
|
86
|
+
</span>
|
|
87
|
+
) : null}
|
|
88
|
+
</Link>
|
|
89
|
+
);
|
|
90
|
+
})}
|
|
91
|
+
</nav>
|
|
92
|
+
|
|
93
|
+
<div className="border-t border-border p-2">
|
|
94
|
+
<div className="flex items-center gap-2 rounded-md px-1.5 py-1">
|
|
95
|
+
<Avatar name={email} size="sm" />
|
|
96
|
+
<span className="min-w-0 flex-1 truncate text-[12px] text-muted-foreground">
|
|
97
|
+
{email}
|
|
98
|
+
</span>
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
onClick={onSignOut}
|
|
102
|
+
title="Sign out"
|
|
103
|
+
aria-label="Sign out"
|
|
104
|
+
className="rounded p-1 text-muted-foreground transition-colors hover:bg-surface-2 hover:text-foreground"
|
|
105
|
+
>
|
|
106
|
+
<LogOut className="size-3.5" />
|
|
107
|
+
</button>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</aside>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
import { stageById } from "@/lib/pipeline";
|
|
4
|
+
|
|
5
|
+
const DOT: Record<string, string> = {
|
|
6
|
+
lead: "bg-stage-lead",
|
|
7
|
+
qualified: "bg-stage-qualified",
|
|
8
|
+
proposal: "bg-stage-proposal",
|
|
9
|
+
won: "bg-stage-won",
|
|
10
|
+
lost: "bg-stage-lost",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A stage as a dot plus a label. A dot carries the status at a glance in a
|
|
15
|
+
* dense row without the visual weight of a filled chip on every line.
|
|
16
|
+
*/
|
|
17
|
+
export function StageBadge({
|
|
18
|
+
stage,
|
|
19
|
+
className,
|
|
20
|
+
}: {
|
|
21
|
+
stage: string;
|
|
22
|
+
className?: string;
|
|
23
|
+
}) {
|
|
24
|
+
const meta = stageById(stage);
|
|
25
|
+
return (
|
|
26
|
+
<span className={cn("inline-flex items-center gap-1.5 whitespace-nowrap", className)}>
|
|
27
|
+
<span
|
|
28
|
+
className={cn("size-1.5 shrink-0 rounded-full", DOT[stage] ?? "bg-muted-foreground")}
|
|
29
|
+
/>
|
|
30
|
+
<span className="text-muted-foreground">{meta?.label ?? stage}</span>
|
|
31
|
+
</span>
|
|
32
|
+
);
|
|
33
|
+
}
|