/** The trend deep-dive — a period of the business and what explains it. */ import { useState } from "react"; import { Text } from "@lotics/ui/text"; import { colors, ramp, solid, tint } from "@lotics/ui/colors"; import { Accordion, AccordionContent, AccordionHeader, AccordionMeta, AccordionTitle, } from "@lotics/ui/accordion"; import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu"; import { Avatar } from "@lotics/ui/avatar"; import { Status } from "@lotics/ui/status"; import { Count } from "@lotics/ui/count"; import { BarChart } from "@lotics/ui/bar_chart"; import { LineChart } from "@lotics/ui/line_chart"; import { PieChart } from "@lotics/ui/pie_chart"; import { Progress } from "@lotics/ui/progress"; import { Sparkline } from "@lotics/ui/sparkline"; import { Trend } from "@lotics/ui/trend"; import { Box } from "@lotics/ui/box"; import { Button } from "@lotics/ui/button"; import { Card, CardHeader, CardHeaderTitle, CardHeaderMeta } from "@lotics/ui/card"; import { DateFilter, type DateFilterFieldLabels } from "@lotics/ui/date_filter"; import { type DateFilterValue } from "@lotics/ui/date_filter"; import { Divider } from "@lotics/ui/divider"; import { Drawer, DrawerScrollArea } from "@lotics/ui/drawer"; import { RowFocusEntry } from "@lotics/ui/row_focus_entry"; import { PressableRow } from "@lotics/ui/pressable_row"; import { ScrollArea } from "@lotics/ui/scroll_area"; import { Stack } from "@lotics/ui/stack"; import { type IconName } from "@lotics/ui/icon"; import { MetricStrip } from "@lotics/ui/metric_strip"; import { Peek } from "@lotics/ui/peek"; import { Funnel } from "@lotics/ui/funnel"; import { SplitRow, SplitPane } from "@lotics/ui/split_row"; import { formatMoney } from "@lotics/ui/format_money"; import { type InkColor } from "@lotics/ui/text_ink"; /** The kit's formatters default to the home market, which abbreviates a * magnitude in that market's own words. This screen reads in English, so every * figure states the tag its captions already use — "₫1.05B", "17.4%". */ const LOCALE = "en-US"; const money = (value: number): string => formatMoney(value, { locale: LOCALE }); // ───────────────────────────────────────────────────────────────────────────── // Template, Dashboard — the executive operations dashboard. One // screen that answers "how is this month going, where is it stuck, who pays // the bills". // // Bands: header + period badge, MetricStrip, revenue by // month (BarChart, emerald = money) next to the sales pipeline // (a split Progress, blue = pipeline), Needs attention (Accordion rows — // icon + label + a Count; expanding opens the records behind the // count, each record carries its domain ⋯ ActionMenu), Top customers // (Avatar + revenue + margin badge; the name Peeks the dossier). // ───────────────────────────────────────────────────────────────────────────── const DOANH_THU_THANG = [ { label: "Jan", month: "2026-01", value: 842 }, { label: "Feb", month: "2026-02", value: 918 }, { label: "Mar", month: "2026-03", value: 1054 }, { label: "Apr", month: "2026-04", value: 986 }, { label: "May", month: "2026-05", value: 1152 }, { label: "Jun", month: "2026-06", value: 1284 }, ]; // Per-period KPI datasets — the date filter actually filters. Custom ranges // outside the mock months keep the latest dataset (mock limitation). const KY_DU_LIEU: Record< string, { doanhThu: number; doanhThuTrend: number; doanhThuCaption: string; bienLai: number; bienLaiTrend: number; bienLaiCaption: string; donDangXuLy: number; donCaption: string; congNo: number; congNoCaption: string; } > = { "2026-04": { doanhThu: 986_000_000, doanhThuTrend: -6, doanhThuCaption: "Mar: ₫1.05B", bienLai: 16.8, bienLaiTrend: -1, bienLaiCaption: "Mar: 17.4%", donDangXuLy: 31, donCaption: "2 orders past delivery date", congNo: 512_000_000, congNoCaption: "8 customers overdue, up 4%", }, "2026-05": { doanhThu: 1_152_000_000, doanhThuTrend: 17, doanhThuCaption: "Apr: ₫986M", bienLai: 17.1, bienLaiTrend: 2, bienLaiCaption: "Apr: 16.8%", donDangXuLy: 34, donCaption: "3 orders past delivery date", congNo: 449_000_000, congNoCaption: "5 customers overdue, down 12%", }, "2026-06": { doanhThu: 1_284_000_000, doanhThuTrend: 11, doanhThuCaption: "May: ₫1.15B", bienLai: 18.4, bienLaiTrend: 1, bienLaiCaption: "May: 17.1%", donDangXuLy: 37, donCaption: "4 orders past delivery date", congNo: 486_000_000, congNoCaption: "6 customers overdue, up 8%", }, }; const KY_CUOI = "2026-06"; const NHAN_BO_LOC: Partial = { today: "Today", yesterday: "Yesterday", tomorrow: "Tomorrow", thisWeek: "This week", thisMonth: "This month", lastMonth: "Last month", from: "From", to: "To", selectDateRange: "Select date range", selectDate: "Select date", clear: "Clear", done: "Done", placeholder: "All time", }; function thangRange(year: number, monthIndex: number): DateFilterValue { return { start: { date: new Date(year, monthIndex, 1), time: null }, end: { date: new Date(year, monthIndex + 1, 0), time: null }, }; } /** "YYYY-MM" when the range is exactly one calendar month, else null. */ function thangKey(value: DateFilterValue): string | null { const s = value.start.date; const e = value.end.date; if (!s || !e) return null; const wholeMonth = s.getDate() === 1 && s.getMonth() === e.getMonth() && s.getFullYear() === e.getFullYear() && e.getDate() === new Date(e.getFullYear(), e.getMonth() + 1, 0).getDate(); return wholeMonth ? `${s.getFullYear()}-${String(s.getMonth() + 1).padStart(2, "0")}` : null; } const PHEU_STAGES = [ { key: "tiepcan", label: "Prospecting", count: 46 }, { key: "baogia", label: "Quote sent", count: 28 }, { key: "damphan", label: "Negotiation", count: 14 }, { key: "chot", label: "Closed won", count: 9 }, ]; // One hue, shaded along the funnel — closed-won (the win) is the strongest. const PHEU_RAMP = ramp("blue", PHEU_STAGES.length).reverse(); const PHEU = PHEU_STAGES.map((p, i) => ({ ...p, color: PHEU_RAMP[i] })); // Margin per month — the LineChart series. const BIEN_LAI_THANG = [ { x: "Jan", y: 15.9 }, { x: "Feb", y: 16.4 }, { x: "Mar", y: 17.4 }, { x: "Apr", y: 16.8 }, { x: "May", y: 17.1 }, { x: "Jun", y: 18.4 }, ]; // Revenue mix by customer industry — the PieChart slices. const CO_CAU = [ { label: "Machining", value: 392, color: solid("blue") }, { label: "Furniture", value: 286, color: solid("emerald") }, { label: "Plastics", value: 248, color: solid("amber") }, { label: "Other", value: 358, color: solid("zinc") }, ]; const KHACH_DAN_DAU = [ { ten: "Atlas Components", nganh: "Export machining", doanhThu: 312_400_000, bienLai: 22, trend: [38, 44, 51, 47, 56, 62] }, { ten: "Crestline Plastics", nganh: "Household plastics", doanhThu: 248_900_000, bienLai: 19, trend: [42, 39, 45, 41, 44, 43] }, { ten: "Brightline Batteries", nganh: "Electrical — batteries", doanhThu: 186_200_000, bienLai: 11, trend: [40, 36, 33, 31, 28, 26] }, { ten: "Vittoria Accessories", nganh: "Bicycle accessories", doanhThu: 154_700_000, bienLai: 24, trend: [18, 22, 25, 24, 29, 33] }, { ten: "Northwind Furniture", nganh: "Export furniture", doanhThu: 121_500_000, bienLai: 9, trend: [25, 23, 20, 22, 19, 17] }, ]; // Each alert carries the records behind its count — pressing the row opens // them inline. The count alone says "6 overdue"; the drill-down answers // "which 6, how bad, what do I open next". Every expanded record gets the // alert's domain actions via its ⋯ menu — no dead rows. interface AlertDetail { key: string; icon: IconName; iconColor: InkColor; label: string; count: number; hint: string; tone?: "warning"; records: { id: string; name: string; value: string; note: string; noteTone: "danger" | "muted" }[]; /** A menu item's `label` is a node in general; every one here is a string, * and the drawer reuses the first as a Button title. */ rowActions: (Omit & { label: string })[]; action: string; } const CAN_CHU_Y: AlertDetail[] = [ { key: "congno", icon: "circle-alert", iconColor: "danger", label: "Receivables overdue past 30 days", count: 6, hint: money(286_500_000), records: [ { id: "INV-2026-0231", name: "Brightline Batteries", value: money(86_400_000), note: "42 days late", noteTone: "danger" }, { id: "INV-2026-0246", name: "Northwind Furniture", value: money(64_200_000), note: "35 days late", noteTone: "danger" }, { id: "INV-2026-0252", name: "Crestline Plastics", value: money(52_800_000), note: "31 days late", noteTone: "danger" }, ], rowActions: [ { key: "nhac", label: "Send payment reminder", icon: "bell" }, { key: "doichieu", label: "Send statement", icon: "mail" }, { key: "mo", label: "Open invoice", icon: "external-link" }, ], action: "Open Receivables", }, { key: "trethoigiao", icon: "package", iconColor: "danger", label: "Orders past delivery date", count: 4, hint: "Earliest overdue since 04/06", records: [ { id: "SO-2026-0418", name: "Atlas Components", value: "Due 04/06", note: "8 days late", noteTone: "danger" }, { id: "SO-2026-0431", name: "Vittoria Accessories", value: "Due 06/06", note: "6 days late", noteTone: "danger" }, { id: "SO-2026-0436", name: "Crestline Plastics", value: "Due 09/06", note: "3 days late", noteTone: "danger" }, ], rowActions: [ { key: "baokhach", label: "Notify customer", icon: "mail" }, { key: "doingay", label: "Reschedule delivery", icon: "calendar" }, { key: "mo", label: "Open order", icon: "external-link" }, ], action: "Open Orders", }, { key: "ketsx", icon: "clock", iconColor: "warning", label: "Work orders stuck over 2 days", count: 2, tone: "warning", hint: "Printing stage", records: [ { id: "WO-2026-0102", name: "Atlas 5-ply box", value: "Printing stage", note: "Stuck 4 days", noteTone: "danger" }, { id: "WO-2026-0107", name: "Crestline A3 carton", value: "Printing stage", note: "Stuck 3 days", noteTone: "muted" }, ], rowActions: [ { key: "nhacto", label: "Notify Printing team", icon: "bell" }, { key: "mo", label: "Open work order", icon: "external-link" }, ], action: "Open Production", }, { key: "baogiacho", icon: "file-text", iconColor: "warning", label: "Quotes awaiting approval", count: 3, tone: "warning", hint: "Send by 13/06", records: [ { id: "QT-2026-0089", name: "Atlas Components", value: money(148_000_000), note: "Waiting 2 days", noteTone: "muted" }, { id: "QT-2026-0091", name: "Eastgate Trading Co.", value: money(96_500_000), note: "Waiting 1 day", noteTone: "muted" }, { id: "QT-2026-0092", name: "Crestline Plastics", value: money(54_200_000), note: "Today", noteTone: "muted" }, ], rowActions: [ { key: "duyet", label: "Approve quote", icon: "check" }, { key: "mo", label: "Open quote", icon: "external-link" }, { key: "tuchoi", label: "Reject", icon: "x", danger: true }, ], action: "Open Quotes", }, ]; // Customer dossier behind the leaderboard name — context glance, the row's // job is reading, not working the record. function KhachPeek({ kh }: { kh: (typeof KHACH_DAN_DAU)[number] }) { return ( {kh.ten} {kh.nganh} {([ ["6-month revenue", money(kh.doanhThu)], ["Margin", `${kh.bienLai}%`], ] as const).map(([label, value]) => ( {label} {value} ))}