@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,210 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { callFn, db, useRouter } from "@pylonsync/react";
|
|
5
|
+
import { useAuth } from "@pylonsync/client";
|
|
6
|
+
import { Sidebar } from "@/components/sidebar";
|
|
7
|
+
import { CommandPalette } from "@/components/command-palette";
|
|
8
|
+
import type { SearchItem } from "@/lib/search";
|
|
9
|
+
import { counts, isOpen } from "@/lib/tickets";
|
|
10
|
+
|
|
11
|
+
export interface CustomerRow {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
email: string;
|
|
15
|
+
company?: string | null;
|
|
16
|
+
createdAt?: string | null;
|
|
17
|
+
}
|
|
18
|
+
export interface TicketRow {
|
|
19
|
+
id: string;
|
|
20
|
+
subject: string;
|
|
21
|
+
status: string;
|
|
22
|
+
priority: string;
|
|
23
|
+
customerId?: string | null;
|
|
24
|
+
assigneeId?: string | null;
|
|
25
|
+
firstRespondedAt?: string | null;
|
|
26
|
+
createdAt?: string | null;
|
|
27
|
+
updatedAt?: string | null;
|
|
28
|
+
}
|
|
29
|
+
export interface MessageRow {
|
|
30
|
+
id: string;
|
|
31
|
+
ticketId: string;
|
|
32
|
+
body: string;
|
|
33
|
+
fromCustomer?: boolean | null;
|
|
34
|
+
internal?: boolean | null;
|
|
35
|
+
authorId?: string | null;
|
|
36
|
+
createdAt?: string | null;
|
|
37
|
+
}
|
|
38
|
+
export interface UserRow {
|
|
39
|
+
id: string;
|
|
40
|
+
email: string;
|
|
41
|
+
displayName?: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface WorkspaceData {
|
|
45
|
+
customers: CustomerRow[];
|
|
46
|
+
tickets: TicketRow[];
|
|
47
|
+
messages: MessageRow[];
|
|
48
|
+
agents: UserRow[];
|
|
49
|
+
customerName: (id: string | null | undefined) => string | null;
|
|
50
|
+
agentName: (id: string | null | undefined) => string | null;
|
|
51
|
+
loading: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The app shell, and the ONLY place that touches `db`.
|
|
56
|
+
*
|
|
57
|
+
* Every view below receives plain data through the render prop and reports
|
|
58
|
+
* changes through callbacks, which is what lets the components in `components/`
|
|
59
|
+
* render from fixtures in a test with nothing mocked.
|
|
60
|
+
*
|
|
61
|
+
* Each `db.useQuery` is live: a ticket another agent assigns, or a reply they
|
|
62
|
+
* send, arrives here and re-renders the queue. Your own writes take the same
|
|
63
|
+
* path, so there's one behaviour to reason about rather than two.
|
|
64
|
+
*/
|
|
65
|
+
export function Workspace({
|
|
66
|
+
email,
|
|
67
|
+
pathname,
|
|
68
|
+
children,
|
|
69
|
+
}: {
|
|
70
|
+
email: string;
|
|
71
|
+
pathname: string;
|
|
72
|
+
children: (data: WorkspaceData) => React.ReactNode;
|
|
73
|
+
}) {
|
|
74
|
+
const router = useRouter();
|
|
75
|
+
const { signOut } = useAuth();
|
|
76
|
+
const { data: tickets, loading } = db.useQuery<TicketRow>("Ticket");
|
|
77
|
+
const { data: customers } = db.useQuery<CustomerRow>("Customer");
|
|
78
|
+
const { data: messages } = db.useQuery<MessageRow>("Message");
|
|
79
|
+
const { data: users } = db.useQuery<UserRow>("User");
|
|
80
|
+
|
|
81
|
+
const [paletteOpen, setPaletteOpen] = useState(false);
|
|
82
|
+
const seeded = useRef(false);
|
|
83
|
+
|
|
84
|
+
const ticketList = tickets ?? [];
|
|
85
|
+
const customerList = customers ?? [];
|
|
86
|
+
const agentList = users ?? [];
|
|
87
|
+
|
|
88
|
+
// A brand-new helpdesk gets a realistic queue once, so the inbox never opens
|
|
89
|
+
// empty. `seedWorkspace` no-ops when anything exists; the ref stops a
|
|
90
|
+
// re-render from firing a second call mid-flight.
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (loading || seeded.current) return;
|
|
93
|
+
if (ticketList.length > 0) {
|
|
94
|
+
seeded.current = true;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
seeded.current = true;
|
|
98
|
+
callFn("seedWorkspace", {}).catch(() => {
|
|
99
|
+
// A failed seed leaves an empty inbox, which is usable — the queue has an
|
|
100
|
+
// empty state that offers to open the first ticket.
|
|
101
|
+
seeded.current = false;
|
|
102
|
+
});
|
|
103
|
+
}, [loading, ticketList.length]);
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
function onKey(event: KeyboardEvent) {
|
|
107
|
+
const target = event.target as HTMLElement | null;
|
|
108
|
+
const typing =
|
|
109
|
+
!!target &&
|
|
110
|
+
(target.tagName === "INPUT" ||
|
|
111
|
+
target.tagName === "TEXTAREA" ||
|
|
112
|
+
target.isContentEditable);
|
|
113
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
setPaletteOpen((open) => !open);
|
|
116
|
+
} else if (event.key === "/" && !typing) {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
setPaletteOpen(true);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
window.addEventListener("keydown", onKey);
|
|
122
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
123
|
+
}, []);
|
|
124
|
+
|
|
125
|
+
const lookups = useMemo(() => {
|
|
126
|
+
const customerById = new Map(customerList.map((c) => [c.id, c] as const));
|
|
127
|
+
const userById = new Map(agentList.map((u) => [u.id, u] as const));
|
|
128
|
+
return { customerById, userById };
|
|
129
|
+
}, [customerList, agentList]);
|
|
130
|
+
|
|
131
|
+
const searchIndex = useMemo<SearchItem[]>(
|
|
132
|
+
() => [
|
|
133
|
+
...ticketList.map((ticket) => ({
|
|
134
|
+
id: `ticket:${ticket.id}`,
|
|
135
|
+
type: "deal" as const,
|
|
136
|
+
title: ticket.subject,
|
|
137
|
+
subtitle: lookups.customerById.get(ticket.customerId ?? "")?.name,
|
|
138
|
+
href: `/tickets/${ticket.id}`,
|
|
139
|
+
})),
|
|
140
|
+
...customerList.map((customer) => ({
|
|
141
|
+
id: `customer:${customer.id}`,
|
|
142
|
+
type: "contact" as const,
|
|
143
|
+
title: customer.name,
|
|
144
|
+
subtitle: customer.company ?? undefined,
|
|
145
|
+
href: "/customers",
|
|
146
|
+
keywords: customer.email,
|
|
147
|
+
})),
|
|
148
|
+
],
|
|
149
|
+
[ticketList, customerList, lookups],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const queue = counts(ticketList);
|
|
153
|
+
|
|
154
|
+
const data: WorkspaceData = {
|
|
155
|
+
customers: customerList,
|
|
156
|
+
tickets: ticketList,
|
|
157
|
+
messages: messages ?? [],
|
|
158
|
+
agents: agentList,
|
|
159
|
+
customerName: (id) => lookups.customerById.get(id ?? "")?.name ?? null,
|
|
160
|
+
agentName: (id) => {
|
|
161
|
+
const user = lookups.userById.get(id ?? "");
|
|
162
|
+
return user?.displayName || user?.email || null;
|
|
163
|
+
},
|
|
164
|
+
loading,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<div className="flex h-screen overflow-hidden">
|
|
169
|
+
<Sidebar
|
|
170
|
+
workspace="Helpdesk"
|
|
171
|
+
email={email}
|
|
172
|
+
pathname={pathname}
|
|
173
|
+
counts={{
|
|
174
|
+
"/": ticketList.filter(isOpen).length,
|
|
175
|
+
"/customers": customerList.length,
|
|
176
|
+
}}
|
|
177
|
+
onOpenCommand={() => setPaletteOpen(true)}
|
|
178
|
+
onSignOut={async () => {
|
|
179
|
+
await signOut();
|
|
180
|
+
window.location.assign("/login");
|
|
181
|
+
}}
|
|
182
|
+
/>
|
|
183
|
+
<main className="flex min-w-0 flex-1 flex-col">{children(data)}</main>
|
|
184
|
+
|
|
185
|
+
<CommandPalette
|
|
186
|
+
open={paletteOpen}
|
|
187
|
+
items={searchIndex}
|
|
188
|
+
actions={[
|
|
189
|
+
{
|
|
190
|
+
id: "new-ticket",
|
|
191
|
+
label: "New ticket",
|
|
192
|
+
run: () => router.push("/?new=ticket"),
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
id: "unassigned",
|
|
196
|
+
label: `Unassigned (${queue.unassigned})`,
|
|
197
|
+
run: () => router.push("/?filter=unassigned"),
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: "go-customers",
|
|
201
|
+
label: "Go to Customers",
|
|
202
|
+
run: () => router.push("/customers"),
|
|
203
|
+
},
|
|
204
|
+
]}
|
|
205
|
+
onClose={() => setPaletteOpen(false)}
|
|
206
|
+
onSelect={(item) => router.push(item.href)}
|
|
207
|
+
/>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {
|
|
2
|
+
entity,
|
|
3
|
+
field,
|
|
4
|
+
policy,
|
|
5
|
+
auth,
|
|
6
|
+
buildManifest,
|
|
7
|
+
discoverAppRoutes,
|
|
8
|
+
font,
|
|
9
|
+
} from "@pylonsync/sdk";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// helpdesk — a support inbox for a small team: customers write in, agents triage
|
|
13
|
+
// by priority and SLA, and every reply lands on a shared thread.
|
|
14
|
+
//
|
|
15
|
+
// The realtime hook: the queue subscribes to Ticket, so a new ticket appears at
|
|
16
|
+
// the top of everyone's inbox as it arrives, and an assignment shows up before
|
|
17
|
+
// two agents start the same reply.
|
|
18
|
+
//
|
|
19
|
+
// • Customer — the person who wrote in. Holds email, so never public.
|
|
20
|
+
// • Ticket — subject, status, priority, assignee. The queue.
|
|
21
|
+
// • Message — one entry on a thread; `internal` marks an agent-only note.
|
|
22
|
+
// • User — an agent (email/password).
|
|
23
|
+
//
|
|
24
|
+
// TRUST MODEL: an internal tool for ONE support team. Every entity is readable
|
|
25
|
+
// and writable by any SIGNED-IN user and by nobody else. There is no customer
|
|
26
|
+
// portal here — adding one means a separate, narrower policy scoped to the
|
|
27
|
+
// customer's own tickets, NOT loosening these.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
const TEAM = "auth.userId != null";
|
|
31
|
+
|
|
32
|
+
const Customer = entity(
|
|
33
|
+
"Customer",
|
|
34
|
+
{
|
|
35
|
+
name: field.string(),
|
|
36
|
+
email: field.string(),
|
|
37
|
+
company: field.string().optional(),
|
|
38
|
+
createdAt: field.datetime().defaultNow(),
|
|
39
|
+
},
|
|
40
|
+
{ indexes: [{ name: "by_email", fields: ["email"], unique: false }] },
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const Ticket = entity(
|
|
44
|
+
"Ticket",
|
|
45
|
+
{
|
|
46
|
+
subject: field.string(),
|
|
47
|
+
// "open" | "pending" | "solved" | "closed" — see STATUSES in lib/tickets.ts.
|
|
48
|
+
status: field.string().default("open"),
|
|
49
|
+
// "urgent" | "high" | "normal" | "low" — sets the first-response window.
|
|
50
|
+
priority: field.string().default("normal"),
|
|
51
|
+
customerId: field.id("Customer").optional(),
|
|
52
|
+
assigneeId: field.id("User").optional(),
|
|
53
|
+
// When an agent first replied. Null means the customer is still waiting,
|
|
54
|
+
// which is what the SLA state is computed from.
|
|
55
|
+
firstRespondedAt: field.datetime().optional(),
|
|
56
|
+
createdAt: field.datetime().defaultNow(),
|
|
57
|
+
updatedAt: field.datetime().defaultNow(),
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
indexes: [
|
|
61
|
+
{ name: "by_status", fields: ["status"], unique: false },
|
|
62
|
+
{ name: "by_assignee", fields: ["assigneeId"], unique: false },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const Message = entity(
|
|
68
|
+
"Message",
|
|
69
|
+
{
|
|
70
|
+
ticketId: field.id("Ticket"),
|
|
71
|
+
body: field.string(),
|
|
72
|
+
// True when the customer wrote it rather than an agent.
|
|
73
|
+
fromCustomer: field.boolean().default(false),
|
|
74
|
+
// An internal note stays with the team if you later add a customer portal.
|
|
75
|
+
internal: field.boolean().default(false),
|
|
76
|
+
authorId: field.id("User").readonly().optional(),
|
|
77
|
+
createdAt: field.datetime().defaultNow(),
|
|
78
|
+
},
|
|
79
|
+
{ indexes: [{ name: "by_ticket", fields: ["ticketId"], unique: false }] },
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const User = entity(
|
|
83
|
+
"User",
|
|
84
|
+
{
|
|
85
|
+
email: field.string(),
|
|
86
|
+
displayName: field.string().optional(),
|
|
87
|
+
passwordHash: field.string().serverOnly().optional(),
|
|
88
|
+
avatarColor: field.string().optional(),
|
|
89
|
+
emailVerified: field.datetime().optional(),
|
|
90
|
+
createdAt: field.datetime().defaultNow(),
|
|
91
|
+
},
|
|
92
|
+
{ indexes: [{ name: "by_email", fields: ["email"], unique: true }] },
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const teamPolicy = (name: string, entityName: string) =>
|
|
96
|
+
policy({
|
|
97
|
+
name,
|
|
98
|
+
entity: entityName,
|
|
99
|
+
allowRead: TEAM,
|
|
100
|
+
allowInsert: TEAM,
|
|
101
|
+
allowUpdate: TEAM,
|
|
102
|
+
allowDelete: TEAM,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const userPolicy = policy({
|
|
106
|
+
name: "user_team_read",
|
|
107
|
+
entity: "User",
|
|
108
|
+
allowRead: TEAM,
|
|
109
|
+
allowInsert: "false",
|
|
110
|
+
allowUpdate: "false",
|
|
111
|
+
allowDelete: "false",
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const manifest = buildManifest({
|
|
115
|
+
name: "__APP_NAME__",
|
|
116
|
+
version: "0.1.0",
|
|
117
|
+
entities: [Customer, Ticket, Message, User],
|
|
118
|
+
queries: [],
|
|
119
|
+
actions: [],
|
|
120
|
+
policies: [
|
|
121
|
+
teamPolicy("customer_team", "Customer"),
|
|
122
|
+
teamPolicy("ticket_team", "Ticket"),
|
|
123
|
+
teamPolicy("message_team", "Message"),
|
|
124
|
+
userPolicy,
|
|
125
|
+
],
|
|
126
|
+
auth: auth(),
|
|
127
|
+
fonts: [
|
|
128
|
+
font({
|
|
129
|
+
family: "Inter",
|
|
130
|
+
variable: "--font-sans",
|
|
131
|
+
weights: ["400", "500", "600", "700"],
|
|
132
|
+
subsets: ["latin"],
|
|
133
|
+
display: "swap",
|
|
134
|
+
preload: true,
|
|
135
|
+
}),
|
|
136
|
+
],
|
|
137
|
+
routes: await discoverAppRoutes(),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
141
|
+
|
|
142
|
+
export default manifest;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
import { accentIndex, initials } from "@/lib/format";
|
|
4
|
+
|
|
5
|
+
// Deterministic accent per record — the same company keeps its colour across
|
|
6
|
+
// sessions and machines without storing one. Hues are muted so a dense list
|
|
7
|
+
// doesn't turn into confetti.
|
|
8
|
+
const ACCENTS = [
|
|
9
|
+
"bg-indigo-500/15 text-indigo-500 dark:text-indigo-300",
|
|
10
|
+
"bg-emerald-500/15 text-emerald-600 dark:text-emerald-300",
|
|
11
|
+
"bg-amber-500/15 text-amber-600 dark:text-amber-300",
|
|
12
|
+
"bg-rose-500/15 text-rose-500 dark:text-rose-300",
|
|
13
|
+
"bg-sky-500/15 text-sky-600 dark:text-sky-300",
|
|
14
|
+
"bg-violet-500/15 text-violet-500 dark:text-violet-300",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function Avatar({
|
|
18
|
+
name,
|
|
19
|
+
size = "md",
|
|
20
|
+
className,
|
|
21
|
+
}: {
|
|
22
|
+
name: string | null | undefined;
|
|
23
|
+
size?: "sm" | "md";
|
|
24
|
+
className?: string;
|
|
25
|
+
}) {
|
|
26
|
+
const label = initials(name);
|
|
27
|
+
return (
|
|
28
|
+
<span
|
|
29
|
+
aria-hidden="true"
|
|
30
|
+
className={cn(
|
|
31
|
+
"inline-flex shrink-0 select-none items-center justify-center rounded-full font-medium",
|
|
32
|
+
size === "sm" ? "size-5 text-[9px]" : "size-6 text-[10px]",
|
|
33
|
+
ACCENTS[accentIndex(name ?? "?", ACCENTS.length)],
|
|
34
|
+
className,
|
|
35
|
+
)}
|
|
36
|
+
>
|
|
37
|
+
{label}
|
|
38
|
+
</span>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Building2, Plus, Search, User } from "lucide-react";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
import { Kbd } from "@/components/kbd";
|
|
5
|
+
import { moveSelection, searchItems, type SearchItem } from "@/lib/search";
|
|
6
|
+
|
|
7
|
+
const TYPE_ICON: Record<SearchItem["type"], React.ReactNode> = {
|
|
8
|
+
deal: <Search />,
|
|
9
|
+
company: <Building2 />,
|
|
10
|
+
contact: <User />,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export interface CommandAction {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
run: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* ⌘K. Searches the synced replica in memory, so results land as you type with
|
|
21
|
+
* no request per keystroke — see lib/search.ts for the ranking.
|
|
22
|
+
*
|
|
23
|
+
* Presentational: items and actions come in as props, selection is delegated
|
|
24
|
+
* through onSelect. Keyboard handling lives here because it belongs to the
|
|
25
|
+
* widget, not to the page that opened it.
|
|
26
|
+
*/
|
|
27
|
+
export function CommandPalette({
|
|
28
|
+
open,
|
|
29
|
+
items,
|
|
30
|
+
actions = [],
|
|
31
|
+
onClose,
|
|
32
|
+
onSelect,
|
|
33
|
+
}: {
|
|
34
|
+
open: boolean;
|
|
35
|
+
items: SearchItem[];
|
|
36
|
+
actions?: CommandAction[];
|
|
37
|
+
onClose: () => void;
|
|
38
|
+
onSelect: (item: SearchItem) => void;
|
|
39
|
+
}) {
|
|
40
|
+
const [query, setQuery] = useState("");
|
|
41
|
+
const [index, setIndex] = useState(0);
|
|
42
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
43
|
+
|
|
44
|
+
const results = useMemo(() => searchItems(items, query), [items, query]);
|
|
45
|
+
const shownActions = query.trim() ? [] : actions;
|
|
46
|
+
const rows = useMemo(
|
|
47
|
+
() => [
|
|
48
|
+
...shownActions.map((a) => ({ kind: "action" as const, action: a })),
|
|
49
|
+
...results.map((item) => ({ kind: "item" as const, item })),
|
|
50
|
+
],
|
|
51
|
+
[shownActions, results],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!open) return;
|
|
56
|
+
setQuery("");
|
|
57
|
+
setIndex(0);
|
|
58
|
+
// Focus after paint so the dialog is mounted and the caret lands.
|
|
59
|
+
const id = requestAnimationFrame(() => inputRef.current?.focus());
|
|
60
|
+
return () => cancelAnimationFrame(id);
|
|
61
|
+
}, [open]);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
setIndex(0);
|
|
65
|
+
}, [query]);
|
|
66
|
+
|
|
67
|
+
if (!open) return null;
|
|
68
|
+
|
|
69
|
+
function commit(at: number) {
|
|
70
|
+
const row = rows[at];
|
|
71
|
+
if (!row) return;
|
|
72
|
+
if (row.kind === "action") row.action.run();
|
|
73
|
+
else onSelect(row.item);
|
|
74
|
+
onClose();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div
|
|
79
|
+
className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[12vh]"
|
|
80
|
+
// Clicking the backdrop dismisses; clicks inside the panel must not.
|
|
81
|
+
onMouseDown={(event) => {
|
|
82
|
+
if (event.target === event.currentTarget) onClose();
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
<div
|
|
86
|
+
role="dialog"
|
|
87
|
+
aria-modal="true"
|
|
88
|
+
aria-label="Search"
|
|
89
|
+
className="w-full max-w-lg overflow-hidden rounded-xl border border-border bg-popover shadow-2xl"
|
|
90
|
+
onKeyDown={(event) => {
|
|
91
|
+
if (event.key === "Escape") {
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
onClose();
|
|
94
|
+
} else if (event.key === "ArrowDown") {
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
setIndex((i) => moveSelection(i, 1, rows.length));
|
|
97
|
+
} else if (event.key === "ArrowUp") {
|
|
98
|
+
event.preventDefault();
|
|
99
|
+
setIndex((i) => moveSelection(i, -1, rows.length));
|
|
100
|
+
} else if (event.key === "Enter") {
|
|
101
|
+
event.preventDefault();
|
|
102
|
+
commit(index);
|
|
103
|
+
}
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
<div className="flex items-center gap-2 border-b border-border px-3">
|
|
107
|
+
<Search className="size-4 shrink-0 text-muted-foreground" />
|
|
108
|
+
<input
|
|
109
|
+
ref={inputRef}
|
|
110
|
+
value={query}
|
|
111
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
112
|
+
placeholder="Search deals, companies, contacts…"
|
|
113
|
+
aria-label="Search deals, companies and contacts"
|
|
114
|
+
className="h-11 flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground"
|
|
115
|
+
/>
|
|
116
|
+
<Kbd>esc</Kbd>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<div className="max-h-80 overflow-y-auto p-1.5">
|
|
120
|
+
{rows.length === 0 ? (
|
|
121
|
+
<p className="px-3 py-6 text-center text-[12px] text-muted-foreground">
|
|
122
|
+
No matches for “{query}”
|
|
123
|
+
</p>
|
|
124
|
+
) : (
|
|
125
|
+
rows.map((row, at) => (
|
|
126
|
+
<button
|
|
127
|
+
key={row.kind === "action" ? row.action.id : row.item.id}
|
|
128
|
+
type="button"
|
|
129
|
+
// Pointer hover moves selection so mouse and keyboard agree.
|
|
130
|
+
onMouseMove={() => setIndex(at)}
|
|
131
|
+
onClick={() => commit(at)}
|
|
132
|
+
aria-selected={at === index}
|
|
133
|
+
className={cn(
|
|
134
|
+
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left text-[13px] transition-colors",
|
|
135
|
+
"[&_svg]:size-3.5 [&_svg]:shrink-0 [&_svg]:text-muted-foreground",
|
|
136
|
+
at === index ? "bg-surface-2" : "hover:bg-surface-2/60",
|
|
137
|
+
)}
|
|
138
|
+
>
|
|
139
|
+
{row.kind === "action" ? (
|
|
140
|
+
<>
|
|
141
|
+
<Plus />
|
|
142
|
+
<span className="flex-1 truncate">{row.action.label}</span>
|
|
143
|
+
</>
|
|
144
|
+
) : (
|
|
145
|
+
<>
|
|
146
|
+
{TYPE_ICON[row.item.type]}
|
|
147
|
+
<span className="flex-1 truncate">{row.item.title}</span>
|
|
148
|
+
{row.item.subtitle ? (
|
|
149
|
+
<span className="truncate text-[12px] text-muted-foreground">
|
|
150
|
+
{row.item.subtitle}
|
|
151
|
+
</span>
|
|
152
|
+
) : null}
|
|
153
|
+
</>
|
|
154
|
+
)}
|
|
155
|
+
</button>
|
|
156
|
+
))
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
export interface ColumnDef<Row> {
|
|
5
|
+
key: string;
|
|
6
|
+
header: string;
|
|
7
|
+
/** Cell content. Return a string and it's rendered as-is. */
|
|
8
|
+
cell: (row: Row) => React.ReactNode;
|
|
9
|
+
/** Right-align and tabular-figure a numeric column. */
|
|
10
|
+
numeric?: boolean;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A dense list view. Rows are 36px so a screenful is a screenful of data, and
|
|
16
|
+
* the header sticks while the body scrolls.
|
|
17
|
+
*
|
|
18
|
+
* Generic and presentational — every view here uses the same one, which is why
|
|
19
|
+
* companies and contacts look like the same product.
|
|
20
|
+
*/
|
|
21
|
+
export function DataTable<Row extends { id: string }>({
|
|
22
|
+
rows,
|
|
23
|
+
columns,
|
|
24
|
+
onRowClick,
|
|
25
|
+
empty,
|
|
26
|
+
}: {
|
|
27
|
+
rows: Row[];
|
|
28
|
+
columns: ColumnDef<Row>[];
|
|
29
|
+
onRowClick?: (row: Row) => void;
|
|
30
|
+
empty?: React.ReactNode;
|
|
31
|
+
}) {
|
|
32
|
+
if (rows.length === 0 && empty) return <>{empty}</>;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
36
|
+
<table className="w-full border-collapse text-[13px]">
|
|
37
|
+
<thead className="sticky top-0 z-10 bg-background">
|
|
38
|
+
<tr className="hairline">
|
|
39
|
+
{columns.map((column) => (
|
|
40
|
+
<th
|
|
41
|
+
key={column.key}
|
|
42
|
+
scope="col"
|
|
43
|
+
className={cn(
|
|
44
|
+
"h-8 px-4 text-left text-[11px] font-medium text-muted-foreground",
|
|
45
|
+
column.numeric && "text-right",
|
|
46
|
+
column.className,
|
|
47
|
+
)}
|
|
48
|
+
>
|
|
49
|
+
{column.header}
|
|
50
|
+
</th>
|
|
51
|
+
))}
|
|
52
|
+
</tr>
|
|
53
|
+
</thead>
|
|
54
|
+
<tbody>
|
|
55
|
+
{rows.map((row) => (
|
|
56
|
+
<tr
|
|
57
|
+
key={row.id}
|
|
58
|
+
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
59
|
+
tabIndex={onRowClick ? 0 : undefined}
|
|
60
|
+
onKeyDown={
|
|
61
|
+
onRowClick
|
|
62
|
+
? (event) => {
|
|
63
|
+
if (event.key === "Enter") onRowClick(row);
|
|
64
|
+
}
|
|
65
|
+
: undefined
|
|
66
|
+
}
|
|
67
|
+
className={cn(
|
|
68
|
+
"border-b border-border/60 transition-colors",
|
|
69
|
+
onRowClick && "cursor-pointer hover:bg-surface-1",
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
{columns.map((column) => (
|
|
73
|
+
<td
|
|
74
|
+
key={column.key}
|
|
75
|
+
className={cn(
|
|
76
|
+
"h-9 max-w-0 truncate px-4",
|
|
77
|
+
column.numeric && "tabular text-right",
|
|
78
|
+
column.className,
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
{column.cell(row)}
|
|
82
|
+
</td>
|
|
83
|
+
))}
|
|
84
|
+
</tr>
|
|
85
|
+
))}
|
|
86
|
+
</tbody>
|
|
87
|
+
</table>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,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
|
+
}
|