@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,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,147 @@
|
|
|
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 { Textarea } from "@/components/ui/textarea";
|
|
14
|
+
import { PRIORITIES } from "@/lib/tickets";
|
|
15
|
+
|
|
16
|
+
export interface TicketDraft {
|
|
17
|
+
subject: string;
|
|
18
|
+
body: string;
|
|
19
|
+
customerId: string;
|
|
20
|
+
priority: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Open a ticket on a customer's behalf — the "log a phone call" path. The body
|
|
25
|
+
* is required as well as the subject: a ticket whose thread starts empty tells
|
|
26
|
+
* the next agent nothing about what was actually reported.
|
|
27
|
+
*/
|
|
28
|
+
export function NewTicketDialog({
|
|
29
|
+
open,
|
|
30
|
+
customers,
|
|
31
|
+
onOpenChange,
|
|
32
|
+
onCreate,
|
|
33
|
+
}: {
|
|
34
|
+
open: boolean;
|
|
35
|
+
customers: Array<{ id: string; name: string }>;
|
|
36
|
+
onOpenChange: (open: boolean) => void;
|
|
37
|
+
onCreate: (draft: TicketDraft) => void | Promise<void>;
|
|
38
|
+
}) {
|
|
39
|
+
const [subject, setSubject] = useState("");
|
|
40
|
+
const [body, setBody] = useState("");
|
|
41
|
+
const [customerId, setCustomerId] = useState("");
|
|
42
|
+
const [priority, setPriority] = useState("normal");
|
|
43
|
+
const [busy, setBusy] = useState(false);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!open) return;
|
|
47
|
+
setSubject("");
|
|
48
|
+
setBody("");
|
|
49
|
+
setCustomerId("");
|
|
50
|
+
setPriority("normal");
|
|
51
|
+
setBusy(false);
|
|
52
|
+
}, [open]);
|
|
53
|
+
|
|
54
|
+
const canSubmit = subject.trim().length > 0 && body.trim().length > 0 && !busy;
|
|
55
|
+
|
|
56
|
+
async function submit(event: React.FormEvent) {
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
if (!canSubmit) return;
|
|
59
|
+
setBusy(true);
|
|
60
|
+
try {
|
|
61
|
+
await onCreate({
|
|
62
|
+
subject: subject.trim(),
|
|
63
|
+
body: body.trim(),
|
|
64
|
+
customerId,
|
|
65
|
+
priority,
|
|
66
|
+
});
|
|
67
|
+
onOpenChange(false);
|
|
68
|
+
} finally {
|
|
69
|
+
setBusy(false);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
75
|
+
<DialogContent>
|
|
76
|
+
<DialogHeader>
|
|
77
|
+
<DialogTitle>New ticket</DialogTitle>
|
|
78
|
+
</DialogHeader>
|
|
79
|
+
<form onSubmit={submit} className="grid gap-4">
|
|
80
|
+
<div className="space-y-1.5">
|
|
81
|
+
<Label htmlFor="ticket-subject">Subject</Label>
|
|
82
|
+
<Input
|
|
83
|
+
id="ticket-subject"
|
|
84
|
+
autoFocus
|
|
85
|
+
value={subject}
|
|
86
|
+
placeholder="Export is timing out on large date ranges"
|
|
87
|
+
onChange={(event) => setSubject(event.target.value)}
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div className="grid grid-cols-2 gap-3">
|
|
92
|
+
<div className="space-y-1.5">
|
|
93
|
+
<Label htmlFor="ticket-customer">Customer</Label>
|
|
94
|
+
<Select
|
|
95
|
+
id="ticket-customer"
|
|
96
|
+
value={customerId}
|
|
97
|
+
onChange={(event) => setCustomerId(event.target.value)}
|
|
98
|
+
>
|
|
99
|
+
<option value="">—</option>
|
|
100
|
+
{customers.map((customer) => (
|
|
101
|
+
<option key={customer.id} value={customer.id}>
|
|
102
|
+
{customer.name}
|
|
103
|
+
</option>
|
|
104
|
+
))}
|
|
105
|
+
</Select>
|
|
106
|
+
</div>
|
|
107
|
+
<div className="space-y-1.5">
|
|
108
|
+
<Label htmlFor="ticket-priority">Priority</Label>
|
|
109
|
+
<Select
|
|
110
|
+
id="ticket-priority"
|
|
111
|
+
value={priority}
|
|
112
|
+
onChange={(event) => setPriority(event.target.value)}
|
|
113
|
+
>
|
|
114
|
+
{PRIORITIES.map((p) => (
|
|
115
|
+
<option key={p.id} value={p.id}>
|
|
116
|
+
{p.label}
|
|
117
|
+
</option>
|
|
118
|
+
))}
|
|
119
|
+
</Select>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div className="space-y-1.5">
|
|
124
|
+
<Label htmlFor="ticket-body">What did they report?</Label>
|
|
125
|
+
<Textarea
|
|
126
|
+
id="ticket-body"
|
|
127
|
+
rows={4}
|
|
128
|
+
value={body}
|
|
129
|
+
placeholder="Pulling a 90-day export spins and eventually errors…"
|
|
130
|
+
onChange={(event) => setBody(event.target.value)}
|
|
131
|
+
className="resize-none"
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<DialogFooter>
|
|
136
|
+
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
|
137
|
+
Cancel
|
|
138
|
+
</Button>
|
|
139
|
+
<Button type="submit" disabled={!canSubmit}>
|
|
140
|
+
{busy ? "Creating…" : "Create ticket"}
|
|
141
|
+
</Button>
|
|
142
|
+
</DialogFooter>
|
|
143
|
+
</form>
|
|
144
|
+
</DialogContent>
|
|
145
|
+
</Dialog>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -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,61 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
import { priorityById, statusById } from "@/lib/tickets";
|
|
4
|
+
|
|
5
|
+
const PRIORITY_DOT: Record<string, string> = {
|
|
6
|
+
urgent: "bg-stage-lost",
|
|
7
|
+
high: "bg-stage-proposal",
|
|
8
|
+
normal: "bg-stage-qualified",
|
|
9
|
+
low: "bg-stage-lead",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Priority as a dot plus a label — readable in a dense row without a chip. */
|
|
13
|
+
export function PriorityBadge({
|
|
14
|
+
priority,
|
|
15
|
+
className,
|
|
16
|
+
}: {
|
|
17
|
+
priority: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
}) {
|
|
20
|
+
const meta = priorityById(priority);
|
|
21
|
+
return (
|
|
22
|
+
<span className={cn("inline-flex items-center gap-1.5 whitespace-nowrap", className)}>
|
|
23
|
+
<span
|
|
24
|
+
className={cn(
|
|
25
|
+
"size-1.5 shrink-0 rounded-full",
|
|
26
|
+
PRIORITY_DOT[priority] ?? "bg-muted-foreground",
|
|
27
|
+
)}
|
|
28
|
+
/>
|
|
29
|
+
<span className="text-muted-foreground">{meta?.label ?? priority}</span>
|
|
30
|
+
</span>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const STATUS_STYLE: Record<string, string> = {
|
|
35
|
+
open: "border-stage-qualified/40 text-stage-qualified",
|
|
36
|
+
pending: "border-stage-proposal/40 text-stage-proposal",
|
|
37
|
+
solved: "border-stage-won/40 text-stage-won",
|
|
38
|
+
closed: "border-border text-muted-foreground",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Status as an outlined chip — it's the field agents filter on most. */
|
|
42
|
+
export function StatusBadge({
|
|
43
|
+
status,
|
|
44
|
+
className,
|
|
45
|
+
}: {
|
|
46
|
+
status: string;
|
|
47
|
+
className?: string;
|
|
48
|
+
}) {
|
|
49
|
+
const meta = statusById(status);
|
|
50
|
+
return (
|
|
51
|
+
<span
|
|
52
|
+
className={cn(
|
|
53
|
+
"inline-flex h-5 items-center rounded border px-1.5 text-[11px] font-medium whitespace-nowrap",
|
|
54
|
+
STATUS_STYLE[status] ?? "border-border text-muted-foreground",
|
|
55
|
+
className,
|
|
56
|
+
)}
|
|
57
|
+
>
|
|
58
|
+
{meta?.label ?? status}
|
|
59
|
+
</span>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Dialog,
|
|
4
|
+
DialogContent,
|
|
5
|
+
DialogFooter,
|
|
6
|
+
DialogHeader,
|
|
7
|
+
DialogTitle,
|
|
8
|
+
} from "@/components/ui/dialog";
|
|
9
|
+
import { Button } from "@/components/ui/button";
|
|
10
|
+
import { Input } from "@/components/ui/input";
|
|
11
|
+
import { Label } from "@/components/ui/label";
|
|
12
|
+
import { Select } from "@/components/ui/select";
|
|
13
|
+
|
|
14
|
+
export interface FieldSpec {
|
|
15
|
+
name: string;
|
|
16
|
+
label: string;
|
|
17
|
+
type?: "text" | "email" | "tel";
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
required?: boolean;
|
|
20
|
+
/** Renders a picker instead of a text input. */
|
|
21
|
+
options?: Array<{ value: string; label: string }>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A create dialog driven by a field list — used for companies and contacts,
|
|
26
|
+
* which differ only in their fields. Deals get their own dialog because value,
|
|
27
|
+
* stage and close date need real layout rather than a stack of text inputs.
|
|
28
|
+
*/
|
|
29
|
+
export function RecordDialog({
|
|
30
|
+
open,
|
|
31
|
+
title,
|
|
32
|
+
fields,
|
|
33
|
+
submitLabel,
|
|
34
|
+
onOpenChange,
|
|
35
|
+
onCreate,
|
|
36
|
+
}: {
|
|
37
|
+
open: boolean;
|
|
38
|
+
title: string;
|
|
39
|
+
fields: FieldSpec[];
|
|
40
|
+
submitLabel: string;
|
|
41
|
+
onOpenChange: (open: boolean) => void;
|
|
42
|
+
onCreate: (values: Record<string, string>) => void | Promise<void>;
|
|
43
|
+
}) {
|
|
44
|
+
const [values, setValues] = useState<Record<string, string>>({});
|
|
45
|
+
const [busy, setBusy] = useState(false);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!open) return;
|
|
49
|
+
setValues({});
|
|
50
|
+
setBusy(false);
|
|
51
|
+
}, [open]);
|
|
52
|
+
|
|
53
|
+
const missing = fields.some(
|
|
54
|
+
(field) => field.required && !(values[field.name] ?? "").trim(),
|
|
55
|
+
);
|
|
56
|
+
const canSubmit = !missing && !busy;
|
|
57
|
+
|
|
58
|
+
async function submit(event: React.FormEvent) {
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
if (!canSubmit) return;
|
|
61
|
+
setBusy(true);
|
|
62
|
+
try {
|
|
63
|
+
const trimmed: Record<string, string> = {};
|
|
64
|
+
for (const [key, value] of Object.entries(values)) {
|
|
65
|
+
const clean = value.trim();
|
|
66
|
+
if (clean) trimmed[key] = clean;
|
|
67
|
+
}
|
|
68
|
+
await onCreate(trimmed);
|
|
69
|
+
onOpenChange(false);
|
|
70
|
+
} finally {
|
|
71
|
+
setBusy(false);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
77
|
+
<DialogContent>
|
|
78
|
+
<DialogHeader>
|
|
79
|
+
<DialogTitle>{title}</DialogTitle>
|
|
80
|
+
</DialogHeader>
|
|
81
|
+
<form onSubmit={submit} className="grid gap-4">
|
|
82
|
+
{fields.map((field, index) => (
|
|
83
|
+
<div key={field.name} className="space-y-1.5">
|
|
84
|
+
<Label htmlFor={`record-${field.name}`}>{field.label}</Label>
|
|
85
|
+
{field.options ? (
|
|
86
|
+
<Select
|
|
87
|
+
id={`record-${field.name}`}
|
|
88
|
+
value={values[field.name] ?? ""}
|
|
89
|
+
onChange={(event) =>
|
|
90
|
+
setValues((v) => ({ ...v, [field.name]: event.target.value }))
|
|
91
|
+
}
|
|
92
|
+
>
|
|
93
|
+
<option value="">—</option>
|
|
94
|
+
{field.options.map((option) => (
|
|
95
|
+
<option key={option.value} value={option.value}>
|
|
96
|
+
{option.label}
|
|
97
|
+
</option>
|
|
98
|
+
))}
|
|
99
|
+
</Select>
|
|
100
|
+
) : (
|
|
101
|
+
<Input
|
|
102
|
+
id={`record-${field.name}`}
|
|
103
|
+
autoFocus={index === 0}
|
|
104
|
+
type={field.type ?? "text"}
|
|
105
|
+
placeholder={field.placeholder}
|
|
106
|
+
value={values[field.name] ?? ""}
|
|
107
|
+
onChange={(event) =>
|
|
108
|
+
setValues((v) => ({ ...v, [field.name]: event.target.value }))
|
|
109
|
+
}
|
|
110
|
+
/>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
))}
|
|
114
|
+
<DialogFooter>
|
|
115
|
+
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
|
116
|
+
Cancel
|
|
117
|
+
</Button>
|
|
118
|
+
<Button type="submit" disabled={!canSubmit}>
|
|
119
|
+
{busy ? "Saving…" : submitLabel}
|
|
120
|
+
</Button>
|
|
121
|
+
</DialogFooter>
|
|
122
|
+
</form>
|
|
123
|
+
</DialogContent>
|
|
124
|
+
</Dialog>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link } from "@pylonsync/react";
|
|
3
|
+
import { Command, Inbox, 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: "Inbox", icon: <Inbox /> },
|
|
17
|
+
{ href: "/customers", label: "Customers", icon: <Users /> },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The fixed left rail. Presentational: the active path, the counts, and the
|
|
22
|
+
* sign-out handler all arrive as props, so it renders identically in a test.
|
|
23
|
+
*/
|
|
24
|
+
export function Sidebar({
|
|
25
|
+
workspace,
|
|
26
|
+
email,
|
|
27
|
+
pathname,
|
|
28
|
+
counts,
|
|
29
|
+
onOpenCommand,
|
|
30
|
+
onSignOut,
|
|
31
|
+
}: {
|
|
32
|
+
workspace: string;
|
|
33
|
+
email: string;
|
|
34
|
+
pathname: string;
|
|
35
|
+
counts?: Record<string, number>;
|
|
36
|
+
onOpenCommand: () => void;
|
|
37
|
+
onSignOut: () => void;
|
|
38
|
+
}) {
|
|
39
|
+
return (
|
|
40
|
+
<aside className="flex w-[224px] shrink-0 flex-col border-r border-border bg-surface-1">
|
|
41
|
+
<div className="flex h-12 items-center gap-2 px-3">
|
|
42
|
+
<span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-primary text-[11px] font-bold text-primary-foreground">
|
|
43
|
+
{workspace.slice(0, 1).toUpperCase()}
|
|
44
|
+
</span>
|
|
45
|
+
<span className="truncate text-[13px] font-semibold">{workspace}</span>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div className="px-2 pb-2">
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
onClick={onOpenCommand}
|
|
52
|
+
className={cn(
|
|
53
|
+
"flex w-full items-center gap-2 rounded-md border border-border bg-background px-2 py-1.5",
|
|
54
|
+
"text-[12px] text-muted-foreground transition-colors hover:border-ring/40 hover:text-foreground",
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
<Command className="size-3.5" />
|
|
58
|
+
<span className="flex-1 text-left">Search…</span>
|
|
59
|
+
<Kbd>⌘K</Kbd>
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<nav className="flex-1 space-y-0.5 px-2" aria-label="Main">
|
|
64
|
+
{NAV.map((item) => {
|
|
65
|
+
const active =
|
|
66
|
+
item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
|
67
|
+
return (
|
|
68
|
+
<Link
|
|
69
|
+
key={item.href}
|
|
70
|
+
href={item.href}
|
|
71
|
+
aria-current={active ? "page" : undefined}
|
|
72
|
+
className={cn(
|
|
73
|
+
"flex h-7 items-center gap-2 rounded-md px-2 text-[13px] transition-colors",
|
|
74
|
+
"[&_svg]:size-3.5 [&_svg]:shrink-0",
|
|
75
|
+
active
|
|
76
|
+
? "bg-surface-2 font-medium text-foreground"
|
|
77
|
+
: "text-muted-foreground hover:bg-surface-2/60 hover:text-foreground",
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
{item.icon}
|
|
81
|
+
<span className="flex-1 truncate">{item.label}</span>
|
|
82
|
+
{counts?.[item.href] ? (
|
|
83
|
+
<span className="tabular text-[11px] text-muted-foreground">
|
|
84
|
+
{counts[item.href]}
|
|
85
|
+
</span>
|
|
86
|
+
) : null}
|
|
87
|
+
</Link>
|
|
88
|
+
);
|
|
89
|
+
})}
|
|
90
|
+
</nav>
|
|
91
|
+
|
|
92
|
+
<div className="border-t border-border p-2">
|
|
93
|
+
<div className="flex items-center gap-2 rounded-md px-1.5 py-1">
|
|
94
|
+
<Avatar name={email} size="sm" />
|
|
95
|
+
<span className="min-w-0 flex-1 truncate text-[12px] text-muted-foreground">
|
|
96
|
+
{email}
|
|
97
|
+
</span>
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
onClick={onSignOut}
|
|
101
|
+
title="Sign out"
|
|
102
|
+
aria-label="Sign out"
|
|
103
|
+
className="rounded p-1 text-muted-foreground transition-colors hover:bg-surface-2 hover:text-foreground"
|
|
104
|
+
>
|
|
105
|
+
<LogOut className="size-3.5" />
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</aside>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
import { duration } from "@/lib/format";
|
|
4
|
+
import { minutesToBreach, slaState, type Ticket } from "@/lib/tickets";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How long is left to reply, or how long we're already late.
|
|
8
|
+
*
|
|
9
|
+
* Renders nothing once a ticket has been answered or closed — a queue where
|
|
10
|
+
* every row carries a badge is a queue where none of them mean anything.
|
|
11
|
+
*/
|
|
12
|
+
export function SlaIndicator({
|
|
13
|
+
ticket,
|
|
14
|
+
now,
|
|
15
|
+
className,
|
|
16
|
+
}: {
|
|
17
|
+
ticket: Ticket;
|
|
18
|
+
/** Injectable so the value is deterministic in a test. */
|
|
19
|
+
now?: number;
|
|
20
|
+
className?: string;
|
|
21
|
+
}) {
|
|
22
|
+
const state = slaState(ticket, now);
|
|
23
|
+
if (state === "none" || state === "met") return null;
|
|
24
|
+
|
|
25
|
+
const minutes = minutesToBreach(ticket, now);
|
|
26
|
+
if (minutes === null) return null;
|
|
27
|
+
|
|
28
|
+
const breached = state === "breached";
|
|
29
|
+
return (
|
|
30
|
+
<span
|
|
31
|
+
title={breached ? "First response overdue" : "First response due"}
|
|
32
|
+
className={cn(
|
|
33
|
+
"tabular inline-flex items-center gap-1 text-[11px] whitespace-nowrap",
|
|
34
|
+
breached ? "text-destructive" : "text-muted-foreground",
|
|
35
|
+
className,
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
<span
|
|
39
|
+
className={cn(
|
|
40
|
+
"size-1.5 rounded-full",
|
|
41
|
+
breached ? "bg-destructive" : "bg-muted-foreground/60",
|
|
42
|
+
)}
|
|
43
|
+
/>
|
|
44
|
+
{breached ? `${duration(minutes)} over` : `${duration(minutes)} left`}
|
|
45
|
+
</span>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Lock } from "lucide-react";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
import { Avatar } from "@/components/avatar";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
7
|
+
import { relativeTime } from "@/lib/format";
|
|
8
|
+
|
|
9
|
+
export interface MessageRow {
|
|
10
|
+
id: string;
|
|
11
|
+
body: string;
|
|
12
|
+
fromCustomer?: boolean | null;
|
|
13
|
+
internal?: boolean | null;
|
|
14
|
+
authorId?: string | null;
|
|
15
|
+
createdAt?: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The conversation, oldest first, with the reply box pinned at the bottom.
|
|
20
|
+
*
|
|
21
|
+
* Internal notes are visually distinct because sending one to the customer by
|
|
22
|
+
* mistake is the expensive error in a helpdesk — the composer says which mode
|
|
23
|
+
* it's in, and the note keeps that marking in the thread forever.
|
|
24
|
+
*/
|
|
25
|
+
export function Thread({
|
|
26
|
+
messages,
|
|
27
|
+
authorName,
|
|
28
|
+
customerName,
|
|
29
|
+
now,
|
|
30
|
+
onSend,
|
|
31
|
+
}: {
|
|
32
|
+
messages: MessageRow[];
|
|
33
|
+
authorName: (id: string | null | undefined) => string | null;
|
|
34
|
+
customerName: string | null;
|
|
35
|
+
now?: number;
|
|
36
|
+
onSend: (body: string, internal: boolean) => void | Promise<void>;
|
|
37
|
+
}) {
|
|
38
|
+
const [body, setBody] = useState("");
|
|
39
|
+
const [internal, setInternal] = useState(false);
|
|
40
|
+
const [busy, setBusy] = useState(false);
|
|
41
|
+
|
|
42
|
+
const ordered = [...messages].sort((a, b) =>
|
|
43
|
+
(a.createdAt ?? "").localeCompare(b.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 onSend(text, internal);
|
|
53
|
+
setBody("");
|
|
54
|
+
} finally {
|
|
55
|
+
setBusy(false);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
61
|
+
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-6 py-5">
|
|
62
|
+
{ordered.length === 0 ? (
|
|
63
|
+
<p className="py-8 text-center text-[12px] text-muted-foreground">
|
|
64
|
+
No messages on this ticket yet.
|
|
65
|
+
</p>
|
|
66
|
+
) : (
|
|
67
|
+
ordered.map((message) => {
|
|
68
|
+
const who = message.fromCustomer
|
|
69
|
+
? customerName
|
|
70
|
+
: authorName(message.authorId);
|
|
71
|
+
return (
|
|
72
|
+
<article
|
|
73
|
+
key={message.id}
|
|
74
|
+
className={cn(
|
|
75
|
+
"rounded-lg border p-3",
|
|
76
|
+
message.internal
|
|
77
|
+
? "border-stage-proposal/30 bg-stage-proposal/5"
|
|
78
|
+
: message.fromCustomer
|
|
79
|
+
? "border-border bg-surface-1"
|
|
80
|
+
: "border-border bg-card",
|
|
81
|
+
)}
|
|
82
|
+
>
|
|
83
|
+
<div className="mb-1.5 flex items-center gap-2 text-[11px] text-muted-foreground">
|
|
84
|
+
<Avatar name={who ?? "?"} size="sm" />
|
|
85
|
+
<span className="font-medium text-foreground">{who ?? "Unknown"}</span>
|
|
86
|
+
{message.internal ? (
|
|
87
|
+
<span className="inline-flex items-center gap-1 text-stage-proposal">
|
|
88
|
+
<Lock className="size-3" />
|
|
89
|
+
Internal note
|
|
90
|
+
</span>
|
|
91
|
+
) : null}
|
|
92
|
+
<time
|
|
93
|
+
className="ml-auto"
|
|
94
|
+
dateTime={message.createdAt ?? undefined}
|
|
95
|
+
>
|
|
96
|
+
{relativeTime(message.createdAt, now)}
|
|
97
|
+
</time>
|
|
98
|
+
</div>
|
|
99
|
+
<p className="whitespace-pre-wrap text-[13px] leading-6">
|
|
100
|
+
{message.body}
|
|
101
|
+
</p>
|
|
102
|
+
</article>
|
|
103
|
+
);
|
|
104
|
+
})
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<form
|
|
109
|
+
onSubmit={submit}
|
|
110
|
+
className={cn(
|
|
111
|
+
"shrink-0 border-t p-3 transition-colors",
|
|
112
|
+
internal ? "border-stage-proposal/40 bg-stage-proposal/5" : "border-border",
|
|
113
|
+
)}
|
|
114
|
+
>
|
|
115
|
+
<Textarea
|
|
116
|
+
value={body}
|
|
117
|
+
rows={3}
|
|
118
|
+
placeholder={internal ? "Internal note — the customer won't see this…" : "Reply to the customer…"}
|
|
119
|
+
aria-label={internal ? "Internal note" : "Reply"}
|
|
120
|
+
onChange={(event) => setBody(event.target.value)}
|
|
121
|
+
onKeyDown={(event) => {
|
|
122
|
+
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) submit(event);
|
|
123
|
+
}}
|
|
124
|
+
className="min-h-20 resize-none"
|
|
125
|
+
/>
|
|
126
|
+
<div className="mt-2 flex items-center justify-between gap-2">
|
|
127
|
+
<label className="flex items-center gap-2 text-[12px] text-muted-foreground">
|
|
128
|
+
<input
|
|
129
|
+
type="checkbox"
|
|
130
|
+
checked={internal}
|
|
131
|
+
onChange={(event) => setInternal(event.target.checked)}
|
|
132
|
+
className="size-3.5 accent-[var(--stage-proposal)]"
|
|
133
|
+
/>
|
|
134
|
+
Internal note
|
|
135
|
+
</label>
|
|
136
|
+
<Button type="submit" size="sm" disabled={!body.trim() || busy}>
|
|
137
|
+
{busy ? "Sending…" : internal ? "Add note" : "Send reply"}
|
|
138
|
+
</Button>
|
|
139
|
+
</div>
|
|
140
|
+
</form>
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|