@lotics/ui 11.8.9 → 12.0.0

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.
@@ -12,10 +12,9 @@ import { Alert } from "@lotics/ui/alert";
12
12
  import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
13
13
  import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
14
14
  import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
15
- import { FileBadge } from "@lotics/ui/file_badge";
16
15
  import { Finding, FindingComparison } from "@lotics/ui/finding";
17
16
  import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
18
- import { type DisplayFile } from "@lotics/ui/file_thumbnail";
17
+ import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
19
18
  import { FormTextInput } from "@lotics/ui/form_text_input";
20
19
  import { CheckboxInput } from "@lotics/ui/checkbox_input";
21
20
  import { SearchInput } from "@lotics/ui/search_input";
@@ -43,25 +42,35 @@ import { CompletionState } from "@lotics/ui/completion_state";
43
42
  // dialogue-shaped file iteration happens in the Lotics chat; the fork is the ONE
44
43
  // AI entry point, never per-row AI buttons). Plus CREATE DOCUMENTS: a
45
44
  // readiness checklist → generate → the files land back on the record. All
46
- // mock, useState + the timer that streams the AgentRun.
45
+ // mock, useState + the timer that streams the AgentRun. The record template
46
+ // (tpl_record) carries this desk as its Documents section — change it in
47
+ // BOTH. One sanctioned divergence: GENERATION. This page has no output
48
+ // pipeline, so Create documents runs as the toolbar dialog here; tpl_record
49
+ // moves it into its Document set OUTPUT section (intake desk / output section
50
+ // split) and drops the toolbar button.
47
51
  // ─────────────────────────────────────────────────────────────────────────────
48
52
 
49
53
  type ScriptStep = Omit<AgentRunStep, "status">;
50
54
  type Task = "extract" | "check";
51
55
  type Phase = "fork" | "running" | "review" | "done";
52
56
 
53
- interface Doc { id: string; name: string; mimeType: string; kind: string; sizeKB: number; added: string; addedAt: number }
57
+ interface Doc { id: string; name: string; mimeType: string; kind: string; sizeKB: number; added: string; addedAt: number; url?: string }
54
58
 
55
59
  // Display derives from the canonical numeric — strings would sort "8.4 MB" < "96 KB".
56
60
  function fmtSize(kb: number): string {
57
61
  if (kb <= 0) return "—";
58
62
  return kb < 1024 ? `${kb} KB` : `${(kb / 1024).toFixed(1)} MB`;
59
63
  }
64
+ const MOCK_PHOTO_URL =
65
+ "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMjAnIGhlaWdodD0nMzIwJz48cmVjdCB3aWR0aD0nMzIwJyBoZWlnaHQ9JzMyMCcgZmlsbD0nI2JmZGJmZScvPjxyZWN0IHk9JzIxMCcgd2lkdGg9JzMyMCcgaGVpZ2h0PScxMTAnIGZpbGw9JyNhOGEyOWUnLz48cmVjdCB4PScyMCcgeT0nMTUwJyB3aWR0aD0nMTI0JyBoZWlnaHQ9JzYwJyBmaWxsPScjZGMyNjI2Jy8+PHJlY3QgeD0nMTUyJyB5PScxNTAnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyMyNTYzZWInLz48cmVjdCB4PSc4NicgeT0nODgnIHdpZHRoPScxMjQnIGhlaWdodD0nNjAnIGZpbGw9JyNmNTllMGInLz48cmVjdCB4PScyNjInIHk9JzI0JyB3aWR0aD0nMTAnIGhlaWdodD0nMTg2JyBmaWxsPScjNTI1MjUyJy8+PHJlY3QgeD0nMTUwJyB5PScyNCcgd2lkdGg9JzEyMicgaGVpZ2h0PScxMCcgZmlsbD0nIzUyNTI1MicvPjwvc3ZnPg==";
60
66
  const DOCS: Doc[] = [
61
67
  { id: "f1", name: "invoice.pdf", mimeType: "application/pdf", kind: "PDF", sizeKB: 214, added: "26 Jun", addedAt: 626 },
62
68
  { id: "f2", name: "packing-list.pdf", mimeType: "application/pdf", kind: "PDF", sizeKB: 96, added: "26 Jun", addedAt: 626 },
63
69
  { id: "f3", name: "booking-confirmation.pdf", mimeType: "application/pdf", kind: "PDF", sizeKB: 182, added: "28 Jun", addedAt: 628 },
64
70
  { id: "f4", name: "photos.zip", mimeType: "application/zip", kind: "ZIP", sizeKB: 8602, added: "30 Jun", addedAt: 630 },
71
+ // an IMAGE file — the register renders it as a real square thumbnail (the
72
+ // SVG data URI stands in for the stored photo URL a live app serves)
73
+ { id: "f5", name: "delivery-photo.jpg", mimeType: "image/jpeg", kind: "JPG", sizeKB: 1424, added: "30 Jun", addedAt: 630, url: MOCK_PHOTO_URL },
65
74
  ];
