From 31ab6ae260e9a9d314b36804cc58756cd510e1a4 Mon Sep 17 00:00:00 2001 From: estifanos Date: Tue, 28 Jul 2026 07:14:03 +0000 Subject: [PATCH] amharic date picker --- .../src/app/components/AmharicDatePicker.tsx | 40 ++++- libs/ui/src/lib/data/AdvancedTable.md | 156 ++++++++++++++++++ 2 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 libs/ui/src/lib/data/AdvancedTable.md diff --git a/apps/portal/src/app/components/AmharicDatePicker.tsx b/apps/portal/src/app/components/AmharicDatePicker.tsx index 3798fcddc..7010078e5 100644 --- a/apps/portal/src/app/components/AmharicDatePicker.tsx +++ b/apps/portal/src/app/components/AmharicDatePicker.tsx @@ -12,18 +12,47 @@ const EC_MONTHS_AM = [ 'መጋቢት', 'ሚያዝያ', 'ግንቦት', 'ሰኔ', 'ሐምሌ', 'ነሐሴ', 'ጳጉሜ', ]; +// EthDateTime.fromEuropeanDate() computes the day from a raw UTC-epoch +// difference. A local-midnight Date in any positive-UTC-offset timezone +// (e.g. Ethiopia, UTC+3) lands in the previous UTC day and converts to +// yesterday's Ethiopian date. Re-embedding the same Y/M/D at UTC noon fixes +// the day regardless of the runtime's timezone. +function toEthDateTime(date: Date): EthDateTime { + const utcNoon = new Date( + Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12), + ); + return EthDateTime.fromEuropeanDate(utcNoon); +} + +function ethMonthName(date: Date): string { + try { + return EC_MONTHS_AM[toEthDateTime(date).month - 1] ?? ''; + } catch { + return ''; + } +} + function toAmharicDisplay(date: Date): string { try { - const eth = EthDateTime.fromEuropeanDate(date); - return `${EC_MONTHS_AM[eth.month - 1]} ${eth.date}/${eth.year}`; + const eth = toEthDateTime(date); + return `${ethMonthName(date)} ${eth.date}/${eth.year}`; } catch { return date.toLocaleDateString('en-US'); } } +// react-day-picker calls these with the Gregorian Date it tracks internally; +// override so the caption/dropdown show Amharic month names instead of the +// library's Latin transliteration (triggered by numerals="latn" below). +const ETH_FORMATTERS = { + formatCaption: (month: Date) => + `${ethMonthName(month)} ${toEthDateTime(month).year}`, + formatMonthDropdown: (month: Date) => ethMonthName(month), +}; + export function toEthiopicDateLabel(date: Date): string { try { - const eth = EthDateTime.fromEuropeanDate(date); + const eth = toEthDateTime(date); return `EC ${eth.year}-${String(eth.month).padStart(2, '0')}-${String(eth.date).padStart(2, '0')}`; } catch { return date.toLocaleDateString('en-US'); @@ -102,7 +131,10 @@ export function AmharicDatePicker({ { onChange?.(date ?? null); close(); @@ -112,6 +144,8 @@ export function AmharicDatePicker({ { onChange?.(date ?? null); close(); diff --git a/libs/ui/src/lib/data/AdvancedTable.md b/libs/ui/src/lib/data/AdvancedTable.md new file mode 100644 index 000000000..74bcd0512 --- /dev/null +++ b/libs/ui/src/lib/data/AdvancedTable.md @@ -0,0 +1,156 @@ +# AdvancedTable + +Server-paginated data table with a column-visibility ("View") menu. Built on Mantine `Table`. Portable — two files, no app-specific imports. + +## Files + +- `AdvancedTable.tsx` — the component. +- `useServerTable.ts` — small hook for page-index + search-query state (optional, but pairs with it). + +To use in another project, copy both files as-is into that project and export them from your UI barrel (or import by relative path). + +## Dependencies + +``` +@mantine/core (tested on ^8) +@tabler/icons-react (IconRefresh, IconEye, IconInbox) +react-i18next (useTranslation) +react (>=17, uses hooks) +``` + +If the target project doesn't use `react-i18next`, replace the `t(key, fallback)` calls with plain strings — the component only reads the fallback text, translation is not load-bearing. + +## Install + +```bash +npm install @mantine/core @tabler/icons-react react-i18next +``` + +Mantine must already be set up with `MantineProvider` in the app root — this component does not wrap one. + +## Copy the source + +Copy `AdvancedTable.tsx` and `useServerTable.ts` into the new project (e.g. `src/components/table/`). No modifications needed unless you're renaming the i18n keys. + +## API + +### `AdvancedColumn` + +| Field | Type | Required | Notes | +|---|---|---|---| +| `header` | `ReactNode` | yes | Column heading, also used as the label in the View menu. | +| `accessorKey` | `string` | no | Dot-path into the row (e.g. `"expectation.name"`), used when `cell` is omitted. | +| `cell` | `(ctx: { row: { original: T }; value: unknown }) => ReactNode` | no | Custom cell renderer. Takes priority over `accessorKey`. | +| `size` | `number` | no | Column width in px. | +| `align` | `'left' \| 'center' \| 'right'` | no | Text alignment for header + cells. | +| `enabled` | `boolean` | no | Whether the column starts **visible**. Default `true`. Toggled at runtime via the View menu. | + +### `AdvancedTable` props + +| Prop | Type | Required | Notes | +|---|---|---|---| +| `columns` | `AdvancedColumn[]` | yes | | +| `data` | `T[]` | yes | Rows for the **current page** only — not the full dataset. | +| `tableName` | `string` | yes | Shown as the table title. | +| `itemCount` | `number` | yes | Total row count on the server. Drives pagination and the count badge — not `data.length`. | +| `pageIndex` | `number` | yes | 0-based current page. | +| `onPageChange` | `(pageIndex: number) => void` | yes | | +| `pageSize` | `number` | no | Default `10`. Pagination only renders when `itemCount > pageSize`. | +| `refresh` | `() => void` | no | Shows a Refresh button when provided. | +| `onSearchChange` | `(q: string) => void` | no | Reserved for a search box; not currently rendered by the component itself (wire your own input and call this, or drive `useServerTable`'s `setQ`). | +| `isLoading` | `boolean` | no | Shows a loader row; also spins the Refresh button. | +| `emptyText` | `string` | no | Message when `data` is empty. | + +Rows should have an `id: string \| number` field — used as the React key (falls back to row index if absent). + +### `useServerTable(opts?)` + +```ts +const { pageIndex, setPageIndex, q, setQ, pageSize, skip, take } = useServerTable({ pageSize: 10 }); +``` + +Centralizes page-index + search-query state for a server-paginated list. `setQ` resets `pageIndex` back to 0. `skip`/`take` are ready to drop into an offset-based API call. + +## Behavior notes + +- **Column visibility** is local UI state (`useState`), re-initialized from each column's `enabled` on mount — it does not persist across reloads or sync back to the caller. +- At least one column always stays visible; the View menu disables unchecking the last one. +- The View menu closes only via outside click (`closeOnItemClick={false}`), so multiple columns can be toggled per open. + +## Usage + +### Basic + +```tsx +import { AdvancedTable, type AdvancedColumn } from './table/AdvancedTable'; + +interface User { + id: string; + name: string; + email: string; +} + +const columns: AdvancedColumn[] = [ + { header: 'Name', accessorKey: 'name' }, + { header: 'Email', accessorKey: 'email', enabled: true }, +]; + + {}} +/> +``` + +### Server-paginated, with refresh and a custom action column + +```tsx +import { AdvancedTable, useServerTable, type AdvancedColumn } from './table/AdvancedTable'; + +function UsersTable() { + const { pageIndex, setPageIndex, skip, take } = useServerTable({ pageSize: 10 }); + const { data, isFetching, refetch } = useGetUsersQuery({ skip, take }); + + const users = data?.items ?? []; + const totalCount = data?.total ?? 0; + + const columns: AdvancedColumn[] = [ + { header: 'Name', accessorKey: 'name' }, + { header: 'Email', accessorKey: 'email' }, + { + header: 'Status', + align: 'center', + cell: ({ row }) => (row.original.active ? 'Active' : 'Inactive'), + }, + { + header: '', + size: 80, + cell: ({ row }) => ( + onEdit(row.original)}> + + + ), + }, + ]; + + return ( + + ); +} +``` + +Real reference implementation: `apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx` (`ProfessionTab`).