@pylonsync/create-pylon 0.3.344 → 0.3.346
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-pylon.js +35 -0
- package/package.json +1 -1
- package/templates/crm/AGENTS.md +116 -0
- package/templates/crm/CLAUDE.md +7 -0
- package/templates/crm/README.md +96 -0
- package/templates/crm/app/companies/companies-view.tsx +129 -0
- package/templates/crm/app/companies/page.tsx +17 -0
- package/templates/crm/app/contacts/contacts-view.tsx +139 -0
- package/templates/crm/app/contacts/page.tsx +17 -0
- package/templates/crm/app/deals/[id]/deal-view.tsx +189 -0
- package/templates/crm/app/deals/[id]/page.tsx +23 -0
- package/templates/crm/app/error.tsx +43 -0
- package/templates/crm/app/globals.css +201 -0
- package/templates/crm/app/layout.tsx +31 -0
- package/templates/crm/app/login/auth-form.tsx +122 -0
- package/templates/crm/app/login/page.tsx +38 -0
- package/templates/crm/app/not-found.tsx +29 -0
- package/templates/crm/app/page.tsx +38 -0
- package/templates/crm/app/pipeline-view.tsx +99 -0
- package/templates/crm/app/robots.ts +12 -0
- package/templates/crm/app/sitemap.ts +27 -0
- package/templates/crm/app/workspace.tsx +231 -0
- package/templates/crm/app.ts +171 -0
- package/templates/crm/bunfig +4 -0
- package/templates/crm/components/activity-timeline.tsx +133 -0
- package/templates/crm/components/avatar.tsx +40 -0
- package/templates/crm/components/board-skeleton.tsx +44 -0
- package/templates/crm/components/command-palette.tsx +162 -0
- package/templates/crm/components/data-table.tsx +90 -0
- package/templates/crm/components/deal-card.tsx +91 -0
- package/templates/crm/components/deal-dialog.tsx +169 -0
- package/templates/crm/components/empty-state.tsx +43 -0
- package/templates/crm/components/kbd.tsx +23 -0
- package/templates/crm/components/metrics-bar.tsx +46 -0
- package/templates/crm/components/page-header.tsx +36 -0
- package/templates/crm/components/pipeline-board.tsx +105 -0
- package/templates/crm/components/record-dialog.tsx +126 -0
- package/templates/crm/components/sidebar.tsx +112 -0
- package/templates/crm/components/stage-badge.tsx +33 -0
- package/templates/crm/components/ui/badge.tsx +37 -0
- package/templates/crm/components/ui/button.tsx +56 -0
- package/templates/crm/components/ui/card.tsx +90 -0
- package/templates/crm/components/ui/dialog.tsx +110 -0
- package/templates/crm/components/ui/input.tsx +30 -0
- package/templates/crm/components/ui/label.tsx +21 -0
- package/templates/crm/components/ui/select.tsx +39 -0
- package/templates/crm/components/ui/table.tsx +124 -0
- package/templates/crm/components/ui/textarea.tsx +24 -0
- package/templates/crm/components.json +20 -0
- package/templates/crm/functions/createDeal.ts +59 -0
- package/templates/crm/functions/logActivity.ts +49 -0
- package/templates/crm/functions/moveDeal.ts +53 -0
- package/templates/crm/functions/seedWorkspace.ts +63 -0
- package/templates/crm/gitignore +10 -0
- package/templates/crm/lib/pipeline.ts +221 -0
- package/templates/crm/lib/search.ts +78 -0
- package/templates/crm/lib/seed.ts +130 -0
- package/templates/crm/lib/utils.ts +10 -0
- package/templates/crm/package.json +39 -0
- package/templates/crm/tests/components.test.tsx +245 -0
- package/templates/crm/tests/pipeline.test.ts +219 -0
- package/templates/crm/tests/search.test.ts +83 -0
- package/templates/crm/tests/setup.ts +5 -0
- package/templates/crm/tsconfig.json +33 -0
- package/templates/helpdesk/AGENTS.md +116 -0
- package/templates/helpdesk/CLAUDE.md +7 -0
- package/templates/helpdesk/README.md +90 -0
- package/templates/helpdesk/app/customers/customers-view.tsx +140 -0
- package/templates/helpdesk/app/customers/page.tsx +17 -0
- package/templates/helpdesk/app/error.tsx +43 -0
- package/templates/helpdesk/app/globals.css +201 -0
- package/templates/helpdesk/app/inbox-view.tsx +147 -0
- package/templates/helpdesk/app/layout.tsx +31 -0
- package/templates/helpdesk/app/login/auth-form.tsx +122 -0
- package/templates/helpdesk/app/login/page.tsx +38 -0
- package/templates/helpdesk/app/not-found.tsx +29 -0
- package/templates/helpdesk/app/page.tsx +38 -0
- package/templates/helpdesk/app/robots.ts +12 -0
- package/templates/helpdesk/app/sitemap.ts +27 -0
- package/templates/helpdesk/app/tickets/[id]/page.tsx +23 -0
- package/templates/helpdesk/app/tickets/[id]/ticket-view.tsx +164 -0
- package/templates/helpdesk/app/workspace.tsx +210 -0
- package/templates/helpdesk/app.ts +142 -0
- package/templates/helpdesk/bunfig +4 -0
- package/templates/helpdesk/components/avatar.tsx +40 -0
- package/templates/helpdesk/components/command-palette.tsx +162 -0
- package/templates/helpdesk/components/data-table.tsx +90 -0
- package/templates/helpdesk/components/empty-state.tsx +43 -0
- package/templates/helpdesk/components/kbd.tsx +23 -0
- package/templates/helpdesk/components/new-ticket-dialog.tsx +147 -0
- package/templates/helpdesk/components/page-header.tsx +36 -0
- package/templates/helpdesk/components/priority-badge.tsx +61 -0
- package/templates/helpdesk/components/record-dialog.tsx +126 -0
- package/templates/helpdesk/components/sidebar.tsx +111 -0
- package/templates/helpdesk/components/sla-indicator.tsx +47 -0
- package/templates/helpdesk/components/thread.tsx +143 -0
- package/templates/helpdesk/components/ticket-list.tsx +78 -0
- package/templates/helpdesk/components/ui/badge.tsx +37 -0
- package/templates/helpdesk/components/ui/button.tsx +56 -0
- package/templates/helpdesk/components/ui/card.tsx +90 -0
- package/templates/helpdesk/components/ui/dialog.tsx +110 -0
- package/templates/helpdesk/components/ui/input.tsx +30 -0
- package/templates/helpdesk/components/ui/label.tsx +21 -0
- package/templates/helpdesk/components/ui/select.tsx +39 -0
- package/templates/helpdesk/components/ui/table.tsx +124 -0
- package/templates/helpdesk/components/ui/textarea.tsx +24 -0
- package/templates/helpdesk/components.json +20 -0
- package/templates/helpdesk/functions/createTicket.ts +62 -0
- package/templates/helpdesk/functions/replyToTicket.ts +55 -0
- package/templates/helpdesk/functions/seedWorkspace.ts +52 -0
- package/templates/helpdesk/functions/setTicketState.ts +58 -0
- package/templates/helpdesk/gitignore +10 -0
- package/templates/helpdesk/lib/format.ts +53 -0
- package/templates/helpdesk/lib/search.ts +78 -0
- package/templates/helpdesk/lib/seed.ts +185 -0
- package/templates/helpdesk/lib/tickets.ts +184 -0
- package/templates/helpdesk/lib/utils.ts +10 -0
- package/templates/helpdesk/package.json +39 -0
- package/templates/helpdesk/tests/components.test.tsx +202 -0
- package/templates/helpdesk/tests/search.test.ts +83 -0
- package/templates/helpdesk/tests/setup.ts +5 -0
- package/templates/helpdesk/tests/tickets.test.ts +215 -0
- package/templates/helpdesk/tsconfig.json +33 -0
- package/templates/inventory/AGENTS.md +116 -0
- package/templates/inventory/CLAUDE.md +7 -0
- package/templates/inventory/README.md +103 -0
- package/templates/inventory/app/error.tsx +43 -0
- package/templates/inventory/app/globals.css +201 -0
- package/templates/inventory/app/layout.tsx +31 -0
- package/templates/inventory/app/login/auth-form.tsx +122 -0
- package/templates/inventory/app/login/page.tsx +38 -0
- package/templates/inventory/app/movements/movements-view.tsx +103 -0
- package/templates/inventory/app/movements/page.tsx +17 -0
- package/templates/inventory/app/not-found.tsx +29 -0
- package/templates/inventory/app/page.tsx +34 -0
- package/templates/inventory/app/products/[id]/page.tsx +23 -0
- package/templates/inventory/app/products/[id]/product-view.tsx +178 -0
- package/templates/inventory/app/products-view.tsx +213 -0
- package/templates/inventory/app/robots.ts +12 -0
- package/templates/inventory/app/sitemap.ts +27 -0
- package/templates/inventory/app/workspace.tsx +172 -0
- package/templates/inventory/app.ts +154 -0
- package/templates/inventory/bunfig +4 -0
- package/templates/inventory/components/avatar.tsx +40 -0
- package/templates/inventory/components/command-palette.tsx +162 -0
- package/templates/inventory/components/data-table.tsx +90 -0
- package/templates/inventory/components/empty-state.tsx +43 -0
- package/templates/inventory/components/kbd.tsx +23 -0
- package/templates/inventory/components/movement-dialog.tsx +182 -0
- package/templates/inventory/components/page-header.tsx +36 -0
- package/templates/inventory/components/record-dialog.tsx +126 -0
- package/templates/inventory/components/sidebar.tsx +111 -0
- package/templates/inventory/components/stock-level.tsx +47 -0
- package/templates/inventory/components/summary-bar.tsx +56 -0
- package/templates/inventory/components/ui/badge.tsx +37 -0
- package/templates/inventory/components/ui/button.tsx +56 -0
- package/templates/inventory/components/ui/card.tsx +90 -0
- package/templates/inventory/components/ui/dialog.tsx +110 -0
- package/templates/inventory/components/ui/input.tsx +30 -0
- package/templates/inventory/components/ui/label.tsx +21 -0
- package/templates/inventory/components/ui/select.tsx +39 -0
- package/templates/inventory/components/ui/table.tsx +124 -0
- package/templates/inventory/components/ui/textarea.tsx +24 -0
- package/templates/inventory/components.json +20 -0
- package/templates/inventory/functions/recordMovement.ts +72 -0
- package/templates/inventory/functions/seedWorkspace.ts +40 -0
- package/templates/inventory/gitignore +10 -0
- package/templates/inventory/lib/format.ts +53 -0
- package/templates/inventory/lib/search.ts +78 -0
- package/templates/inventory/lib/seed.ts +102 -0
- package/templates/inventory/lib/stock.ts +207 -0
- package/templates/inventory/lib/utils.ts +10 -0
- package/templates/inventory/package.json +39 -0
- package/templates/inventory/tests/components.test.tsx +155 -0
- package/templates/inventory/tests/search.test.ts +83 -0
- package/templates/inventory/tests/setup.ts +5 -0
- package/templates/inventory/tests/stock.test.ts +255 -0
- package/templates/inventory/tsconfig.json +33 -0
- package/templates/invoices/AGENTS.md +116 -0
- package/templates/invoices/CLAUDE.md +7 -0
- package/templates/invoices/README.md +99 -0
- package/templates/invoices/app/clients/clients-view.tsx +130 -0
- package/templates/invoices/app/clients/page.tsx +17 -0
- package/templates/invoices/app/error.tsx +43 -0
- package/templates/invoices/app/globals.css +201 -0
- package/templates/invoices/app/invoices/[id]/invoice-view.tsx +253 -0
- package/templates/invoices/app/invoices/[id]/page.tsx +23 -0
- package/templates/invoices/app/invoices-view.tsx +183 -0
- package/templates/invoices/app/layout.tsx +31 -0
- package/templates/invoices/app/login/auth-form.tsx +122 -0
- package/templates/invoices/app/login/page.tsx +38 -0
- package/templates/invoices/app/not-found.tsx +29 -0
- package/templates/invoices/app/page.tsx +36 -0
- package/templates/invoices/app/robots.ts +12 -0
- package/templates/invoices/app/sitemap.ts +27 -0
- package/templates/invoices/app/workspace.tsx +174 -0
- package/templates/invoices/app.ts +169 -0
- package/templates/invoices/bunfig +4 -0
- package/templates/invoices/components/avatar.tsx +40 -0
- package/templates/invoices/components/command-palette.tsx +162 -0
- package/templates/invoices/components/data-table.tsx +90 -0
- package/templates/invoices/components/empty-state.tsx +43 -0
- package/templates/invoices/components/kbd.tsx +23 -0
- package/templates/invoices/components/line-items.tsx +172 -0
- package/templates/invoices/components/page-header.tsx +36 -0
- package/templates/invoices/components/payment-dialog.tsx +132 -0
- package/templates/invoices/components/record-dialog.tsx +126 -0
- package/templates/invoices/components/sidebar.tsx +111 -0
- package/templates/invoices/components/status-badge.tsx +35 -0
- package/templates/invoices/components/summary-bar.tsx +65 -0
- package/templates/invoices/components/totals-panel.tsx +70 -0
- package/templates/invoices/components/ui/badge.tsx +37 -0
- package/templates/invoices/components/ui/button.tsx +56 -0
- package/templates/invoices/components/ui/card.tsx +90 -0
- package/templates/invoices/components/ui/dialog.tsx +110 -0
- package/templates/invoices/components/ui/input.tsx +30 -0
- package/templates/invoices/components/ui/label.tsx +21 -0
- package/templates/invoices/components/ui/select.tsx +39 -0
- package/templates/invoices/components/ui/table.tsx +124 -0
- package/templates/invoices/components/ui/textarea.tsx +24 -0
- package/templates/invoices/components.json +20 -0
- package/templates/invoices/functions/createInvoice.ts +49 -0
- package/templates/invoices/functions/recordPayment.ts +72 -0
- package/templates/invoices/functions/seedWorkspace.ts +57 -0
- package/templates/invoices/functions/setInvoiceStatus.ts +35 -0
- package/templates/invoices/gitignore +10 -0
- package/templates/invoices/lib/billing.ts +259 -0
- package/templates/invoices/lib/format.ts +53 -0
- package/templates/invoices/lib/search.ts +78 -0
- package/templates/invoices/lib/seed.ts +190 -0
- package/templates/invoices/lib/utils.ts +10 -0
- package/templates/invoices/package.json +39 -0
- package/templates/invoices/tests/billing.test.ts +283 -0
- package/templates/invoices/tests/components.test.tsx +172 -0
- package/templates/invoices/tests/search.test.ts +83 -0
- package/templates/invoices/tests/setup.ts +5 -0
- package/templates/invoices/tsconfig.json +33 -0
- package/templates/projects/AGENTS.md +116 -0
- package/templates/projects/CLAUDE.md +7 -0
- package/templates/projects/README.md +96 -0
- package/templates/projects/app/clients/clients-view.tsx +126 -0
- package/templates/projects/app/clients/page.tsx +17 -0
- package/templates/projects/app/error.tsx +43 -0
- package/templates/projects/app/globals.css +201 -0
- package/templates/projects/app/layout.tsx +31 -0
- package/templates/projects/app/login/auth-form.tsx +122 -0
- package/templates/projects/app/login/page.tsx +38 -0
- package/templates/projects/app/not-found.tsx +29 -0
- package/templates/projects/app/page.tsx +30 -0
- package/templates/projects/app/projects/[id]/page.tsx +23 -0
- package/templates/projects/app/projects/[id]/project-view.tsx +179 -0
- package/templates/projects/app/projects-view.tsx +181 -0
- package/templates/projects/app/robots.ts +12 -0
- package/templates/projects/app/sitemap.ts +27 -0
- package/templates/projects/app/workspace.tsx +190 -0
- package/templates/projects/app.ts +186 -0
- package/templates/projects/bunfig +4 -0
- package/templates/projects/components/avatar.tsx +40 -0
- package/templates/projects/components/board-skeleton.tsx +37 -0
- package/templates/projects/components/budget-bar.tsx +61 -0
- package/templates/projects/components/command-palette.tsx +162 -0
- package/templates/projects/components/data-table.tsx +90 -0
- package/templates/projects/components/empty-state.tsx +43 -0
- package/templates/projects/components/kbd.tsx +23 -0
- package/templates/projects/components/page-header.tsx +36 -0
- package/templates/projects/components/record-dialog.tsx +126 -0
- package/templates/projects/components/sidebar.tsx +111 -0
- package/templates/projects/components/task-board.tsx +109 -0
- package/templates/projects/components/task-card.tsx +77 -0
- package/templates/projects/components/time-dialog.tsx +114 -0
- package/templates/projects/components/ui/badge.tsx +37 -0
- package/templates/projects/components/ui/button.tsx +56 -0
- package/templates/projects/components/ui/card.tsx +90 -0
- package/templates/projects/components/ui/dialog.tsx +110 -0
- package/templates/projects/components/ui/input.tsx +30 -0
- package/templates/projects/components/ui/label.tsx +21 -0
- package/templates/projects/components/ui/select.tsx +39 -0
- package/templates/projects/components/ui/table.tsx +124 -0
- package/templates/projects/components/ui/textarea.tsx +24 -0
- package/templates/projects/components.json +20 -0
- package/templates/projects/functions/logTime.ts +49 -0
- package/templates/projects/functions/moveTask.ts +40 -0
- package/templates/projects/functions/seedWorkspace.ts +59 -0
- package/templates/projects/gitignore +10 -0
- package/templates/projects/lib/format.ts +53 -0
- package/templates/projects/lib/search.ts +78 -0
- package/templates/projects/lib/seed.ts +140 -0
- package/templates/projects/lib/utils.ts +10 -0
- package/templates/projects/lib/work.ts +233 -0
- package/templates/projects/package.json +39 -0
- package/templates/projects/tests/components.test.tsx +200 -0
- package/templates/projects/tests/search.test.ts +83 -0
- package/templates/projects/tests/setup.ts +5 -0
- package/templates/projects/tests/work.test.ts +238 -0
- package/templates/projects/tsconfig.json +33 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, 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 { REASONS, parseCount, reasonById } from "@/lib/stock";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Record a movement.
|
|
17
|
+
*
|
|
18
|
+
* The user types a POSITIVE count and picks a reason; the direction comes from
|
|
19
|
+
* the reason. Asking someone to type "-3" for a sale is how you get a "+3" sale
|
|
20
|
+
* and a shelf that disagrees with the system.
|
|
21
|
+
*
|
|
22
|
+
* "Stock count" is the exception: it sets the level TO a number rather than
|
|
23
|
+
* adjusting by one, because that's what counting a shelf actually produces. The
|
|
24
|
+
* delta is computed from the current level here so the ledger still only ever
|
|
25
|
+
* receives deltas.
|
|
26
|
+
*/
|
|
27
|
+
export function MovementDialog({
|
|
28
|
+
open,
|
|
29
|
+
products,
|
|
30
|
+
currentLevel,
|
|
31
|
+
defaultProductId,
|
|
32
|
+
onOpenChange,
|
|
33
|
+
onRecord,
|
|
34
|
+
}: {
|
|
35
|
+
open: boolean;
|
|
36
|
+
products: Array<{ id: string; name: string; sku: string }>;
|
|
37
|
+
/** On-hand for a product, so a stock count can be turned into a delta. */
|
|
38
|
+
currentLevel: (productId: string) => number;
|
|
39
|
+
defaultProductId?: string;
|
|
40
|
+
onOpenChange: (open: boolean) => void;
|
|
41
|
+
onRecord: (
|
|
42
|
+
productId: string,
|
|
43
|
+
delta: number,
|
|
44
|
+
reason: string,
|
|
45
|
+
note: string,
|
|
46
|
+
) => void | Promise<void>;
|
|
47
|
+
}) {
|
|
48
|
+
const [productId, setProductId] = useState(defaultProductId ?? "");
|
|
49
|
+
const [reason, setReason] = useState("received");
|
|
50
|
+
const [count, setCount] = useState("");
|
|
51
|
+
const [note, setNote] = useState("");
|
|
52
|
+
const [busy, setBusy] = useState(false);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!open) return;
|
|
56
|
+
setProductId(defaultProductId ?? "");
|
|
57
|
+
setReason("received");
|
|
58
|
+
setCount("");
|
|
59
|
+
setNote("");
|
|
60
|
+
setBusy(false);
|
|
61
|
+
}, [open, defaultProductId]);
|
|
62
|
+
|
|
63
|
+
const isCount = reason === "count";
|
|
64
|
+
const parsed = parseCount(count);
|
|
65
|
+
const level = productId ? currentLevel(productId) : 0;
|
|
66
|
+
|
|
67
|
+
// For a stock count the typed number is the NEW level, so the delta is the
|
|
68
|
+
// difference. For everything else the reason supplies the sign.
|
|
69
|
+
const delta = useMemo(() => {
|
|
70
|
+
if (parsed === null) return null;
|
|
71
|
+
if (isCount) return parsed - level;
|
|
72
|
+
const direction = reasonById(reason)?.direction;
|
|
73
|
+
const magnitude = Math.abs(parsed);
|
|
74
|
+
if (magnitude === 0) return null;
|
|
75
|
+
return direction === "out" ? -magnitude : magnitude;
|
|
76
|
+
}, [parsed, isCount, level, reason]);
|
|
77
|
+
|
|
78
|
+
const wouldGoNegative = !isCount && delta !== null && level + delta < 0;
|
|
79
|
+
const canSubmit =
|
|
80
|
+
productId !== "" && delta !== null && delta !== 0 && !wouldGoNegative && !busy;
|
|
81
|
+
|
|
82
|
+
async function submit(event: React.FormEvent) {
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
if (!canSubmit) return;
|
|
85
|
+
setBusy(true);
|
|
86
|
+
try {
|
|
87
|
+
await onRecord(productId, delta as number, reason, note.trim());
|
|
88
|
+
onOpenChange(false);
|
|
89
|
+
} finally {
|
|
90
|
+
setBusy(false);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
96
|
+
<DialogContent>
|
|
97
|
+
<DialogHeader>
|
|
98
|
+
<DialogTitle>Record movement</DialogTitle>
|
|
99
|
+
</DialogHeader>
|
|
100
|
+
<form onSubmit={submit} className="grid gap-4">
|
|
101
|
+
<div className="space-y-1.5">
|
|
102
|
+
<Label htmlFor="movement-product">Product</Label>
|
|
103
|
+
<Select
|
|
104
|
+
id="movement-product"
|
|
105
|
+
value={productId}
|
|
106
|
+
onChange={(event) => setProductId(event.target.value)}
|
|
107
|
+
>
|
|
108
|
+
<option value="">—</option>
|
|
109
|
+
{products.map((product) => (
|
|
110
|
+
<option key={product.id} value={product.id}>
|
|
111
|
+
{product.sku} · {product.name}
|
|
112
|
+
</option>
|
|
113
|
+
))}
|
|
114
|
+
</Select>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div className="grid grid-cols-2 gap-3">
|
|
118
|
+
<div className="space-y-1.5">
|
|
119
|
+
<Label htmlFor="movement-reason">Reason</Label>
|
|
120
|
+
<Select
|
|
121
|
+
id="movement-reason"
|
|
122
|
+
value={reason}
|
|
123
|
+
onChange={(event) => setReason(event.target.value)}
|
|
124
|
+
>
|
|
125
|
+
{REASONS.map((r) => (
|
|
126
|
+
<option key={r.id} value={r.id}>
|
|
127
|
+
{r.label}
|
|
128
|
+
</option>
|
|
129
|
+
))}
|
|
130
|
+
</Select>
|
|
131
|
+
</div>
|
|
132
|
+
<div className="space-y-1.5">
|
|
133
|
+
<Label htmlFor="movement-count">
|
|
134
|
+
{isCount ? "Counted" : "Quantity"}
|
|
135
|
+
</Label>
|
|
136
|
+
<Input
|
|
137
|
+
id="movement-count"
|
|
138
|
+
autoFocus
|
|
139
|
+
inputMode="numeric"
|
|
140
|
+
placeholder={isCount ? String(level) : "10"}
|
|
141
|
+
value={count}
|
|
142
|
+
onChange={(event) => setCount(event.target.value)}
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
{productId ? (
|
|
148
|
+
<p className="text-[11px] text-muted-foreground">
|
|
149
|
+
On hand {level}
|
|
150
|
+
{delta !== null && delta !== 0 ? ` → ${level + delta}` : ""}
|
|
151
|
+
</p>
|
|
152
|
+
) : null}
|
|
153
|
+
{wouldGoNegative ? (
|
|
154
|
+
<p className="text-[11px] text-destructive">
|
|
155
|
+
That would go below zero. Record a stock count if the shelf
|
|
156
|
+
disagrees.
|
|
157
|
+
</p>
|
|
158
|
+
) : null}
|
|
159
|
+
|
|
160
|
+
<div className="space-y-1.5">
|
|
161
|
+
<Label htmlFor="movement-note">Note</Label>
|
|
162
|
+
<Input
|
|
163
|
+
id="movement-note"
|
|
164
|
+
placeholder="PO number, or why"
|
|
165
|
+
value={note}
|
|
166
|
+
onChange={(event) => setNote(event.target.value)}
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<DialogFooter>
|
|
171
|
+
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
|
172
|
+
Cancel
|
|
173
|
+
</Button>
|
|
174
|
+
<Button type="submit" disabled={!canSubmit}>
|
|
175
|
+
{busy ? "Recording…" : "Record"}
|
|
176
|
+
</Button>
|
|
177
|
+
</DialogFooter>
|
|
178
|
+
</form>
|
|
179
|
+
</DialogContent>
|
|
180
|
+
</Dialog>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
@@ -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,126 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Dialog,
|
|
4
|
+
DialogContent,
|
|
5
|
+
DialogFooter,
|
|
6
|
+
DialogHeader,
|
|
7
|
+
DialogTitle,
|
|
8
|
+
} from "@/components/ui/dialog";
|
|
9
|
+
import { Button } from "@/components/ui/button";
|
|
10
|
+
import { Input } from "@/components/ui/input";
|
|
11
|
+
import { Label } from "@/components/ui/label";
|
|
12
|
+
import { Select } from "@/components/ui/select";
|
|
13
|
+
|
|
14
|
+
export interface FieldSpec {
|
|
15
|
+
name: string;
|
|
16
|
+
label: string;
|
|
17
|
+
type?: "text" | "email" | "tel";
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
required?: boolean;
|
|
20
|
+
/** Renders a picker instead of a text input. */
|
|
21
|
+
options?: Array<{ value: string; label: string }>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A create dialog driven by a field list — used for companies and contacts,
|
|
26
|
+
* which differ only in their fields. Deals get their own dialog because value,
|
|
27
|
+
* stage and close date need real layout rather than a stack of text inputs.
|
|
28
|
+
*/
|
|
29
|
+
export function RecordDialog({
|
|
30
|
+
open,
|
|
31
|
+
title,
|
|
32
|
+
fields,
|
|
33
|
+
submitLabel,
|
|
34
|
+
onOpenChange,
|
|
35
|
+
onCreate,
|
|
36
|
+
}: {
|
|
37
|
+
open: boolean;
|
|
38
|
+
title: string;
|
|
39
|
+
fields: FieldSpec[];
|
|
40
|
+
submitLabel: string;
|
|
41
|
+
onOpenChange: (open: boolean) => void;
|
|
42
|
+
onCreate: (values: Record<string, string>) => void | Promise<void>;
|
|
43
|
+
}) {
|
|
44
|
+
const [values, setValues] = useState<Record<string, string>>({});
|
|
45
|
+
const [busy, setBusy] = useState(false);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!open) return;
|
|
49
|
+
setValues({});
|
|
50
|
+
setBusy(false);
|
|
51
|
+
}, [open]);
|
|
52
|
+
|
|
53
|
+
const missing = fields.some(
|
|
54
|
+
(field) => field.required && !(values[field.name] ?? "").trim(),
|
|
55
|
+
);
|
|
56
|
+
const canSubmit = !missing && !busy;
|
|
57
|
+
|
|
58
|
+
async function submit(event: React.FormEvent) {
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
if (!canSubmit) return;
|
|
61
|
+
setBusy(true);
|
|
62
|
+
try {
|
|
63
|
+
const trimmed: Record<string, string> = {};
|
|
64
|
+
for (const [key, value] of Object.entries(values)) {
|
|
65
|
+
const clean = value.trim();
|
|
66
|
+
if (clean) trimmed[key] = clean;
|
|
67
|
+
}
|
|
68
|
+
await onCreate(trimmed);
|
|
69
|
+
onOpenChange(false);
|
|
70
|
+
} finally {
|
|
71
|
+
setBusy(false);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
77
|
+
<DialogContent>
|
|
78
|
+
<DialogHeader>
|
|
79
|
+
<DialogTitle>{title}</DialogTitle>
|
|
80
|
+
</DialogHeader>
|
|
81
|
+
<form onSubmit={submit} className="grid gap-4">
|
|
82
|
+
{fields.map((field, index) => (
|
|
83
|
+
<div key={field.name} className="space-y-1.5">
|
|
84
|
+
<Label htmlFor={`record-${field.name}`}>{field.label}</Label>
|
|
85
|
+
{field.options ? (
|
|
86
|
+
<Select
|
|
87
|
+
id={`record-${field.name}`}
|
|
88
|
+
value={values[field.name] ?? ""}
|
|
89
|
+
onChange={(event) =>
|
|
90
|
+
setValues((v) => ({ ...v, [field.name]: event.target.value }))
|
|
91
|
+
}
|
|
92
|
+
>
|
|
93
|
+
<option value="">—</option>
|
|
94
|
+
{field.options.map((option) => (
|
|
95
|
+
<option key={option.value} value={option.value}>
|
|
96
|
+
{option.label}
|
|
97
|
+
</option>
|
|
98
|
+
))}
|
|
99
|
+
</Select>
|
|
100
|
+
) : (
|
|
101
|
+
<Input
|
|
102
|
+
id={`record-${field.name}`}
|
|
103
|
+
autoFocus={index === 0}
|
|
104
|
+
type={field.type ?? "text"}
|
|
105
|
+
placeholder={field.placeholder}
|
|
106
|
+
value={values[field.name] ?? ""}
|
|
107
|
+
onChange={(event) =>
|
|
108
|
+
setValues((v) => ({ ...v, [field.name]: event.target.value }))
|
|
109
|
+
}
|
|
110
|
+
/>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
))}
|
|
114
|
+
<DialogFooter>
|
|
115
|
+
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
|
116
|
+
Cancel
|
|
117
|
+
</Button>
|
|
118
|
+
<Button type="submit" disabled={!canSubmit}>
|
|
119
|
+
{busy ? "Saving…" : submitLabel}
|
|
120
|
+
</Button>
|
|
121
|
+
</DialogFooter>
|
|
122
|
+
</form>
|
|
123
|
+
</DialogContent>
|
|
124
|
+
</Dialog>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link } from "@pylonsync/react";
|
|
3
|
+
import { ArrowLeftRight, Boxes, Command, LogOut } 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: "Products", icon: <Boxes /> },
|
|
17
|
+
{ href: "/movements", label: "Movements", icon: <ArrowLeftRight /> },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The fixed left rail. Presentational: the active path, the counts, and the
|
|
22
|
+
* sign-out handler all arrive as props, so it renders identically in a test.
|
|
23
|
+
*/
|
|
24
|
+
export function Sidebar({
|
|
25
|
+
workspace,
|
|
26
|
+
email,
|
|
27
|
+
pathname,
|
|
28
|
+
counts,
|
|
29
|
+
onOpenCommand,
|
|
30
|
+
onSignOut,
|
|
31
|
+
}: {
|
|
32
|
+
workspace: string;
|
|
33
|
+
email: string;
|
|
34
|
+
pathname: string;
|
|
35
|
+
counts?: Record<string, number>;
|
|
36
|
+
onOpenCommand: () => void;
|
|
37
|
+
onSignOut: () => void;
|
|
38
|
+
}) {
|
|
39
|
+
return (
|
|
40
|
+
<aside className="flex w-[224px] shrink-0 flex-col border-r border-border bg-surface-1">
|
|
41
|
+
<div className="flex h-12 items-center gap-2 px-3">
|
|
42
|
+
<span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-primary text-[11px] font-bold text-primary-foreground">
|
|
43
|
+
{workspace.slice(0, 1).toUpperCase()}
|
|
44
|
+
</span>
|
|
45
|
+
<span className="truncate text-[13px] font-semibold">{workspace}</span>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div className="px-2 pb-2">
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
onClick={onOpenCommand}
|
|
52
|
+
className={cn(
|
|
53
|
+
"flex w-full items-center gap-2 rounded-md border border-border bg-background px-2 py-1.5",
|
|
54
|
+
"text-[12px] text-muted-foreground transition-colors hover:border-ring/40 hover:text-foreground",
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
<Command className="size-3.5" />
|
|
58
|
+
<span className="flex-1 text-left">Search…</span>
|
|
59
|
+
<Kbd>⌘K</Kbd>
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<nav className="flex-1 space-y-0.5 px-2" aria-label="Main">
|
|
64
|
+
{NAV.map((item) => {
|
|
65
|
+
const active =
|
|
66
|
+
item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
|
67
|
+
return (
|
|
68
|
+
<Link
|
|
69
|
+
key={item.href}
|
|
70
|
+
href={item.href}
|
|
71
|
+
aria-current={active ? "page" : undefined}
|
|
72
|
+
className={cn(
|
|
73
|
+
"flex h-7 items-center gap-2 rounded-md px-2 text-[13px] transition-colors",
|
|
74
|
+
"[&_svg]:size-3.5 [&_svg]:shrink-0",
|
|
75
|
+
active
|
|
76
|
+
? "bg-surface-2 font-medium text-foreground"
|
|
77
|
+
: "text-muted-foreground hover:bg-surface-2/60 hover:text-foreground",
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
{item.icon}
|
|
81
|
+
<span className="flex-1 truncate">{item.label}</span>
|
|
82
|
+
{counts?.[item.href] ? (
|
|
83
|
+
<span className="tabular text-[11px] text-muted-foreground">
|
|
84
|
+
{counts[item.href]}
|
|
85
|
+
</span>
|
|
86
|
+
) : null}
|
|
87
|
+
</Link>
|
|
88
|
+
);
|
|
89
|
+
})}
|
|
90
|
+
</nav>
|
|
91
|
+
|
|
92
|
+
<div className="border-t border-border p-2">
|
|
93
|
+
<div className="flex items-center gap-2 rounded-md px-1.5 py-1">
|
|
94
|
+
<Avatar name={email} size="sm" />
|
|
95
|
+
<span className="min-w-0 flex-1 truncate text-[12px] text-muted-foreground">
|
|
96
|
+
{email}
|
|
97
|
+
</span>
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
onClick={onSignOut}
|
|
101
|
+
title="Sign out"
|
|
102
|
+
aria-label="Sign out"
|
|
103
|
+
className="rounded p-1 text-muted-foreground transition-colors hover:bg-surface-2 hover:text-foreground"
|
|
104
|
+
>
|
|
105
|
+
<LogOut className="size-3.5" />
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</aside>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
import { stockState } from "@/lib/stock";
|
|
4
|
+
|
|
5
|
+
const STYLE: Record<string, string> = {
|
|
6
|
+
out: "text-destructive",
|
|
7
|
+
low: "text-stage-proposal",
|
|
8
|
+
ok: "text-foreground",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* On-hand, coloured by whether it needs attention.
|
|
13
|
+
*
|
|
14
|
+
* The number itself carries the colour rather than a separate badge: in a dense
|
|
15
|
+
* table the quantity is what the eye goes to, and a chip beside it is a second
|
|
16
|
+
* thing to read for the same information.
|
|
17
|
+
*/
|
|
18
|
+
export function StockLevel({
|
|
19
|
+
quantity,
|
|
20
|
+
reorderPoint,
|
|
21
|
+
className,
|
|
22
|
+
}: {
|
|
23
|
+
quantity: number;
|
|
24
|
+
reorderPoint?: number | null;
|
|
25
|
+
className?: string;
|
|
26
|
+
}) {
|
|
27
|
+
const state = stockState(quantity, reorderPoint);
|
|
28
|
+
return (
|
|
29
|
+
<span
|
|
30
|
+
className={cn("tabular font-medium", STYLE[state], className)}
|
|
31
|
+
title={
|
|
32
|
+
state === "out"
|
|
33
|
+
? "Out of stock"
|
|
34
|
+
: state === "low"
|
|
35
|
+
? `At or below the reorder point of ${reorderPoint}`
|
|
36
|
+
: undefined
|
|
37
|
+
}
|
|
38
|
+
>
|
|
39
|
+
{quantity}
|
|
40
|
+
{state === "out" ? (
|
|
41
|
+
<span className="ml-1.5 text-[11px] font-normal">out</span>
|
|
42
|
+
) : state === "low" ? (
|
|
43
|
+
<span className="ml-1.5 text-[11px] font-normal">low</span>
|
|
44
|
+
) : null}
|
|
45
|
+
</span>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { money, summarize, type Movement, type Product } from "@/lib/stock";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What\'s on the shelves and what needs attention. All derived from the ledger —
|
|
6
|
+
* see lib/stock.ts, where the arithmetic is unit-tested.
|
|
7
|
+
*/
|
|
8
|
+
export function SummaryBar({
|
|
9
|
+
products,
|
|
10
|
+
movements,
|
|
11
|
+
}: {
|
|
12
|
+
products: Product[];
|
|
13
|
+
movements: Movement[];
|
|
14
|
+
}) {
|
|
15
|
+
const s = summarize(products, movements);
|
|
16
|
+
return (
|
|
17
|
+
<dl className="grid grid-cols-2 gap-px border-b border-border bg-border sm:grid-cols-4">
|
|
18
|
+
<Metric label="Products" value={String(s.skuCount)} hint="active SKUs" />
|
|
19
|
+
<Metric label="Units on hand" value={s.unitCount.toLocaleString("en-US")} hint="across all lines" />
|
|
20
|
+
<Metric label="Stock value" value={money(s.valuationCents)} hint="at cost" />
|
|
21
|
+
<Metric
|
|
22
|
+
label="Needs reorder"
|
|
23
|
+
value={String(s.lowCount + s.outCount)}
|
|
24
|
+
hint={`${s.outCount} out of stock`}
|
|
25
|
+
alarming={s.outCount > 0}
|
|
26
|
+
/>
|
|
27
|
+
</dl>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function Metric({
|
|
32
|
+
label,
|
|
33
|
+
value,
|
|
34
|
+
hint,
|
|
35
|
+
alarming,
|
|
36
|
+
}: {
|
|
37
|
+
label: string;
|
|
38
|
+
value: string;
|
|
39
|
+
hint: string;
|
|
40
|
+
alarming?: boolean;
|
|
41
|
+
}) {
|
|
42
|
+
return (
|
|
43
|
+
<div className="bg-background px-4 py-3">
|
|
44
|
+
<dt className="text-[11px] text-muted-foreground">{label}</dt>
|
|
45
|
+
<dd
|
|
46
|
+
className={
|
|
47
|
+
"tabular mt-0.5 text-[18px] font-semibold tracking-tight" +
|
|
48
|
+
(alarming ? " text-destructive" : "")
|
|
49
|
+
}
|
|
50
|
+
>
|
|
51
|
+
{value}
|
|
52
|
+
</dd>
|
|
53
|
+
<p className="mt-0.5 text-[11px] text-muted-foreground">{hint}</p>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
|
|
6
|
+
const badgeVariants = cva(
|
|
7
|
+
"inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
default: "border-transparent bg-primary text-primary-foreground",
|
|
12
|
+
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
|
13
|
+
destructive: "border-transparent bg-destructive text-white",
|
|
14
|
+
outline: "text-foreground",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
defaultVariants: {
|
|
18
|
+
variant: "default",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
export interface BadgeProps
|
|
24
|
+
extends React.HTMLAttributes<HTMLSpanElement>,
|
|
25
|
+
VariantProps<typeof badgeVariants> {}
|
|
26
|
+
|
|
27
|
+
function Badge({ className, variant, ...props }: BadgeProps) {
|
|
28
|
+
return (
|
|
29
|
+
<span
|
|
30
|
+
data-slot="badge"
|
|
31
|
+
className={cn(badgeVariants({ variant }), className)}
|
|
32
|
+
{...props}
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export { Badge, badgeVariants };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
const buttonVariants = cva(
|
|
8
|
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
|
9
|
+
{
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
13
|
+
destructive:
|
|
14
|
+
"bg-destructive text-white hover:bg-destructive/90",
|
|
15
|
+
outline:
|
|
16
|
+
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
|
17
|
+
secondary:
|
|
18
|
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
19
|
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
20
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
21
|
+
},
|
|
22
|
+
size: {
|
|
23
|
+
default: "h-9 px-4 py-2",
|
|
24
|
+
sm: "h-8 rounded-md px-3 text-xs",
|
|
25
|
+
lg: "h-10 rounded-md px-8",
|
|
26
|
+
icon: "h-9 w-9",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
defaultVariants: {
|
|
30
|
+
variant: "default",
|
|
31
|
+
size: "default",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export interface ButtonProps
|
|
37
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
38
|
+
VariantProps<typeof buttonVariants> {
|
|
39
|
+
asChild?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
43
|
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
44
|
+
const Comp = asChild ? Slot : "button";
|
|
45
|
+
return (
|
|
46
|
+
<Comp
|
|
47
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
48
|
+
ref={ref}
|
|
49
|
+
{...props}
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
Button.displayName = "Button";
|
|
55
|
+
|
|
56
|
+
export { Button, buttonVariants };
|