66
75
 
67
76
  const EXTRACT_STEPS: ScriptStep[] = [
@@ -80,17 +89,17 @@ const CHECK_STEPS: ScriptStep[] = [
80
89
  // CONFLICT — are the SAME `ChangeField` row: an add has no `before`, an update
81
90
  // bands it, a removal is the − band alone, and a conflict's outcome is the
82
91
  // read-only + band over the candidate rows.
83
- const MBL_PROPOSED = "MAEU129394855";
92
+ const CARRIER_REF_PROPOSED = "MAEU129394855";
84
93
  const VESSEL_CURRENT = "MSC AURA";
85
94
  const VESSEL_PROPOSED = "MAERSK SALINA";
86
95
  const CONSIGNEE_CURRENT = "Nordic Furniture AB";
87
96
  const NOTIFY_CURRENT = "Euro Textile Trading GmbH, Frankfurt";
88
- const EXTRACT_REASONING = "The booking confirmation names the substitute vessel for this sailing week; the invoice and the packing list disagree on the consignee.";
97
+ const EXTRACT_REASONING = "The booking confirmation names the substitute vessel for this shipping week; the invoice and the packing list disagree on the consignee.";
89
98
  const CONSIGNEE_OPTIONS = [
90
99
  { value: "Nordic Furniture AB, Jönköping DC", source: "invoice.pdf", recommended: true },
91
100
  { value: "NF Distribution ApS, Kolding", source: "packing-list.pdf" },
92
101
  ];
93
- const EXTRACT_FIELD_IDS = ["mbl", "vessel", "consignee", "gross", "notify"] as const;
102
+ const EXTRACT_FIELD_IDS = ["carrier_ref", "vessel", "consignee", "gross", "notify"] as const;
94
103
 
95
104
  // Cross-check findings — severity reads through ONE colored dot badge (red /
96
105
  // amber / zinc), the rest stays calm text.
@@ -110,7 +119,7 @@ const FINDINGS: Check[] = [
110
119
  },
111
120
  {
112
121
  id: "q4", severity: "warning", title: "No certificate of origin among the documents",
113
- detail: "Destination customs requires a C/O for this HS chapter — request it before the vessel cut-off.",
122
+ detail: "Destination customs requires a certificate of origin for these goods — request it before the shipping cut-off.",
114
123
  },
115
124
  {
116
125
  id: "q3", severity: "info", title: "Booking cut-off is earlier than the invoiced ship week",
@@ -121,7 +130,7 @@ const FINDINGS: Check[] = [
121
130
 
122
131
  // A tiny REAL pdf (data URI) so the gallery preview genuinely renders.
123
132
  const MOCK_PDF_URL = "data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgNjEyIDc5Ml0vQ29udGVudHMgNCAwIFIvUmVzb3VyY2VzPDwvRm9udDw8L0YxIDUgMCBSPj4+Pj4+ZW5kb2JqCjQgMCBvYmo8PC9MZW5ndGggNjM+PnN0cmVhbQpCVCAvRjEgMTggVGYgNzIgNzIwIFRkIChOb3JkaWMgRnVybml0dXJlIC0gbW9jayBkb2N1bWVudCkgVGogRVQKZW5kc3RyZWFtIGVuZG9iago1IDAgb2JqPDwvVHlwZS9Gb250L1N1YnR5cGUvVHlwZTEvQmFzZUZvbnQvSGVsdmV0aWNhPj5lbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTIgMDAwMDAgbiAKMDAwMDAwMDEwMSAwMDAwMCBuIAowMDAwMDAwMjExIDAwMDAwIG4gCjAwMDAwMDAzMjAgMDAwMDAgbiAKdHJhaWxlcjw8L1NpemUgNi9Sb290IDEgMCBSPj4Kc3RhcnR4cmVmCjM4MQolJUVPRg==";
124
- const toDisplay = (d: Doc): DisplayFile => ({ id: d.id, filename: d.name, mimeType: d.mimeType, url: MOCK_PDF_URL });
133
+ const toDisplay = (d: Doc): DisplayFile => ({ id: d.id, filename: d.name, mimeType: d.mimeType, url: d.url ?? MOCK_PDF_URL });
125
134
 
126
135
  // The documents register — columns ONCE (the Table renders the header band
127
136
  // and every cell width from these; facts get dedicated scannable columns
@@ -129,15 +138,15 @@ const toDisplay = (d: Doc): DisplayFile => ({ id: d.id, filename: d.name, mimeTy
129
138
  // (tpl_item_list): leading checkbox + select-all, trailing ⋯ gutter.
130
139
  const DOC_COLUMNS: TableColumn[] = [
131
140
  { key: "doc", label: "Document", flex: 1, sortable: true },
132
- { key: "size", label: "Size", width: 90, align: "right", sortable: true },
133
- { key: "added", label: "Added", width: 96, sortable: true },
141
+ { key: "size", label: "Size", width: 90, align: "right", sortable: true, priority: 3 },
142
+ { key: "added", label: "Added", width: 96, sortable: true, priority: 2 },
134
143
  ];
135
144
 
136
145
  interface Producible { id: string; label: string; file: string; sizeKB: number; needs?: { key: string; label: string }[] }
137
146
  const PRODUCIBLE: Producible[] = [
138
147
  { id: "g1", label: "Shipping instruction", file: "shipping-instruction.pdf", sizeKB: 84 },
139
148
  { id: "g2", label: "Certificate of origin", file: "certificate-of-origin.pdf", sizeKB: 61 },
140
- { id: "g3", label: "Delivery note", file: "delivery-note.pdf", sizeKB: 72, needs: [{ key: "delivery_address", label: "Delivery address" }, { key: "hs_code", label: "HS code" }] },
149
+ { id: "g3", label: "Delivery note", file: "delivery-note.pdf", sizeKB: 72, needs: [{ key: "delivery_address", label: "Delivery address" }, { key: "commodity_code", label: "Commodity code" }] },
141
150
  { id: "g4", label: "Customs declaration draft", file: "customs-declaration-draft.pdf", sizeKB: 118 },
142
151
  ];
143
152
 
@@ -164,7 +173,7 @@ export function TplDocuments() {
164
173
  // The HOST owns the review: one editable proposal per field (editing IS the
165
174
  // review), a tri-state decision per field (dropped rows collapse struck,
166
175
  // out of the apply), and the conflict's pick (empty until the user chooses).
167
- const [mbl, setMbl] = useState(MBL_PROPOSED);
176
+ const [carrierRef, setCarrierRef] = useState(CARRIER_REF_PROPOSED);
168
177
  const [vessel, setVessel] = useState(VESSEL_PROPOSED);
169
178
  const [consignee, setConsignee] = useState<string | null>(null);
170
179
  const [grossWeight, setGrossWeight] = useState("1,540");
@@ -189,7 +198,7 @@ export function TplDocuments() {
189
198
  const commitUpload = () => setFiles((fs) => [...fs, ...picked.filter((p2) => !fs.some((f) => f.id === p2.id))]);
190
199
  const closeAi = () => {
191
200
  setAiOpen(false); setTask(null); setPhase("fork"); setRevealed(0); setUploadFlow(false); setBrief(""); setTaskChoice(null);
192
- setMbl(MBL_PROPOSED); setVessel(VESSEL_PROPOSED); setConsignee(null); setConsigneePick(null); setCustomConsignee(""); setGrossWeight("1,540"); setLineAdd("pending"); setLineEdit({}); setNewItem("Corner protectors, foam"); setNewQty("400"); setEditedQty("1,450"); setFieldDecisions({});
201
+ setCarrierRef(CARRIER_REF_PROPOSED); setVessel(VESSEL_PROPOSED); setConsignee(null); setConsigneePick(null); setCustomConsignee(""); setGrossWeight("1,540"); setLineAdd("pending"); setLineEdit({}); setNewItem("Corner protectors, foam"); setNewQty("400"); setEditedQty("1,450"); setFieldDecisions({});
193
202
  };
194
203
  const startTask = (t: Task) => { setTask(t); setRevealed(0); setPhase("running"); };
195
204
  // ⋯ menu per document row. "Edit with AI" is the app→chat handoff for
@@ -414,7 +423,9 @@ export function TplDocuments() {
414
423
  >
415
424
  <TableCell>
416
425
  <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
417
- <FileBadge mimeType={f.mimeType} />
426
+ {/* ONE square 32px slot for every type: an image fills it as a
427
+ real thumbnail, a document centers its badge in it */}
428
+ <FileThumbnail file={toDisplay(f)} size={32} onPress={() => openPreview(visibleFiles, fi)} />
418
429
  <Text size="sm" weight="medium" numberOfLines={1} style={{ flexShrink: 1 }}>{f.name}</Text>
419
430
  </View>
420
431
  </TableCell>
@@ -477,7 +488,7 @@ export function TplDocuments() {
477
488
  {taskChoice === "check" ? (
478
489
  <FormTextInput
479
490
  label="Instructions (optional)"
480
- placeholder="Anything specific to check — e.g. verify the HS codes against the order"
491
+ placeholder="Anything specific to check — e.g. verify the commodity codes against the order"
481
492
  value={brief}
482
493
  onChangeText={setBrief}
483
494
  multiline
@@ -505,8 +516,8 @@ export function TplDocuments() {
505
516
  <ChangeReasoning>{EXTRACT_REASONING}</ChangeReasoning>
506
517
  <ChangeFields>
507
518
  {/* An ADD — the record holds nothing yet, so no `before`. */}
508
- <ChangeField label="MBL number" value={mbl} summary={mbl} {...fieldRow("mbl")}>
509
- <ChangeValueInput value={mbl} onChangeText={setMbl} accessibilityLabel="MBL number" />
519
+ <ChangeField label="Carrier reference" value={carrierRef} summary={carrierRef} {...fieldRow("carrier_ref")}>
520
+ <ChangeValueInput value={carrierRef} onChangeText={setCarrierRef} accessibilityLabel="Carrier reference" />
510
521
  </ChangeField>
511
522
  {/* An UPDATE — the current value banded above the editor. */}
512
523
  <ChangeField label="Vessel" value={vessel} summary={vessel} before={VESSEL_CURRENT} {...fieldRow("vessel")}>
@@ -31,6 +31,7 @@ import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
31
31
  import { SearchInput } from "@lotics/ui/search_input";
32
32
  import { Select } from "@lotics/ui/select";
33
33
  import { FilterChip, selectSummary } from "@lotics/ui/filter_chip";
34
+ import { ColumnFilter, type ColumnFilterValue } from "@lotics/ui/column_filter";
34
35
  import { OptionList } from "@lotics/ui/option_list";
35
36
  import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
36
37
  import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
@@ -355,13 +356,18 @@ const ASSIGNEES = [...new Set(HO_SO.map((r) => r.phuTrach))].map((n) => ({ label
355
356
 
356
357
  // Columns defined ONCE — Table renders the header from these and each
357
358
  // TableCell takes its width/align by position; the ⋯ is the trailing gutter.
359
+ // `priority` = the MOBILE contract: when the container can't fit every column
360
+ // the highest numbers drop first (Received → Tasks → Status), the identity +
361
+ // Customer + Fee survive longest, and at the register floor rows STACK
362
+ // (label-over-value) so nothing is lost — the drawer carries full detail
363
+ // either way.
358
364
  const COLUMNS: TableColumn[] = [
359
365
  { key: "ma", label: "Record ID", width: 112, sortable: true },
360
- { key: "khach", label: "Customer", flex: 1, sortable: true },
361
- { key: "trangThai", label: "Status", width: 112, sortable: true },
362
- { key: "viec", label: "Tasks", width: 82 },
363
- { key: "phi", label: "Fee", width: 116, align: "right", sortable: true },
364
- { key: "ngayNhan", label: "Received", width: 72, sortable: true },
366
+ { key: "khach", label: "Customer", flex: 1, sortable: true, priority: 1 },
367
+ { key: "trangThai", label: "Status", width: 112, sortable: true, priority: 3 },
368
+ { key: "viec", label: "Tasks", width: 82, priority: 4 },
369
+ { key: "phi", label: "Fee", width: 116, align: "right", sortable: true, priority: 2 },
370
+ { key: "ngayNhan", label: "Received", width: 72, sortable: true, priority: 5 },
365
371
  ];
366
372
 
367
373
  // DOCUMENT — the paper the row needs NOW: while the fee is unpaid the
@@ -682,7 +688,6 @@ function HoSoWorkspace({ hs, daThu, onPaid, tasks, onTasksChange, suggestions, o
682
688
  <Button
683
689
  title="Add file"
684
690
  color="secondary"
685
- icon="plus"
686
691
  onPress={() => {
687
692
  void pickFiles({ multiple: true }).then((picked) => {
688
693
  if (picked.length > 0) setFiles((prev) => [...prev, ...toDisplayFiles(picked)]);
@@ -1143,6 +1148,8 @@ export function TplItemList() {
1143
1148
  const [sort, setSort] = useState<SortState | null>(null);
1144
1149
  const [assignee, setAssignee] = useState<string[]>([]);
1145
1150
  const [feeStatus, setFeeStatus] = useState<"paid" | "unpaid" | null>(null);
1151
+ // The per-column pill (ColumnFilter, number variant) — min/max over the Fee column.
1152
+ const [feeAmount, setFeeAmount] = useState<ColumnFilterValue | undefined>(undefined);
1146
1153
  const sel = useSelection();
1147
1154
 
1148
1155
  // Filter / sort changes reset paging — page 3 of the old ordering is
@@ -1169,6 +1176,10 @@ export function TplItemList() {
1169
1176
  if (assignee.length > 0 && !assignee.includes(r.phuTrach)) return false;
1170
1177
  if (feeStatus === "paid" && !daThuOf(r)) return false;
1171
1178
  if (feeStatus === "unpaid" && daThuOf(r)) return false;
1179
+ if (feeAmount?.kind === "number") {
1180
+ if (feeAmount.min != null && r.phi < feeAmount.min) return false;
1181
+ if (feeAmount.max != null && r.phi > feeAmount.max) return false;
1182
+ }
1172
1183
  if (q && !`${r.ma} ${r.khach} ${r.dienThoai}`.toLowerCase().includes(q)) return false;
1173
1184
  return true;
1174
1185
  });
@@ -1251,10 +1262,10 @@ export function TplItemList() {
1251
1262
  {/* Single-select sibling — the render-prop `close` shuts the popover on
1252
1263
  pick (the multi above stays open to collect several values). */}
1253
1264
  <FilterChip
1254
- label="Fee"
1265
+ label="Payment"
1255
1266
  summary={feeStatus === "paid" ? "Paid" : feeStatus === "unpaid" ? "Unpaid" : undefined}
1256
1267
  onClear={() => { setFeeStatus(null); setPage(0); }}
1257
- clearLabel="Clear fee filter"
1268
+ clearLabel="Clear payment filter"
1258
1269
  >
1259
1270
  {({ close }) => (
1260
1271
  <OptionList
@@ -1266,6 +1277,14 @@ export function TplItemList() {
1266
1277
  />
1267
1278
  )}
1268
1279
  </FilterChip>
1280
+ {/* the per-column filter pill — the NUMBER variant (min/max) over
1281
+ the Fee column; text/select variants live on the Tables gallery
1282
+ page. Filter changes reset paging like every other control. */}
1283
+ <ColumnFilter
1284
+ column={{ key: "phi", label: "Fee", type: "number" }}
1285
+ value={feeAmount}
1286
+ onChange={(v) => { setFeeAmount(v); setPage(0); }}
1287
+ />
1269
1288
  </View>
1270
1289
  <CreateRecordDialog onCreate={createRecord} />
1271
1290
  </View>