@lotics/ui 47.13.1 → 47.15.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.
@@ -398,6 +398,38 @@ badge. Every period-dependent number MUST follow the selection. Pass `includeTim
398
398
  time-of-day matters: the trigger previews the chosen time (locale-aware) and the `labels` prop
399
399
  translates the presets, footer buttons, placeholder, and the time-segment editors.
400
400
 
401
+ **One field, every grain — a `SegmentedControl` of grains beside it re-adds the mode the field
402
+ removed.** The presets already run day → week → month → quarter → year, and the trigger folds a
403
+ whole period back to its own name ("Tháng 9 năm 2026", "Quý 3 năm 2026", "2026"), so the grain is
404
+ something the reader SETS and READS in one control. Putting *Tháng | Quý | Năm* next to it splits
405
+ one fact across two controls that can disagree — pick "Quý" beside a range of eleven days and the
406
+ screen states a grain nothing on it is computed at.
407
+
408
+ **A report's comparator is DERIVED from the selected range, never chosen.** "So với kỳ trước" is
409
+ `previousPeriod(value)` from `@lotics/ui/date_filter_presets` — a whole month steps back a whole
410
+ month, a quarter a quarter, a year a year, and anything else steps back by its own length. Feed
411
+ that range to the same query the selection feeds and put the result ON the figure: a
412
+ `KPIStripItem`'s `trend` / `caption`, `Metric`'s `previousValue`, a second `LineChart` series
413
+ (`series` is what makes two periods one chart on one scale), `TrendFooter` under a chart card. A
414
+ second date field, a "compare to" picker or a comparison toggle beside the filter is the same
415
+ mistake in three shapes — it lets a reader select a comparator that is not the period they are
416
+ looking at, and every figure on the screen then answers a question nobody asked. `null` back means
417
+ the range is open-ended: show the figure with no delta rather than inventing one.
418
+
419
+ **A period still RUNNING is compared TO DATE — `periodToDate(value, now)`.** Six days of this
420
+ month set beside a whole previous month reads as a collapse that never happened, and it lands on
421
+ the first figure anyone looks at, so both sides are cut to the days that have elapsed: it hands
422
+ back the selection ending today, the comparator cut to the same count from its own first day
423
+ (clamped to that period's end, so 30 days of March meet all 28 of February), and
424
+ `running` / `elapsedDays` / `totalDays` for the caption that says how far in the reader is. A
425
+ closed period keeps both sides whole.
426
+
427
+ **A surface keyed by a whole month takes a `MonthStepper`, not a range field.** A monthly plan, a
428
+ roster, a month's attendance cannot take "the 3rd to the 19th", so a range control offers moves
429
+ that do not exist. The stepper's bound is its contract: pass `max` as this month on any surface
430
+ about what has happened, or the reader walks into a month nobody has planned and reads it as a
431
+ plan. Two hand-rolled steppers in one app disagreed on exactly this.
432
+
401
433
  ## A calendar answers WHEN — and only when the position on a timeline is the question
402
434
 
403
435
  A calendar is not a prettier register. Reach for one when the reader's question is *when does
@@ -406,6 +438,19 @@ at the same time. When the question is *which of these needs me*, the answer is
406
438
  a date column, and a calendar buries it: thirty rows of a table are scannable, thirty rows
407
439
  scattered across a month grid are a search.
408
440
 
441
+ **A weekly PATTERN with no dates is a `Timetable`, not a calendar.** Which days a departure
442
+ runs, which hours a lane is open: nothing has a date to be placed on, so a week view pages
443
+ between identical weeks and draws the same four facts seven times. Rows are the slots, the
444
+ fixed positions are columns named once in the header, today is a tinted column, and a mark is
445
+ its own editor.
446
+
447
+ **A subject-by-date sheet of STATES is a `StateMatrix`.** Who was in on which day, which vehicle
448
+ ran on the 14th: the reader compares a subject against a date and reads a category, not a
449
+ number. Rows are the subjects with a pinned name and figure, the dates are columns named once,
450
+ a glyph per cell says the state, and the legend is derived from the same list the cells draw
451
+ from. A calendar would scatter thirty subjects across a month grid; a `Matrix` would put a
452
+ number where a blank means "nothing was due".
453
+
409
454
  Three rules follow from that, and each of them is about legibility rather than taste.
410
455
 
411
456
  **A grid is unreadable in two directions.** Seven equal columns need roughly 110px each to hold
@@ -647,6 +692,11 @@ form: per container, collect its own `row-gap` and the **max `row-gap` of its vi
647
692
  different quantity. `ChargeLines` takes `formatMoney` and `formatQuantity` for this reason, both
648
693
  at the BAND so two lines cannot disagree, and threads them to the locked value and the inline
649
694
  editor alike. Any new component that displays a caller's number owes the same hook.
695
+ - **An axis over whole things has no half.** A count of vehicles, contracts or trips cannot be
696
+ 0,5, so gridlines at `0 · 0,5 · 1 · 1,5 · 2` state something false about the SUBJECT rather than
697
+ about the layout, and they appear exactly where the numbers are smallest — a five-tick target
698
+ starts reaching below the unit at a maximum of two. The kit's charts derive this from the values
699
+ themselves; anywhere you draw a scale by hand, the step comes from what the data is made of.
650
700
  - **A column states its unit ONCE — which is only possible if it holds ONE unit.** State it in the
651
701
  header band and leave the cells bare. The corollary is the one that bites: a label/value list
652
702
  whose rows are a date range, a sum of money and a COUNT has no single unit to state, so each
@@ -667,7 +717,8 @@ form: per container, collect its own `row-gap` and the **max `row-gap` of its vi
667
717
  | `medium` | `22 thg 5, 2026` |
668
718
  | `long` | `22 tháng 5, 2026` |
669
719
  | `dayMonth` | `22 thg 5` |
670
- | `monthYear` | `Tháng 5 2026` |
720
+ | `monthYear` | `Tháng 5 năm 2026` |
721
+ | `quarterYear` | `Quý 2 năm 2026` (`Q2 2026` outside Vietnamese) |
671
722
 
672
723
  **`time: true` is orthogonal** — it prepends the 24h time to ANY style (`14:30 22/05/2026`,
673
724
  `14:30 22 tháng 5, 2026`); `compact` drops the year on the `date` style; `emptyLabel` renders
@@ -1123,6 +1174,15 @@ When a region genuinely must sit beside the list (a source/remainder summary, a
1123
1174
  make it a TOP summary header or move it into a `Popover`/sheet at the decision point — not a
1124
1175
  standing column.
1125
1176
 
1177
+ **A screen is cut by the JOB, never by the subject.** Two jobs done at different hours — watching
1178
+ exceptions all day, signing a count once at its end — are two surfaces even when both are about
1179
+ the same day; a screen that stacks them puts the act done once under the list read least, and the
1180
+ one fact they share travels as a single line. The work screen opens on what the person DOES, then
1181
+ what they look up; an end-of-period act sits with the book it lands in — the register of periods
1182
+ above, the picked period's form below. A working screen summarises with `SummaryLine`; `KPIStrip`
1183
+ is a dashboard band. And a screen keyed by a period is named for the period it shows ("Trong
1184
+ ngày"), never for the default its filter happens to hold ("Hôm nay").
1185
+
1126
1186
  ## One control radius
1127
1187
 
1128
1188
  Every interactive control (`Button`, the inputs/selects/pickers, `SearchInput`, `MenuButton`,
@@ -1594,6 +1654,19 @@ next step. When the state is already derivable from a neighbour, the badge is a
1594
1654
  fact, free to disagree with the first, and it costs a column on every screen to say what the
1595
1655
  reader already read.
1596
1656
 
1657
+ **A state the system DERIVES from an absence is pending until a person confirms it.** No chuyến
1658
+ past the slot, no reply past the deadline, no scan past the cut-off: the system can say the thing
1659
+ did not happen, never WHY, and the why is the only fact anyone acts on. Label such a row as the
1660
+ question ("Chưa xác nhận"), tone it as attention rather than failure, and put the confirming act
1661
+ on the row; the verdict word ("Nghỉ") appears only once the reason is on record. Painting the
1662
+ derived absence with the verdict spends the register's one alarm colour on the commonest row of
1663
+ the morning and accuses a row nobody has looked at yet.
1664
+
1665
+ **A strip whose every option shows the same register with fewer rows is a FILTER**, so it is
1666
+ `ChipGroup` with counts and `overflow="scroll"`, opening on the partition that owes the act.
1667
+ `Tabs` is for sections whose content differs; `PipelineTabs` for stages that change what the
1668
+ register under them IS.
1669
+
1597
1670
  The clean discriminator is what the badge would say that the row does not:
1598
1671
 
1599
1672
  - A gate register whose status reads *"paperwork not closed"* keeps its column. No other cell
@@ -1867,7 +1940,7 @@ information.
1867
1940
  first word.** `avatarInitials` takes a name's first (and last) letters, which is right for a
1868
1941
  person and wrong for an ORGANIZATION: an organization's name opens with its legal form, so a
1869
1942
  register of companies marks the same letter on every row — and it is the same failure the derived
1870
- hue was written to fix, arriving through the glyph instead of the ground. Strip the form from
1943
+ hue was written to fix, arriving through the glyph instead of the ground. Strip the form (`stripLegalForm`, `@lotics/ui/organization_name`) from
1871
1944
  both ends before the name reaches the mark, and use the stripped name for the row's TITLE too:
1872
1945
  whatever eats the first letters of a mark is also eating the first 60px of the column beside it,
1873
1946
  where the words that tell two customers apart fall off the end. The legal name belongs on the
@@ -1904,8 +1977,14 @@ information.
1904
1977
  subject. Keep the ordinal where order is the thing being EDITED, and then as text in the
1905
1978
  supporting line — never as the mark. A surface that needs to state position has its own readout
1906
1979
  (a drawer's `3/8`, a stepper's rail).
1907
- - **The rung follows what the row is ABOUT**: `lg` where the entity is the row's subject, `md`
1908
- where it is one field among columns (see `avatar_size`).
1980
+ - **The rung follows what the reader AIMS AT**: `lg` where the mark is the thing the eye lands on
1981
+ the row's subject, or an ORGANIZATION in its own column, whose logo is what a reader scans a
1982
+ book of movements by. `md` only where the mark is incidental to a value the reader came for (see
1983
+ `avatar_size`). Read as "subject ⇒ `lg`, field column ⇒ `md`" this drew a 28px disc in a carrier
1984
+ column and the register was reported as flat — the same complaint the leading-mark rule above
1985
+ names, arriving through the rung instead of the absence. The budget is the real constraint: `lg`
1986
+ is 12px wider and takes them from the flexible column the name is in, so a column too tight to
1987
+ seat one takes `md` and says so.
1909
1988
  - **A column of faces must not draw an UNBOUNDED audience as faces.** Where a cell answers "who
1910
1989
  can reach this", the commonest state is often *everyone* — and three sampled avatars assert
1911
1990
  something false about it. Draw the
@@ -335,6 +335,15 @@ in a `DataGrid`), where a cell varying with its data breaks the row rhythm the g
335
335
  on a record surface; reserve in a table. Worked both ways in `dev/pages/inline_edit.tsx`
336
336
  § Multiline — the same value in a reserved field and a grown one, side by side.
337
337
 
338
+ **In a table the WIDTH is reserved too, and by the CELL — the editor never states its own.** An
339
+ inline editor is sized by what it holds: at rest a chip around the formatted value, focused a real
340
+ input at its natural width. Put one in a `TableCell` with nothing bounding it and the two states
341
+ are different boxes — the focused one grows sideways out of its column, over the neighbour, and
342
+ takes the row's height with it, on the one row somebody is working in. So wrap it in a `View` with
343
+ a fixed `width`, one number for the column, and both states land on the same rectangle.
344
+ (`NumberInput` already refuses to state a width for exactly this reason — see its `width: "100%"`
345
+ comment — but "fills its container" only bounds anything once a container has a size.)
346
+
338
347
  ### The row stack — `DetailTable` + `DetailRow`
339
348
 
340
349
  A STACK of rows lives in a `DetailTable` (label, value laid out like a TABLE: `labelWidth` /
@@ -765,12 +774,10 @@ chronological order; each phase owns ITS OWN fee rows + phase total (the invoice
765
774
  level up — money never reads as one pot); each closes with a SEQUENTIAL confirm gate that names
766
775
  its blockers ("Confirm check-in first."). The header status chip carries the phase.
767
776
 
768
- A LONG record surface pairs with a LEFT OUTLINE RAIL — `MenuButton` items + `useSectionNav`
769
- (scroll-spy: jump to a section, the highlight follows the scroll); the active item is
770
- `current={activeKey === key}`, the NAVIGATION state, never `selected`, which is listbox
771
- selection. On narrow containers the rail
772
- becomes a PINNED bar naming the current section that opens a full-page section-picker `Modal`. A
773
- per-phase dot on a rail item carries its confirmed state. A direction that is a TYPE (one record
777
+ A LONG record surface pairs with a LEFT OUTLINE RAIL — `SectionNav` for the chrome,
778
+ `useSectionNav` for the scroll-spy that feeds it `activeKey` and `jumpTo`. On narrow containers
779
+ the same list collapses to a pinned bar naming the current section, which opens it as a picker
780
+ anchored to that bar. A per-phase `issue` dot on an item carries its confirmed state. A direction that is a TYPE (one record
774
781
  per event) is instead a discriminator chosen ONCE at creation — a `SegmentedControl` in the
775
782
  create step, never a toggle on the record.
776
783
 
package/docs/reviewing.md CHANGED
@@ -333,6 +333,13 @@ defect, so finding it on one panel is a reason to sweep the siblings.
333
333
  that drifts across the track means each one was offset by a guess at its own width.
334
334
  - **A row beat belongs to the PAGE** — collect every row primitive's height on one screen before
335
335
  calling any of them right.
336
+ - **A bar's TRACK, not its card.** A chart whose label column and value column are FIXED spends
337
+ those pixels first, so a narrow pane leaves the plot itself with almost nothing — and a bar has
338
+ a minimum width, so every row draws the same short stub and the picture states that all the
339
+ entities are equal. It measures perfectly: the card is there, the rows are there, the figures
340
+ are right. Collect the track's width and each bar's, per row: a set of identical bar widths
341
+ under differing values is the finding, and so is any track under about a third of the card.
342
+ Same collection catches the axis underneath it, whose labels stack rather than crowd.
336
343
  - **Cards laid side by side: collect their BOTTOM edges, not their tops.** Tops align for free —
337
344
  the row sets them — so every horizontal check passes while the row ends on a staircase. One
338
345
  `getBoundingClientRect().bottom` per card in a row: more than one value is the finding, and the
@@ -342,6 +349,15 @@ defect, so finding it on one panel is a reason to sweep the siblings.
342
349
  neighbour, give the short card the fact it is missing, or re-pair the row. Screenshots are how
343
350
  this is noticed and the rects are how it is aimed: a staircase is obvious on sight and invisible
344
351
  to every probe above this one.
352
+ - **A scroller's CONTENT against the scroller — measure both, never just the frame.** A horizontal
353
+ scroll region lays its child out in a ROW, so a grid, a strip or a rail narrower than its frame
354
+ keeps its own content width and leaves the rest of the box empty; a `min-width: 100%` on the
355
+ content container does not carry to the child, which is what makes this look already fixed in
356
+ the source. It never appears as the blank space it is — the child sits at its floor, so what
357
+ shows is its CELLS wrapping their values onto two lines while a third of the card beside them is
358
+ drawn on by nothing. Walk up from one cell to the ancestor whose parent has `overflow-x: auto`,
359
+ comparing widths at each step; a child narrower than that frame is the finding, and the fix is
360
+ to let the child grow, never to widen the cells by hand.
345
361
 
346
362
  → [composition.md](./composition.md) §"Canvas & content column", §"Cards — banded and composable", §"No dead rows", §"The register's own craft".
347
363
 
@@ -583,6 +599,20 @@ explicit one, so a set where "the important ones are annotated" sheds the unanno
583
599
  first — annotating some columns and not others is the failure, not the fix. If the register has
584
600
  more than one column, either all of them carry a rank or none do.
585
601
 
602
+ **Then ask what the FIRST survivor is, because position outranks every rank.** Column 0 is the
603
+ row's identity and never drops, whatever number it carries — so if the survivor list opens on
604
+ something that does not identify the row (a time, a date, a sequence number), the finding is the
605
+ column ORDER and no amount of re-ranking touches it. It hides behind the reading convention:
606
+ anything read as a schedule is ordered time-first, which quietly makes the clock the one column a
607
+ phone cannot lose. Chasing it through the ranks finds nothing, because the rank was never what
608
+ kept it.
609
+
610
+ **And count each survivor as a FACT, not a column.** Two columns holding one fact-pair — declared
611
+ against counted, quoted against invoiced, planned against actual — mean nothing apart, so the fit
612
+ drops whichever it reaches first and the survivor states a number with nothing to compare it to.
613
+ That is worse than dropping both. Put the pair in ONE cell (`38 → 41`) and it survives or sheds
614
+ whole, which also halves what the width has to carry.
615
+
586
616
  ### 8h-bis. A cell that grows a second line lifts its first
587
617
  **Collect** one column's first-line `y` MINUS its row's top, for every row; report the SET.
588
618
 
@@ -639,6 +669,47 @@ reports two cells. And a `Text` wrapping another `Text` keeps its own words in a
639
669
  so a walk over leaf ELEMENTS measures only the nested span and reports the cell starting a
640
670
  hundred pixels in; measure text nodes with a `Range`.
641
671
 
672
+ ### 8k. Opinions the reader cannot operate — authored bands, and pills beside a search
673
+
674
+ Two defects of one kind: the screen holds a view the reader can neither inspect nor put down. Both
675
+ render perfectly, and neither shows up in any measurement of treatment.
676
+
677
+ **Measure, bands.** Count the group bands a register renders, then look in the filter band for a
678
+ control that produced them. Bands with no `group by` control are AUTHORED — the screen decided the
679
+ cut. Then ask the harder half: is the value those bands group on also a COLUMN? A register that
680
+ bands by something it never shows as a column leaves that fact unsortable and unfilterable, and it
681
+ disappears the moment the reader wants a different cut.
682
+
683
+ ```js
684
+ // bands rendered vs. a control that could have produced them
685
+ const bands = document.querySelectorAll('[data-table-group], [role="rowgroup"] > [role="presentation"]').length
686
+ const controls = [...document.querySelectorAll('button, select')].map(e => e.textContent.trim())
687
+ ```
688
+
689
+ **Measure, pills.** In one filter band, count `SearchInput`s and count pill rows (`ChipGroup`). Both
690
+ above zero is the finding: a row of pills next to a search box is two controls competing to be read
691
+ first, and the pills win on ink while the search wins on habit.
692
+
693
+ ```js
694
+ const band = document.querySelector('[data-filter-band]')
695
+ band && { search: band.querySelectorAll('input[type="search"], input[type="text"]').length,
696
+ pills: band.querySelectorAll('[role="radiogroup"], [data-chip-group]').length }
697
+ ```
698
+
699
+ **The source-side signature is cheaper than either.** A file rendering band headings whose label
700
+ comes from the row's own data, with no state driving it — a `groupBy`/`lens` param absent from the
701
+ url or component state. And an import of both `search_input` and `chip_group` in one screen.
702
+
703
+ **What the fix is not.** Deleting the bands is not the fix, and neither is keeping them and adding a
704
+ chip. Ship the value as a column first, then offer grouping as a control defaulting to none, and
705
+ drop that column while its own grouping is on — the band states it once, the column would repeat it
706
+ on every row beneath it.
707
+
708
+ → [composition.md](./composition.md) §"A register that TRIAGES — `TableGroup`" (flat is the
709
+ default) and §"One view-control vocabulary" (a `ChipGroup` lens only where the band has no search).
710
+ Both rules predate this probe; it exists because reading them is not the same as noticing you broke
711
+ them.
712
+
642
713
  ### 9. The absence pass — what catches "bland"
643
714
  Every probe above reads an element that EXISTS. None fires on a screen where nothing is wrong and
644
715
  nothing is right, which is the screen reported as bland. Defect-removal converges on *inoffensive*;
package/docs/templates.md CHANGED
@@ -805,7 +805,7 @@ quick-capture templates. Top → bottom:
805
805
  so a walk-back is one handoff at a time.
806
806
  - **Danger zone** — with the full section anatomy (heading + description) like every other
807
807
  section; the fenced red `DangerZone` card is the section's BODY.
808
- - A LEFT OUTLINE RAIL (`MenuButton` + `useSectionNav`) — **the rail is the page's COMPLETE
808
+ - A LEFT OUTLINE RAIL (`SectionNav` + `useSectionNav`) — **the rail is the page's COMPLETE
809
809
  map** (every section including Danger zone; subsections never join it) — headed by the
810
810
  bare circular `BackButton`; it scroll-spies the active section (`current={activeKey === key}`
811
811
  on each item — navigation, not selection), and on narrow containers becomes the pinned
@@ -2,7 +2,6 @@ import { useState } from "react";
2
2
  import { ScrollView, View } from "react-native";
3
3
  import { Text } from "@lotics/ui/text";
4
4
  import { colors, solid } from "@lotics/ui/colors";
5
- import { Accordion, AccordionContent, AccordionHeader } from "@lotics/ui/accordion";
6
5
  import { ActionMenu } from "@lotics/ui/action_menu";
7
6
  import { Avatar } from "@lotics/ui/avatar";
8
7
  import { Badge } from "@lotics/ui/badge";
@@ -14,6 +13,7 @@ import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
14
13
  import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
15
14
  import { KPIStrip } from "@lotics/ui/kpi_strip";
16
15
  import { Picker } from "@lotics/ui/picker";
16
+ import { StateMatrix, type StateMatrixState } from "@lotics/ui/state_matrix";
17
17
  import { Tabs } from "@lotics/ui/tabs";
18
18
 
19
19
  // ─────────────────────────────────────────────────────────────────────────────
@@ -24,8 +24,9 @@ import { Tabs } from "@lotics/ui/tabs";
24
24
  // Grammar: zinc-50 canvas, header + month Picker, KPIStrip (four counts),
25
25
  // Tabs Today / This week. "Today" = one banded roster Card — each row
26
26
  // press-opens the employee workspace Drawer (◀ ▶ sequencing) and carries a
27
- // ⋯ ActionMenu with the day's quick fixes. "This week" = per-person Accordion
28
- // rows: five day markers, expanding to the day-by-day detail in place.
27
+ // ⋯ ActionMenu with the day's quick fixes. "This week" = one StateMatrix —
28
+ // a person per row, the weekdays named once across the top, a glyph per day;
29
+ // a cell opens the same workspace Drawer.
29
30
  // ─────────────────────────────────────────────────────────────────────────────
30
31
 
31
32
  type TrangThai = "co_mat" | "di_muon" | "vang" | "nghi_phep";
@@ -66,12 +67,13 @@ const TRANG_THAI: Record<TrangThai, { label: string; color: "emerald" | "amber"
66
67
  nghi_phep: { label: "On leave", color: "blue" },
67
68
  };
68
69
 
69
- const NGAY_CONG: Record<NgayCong, { bg: string; border: string; label: string }> = {
70
- du: { bg: solid("emerald"), border: solid("emerald"), label: "Full day" },
71
- muon: { bg: solid("amber"), border: solid("amber"), label: "Late" },
72
- vang: { bg: colors.white, border: solid("red"), label: "Absent" },
73
- phep: { bg: solid("blue"), border: solid("blue"), label: "On leave" },
74
- };
70
+ const NGAY_CONG: (StateMatrixState & { key: NgayCong })[] = [
71
+ { key: "du", label: "Full day", color: "emerald" },
72
+ { key: "muon", label: "Late", color: "amber" },
73
+ { key: "vang", label: "Absent", color: "red", shape: "hollow" },
74
+ { key: "phep", label: "On leave", color: "blue", shape: "dot" },
75
+ ];
76
+ const ngayCong = (k: NgayCong) => NGAY_CONG.find((n) => n.key === k) ?? NGAY_CONG[0];
75
77
 
76
78
  const THANG = [
77
79
  { label: "April 2026", value: "2026-04" },
@@ -85,12 +87,9 @@ const TAB = [
85
87
  ];
86
88
 
87
89
  const NGAY_TUAN = ["T2", "T3", "T4", "T5", "T6"];
90
+ const NGAY_TUAN_DAY = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"];
88
91
 
89
- const rowPad = { paddingHorizontal: 20, flexDirection: "row" as const, alignItems: "center" as const, gap: 12 };
90
92
 
91
- // Week-view widths (the Accordion grid: day cells + week total + disclosure).
92
- // The Today roster uses COLUMNS below, rendered by Table.
93
- const W = { tong: 72, cell: 22, chevron: 28 };
94
93
 
95
94
  const COLUMNS: TableColumn[] = [
96
95
  { key: "ten", label: "Employee", flex: 1, sortable: true },
@@ -104,12 +103,23 @@ function badgeLabel(nv: NhanVien): string {
104
103
  return nv.trangThai === "di_muon" && nv.muonPhut !== undefined ? `${tt.label} ${nv.muonPhut}ph` : tt.label;
105
104
  }
106
105
 
107
- /** One weekday marker — filled means worked, hollow red ring means absent. */
106
+ /** One weekday marker in the drawer's day list the same glyph the week grid draws. */
108
107
  function DayCell({ ngay }: { ngay: NgayCong }) {
109
- const s = NGAY_CONG[ngay];
108
+ const s = ngayCong(ngay);
109
+ const hollow = s.shape === "hollow";
110
+ const dot = s.shape === "dot";
110
111
  return (
111
- <View style={{ width: W.cell, alignItems: "center" }}>
112
- <View style={{ width: 13, height: 13, borderRadius: 4, backgroundColor: s.bg, borderWidth: 1, borderColor: s.border }} />
112
+ <View style={{ width: 22, alignItems: "center" }}>
113
+ <View
114
+ style={{
115
+ width: dot ? 6 : 12,
116
+ height: dot ? 6 : 12,
117
+ borderRadius: dot ? 999 : 3,
118
+ backgroundColor: hollow ? colors.white : solid(s.color),
119
+ borderWidth: hollow ? 1.5 : 0,
120
+ borderColor: solid(s.color),
121
+ }}
122
+ />
113
123
  </View>
114
124
  );
115
125
  }
@@ -160,38 +170,6 @@ function HomNayRow({ nv, selected, onPress }: { nv: NhanVien; selected: boolean;
160
170
  );
161
171
  }
162
172
 
163
- /** Week row — read-only drill-down: expands in place to the day-by-day detail. */
164
- function TuanRow({ nv }: { nv: NhanVien }) {
165
- return (
166
- <Accordion>
167
- <AccordionHeader accessibilityLabel={`Week detail for ${nv.ten}`}>
168
- <View style={{ flex: 1, gap: 2 }}>
169
- <Text size="sm" weight="medium" leading="tight" numberOfLines={1}>{nv.ten}</Text>
170
- {/* Body size: `medium` above already separates the pair, so the rung
171
- down would be a second demotion on the line that says WHICH
172
- person this is. */}
173
- <Text size="sm" color="muted" leading="tight" numberOfLines={1}>{nv.chucVu}</Text>
174
- </View>
175
- <View style={{ flexDirection: "row", gap: 10 }}>
176
- {nv.tuan.map((ngay, i) => <DayCell key={NGAY_TUAN[i]} ngay={ngay} />)}
177
- </View>
178
- <View style={{ width: W.tong, alignItems: "flex-end" }}>
179
- <Text size="sm" weight="medium" tabular>{nv.tongTuan}</Text>
180
- </View>
181
- </AccordionHeader>
182
- <AccordionContent>
183
- {nv.tuan.map((ngay, i) => (
184
- <View key={NGAY_TUAN[i]} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 32 }}>
185
- <Text size="xs" color="muted" tabular style={{ width: 24 }}>{NGAY_TUAN[i]}</Text>
186
- <DayCell ngay={ngay} />
187
- <Text size="sm">{NGAY_CONG[ngay].label}</Text>
188
- </View>
189
- ))}
190
- </AccordionContent>
191
- </Accordion>
192
- );
193
- }
194
-
195
173
  function KVRow({ label, value }: { label: string; value: string }) {
196
174
  return (
197
175
  <View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 24 }}>
@@ -234,7 +212,7 @@ function NhanVienWorkspace({ nv }: { nv: NhanVien }) {
234
212
  <View key={NGAY_TUAN[i]} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 24 }}>
235
213
  <Text size="xs" color="muted" tabular style={{ width: 24 }}>{NGAY_TUAN[i]}</Text>
236
214
  <DayCell ngay={ngay} />
237
- <Text size="sm">{NGAY_CONG[ngay].label}</Text>
215
+ <Text size="sm">{ngayCong(ngay).label}</Text>
238
216
  </View>
239
217
  ))}
240
218
  </View>
@@ -258,7 +236,6 @@ export function TplAttendance() {
258
236
  const onSort = (key: string) => setSort(cycleSort(sort, key));
259
237
 
260
238
  const dem = (tt: TrangThai) => NHAN_VIEN.filter((nv) => nv.trangThai === tt).length;
261
- const eyebrow = (label: string) => <Text size="xs" color="muted" weight="medium">{label}</Text>;
262
239
 
263
240
  // The Today roster, ordered by the active sort (Hours sorts numerically).
264
241
  const sorted = sortBy(NHAN_VIEN, sort, (nv, key) =>
@@ -310,35 +287,29 @@ export function TplAttendance() {
310
287
  </Table>
311
288
  </Card>
312
289
  ) : (
313
- <Card style={{ padding: 0 }}>
314
- <View style={[rowPad, { paddingVertical: 10 }]}>
315
- <View style={{ flex: 1 }}>{eyebrow("Employee")}</View>
316
- <View style={{ flexDirection: "row", gap: 10 }}>
317
- {NGAY_TUAN.map((d) => (
318
- <View key={d} style={{ width: W.cell, alignItems: "center" }}>{eyebrow(d)}</View>
319
- ))}
320
- </View>
321
- <View style={{ width: W.tong, alignItems: "flex-end" }}>{eyebrow("Week total")}</View>
322
- <View style={{ width: W.chevron - 12 }} />
323
- </View>
324
- <View style={{ paddingHorizontal: 20 }}>
325
- {NHAN_VIEN.map((nv) => (
326
- <View key={nv.id}>
327
- <Divider />
328
- <TuanRow nv={nv} />
329
- </View>
330
- ))}
331
- </View>
332
- <Divider />
333
- {/* legend band — what each marker means */}
334
- <View style={[rowPad, { paddingVertical: 12, gap: 18 }]}>
335
- {(Object.keys(NGAY_CONG) as NgayCong[]).map((k) => (
336
- <View key={k} style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
337
- <DayCell ngay={k} />
338
- <Text size="xs" color="muted">{NGAY_CONG[k].label}</Text>
339
- </View>
340
- ))}
341
- </View>
290
+ <Card>
291
+ {/* The grid owns its axis, its legend and its scroll; a cell is a door to the
292
+ same workspace the roster opens. Absent is HOLLOW and leave is a DOT so the
293
+ two kinds of "not here" read apart before the legend is consulted. */}
294
+ <StateMatrix
295
+ columns={NGAY_TUAN.map((d, i) => ({ key: d, label: d, current: i === 2 }))}
296
+ states={NGAY_CONG}
297
+ rows={NHAN_VIEN.map((nv) => ({
298
+ key: nv.id,
299
+ leading: <Avatar name={nv.ten} size="sm" />,
300
+ label: nv.ten,
301
+ total: nv.tongTuan,
302
+ cells: nv.tuan.map((ngay, i) => ({
303
+ state: ngay,
304
+ pressable: true,
305
+ name: `${nv.ten}, ${NGAY_TUAN_DAY[i]}: ${ngayCong(ngay).label}`,
306
+ })),
307
+ }))}
308
+ onPressCell={(rowKey) => setOpenId(rowKey)}
309
+ rowLabelWidth={220}
310
+ cellWidth={36}
311
+ labels={{ total: "Week total" }}
312
+ />
342
313
  </Card>
343
314
  )}
344
315
  </View>
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
2
2
  import { Pressable, ScrollView, View } from "react-native";
3
3
  import { Text } from "@lotics/ui/text";
4
4
  import { colors } from "@lotics/ui/colors";
5
+ import { Avatar } from "@lotics/ui/avatar";
5
6
  import { Badge } from "@lotics/ui/badge";
6
7
  import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
7
8
  import { Divider } from "@lotics/ui/divider";
@@ -149,7 +150,15 @@ export function TplPivot() {
149
150
  </CardHeader>
150
151
  <View style={{ paddingHorizontal: 20, paddingVertical: 16 }}>
151
152
  <Matrix
152
- rows={WAREHOUSES.map((w) => ({ key: w.key, label: w.label }))}
153
+ // A row is a SUBJECT, so it carries the same identity mark a
154
+ // warehouse wears anywhere else in the app — square, because a
155
+ // facility is an organization and not a person. The mark is drawn
156
+ // inside `rowLabelWidth`, so nothing else in the grid moves.
157
+ rows={WAREHOUSES.map((w) => ({
158
+ key: w.key,
159
+ label: w.label,
160
+ leading: <Avatar shape="square" size="sm" name={w.label} />,
161
+ }))}
153
162
  cols={CATEGORIES.map((c) => ({ key: c.key, label: c.label }))}
154
163
  value={value}
155
164
  selected={cell}