mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
amharic date picker
This commit is contained in:
@@ -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 {
|
function toAmharicDisplay(date: Date): string {
|
||||||
try {
|
try {
|
||||||
const eth = EthDateTime.fromEuropeanDate(date);
|
const eth = toEthDateTime(date);
|
||||||
return `${EC_MONTHS_AM[eth.month - 1]} ${eth.date}/${eth.year}`;
|
return `${ethMonthName(date)} ${eth.date}/${eth.year}`;
|
||||||
} catch {
|
} catch {
|
||||||
return date.toLocaleDateString('en-US');
|
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 {
|
export function toEthiopicDateLabel(date: Date): string {
|
||||||
try {
|
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')}`;
|
return `EC ${eth.year}-${String(eth.month).padStart(2, '0')}-${String(eth.date).padStart(2, '0')}`;
|
||||||
} catch {
|
} catch {
|
||||||
return date.toLocaleDateString('en-US');
|
return date.toLocaleDateString('en-US');
|
||||||
@@ -102,7 +131,10 @@ export function AmharicDatePicker({
|
|||||||
<EthiopicDayPicker
|
<EthiopicDayPicker
|
||||||
mode="single"
|
mode="single"
|
||||||
selected={value ?? undefined}
|
selected={value ?? undefined}
|
||||||
|
defaultMonth={value ?? undefined}
|
||||||
numerals="latn"
|
numerals="latn"
|
||||||
|
captionLayout="dropdown"
|
||||||
|
formatters={ETH_FORMATTERS}
|
||||||
onSelect={(date: Date | undefined) => {
|
onSelect={(date: Date | undefined) => {
|
||||||
onChange?.(date ?? null);
|
onChange?.(date ?? null);
|
||||||
close();
|
close();
|
||||||
@@ -112,6 +144,8 @@ export function AmharicDatePicker({
|
|||||||
<GregorianDayPicker
|
<GregorianDayPicker
|
||||||
mode="single"
|
mode="single"
|
||||||
selected={value ?? undefined}
|
selected={value ?? undefined}
|
||||||
|
defaultMonth={value ?? undefined}
|
||||||
|
captionLayout="dropdown"
|
||||||
onSelect={(date: Date | undefined) => {
|
onSelect={(date: Date | undefined) => {
|
||||||
onChange?.(date ?? null);
|
onChange?.(date ?? null);
|
||||||
close();
|
close();
|
||||||
|
|||||||
156
libs/ui/src/lib/data/AdvancedTable.md
Normal file
156
libs/ui/src/lib/data/AdvancedTable.md
Normal file
@@ -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<T>`
|
||||||
|
|
||||||
|
| 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<T>` props
|
||||||
|
|
||||||
|
| Prop | Type | Required | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `columns` | `AdvancedColumn<T>[]` | 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<User>[] = [
|
||||||
|
{ header: 'Name', accessorKey: 'name' },
|
||||||
|
{ header: 'Email', accessorKey: 'email', enabled: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
<AdvancedTable
|
||||||
|
columns={columns}
|
||||||
|
data={users}
|
||||||
|
tableName="Users"
|
||||||
|
itemCount={users.length}
|
||||||
|
pageIndex={0}
|
||||||
|
onPageChange={() => {}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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<User>[] = [
|
||||||
|
{ header: 'Name', accessorKey: 'name' },
|
||||||
|
{ header: 'Email', accessorKey: 'email' },
|
||||||
|
{
|
||||||
|
header: 'Status',
|
||||||
|
align: 'center',
|
||||||
|
cell: ({ row }) => (row.original.active ? 'Active' : 'Inactive'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: '',
|
||||||
|
size: 80,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<ActionIcon onClick={() => onEdit(row.original)}>
|
||||||
|
<IconEdit size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdvancedTable
|
||||||
|
columns={columns}
|
||||||
|
data={users}
|
||||||
|
tableName="Users"
|
||||||
|
itemCount={totalCount}
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
onPageChange={setPageIndex}
|
||||||
|
pageSize={10}
|
||||||
|
refresh={refetch}
|
||||||
|
isLoading={isFetching}
|
||||||
|
emptyText="No users found"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Real reference implementation: `apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx` (`ProfessionTab`).
|
||||||
Reference in New Issue
Block a user