/** * Showcase · case6 — 代理店ハンディ (Agency Handy, mobile) * * The Acme consolidation-warehouse handheld app (390×844, gloves-on, scan-heavy), * rebuilt ENTIRELY from real @godxjp/ui primitives — the reference-design "Handy Agency" * design recreated as a skeleton (intent + look), not a transcription of its prototype DOM. * * Composition map (Handy prototype block → @godxjp/ui primitive): * phone shell ................. composed frame (status bar / header / scroll / sticky bar / tabbar) * header text-action .......... Button(ghost, size=sm) — iOS "Chọn" select-mode entry * filter chips ................ ToggleGroup(type=single) — 32px count pills * outbound segmented .......... ToggleGroup(type=single) — Chờ niêm phong / Chờ bàn giao / Đã bàn giao * list-card recipe ............ Card + composed 3-line row + Badge(tone) + select-mode Checkbox * status badge (1:1) .......... Badge tone — attention/info/muted/success/warning, color-locked * select-mode bar ............. primary-soft toolbar strip (count + actions) above tabbar * big scan Button ............. sticky-actions primary "Quét / Tìm mã" (flex-2, dominant) * ItemLookupSheet ............. Sheet(side=bottom) — viewfinder placeholder + manual mono input * package-picker Sheet ........ Sheet(side=bottom) — list of PKG rows + create-new row * ItemFormModal Sheet ......... Sheet(side=bottom) — barcode + qty + radio-row "Đích đến" * detail rows ................. Descriptions + Descriptions.Item(mono) — item detail * confirm Dialog .............. AlertDialog — Seal / Handoff (verb confirm, not "OK") * toast ....................... Toaster + toast.success — "Đã bàn giao … · 16:24" * four list states ............ Loading(Skeleton) · Empty(EmptyState) · Error(Alert) · populated * * DNA applied: comfortable 44px touch density, mono codes (RC-/JAN/PKG-/A-03-02), * fixed color signaling (attention 朱 = Chưa phân loại + fixable errors, info 群青 = open, * muted = in-package/draft, success 若竹 = sealed, warning 山吹 = delivered), tabular-nums, * scan-first (primary action widest), bottom-sheet-driven, vi-first quiet copy, no emoji. */ import * as React from "react"; import { Boxes, Check, Inbox, Package, PackagePlus, Plus, RefreshCw, ScanLine, Search, Truck, X, } from "lucide-react"; import { Button, Heading, Text } from "@godxjp/ui/general"; import { Flex, MobileShell, ResponsiveGrid } from "@godxjp/ui/layout"; import { Badge, type BadgeProps, Card, CardContent, Descriptions, EmptyState, } from "@godxjp/ui/data-display"; import { Checkbox, Input, RadioGroup, ToggleGroup, ToggleGroupItem } from "@godxjp/ui/data-entry"; import { AlertDialog, Alert, AlertTitle, AlertDescription, Sheet, SheetBody, SheetContent, SheetFooter, SheetHeader, SheetTitle, Skeleton, Toaster, toast, } from "@godxjp/ui/feedback"; type BadgeTone = NonNullable; // ── Status vocabulary — 1:1 color-locked (Handy §6b: "same word in different colors is a bug") ── type ItemStatus = "unsorted" | "shelf" | "packed"; const ITEM_STATUS: Record = { unsorted: { label: "Chưa phân loại", tone: "destructive" }, // 朱 attention via destructive-adjacent? -> use warning? see note shelf: { label: "Trên giá hàng", tone: "info" }, // 群青 open packed: { label: "Trong kiện", tone: "muted" }, // draft / in-package }; // Attention (朱) is the fixable-but-needs-action signal. Badge tones expose // success/warning/destructive/info/muted/neutral — there is no dedicated "attention" // tone, so non-destructive "Chưa phân loại" maps to warning (山吹), reserving // destructive (茜) for the irreversible logout confirm only. (see gapNotes) ITEM_STATUS.unsorted.tone = "warning"; type PackingStatus = "active" | "open" | "draft" | "sealed" | "delivered"; const PACKING_STATUS: Record = { active: { label: "Đang làm", tone: "info" }, open: { label: "Đang mở", tone: "info" }, draft: { label: "Nháp", tone: "muted" }, sealed: { label: "Đã niêm phong", tone: "success" }, delivered: { label: "Đã bàn giao", tone: "muted" }, }; // ── Mock data ─────────────────────────────────────────────────────────────── interface HandyItem { id: string; name: string; rc: string; jan: string; status: ItemStatus; qty: number; receivedAt: string; } const ITEMS: HandyItem[] = [ { id: "1", name: "Sữa rửa mặt Hada Labo", rc: "RC-204881", jan: "4987241135219", status: "unsorted", qty: 3, receivedAt: "14:02", }, { id: "2", name: "Kem chống nắng Anessa", rc: "RC-204882", jan: "4909978141004", status: "shelf", qty: 1, receivedAt: "13:48", }, { id: "3", name: "Vitamin DHC B-Complex", rc: "RC-204879", jan: "4511413404164", status: "shelf", qty: 2, receivedAt: "13:31", }, { id: "4", name: "Bàn chải Ora2 Me", rc: "RC-204875", jan: "4903301242697", status: "packed", qty: 4, receivedAt: "11:20", }, ]; interface Packing { id: string; code: string; customer: string; city: string; items: number; status: PackingStatus; slot?: string; } const PACKINGS: Packing[] = [ { id: "p1", code: "PKG-000041", customer: "Bùi Hà", city: "Hà Nội", items: 8, status: "active", slot: "A-03-02", }, { id: "p2", code: "PKG-000040", customer: "Lê Minh", city: "TP.HCM", items: 5, status: "open" }, { id: "p3", code: "PKG-000038", customer: "Trần Linh", city: "Đà Nẵng", items: 12, status: "draft", }, ]; const OUTBOUND: Packing[] = [ { id: "o1", code: "PKG-000037", customer: "Phạm An", city: "Hà Nội", items: 9, status: "sealed", slot: "B-01-04", }, { id: "o2", code: "PKG-000036", customer: "Vũ Nga", city: "TP.HCM", items: 6, status: "sealed", slot: "B-02-01", }, ]; const TABS = [ { id: "inbound", label: "Nhập kho", icon: Inbox }, { id: "packing", label: "Đóng gói", icon: Package }, { id: "outbound", label: "Xuất kho", icon: Truck }, ] as const; const FILTERS = [ { id: "all", label: "Tất cả", count: 42 }, { id: "unsorted", label: "Chưa phân loại", count: 9 }, { id: "shelf", label: "Trên giá hàng", count: 33 }, ] as const; // ── Small composed parts ───────────────────────────────────────────────────── function MonoCode({ children }: { children: React.ReactNode }) { return ( {children} ); } /** The Handy list-card recipe: full-tap row, name + mono codes + badge/meta. */ function ItemListCard({ item, selectMode, selected, onToggle, }: { item: HandyItem; selectMode: boolean; selected: boolean; onToggle: () => void; }) { const st = ITEM_STATUS[item.status]; return ( {selectMode ? ( ) : null} {item.name} {item.rc} JAN {item.jan} {st.label} ×{item.qty} · {item.receivedAt} ); } function PackingListCard({ packing, onTap }: { packing: Packing; onTap?: () => void }) { const st = PACKING_STATUS[packing.status]; return ( {packing.code} {st.label} {packing.customer} · {packing.city} ×{packing.items} {packing.slot ? ` · ${packing.slot}` : ""} ); } /** Three uppercase muted section header + right-aligned mono count. */ function SectionHeader({ children, count }: { children: React.ReactNode; count?: number }) { return ( {children} {count != null ? ( {count} ) : null} ); } // ── Phone shell ────────────────────────────────────────────────────────────── /** * This file used to BUILD the shell: a `Card` + `CardContent flush` phone frame, a * `ui-card-inset-x h-9` status row, an `h-14 border-b` header, a `flex-1 overflow-y-auto` body, a * `shrink-0 border-t` action strip and a `ResponsiveGrid` tab bar. It reproduced the look and * neither behaviour that matters on a device — the document still scrolled, and nothing padded out * of `env(safe-area-inset-*)`. All five bands are now `MobileShell` slots. */ function StatusBar() { return ( <> 9:41 Acme Handy ); } function TabBar({ active, onChange }: { active: string; onChange: (id: string) => void }) { // The tab bar's own tiling — equal width, no seam, no gutter — is the `tabBar` slot's contract, // so there is no grid and no column count here any more. return ( <> {TABS.map((t) => { const isActive = t.id === active; const Icon = t.icon; return ( ); })} ); } // ── Sheets ─────────────────────────────────────────────────────────────────── function ItemLookupSheet({ open, onOpenChange, }: { open: boolean; onOpenChange: (v: boolean) => void; }) { const [code, setCode] = React.useState(""); return ( Quét hoặc nhập mã {/* Viewfinder placeholder — a Card surface, not a hand-rolled illustration */} Hoặc nhập mã thủ công setCode(e.target.value)} placeholder="RC- / PKG- / JAN" className="font-mono" inputMode="text" autoComplete="off" /> ); } function PackagePickerSheet({ open, onOpenChange, count, }: { open: boolean; onOpenChange: (v: boolean) => void; count: number; }) { return ( Gán vào kiện Kiện đang mở {PACKINGS.map((p) => ( { onOpenChange(false); toast.success(`Đã gán ${count} item vào ${p.code}`); }} /> ))} ); } const DESTINATIONS = [ { id: "unsorted", label: "Chưa phân loại", hint: "Để xử lý sau" }, { id: "shelf", label: "Giá hàng", hint: "Lên kệ lưu trữ" }, { id: "open", label: "Kiện đang mở", hint: "Gói cùng đơn hiện tại" }, { id: "new", label: "Tạo kiện mới", hint: "Mở kiện mới cho item" }, ] as const; function ItemFormSheet({ open, onOpenChange, }: { open: boolean; onOpenChange: (v: boolean) => void; }) { const [dest, setDest] = React.useState("unsorted"); const [qty, setQty] = React.useState("1"); return ( Thêm hàng mới Mã vạch Tên hàng (tùy chọn) Số lượng setQty(e.target.value)} className="w-24 tabular-nums" /> Đích đến ({ value: d.id, label: d.label, description: d.hint, }))} /> ); } // ── Tab bodies ─────────────────────────────────────────────────────────────── type ListState = "ready" | "loading" | "empty" | "error"; /** * The SCROLL REGION of the inbound screen — and nothing else. The sticky bar and the tab bar are * `MobileShell` bands now, so this component no longer owns a scroll box, a `flex-1`, or a border. */ function InboundTab({ state, onRetry, selectMode, selected, setSelected, onScan, }: { state: ListState; onRetry: () => void; selectMode: boolean; selected: Set; setSelected: React.Dispatch>>; onScan: () => void; }) { const [filter, setFilter] = React.useState("all"); const visible = ITEMS.filter((i) => filter === "all" ? true : filter === "unsorted" ? i.status === "unsorted" : i.status === "shelf", ); return ( {/* Filter chips — horizontal scroll, count pills */} { if (v) setFilter(v); }} className="w-full overflow-x-auto" > {FILTERS.map((f) => ( {f.label} ))} {state === "loading" ? ( {Array.from({ length: 3 }).map((_, i) => ( ))} ) : state === "error" ? ( Không tải được danh sách Kiểm tra kết nối rồi thử lại. ) : state === "empty" || visible.length === 0 ? ( ); } /** * The inbound screen's `actions` band: the scan-first pair at rest, the contextual pair in select * mode. It lives OUTSIDE the scroll region, so it needs no `position: sticky` and no bottom padding * on the list to clear it, and `MobileShell` gives it the home-indicator inset when no tab bar * follows. */ function InboundActions({ selectMode, selected, onExitSelect, onScan, onAdd, onAssign, }: { selectMode: boolean; selected: Set; onExitSelect: () => void; onScan: () => void; onAdd: () => void; onAssign: () => void; }) { if (!selectMode) { return ( <> ); } return ( {selected.size} {" "} item đã chọn ); } function PackingTab() { const active = PACKINGS.find((p) => p.status === "active"); const others = PACKINGS.filter((p) => p.status !== "active"); return ( {active ? ( Kiện đang làm {PACKING_STATUS.active.label} {active.code} {active.customer} · {active.city} · ×{active.items} · {active.slot} ) : null} Kiện đang mở {others.map((p) => ( undefined} /> ))} ); } function PackingActions({ onScan }: { onScan: () => void }) { return ( ); } /** * `seg` is lifted to the page: the segmented control lives in the scroll region while the verb it * selects lives in the `actions` band, and the two are different MobileShell slots. */ function OutboundTab({ seg, setSeg }: { seg: string; setSeg: (v: string) => void }) { return ( {/* Segmented — outbound status */} { if (v) setSeg(v); }} className="bg-secondary/60 w-full rounded-xl" > Chờ niêm phong Chờ bàn giao Đã bàn giao {seg === "seal" ? "Sẵn sàng niêm phong" : seg === "handoff" ? "Chờ bàn giao" : "Đã bàn giao"} {OUTBOUND.map((p) => ( {p.code} Sẵn sàng niêm phong {p.customer} · {p.city} ×{p.items} · {p.slot}
{p.slot} ×{p.items}
))}
); } function OutboundActions({ seg, onSeal, onHandoff, }: { seg: string; onSeal: () => void; onHandoff: () => void; }) { return seg === "handoff" ? ( ) : ( ); } // ── Root ───────────────────────────────────────────────────────────────────── export default function AgencyHandyShowcase() { const [tab, setTab] = React.useState("inbound"); const [listState, setListState] = React.useState("ready"); const [selectMode, setSelectMode] = React.useState(false); const [selected, setSelected] = React.useState>(new Set()); const [seg, setSeg] = React.useState("seal"); const [lookupOpen, setLookupOpen] = React.useState(false); const [formOpen, setFormOpen] = React.useState(false); const [pickerOpen, setPickerOpen] = React.useState(false); const [sealOpen, setSealOpen] = React.useState(false); const [handoffOpen, setHandoffOpen] = React.useState(false); const headerTitle = tab === "inbound" ? "Nhập kho" : tab === "packing" ? "Đóng gói" : "Xuất kho"; const exitSelect = () => { setSelectMode(false); setSelected(new Set()); }; // SELECT MODE REPLACES THE APP BAR — it does not stack a second strip under it. That is the // platform pattern on both iOS and Android, and it is what the `header` slot is for: one bar to // read at a time. This file once rendered the title bar AND a contextual strip, because // there was no bar to swap. const header = selectMode ? ( {selected.size} đã chọn ) : ( {headerTitle} {tab === "inbound" ? ( ) : null} {/* State switcher — exposes the 4 list states at rest (showcase affordance) */} {tab === "inbound" ? ( { if (v) setListState(v as ListState); }} aria-label="List state (showcase)" > {(["ready", "loading", "empty", "error"] as const).map((s) => ( {s[0]} ))} ) : null} ); const actions = tab === "inbound" ? ( setLookupOpen(true)} onAdd={() => setFormOpen(true)} onAssign={() => setPickerOpen(true)} /> ) : tab === "packing" ? ( setLookupOpen(true)} /> ) : ( setSealOpen(true)} onHandoff={() => setHandoffOpen(true)} /> ); return ( <> {/* The whole phone is ONE primitive now: five bands, no Card frame, no hand-rolled scroll box, * no `min-h-screen` wrapper. The shell is the document's only scroll container and every band * pads itself out of the device safe areas. */} } header={header} actions={actions} tabBar={ { setTab(id); exitSelect(); }} /> } > {tab === "inbound" ? ( setListState("ready")} selectMode={selectMode} selected={selected} setSelected={setSelected} onScan={() => setLookupOpen(true)} /> ) : tab === "packing" ? ( ) : ( )} {/* Sheets */} {/* Confirm dialogs — verb confirm, surface summary; forward step = primary */} { toast.success("Đã niêm phong PKG-000037 · 16:24"); }} /> { toast.success("Đã bàn giao PKG-000037 · 16:24"); }} /> ); }