mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge branch 'dev' of github.com:Tria-plc/emaui into Refactor
Resolved by unifying on the lib/data AdvancedTable (PR #10) as the canonical table component: kept its API plus teammate i18n/feature work, kept the folder-per-table structure (index.tsx + columns.tsx + actions.tsx) across all 27 tables, removed the parallel lib/table implementation, and fixed pre-existing compile breaks in ExamDetailPage/QuestionAssigner/RecordResultModal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,23 @@
|
||||
export * from './lib/input/BilingualInput';
|
||||
export * from './lib/feedback/ConfirmModal';
|
||||
export * from './lib/feedback/ApiErrorAlert';
|
||||
export * from './lib/feedback/notify';
|
||||
export * from './lib/feedback/FeatureUnavailable';
|
||||
export * from './lib/feedback/EmptyState';
|
||||
export * from './lib/feedback/ErrorState';
|
||||
export * from './lib/layout/AppHeader';
|
||||
export * from './lib/layout/AppSidebar';
|
||||
export * from './lib/layout/AppTopNav';
|
||||
export * from './lib/layout/nav-utils';
|
||||
export * from './lib/layout/BrandAvatar';
|
||||
export * from './lib/layout/ColorSchemeToggle';
|
||||
export * from './lib/layout/LanguageSwitcher';
|
||||
export * from './lib/layout/PageHeader';
|
||||
export * from './lib/table/AdvancedTable';
|
||||
export * from "./lib/input/BilingualInput";
|
||||
export * from "./lib/input/AmharicDatePicker";
|
||||
export * from "./lib/feedback/ConfirmModal";
|
||||
export * from "./lib/feedback/ModalFooter";
|
||||
export * from "./lib/feedback/ApiErrorAlert";
|
||||
export * from "./lib/feedback/notify";
|
||||
export * from "./lib/feedback/FeatureUnavailable";
|
||||
export * from "./lib/feedback/EmptyState";
|
||||
export * from "./lib/feedback/ErrorState";
|
||||
export * from "./lib/layout/AppHeader";
|
||||
export * from "./lib/layout/AppSidebar";
|
||||
export * from "./lib/layout/AppTopNav";
|
||||
export * from "./lib/layout/nav-utils";
|
||||
export * from "./lib/layout/BrandAvatar";
|
||||
export * from "./lib/layout/ColorSchemeToggle";
|
||||
export * from "./lib/layout/LanguageSwitcher";
|
||||
export * from "./lib/layout/PageHeader";
|
||||
export * from "./lib/input/PasswordRequirements";
|
||||
export * from "./lib/input/CountrySelect";
|
||||
export * from "./lib/input/phone";
|
||||
export * from "./lib/data/AdvancedTable";
|
||||
export * from "./lib/feedback/use-error-handler";
|
||||
export * from "./lib/data/useServerTable";
|
||||
|
||||
436
libs/ui/src/lib/components/MaritimeLoader.tsx
Normal file
436
libs/ui/src/lib/components/MaritimeLoader.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
import type { CSSProperties, ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export interface MaritimeLoaderProps extends Omit<
|
||||
ComponentPropsWithoutRef<"span">,
|
||||
"children" | "color"
|
||||
> {
|
||||
/** Standalone size. Mantine's Loader size is used automatically when omitted. */
|
||||
size?: number | string;
|
||||
/** Standalone CSS color. Mantine's Loader color is used automatically when omitted. */
|
||||
color?: string;
|
||||
/** Accessible status text. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const styles = `
|
||||
.ema-loader {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: calc(var(--ema-size, var(--loader-size, 80px)) * 1.88);
|
||||
color: var(--ema-color, var(--loader-color, #075985));
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ema-loader__svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: var(--ema-size, var(--loader-size, 80px));
|
||||
overflow: visible;
|
||||
filter: drop-shadow(
|
||||
0 calc(var(--ema-size, var(--loader-size, 80px)) * 0.035)
|
||||
calc(var(--ema-size, var(--loader-size, 80px)) * 0.04)
|
||||
rgb(7 39 58 / 18%)
|
||||
);
|
||||
}
|
||||
|
||||
.ema-loader__ship {
|
||||
transform-box: fill-box;
|
||||
transform-origin: 50% 82%;
|
||||
animation: ema-ship-float 2.55s cubic-bezier(0.45, 0, 0.55, 1) infinite;
|
||||
}
|
||||
|
||||
.ema-loader__shadow {
|
||||
fill: currentColor;
|
||||
opacity: 0.12;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: ema-shadow-breathe 2.55s cubic-bezier(0.45, 0, 0.55, 1) infinite;
|
||||
}
|
||||
|
||||
.ema-loader__deck {
|
||||
fill: currentColor;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.ema-loader__superstructure > path:first-child,
|
||||
.ema-loader__bridge-top {
|
||||
fill: #f8fbfc;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2.4;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.ema-loader__bridge-top { stroke-width: 2; }
|
||||
|
||||
.ema-loader__window {
|
||||
fill: #bfe9ff;
|
||||
stroke: currentColor;
|
||||
stroke-width: 0.7;
|
||||
}
|
||||
|
||||
.ema-loader__cabin-line {
|
||||
fill: currentColor;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.ema-loader__funnel > path:first-child {
|
||||
fill: #eef3f5;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.ema-loader__green { fill: #078930; }
|
||||
.ema-loader__yellow { fill: #fcd116; }
|
||||
.ema-loader__red { fill: #da121a; }
|
||||
|
||||
.ema-loader__mast {
|
||||
fill: currentColor;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.ema-loader__flag-pole {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.ema-loader__flag {
|
||||
transform-box: fill-box;
|
||||
transform-origin: left center;
|
||||
animation: ema-flag-wave 0.95s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.ema-loader__hull {
|
||||
fill: #f8fbfc;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.4;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.ema-loader__lower-hull {
|
||||
fill: currentColor;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.ema-loader__waterline {
|
||||
fill: none;
|
||||
stroke: rgb(255 255 255 / 52%);
|
||||
stroke-width: 2.4;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.ema-loader__bow-highlight {
|
||||
fill: none;
|
||||
stroke: rgb(255 255 255 / 46%);
|
||||
stroke-width: 2.3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.ema-loader__portholes {
|
||||
fill: #d7f2ff;
|
||||
stroke: currentColor;
|
||||
stroke-width: 0.8;
|
||||
}
|
||||
|
||||
.ema-loader__cargo path {
|
||||
fill: none;
|
||||
stroke: rgb(255 255 255 / 22%);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.ema-loader__container-dark rect { fill: #3f4a52; }
|
||||
.ema-loader__container-muted rect { fill: #7d8991; }
|
||||
.ema-loader__container-steel rect { fill: #59656d; }
|
||||
.ema-loader__container-light rect { fill: #aab2b8; }
|
||||
.ema-loader__container-medium rect { fill: #6c7880; }
|
||||
|
||||
.ema-loader__water-back,
|
||||
.ema-loader__water-front,
|
||||
.ema-loader__foam {
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.ema-loader__water-back {
|
||||
stroke: currentColor;
|
||||
stroke-width: 5;
|
||||
opacity: 0.28;
|
||||
stroke-dasharray: 58 12;
|
||||
animation: ema-water-back 2.8s linear infinite;
|
||||
}
|
||||
|
||||
.ema-loader__water-front {
|
||||
stroke: currentColor;
|
||||
stroke-width: 6;
|
||||
opacity: 0.55;
|
||||
stroke-dasharray: 70 10;
|
||||
animation: ema-water-front 1.9s linear infinite;
|
||||
}
|
||||
|
||||
.ema-loader__foam {
|
||||
stroke: rgb(255 255 255 / 78%);
|
||||
stroke-width: 3;
|
||||
stroke-dasharray: 11 7;
|
||||
animation: ema-foam-drift 1.65s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ema-ship-float {
|
||||
0%, 100% { transform: translateY(1.5px) rotate(-0.65deg); }
|
||||
50% { transform: translateY(-3px) rotate(0.65deg); }
|
||||
}
|
||||
|
||||
@keyframes ema-shadow-breathe {
|
||||
0%, 100% { transform: scaleX(1.02); opacity: 0.14; }
|
||||
50% { transform: scaleX(0.9); opacity: 0.08; }
|
||||
}
|
||||
|
||||
@keyframes ema-flag-wave {
|
||||
from { transform: skewY(-3deg) scaleX(0.94); }
|
||||
to { transform: skewY(3deg) scaleX(1.04); }
|
||||
}
|
||||
|
||||
@keyframes ema-water-back {
|
||||
to { stroke-dashoffset: -140; }
|
||||
}
|
||||
|
||||
@keyframes ema-water-front {
|
||||
to { stroke-dashoffset: 160; }
|
||||
}
|
||||
|
||||
@keyframes ema-foam-drift {
|
||||
to { stroke-dashoffset: -36; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ema-loader__ship,
|
||||
.ema-loader__shadow,
|
||||
.ema-loader__flag,
|
||||
.ema-loader__water-back,
|
||||
.ema-loader__water-front,
|
||||
.ema-loader__foam {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.ema-loader__green,
|
||||
.ema-loader__yellow,
|
||||
.ema-loader__red,
|
||||
.ema-loader__container-dark rect,
|
||||
.ema-loader__container-muted rect,
|
||||
.ema-loader__container-steel rect,
|
||||
.ema-loader__container-light rect,
|
||||
.ema-loader__container-medium rect {
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.ema-loader__label {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
margin-top: 4px;
|
||||
color: currentColor;
|
||||
}
|
||||
`;
|
||||
|
||||
function toCssSize(value: number | string | undefined) {
|
||||
return typeof value === "number" ? `${value}px` : value;
|
||||
}
|
||||
|
||||
export function MaritimeLoader({
|
||||
size,
|
||||
color,
|
||||
label = "Loading maritime services",
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: MaritimeLoaderProps) {
|
||||
const cssVariables = {
|
||||
...(size ? { "--ema-size": toCssSize(size) } : {}),
|
||||
...(color ? { "--ema-color": color } : {}),
|
||||
...style,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<span
|
||||
{...props}
|
||||
className={["ema-loader", className].filter(Boolean).join(" ")}
|
||||
style={cssVariables}
|
||||
role="status"
|
||||
aria-label={label}
|
||||
>
|
||||
<style>{styles}</style>
|
||||
|
||||
<svg
|
||||
className="ema-loader__svg"
|
||||
viewBox="0 0 260 138"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<g className="ema-loader__shadow">
|
||||
<ellipse cx="132" cy="113" rx="78" ry="7" />
|
||||
</g>
|
||||
|
||||
<g className="ema-loader__ship">
|
||||
<g className="ema-loader__cargo">
|
||||
<g className="ema-loader__container-dark">
|
||||
<rect x="60" y="54" width="27" height="17" rx="1.5" />
|
||||
<path d="M66 56v13M73 56v13M80 56v13" />
|
||||
</g>
|
||||
<g className="ema-loader__container-steel">
|
||||
<rect x="89" y="54" width="27" height="17" rx="1.5" />
|
||||
<path d="M95 56v13M102 56v13M109 56v13" />
|
||||
</g>
|
||||
<g className="ema-loader__container-light">
|
||||
<rect x="118" y="54" width="27" height="17" rx="1.5" />
|
||||
<path d="M124 56v13M131 56v13M138 56v13" />
|
||||
</g>
|
||||
<g className="ema-loader__container-medium">
|
||||
<rect x="147" y="54" width="27" height="17" rx="1.5" />
|
||||
<path d="M153 56v13M160 56v13M167 56v13" />
|
||||
</g>
|
||||
<g className="ema-loader__container-dark">
|
||||
<rect x="76" y="35" width="27" height="17" rx="1.5" />
|
||||
<path d="M82 37v13M89 37v13M96 37v13" />
|
||||
</g>
|
||||
<g className="ema-loader__container-muted">
|
||||
<rect x="105" y="35" width="27" height="17" rx="1.5" />
|
||||
<path d="M111 37v13M118 37v13M125 37v13" />
|
||||
</g>
|
||||
<g className="ema-loader__container-dark">
|
||||
<rect x="134" y="35" width="27" height="17" rx="1.5" />
|
||||
<path d="M140 37v13M147 37v13M154 37v13" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<path className="ema-loader__deck" d="M42 72H214l-4 6H48z" />
|
||||
|
||||
<g className="ema-loader__superstructure">
|
||||
<path d="M174 41h27l10 31h-42z" />
|
||||
<path className="ema-loader__bridge-top" d="M178 32h20l5 9h-27z" />
|
||||
<rect
|
||||
className="ema-loader__window"
|
||||
x="179"
|
||||
y="45"
|
||||
width="7"
|
||||
height="7"
|
||||
rx="1"
|
||||
/>
|
||||
<rect
|
||||
className="ema-loader__window"
|
||||
x="188"
|
||||
y="45"
|
||||
width="7"
|
||||
height="7"
|
||||
rx="1"
|
||||
/>
|
||||
<rect
|
||||
className="ema-loader__window"
|
||||
x="197"
|
||||
y="45"
|
||||
width="7"
|
||||
height="7"
|
||||
rx="1"
|
||||
/>
|
||||
<rect
|
||||
className="ema-loader__cabin-line"
|
||||
x="180"
|
||||
y="58"
|
||||
width="20"
|
||||
height="2.5"
|
||||
rx="1.25"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g className="ema-loader__funnel">
|
||||
<path d="M166 25h10l3 17h-15z" />
|
||||
<path
|
||||
className="ema-loader__green"
|
||||
d="M166.9 29h9.9l.6 3.5h-11.1z"
|
||||
/>
|
||||
<path
|
||||
className="ema-loader__yellow"
|
||||
d="M166.2 32.5h11.2l.6 3.5h-12.4z"
|
||||
/>
|
||||
<path
|
||||
className="ema-loader__red"
|
||||
d="M165.6 36h12.4l.6 3.5h-13.6z"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g className="ema-loader__mast">
|
||||
<path d="M194 31V12M188 20h12M194 13l11 8M194 13l-9 8" />
|
||||
<circle cx="194" cy="11" r="2" />
|
||||
</g>
|
||||
|
||||
<g>
|
||||
<path className="ema-loader__flag-pole" d="M184 20V8" />
|
||||
<g className="ema-loader__flag">
|
||||
<path
|
||||
className="ema-loader__green"
|
||||
d="M184 8c7-3 11 3 18 0v4c-7 3-11-3-18 0z"
|
||||
/>
|
||||
<path
|
||||
className="ema-loader__yellow"
|
||||
d="M184 12c7-3 11 3 18 0v4c-7 3-11-3-18 0z"
|
||||
/>
|
||||
<path
|
||||
className="ema-loader__red"
|
||||
d="M184 16c7-3 11 3 18 0v4c-7 3-11-3-18 0z"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<path
|
||||
className="ema-loader__hull"
|
||||
d="M28 76h205l-15 18c-8 10-20 15-33 15H66c-13 0-24-5-31-15L23 80c-2-2 0-4 5-4z"
|
||||
/>
|
||||
<path
|
||||
className="ema-loader__lower-hull"
|
||||
d="M34 88h190l-6 6c-8 10-20 15-33 15H66c-13 0-24-5-31-15z"
|
||||
/>
|
||||
<path className="ema-loader__waterline" d="M36 87h188" />
|
||||
<path className="ema-loader__bow-highlight" d="M206 81l15 1-8 9" />
|
||||
|
||||
<g className="ema-loader__portholes">
|
||||
<circle cx="66" cy="91" r="2.2" />
|
||||
<circle cx="78" cy="91" r="2.2" />
|
||||
<circle cx="90" cy="91" r="2.2" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g className="ema-loader__water-back">
|
||||
<path d="M3 112c17-8 30-8 47 0s30 8 47 0 30-8 47 0 30 8 47 0 30-8 47 0 30 8 47 0" />
|
||||
</g>
|
||||
<g className="ema-loader__water-front">
|
||||
<path d="M-8 121c19-9 34-9 53 0s34 9 53 0 34-9 53 0 34 9 53 0 34-9 53 0 34 9 53 0" />
|
||||
</g>
|
||||
<g className="ema-loader__foam">
|
||||
<path d="M29 106c13 4 25 5 38 3" />
|
||||
<path d="M200 108c14 1 24-1 35-5" />
|
||||
</g>
|
||||
</svg>
|
||||
{label && <span className="ema-loader__label">{label}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default MaritimeLoader;
|
||||
|
||||
// how to use this component.
|
||||
// <Center style={{ width: "90vw", height: "90vh" }}>
|
||||
// <MaritimeLoader
|
||||
// size={120}
|
||||
// color="#075985"
|
||||
// label="Loading Maritime Services..."
|
||||
// />
|
||||
// </Center>
|
||||
158
libs/ui/src/lib/data/AdvancedTable.md
Normal file
158
libs/ui/src/lib/data/AdvancedTable.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# 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`. |
|
||||
| `onPageSizeChange` | `(pageSize: number) => void` | no | Shows a page-size `<Select>` (10/20/30/40/50 by default) next to the pagination when given. |
|
||||
| `pageSizeOptions` | `number[]` | no | Options for the page-size select. Default `[10, 20, 30, 40, 50]`. |
|
||||
| `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, setPageSize, skip, take } = useServerTable({ pageSize: 10 });
|
||||
```
|
||||
|
||||
Centralizes page-index + search-query state for a server-paginated list. `setQ` and `setPageSize` both reset `pageIndex` back to 0. `skip`/`take` are ready to drop into an offset-based API call. Wire `setPageSize` into `AdvancedTable`'s `onPageSizeChange` to expose the page-size select.
|
||||
|
||||
## 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`).
|
||||
259
libs/ui/src/lib/data/AdvancedTable.tsx
Normal file
259
libs/ui/src/lib/data/AdvancedTable.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { CSSProperties, ReactNode, useState } from "react";
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Menu,
|
||||
Checkbox,
|
||||
Group,
|
||||
Text,
|
||||
Pagination,
|
||||
Loader,
|
||||
Center,
|
||||
Paper,
|
||||
Select,
|
||||
} from "@mantine/core";
|
||||
import { IconRefresh, IconAdjustmentsHorizontal , IconInbox, } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface AdvancedColumn<T> {
|
||||
header: ReactNode;
|
||||
/** Dot-path into the row, used when no `cell` is given (e.g. "expectation.name"). */
|
||||
accessorKey?: string;
|
||||
cell?: (ctx: { row: { original: T }; value: unknown }) => ReactNode;
|
||||
size?: number;
|
||||
align?: "left" | "center" | "right";
|
||||
/** Whether column starts visible. Default true. */
|
||||
enabled?: boolean;
|
||||
/** Label for the View menu; falls back to `header` when it is a plain string. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface AdvancedTableProps<T> {
|
||||
columns: AdvancedColumn<T>[];
|
||||
data: T[];
|
||||
tableName: string;
|
||||
/** Total item count on the server (drives pagination), not data.length. */
|
||||
itemCount: number;
|
||||
/** 0-based current page. */
|
||||
pageIndex: number;
|
||||
onPageChange: (pageIndex: number) => void;
|
||||
pageSize?: number;
|
||||
/** Shows a page-size <Select> when given; called with the chosen size. */
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
/** Options for the page-size select. Default [10, 20, 30, 40, 50]. */
|
||||
pageSizeOptions?: number[];
|
||||
refresh?: () => void;
|
||||
/** Server-side search — debounced internally. Omit to hide the search box. */
|
||||
onSearchChange?: (q: string) => void;
|
||||
isLoading?: boolean;
|
||||
emptyText?: string;
|
||||
verticalSpacing?: string | number;
|
||||
rowStyle?: (row: T, index: number) => CSSProperties | undefined;
|
||||
/** Makes rows clickable (adds pointer cursor). */
|
||||
onRowClick?: (row: T) => void;
|
||||
}
|
||||
|
||||
function getByPath(obj: unknown, path?: string): unknown {
|
||||
if (!path) return undefined;
|
||||
return path
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(acc, key) =>
|
||||
acc && typeof acc === "object"
|
||||
? (acc as Record<string, unknown>)[key]
|
||||
: undefined,
|
||||
obj,
|
||||
);
|
||||
}
|
||||
|
||||
export function AdvancedTable<T extends { id?: string | number }>({
|
||||
columns,
|
||||
data,
|
||||
tableName,
|
||||
itemCount,
|
||||
pageIndex,
|
||||
onPageChange,
|
||||
pageSize = 10,
|
||||
onPageSizeChange,
|
||||
pageSizeOptions = [10, 20, 30, 40, 50],
|
||||
refresh,
|
||||
isLoading = false,
|
||||
emptyText,
|
||||
verticalSpacing = "sm",
|
||||
rowStyle,
|
||||
onRowClick,
|
||||
}: AdvancedTableProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState<boolean[]>(
|
||||
columns.map((c) => c.enabled ?? true),
|
||||
);
|
||||
const toggleColumn = (i: number) =>
|
||||
setVisible((prev) => {
|
||||
if (prev[i] && prev.filter(Boolean).length === 1) return prev; // keep at least one column visible
|
||||
return prev.map((v, idx) => (idx === i ? !v : v));
|
||||
});
|
||||
const shownColumns = columns.filter((_, i) => visible[i] ?? true);
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Group gap="xs">
|
||||
<Text fw={600}>{""}</Text>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
{refresh && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={refresh}
|
||||
loading={isLoading}
|
||||
>
|
||||
{t("common.refresh", "Refresh")}
|
||||
</Button>
|
||||
)}
|
||||
<Menu
|
||||
closeOnItemClick={false}
|
||||
shadow="md"
|
||||
position="bottom-end"
|
||||
width={220}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftSection={<IconAdjustmentsHorizontal size={16} />}
|
||||
>
|
||||
{t("common.view", "View")}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>
|
||||
{t("common.toggleColumns", "Toggle columns")}
|
||||
</Menu.Label>
|
||||
{columns.map((col, i) => {
|
||||
// A ReactNode header (e.g. a live checkbox or a clickable sort
|
||||
// control) can't be reused as a menu-item label — skip it
|
||||
// rather than nesting interactive markup inside the label.
|
||||
const label = col.label ?? (typeof col.header === "string" ? col.header : null);
|
||||
if (label === null) return null;
|
||||
return (
|
||||
<Menu.Item key={i} onClick={() => toggleColumn(i)}>
|
||||
<Checkbox
|
||||
label={label}
|
||||
checked={visible[i] ?? true}
|
||||
disabled={
|
||||
visible.filter(Boolean).length === 1 &&
|
||||
(visible[i] ?? true)
|
||||
}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
styles={{
|
||||
input: { cursor: "pointer" },
|
||||
label: { cursor: "pointer" },
|
||||
}}
|
||||
/>
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Table.ScrollContainer minWidth={480}>
|
||||
<Table
|
||||
striped
|
||||
highlightOnHover
|
||||
withTableBorder
|
||||
withColumnBorders
|
||||
verticalSpacing={verticalSpacing}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{shownColumns.map((col, i) => (
|
||||
<Table.Th
|
||||
key={i}
|
||||
style={{ width: col.size, textAlign: col.align }}
|
||||
>
|
||||
{col.header}
|
||||
</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{isLoading ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={shownColumns.length}>
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : data.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={shownColumns.length}>
|
||||
<Center py="xl">
|
||||
<Group gap="xs" c="dimmed">
|
||||
<IconInbox size={18} />
|
||||
<Text c="dimmed">
|
||||
{emptyText ?? t("common.noResult", "No results")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Center>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
data.map((row, rowIndex) => (
|
||||
<Table.Tr
|
||||
key={row.id ?? rowIndex}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
style={{
|
||||
...(onRowClick ? { cursor: "pointer" } : undefined),
|
||||
...rowStyle?.(row, rowIndex),
|
||||
}}
|
||||
>
|
||||
{shownColumns.map((col, i) => {
|
||||
const value = getByPath(row, col.accessorKey);
|
||||
return (
|
||||
<Table.Td key={i} style={{ textAlign: col.align }}>
|
||||
{col.cell
|
||||
? col.cell({ row: { original: row }, value })
|
||||
: ((value as ReactNode) ?? "-")}
|
||||
</Table.Td>
|
||||
);
|
||||
})}
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
{(itemCount > pageSize || onPageSizeChange) && (
|
||||
<Group justify="flex-end" mt="md">
|
||||
{onPageSizeChange && (
|
||||
<Select
|
||||
size="sm"
|
||||
w={100}
|
||||
data={pageSizeOptions.map((n) => String(n))}
|
||||
value={String(pageSize)}
|
||||
onChange={(v) => v && onPageSizeChange(Number(v))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
)}
|
||||
{itemCount > pageSize && (
|
||||
<Pagination
|
||||
total={Math.ceil(itemCount / pageSize)}
|
||||
value={pageIndex + 1}
|
||||
onChange={(page) => onPageChange(page - 1)}
|
||||
size="sm"
|
||||
siblings={0}
|
||||
boundaries={1}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
53
libs/ui/src/lib/data/useServerTable.ts
Normal file
53
libs/ui/src/lib/data/useServerTable.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface UseServerTableOptions {
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralizes the page-index + search-query state a server-paginated table
|
||||
* needs. Changing the search query resets paging back to page 0.
|
||||
*/
|
||||
export function useServerTable({ pageSize: initialPageSize = 10 }: UseServerTableOptions = {}) {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSizeInternal] = useState(initialPageSize);
|
||||
const [q, setQInternal] = useState('');
|
||||
|
||||
const setQ = useCallback((value: string) => {
|
||||
setQInternal(value);
|
||||
setPageIndex(0);
|
||||
}, []);
|
||||
|
||||
const setPageSize = useCallback((value: number) => {
|
||||
setPageSizeInternal(value);
|
||||
setPageIndex(0);
|
||||
}, []);
|
||||
|
||||
// Slice an already-fetched array for AdvancedTable when the endpoint has no
|
||||
// skip/take of its own. Clamps pageIndex so deleting the last row of the
|
||||
// last page doesn't strand the table on an empty slice.
|
||||
const paginate = useCallback(
|
||||
<T,>(rows: T[]) => {
|
||||
const lastPage = Math.max(0, Math.ceil(rows.length / pageSize) - 1);
|
||||
const clamped = Math.min(pageIndex, lastPage);
|
||||
return {
|
||||
rows: rows.slice(clamped * pageSize, clamped * pageSize + pageSize),
|
||||
pageIndex: clamped,
|
||||
itemCount: rows.length,
|
||||
};
|
||||
},
|
||||
[pageIndex, pageSize],
|
||||
);
|
||||
|
||||
return {
|
||||
pageIndex,
|
||||
setPageIndex,
|
||||
q,
|
||||
setQ,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
skip: pageIndex * pageSize,
|
||||
take: pageSize,
|
||||
paginate,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Modal, Button, Group, Text } from '@mantine/core';
|
||||
import { Modal, Button, Text } from '@mantine/core';
|
||||
import { ModalFooter } from './ModalFooter';
|
||||
|
||||
interface ConfirmModalProps {
|
||||
opened: boolean;
|
||||
@@ -26,14 +27,14 @@ export function ConfirmModal({
|
||||
<Text size="sm" mb="xl">
|
||||
{message}
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="subtle" onClick={onClose} disabled={loading}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button color="red" onClick={onConfirm} loading={loading}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
38
libs/ui/src/lib/feedback/ModalFooter.tsx
Normal file
38
libs/ui/src/lib/feedback/ModalFooter.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Group, type GroupProps } from '@mantine/core';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
/**
|
||||
* Action row pinned to the bottom of a modal.
|
||||
*
|
||||
* Modal bodies scroll (see the Modal styles in the theme), which would carry the
|
||||
* confirm button off-screen on a tall modal. Sticking to the bottom of the
|
||||
* scrollport keeps the decision reachable from any scroll position. Negative
|
||||
* margins bleed the background over the body's padding so nothing shows through
|
||||
* underneath; on a modal short enough not to scroll this renders identically to
|
||||
* a plain Group.
|
||||
*/
|
||||
export function ModalFooter({
|
||||
children,
|
||||
style,
|
||||
...props
|
||||
}: Omit<GroupProps, 'style'> & { style?: CSSProperties }) {
|
||||
return (
|
||||
<Group
|
||||
justify="flex-end"
|
||||
{...props}
|
||||
style={{
|
||||
position: 'sticky',
|
||||
bottom: 'calc(var(--mb-padding, var(--mantine-spacing-md)) * -1)',
|
||||
marginInline: 'calc(var(--mb-padding, var(--mantine-spacing-md)) * -1)',
|
||||
marginBottom: 'calc(var(--mb-padding, var(--mantine-spacing-md)) * -1)',
|
||||
padding: 'var(--mb-padding, var(--mantine-spacing-md))',
|
||||
background: 'var(--mantine-color-body)',
|
||||
borderTop: '1px solid var(--mantine-color-default-border)',
|
||||
zIndex: 1,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
72
libs/ui/src/lib/feedback/use-error-handler.ts
Normal file
72
libs/ui/src/lib/feedback/use-error-handler.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify } from './notify';
|
||||
|
||||
// Recursive: first non-empty string in a string | array | { message | error | detail }. Never throws.
|
||||
function extractMessage(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'string') return value.trim() || null;
|
||||
if (Array.isArray(value)) {
|
||||
const parts = value.map(extractMessage).filter(Boolean) as string[];
|
||||
return parts.length ? parts.join(', ') : null;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const o = value as Record<string, unknown>;
|
||||
return extractMessage(o.message) ?? extractMessage(o.error) ?? extractMessage(o.detail) ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// HTTP status OR RTK string status (FETCH_ERROR/TIMEOUT_ERROR/PARSING_ERROR/CUSTOM_ERROR) → i18n key.
|
||||
function statusKeyFor(status: number | string | undefined): string {
|
||||
if (status === 400 || status === 422) return 'msg.validationError';
|
||||
if (status === 401) return 'msg.authError';
|
||||
if (status === 403) return 'msg.permissionError';
|
||||
if (status === 404) return 'msg.notFoundError';
|
||||
if (status === 413) return 'msg.fileTooLarge';
|
||||
if (typeof status === 'number' && status >= 500) return 'msg.serverError';
|
||||
if (typeof status === 'string') return 'msg.networkError';
|
||||
return 'msg.genericError';
|
||||
}
|
||||
|
||||
function logError(err: unknown): void {
|
||||
if (err && typeof err === 'object' && 'status' in err) {
|
||||
console.error('[API ERROR]', (err as { status?: unknown }).status, (err as { data?: unknown }).data);
|
||||
return;
|
||||
}
|
||||
console.error('Error caught:', err);
|
||||
}
|
||||
|
||||
export function useErrorHandler() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Priority: backend message (FetchBaseQueryError.data) → Error.message → status/network fallback key.
|
||||
const getErrorMessage = useCallback(
|
||||
(err: unknown): string => {
|
||||
let status: number | string | undefined;
|
||||
if (err && typeof err === 'object' && ('status' in err || 'data' in err)) {
|
||||
status = (err as { status?: number | string }).status;
|
||||
const fromData = extractMessage((err as { data?: unknown }).data);
|
||||
if (fromData) return fromData;
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
const fromError = extractMessage(err.message);
|
||||
if (fromError) return fromError;
|
||||
}
|
||||
return t(statusKeyFor(status));
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleError = useCallback(
|
||||
(err: unknown): string => {
|
||||
logError(err);
|
||||
const message = getErrorMessage(err);
|
||||
notify.error(message);
|
||||
return message;
|
||||
},
|
||||
[getErrorMessage],
|
||||
);
|
||||
|
||||
return { getErrorMessage, handleError };
|
||||
}
|
||||
224
libs/ui/src/lib/input/AmharicDatePicker.css
Normal file
224
libs/ui/src/lib/input/AmharicDatePicker.css
Normal file
@@ -0,0 +1,224 @@
|
||||
/* Restyle the react-day-picker month/year dropdown caption to match Mantine
|
||||
inputs — the library ships it as bare text + an invisible <select>, with
|
||||
no border/box affordance and a hardcoded blue chevron. */
|
||||
.amharic-daypicker {
|
||||
--rdp-accent-color: var(--mantine-primary-color-filled);
|
||||
--rdp-day-height: 36px;
|
||||
--rdp-day-width: 36px;
|
||||
--rdp-day_button-height: 34px;
|
||||
--rdp-day_button-width: 34px;
|
||||
--rdp-weekday-padding: 0.25rem 0;
|
||||
--rdp-nav-height: 2.25rem;
|
||||
--rdp-nav_button-width: 2rem;
|
||||
--rdp-nav_button-height: 2rem;
|
||||
}
|
||||
|
||||
/* Backoffice loads Tailwind's preflight after the package stylesheet. That
|
||||
reset can strip the day-picker table layout, leaving every weekday and day
|
||||
stacked in a single column. Keep the calendar's structural styles local to
|
||||
the component so it renders consistently in every app. */
|
||||
.amharic-daypicker .rdp-months {
|
||||
position: relative;
|
||||
display: flex;
|
||||
max-width: fit-content;
|
||||
}
|
||||
|
||||
.amharic-daypicker .rdp-month {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.amharic-daypicker .rdp-month_grid {
|
||||
display: table;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.amharic-daypicker .rdp-month_grid thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
|
||||
.amharic-daypicker .rdp-month_grid tbody {
|
||||
display: table-row-group;
|
||||
}
|
||||
|
||||
.amharic-daypicker .rdp-month_grid tr {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
.amharic-daypicker .rdp-weekday,
|
||||
.amharic-daypicker .rdp-day {
|
||||
display: table-cell;
|
||||
width: var(--rdp-day-width);
|
||||
height: var(--rdp-day-height);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.amharic-daypicker .rdp-weekday {
|
||||
padding: var(--rdp-weekday-padding);
|
||||
}
|
||||
|
||||
.amharic-daypicker .rdp-day_button {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 100%;
|
||||
background: none;
|
||||
color: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--rdp-day_button-width);
|
||||
height: var(--rdp-day_button-height);
|
||||
}
|
||||
|
||||
.amharic-daypicker .rdp-nav {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--rdp-nav-height);
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.amharic-daypicker .rdp-button_previous,
|
||||
.amharic-daypicker .rdp-button_next {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--rdp-nav_button-width);
|
||||
height: var(--rdp-nav_button-height);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.amharic-daypicker .rdp-dropdowns {
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.amharic-daypicker .rdp-dropdown_root {
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background-color: var(--mantine-color-body);
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.amharic-daypicker .rdp-dropdown_root:hover {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme='dark'] .amharic-daypicker .rdp-dropdown {
|
||||
color-scheme: dark;
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
color: var(--mantine-color-gray-1);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme='dark'] .amharic-daypicker .rdp-dropdown option {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
color: var(--mantine-color-gray-1);
|
||||
}
|
||||
|
||||
.amharic-daypicker .rdp-caption_label {
|
||||
gap: 0.25rem;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
|
||||
.amharic-daypicker .rdp-dropdown_root .rdp-chevron {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
fill: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
/* Prev/next month buttons: bigger tap target + visible button box (border,
|
||||
background, accent-colored chevron) instead of the default bare, tiny
|
||||
blue arrow — the dropdown captions replaced them as the primary nav, so
|
||||
they need to stay easy to spot and hit. */
|
||||
.amharic-daypicker .rdp-button_previous,
|
||||
.amharic-daypicker .rdp-button_next {
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background-color: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.amharic-daypicker .rdp-button_previous:not([aria-disabled='true']):hover,
|
||||
.amharic-daypicker .rdp-button_next:not([aria-disabled='true']):hover {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.amharic-daypicker .rdp-nav .rdp-chevron {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.amharic-daypicker .rdp-month_caption {
|
||||
font-size: inherit;
|
||||
font-weight: 600;
|
||||
margin-inline-end: calc(var(--rdp-nav_button-width) * 2 + 0.5rem);
|
||||
}
|
||||
|
||||
/* Day cells are already circular (--rdp-day_button-border-radius: 100%);
|
||||
the library only outlines the selected one by default — fill it instead
|
||||
so the selection reads as a solid, unambiguous mark. */
|
||||
.amharic-daypicker .rdp-selected .rdp-day_button {
|
||||
background-color: var(--mantine-primary-color-filled);
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
color: var(--mantine-color-white);
|
||||
}
|
||||
|
||||
/* Today indicator — a solid accent-coloured ring that stays clearly visible
|
||||
in both light and dark themes. The border uses the primary filled colour so
|
||||
it stands out against the popup background on either scheme. The text gets
|
||||
the primary colour so the digit is unambiguously "special" even without the
|
||||
ring being thick. */
|
||||
.amharic-daypicker .rdp-today .rdp-day_button {
|
||||
border: 2px solid var(--mantine-primary-color-filled) !important;
|
||||
color: var(--mantine-primary-color-filled);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* When today is also the selected day, keep the filled background but add a
|
||||
contrasting inner ring so the "today" mark isn't lost. */
|
||||
.amharic-daypicker .rdp-today.rdp-selected .rdp-day_button {
|
||||
background-color: var(--mantine-primary-color-filled);
|
||||
border-color: var(--mantine-primary-color-filled) !important;
|
||||
color: var(--mantine-color-white);
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* Dark-mode: bright border ring + bright text, NO filled background.
|
||||
A filled background can end up white on dark surfaces; a ring + coloured
|
||||
digit is always visible regardless of the popup colour scheme. */
|
||||
[data-mantine-color-scheme='dark'] .amharic-daypicker .rdp-today .rdp-day_button {
|
||||
background-color: transparent !important;
|
||||
border: 2px solid #74c0fc !important;
|
||||
color: #74c0fc !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* When today is also selected in dark mode: keep the primary filled background
|
||||
but use the bright ring so today is still distinguishable from other selected days. */
|
||||
[data-mantine-color-scheme='dark'] .amharic-daypicker .rdp-today.rdp-selected .rdp-day_button {
|
||||
background-color: var(--mantine-primary-color-filled) !important;
|
||||
border: 2px solid #74c0fc !important;
|
||||
color: #fff !important;
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Ensure no scrollbar appears on the datepicker popover dropdown */
|
||||
.amharic-daypicker-dropdown {
|
||||
overflow: hidden !important;
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
}
|
||||
|
||||
.amharic-daypicker-dropdown::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
403
libs/ui/src/lib/input/AmharicDatePicker.tsx
Normal file
403
libs/ui/src/lib/input/AmharicDatePicker.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
MantineSize,
|
||||
Popover,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { TimeInput } from '@mantine/dates';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { DayPicker as EthiopicDayPicker } from '@daypicker/ethiopic';
|
||||
import { DayPicker as GregorianDayPicker } from '@daypicker/react';
|
||||
import { IconCalendarEvent } from '@tabler/icons-react';
|
||||
import '@daypicker/react/dist/style.css';
|
||||
import './AmharicDatePicker.css';
|
||||
import {
|
||||
type EthPeriod,
|
||||
ETH_PERIODS,
|
||||
ethMonthName,
|
||||
ethTimeLabel,
|
||||
fromEthTime,
|
||||
toAmharicDisplay,
|
||||
toEthDateTime,
|
||||
toEthTime,
|
||||
} from '@ema-platform/shared';
|
||||
|
||||
export type { EthPeriod };
|
||||
|
||||
// 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),
|
||||
};
|
||||
|
||||
type DateWireFormat = 'iso' | 'date';
|
||||
|
||||
// yyyy-MM-dd, parsed/formatted as a LOCAL calendar date — no Date-object/UTC
|
||||
// round-trip at all, so it can't suffer the timezone off-by-one class of bug
|
||||
// the ISO path below works around. This is the shape filter query params and
|
||||
// plain `date: string` DTO fields expect.
|
||||
function parsePlainDate(value: string): Date | null {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||
if (!m) return null;
|
||||
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
}
|
||||
|
||||
function formatPlainDate(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
// ISO wire format is a full ISO-8601 instant string (e.g.
|
||||
// "2026-07-31T00:00:00.000Z"), what most backend date fields expect. When
|
||||
// `withTime` is off, the picker only selects a calendar day, so the time is
|
||||
// pinned to UTC midnight — reading back with getUTC*() (not local get*())
|
||||
// keeps the calendar day stable regardless of the runtime's timezone,
|
||||
// avoiding the same off-by-one class of bug the UTC-noon workaround above
|
||||
// exists for.
|
||||
function parseWireValue(
|
||||
value: string | null | undefined,
|
||||
format: DateWireFormat,
|
||||
withTime: boolean,
|
||||
): Date | null {
|
||||
if (!value) return null;
|
||||
if (format === 'date') return parsePlainDate(value);
|
||||
const d = new Date(value);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return withTime
|
||||
? d
|
||||
: new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
|
||||
}
|
||||
|
||||
function formatWireValue(
|
||||
date: Date,
|
||||
format: DateWireFormat,
|
||||
withTime: boolean,
|
||||
): string {
|
||||
if (format === 'date') return formatPlainDate(date);
|
||||
return withTime
|
||||
? date.toISOString()
|
||||
: new Date(
|
||||
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()),
|
||||
).toISOString();
|
||||
}
|
||||
|
||||
// Combines a calendar day with a time-of-day, keeping whichever half isn't
|
||||
// changing. `base` is the currently selected Date (may be null if nothing
|
||||
// picked yet); `day`/`time` override only the half that's provided.
|
||||
function mergeDateTime(
|
||||
base: Date | null,
|
||||
day?: Date,
|
||||
time?: { hours: number; minutes: number },
|
||||
): Date {
|
||||
const result = day ? new Date(day) : new Date(base ?? new Date());
|
||||
if (time) {
|
||||
result.setHours(time.hours, time.minutes, 0, 0);
|
||||
} else if (day && base) {
|
||||
result.setHours(base.getHours(), base.getMinutes(), base.getSeconds(), 0);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const YEAR_DROPDOWN_START = new Date(new Date().getFullYear() - 100, 0, 1);
|
||||
const YEAR_DROPDOWN_END = new Date(new Date().getFullYear() + 50, 11, 31);
|
||||
|
||||
export interface AmharicDatePickerProps {
|
||||
label?: React.ReactNode;
|
||||
value?: string | null;
|
||||
onChange?: (value: string) => void;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
error?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
size?: MantineSize;
|
||||
name?: string;
|
||||
onBlur?: () => void;
|
||||
/** Width of the input, same as Mantine's `w` on TextInput/Select — needed
|
||||
* to line this field up with siblings in a filter bar. */
|
||||
w?: number | string;
|
||||
/** Show a time-of-day field alongside the calendar. Off by default —
|
||||
* most callers only need a calendar day. */
|
||||
withTime?: boolean;
|
||||
/** Wire format for `value`/`onChange`: a full ISO-8601 instant (default,
|
||||
* what most backend date fields expect) or a bare `yyyy-mm-dd` calendar
|
||||
* date (what filter query params and plain `date: string` DTO fields
|
||||
* expect). */
|
||||
dateFormat?: DateWireFormat;
|
||||
}
|
||||
|
||||
export function AmharicDatePicker({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
required,
|
||||
placeholder,
|
||||
error,
|
||||
disabled,
|
||||
size,
|
||||
name,
|
||||
onBlur,
|
||||
w,
|
||||
withTime = false,
|
||||
dateFormat = 'iso',
|
||||
}: AmharicDatePickerProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [calendarType, setCalendarType] = useState<'EN' | 'AMH'>(() =>
|
||||
i18n.language?.startsWith('am') ? 'AMH' : 'EN',
|
||||
);
|
||||
const [opened, { close, toggle }] = useDisclosure(false);
|
||||
|
||||
const selected = parseWireValue(value, dateFormat, withTime);
|
||||
|
||||
const dateLabel = selected
|
||||
? calendarType === 'EN'
|
||||
? selected.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
: toAmharicDisplay(selected)
|
||||
: '';
|
||||
const timeLabel =
|
||||
withTime && selected
|
||||
? calendarType === 'AMH'
|
||||
? ` ${ethTimeLabel(selected)}`
|
||||
: ` ${selected.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}`
|
||||
: '';
|
||||
const displayValue = dateLabel + timeLabel;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={close}
|
||||
position="bottom-start"
|
||||
width="auto"
|
||||
trapFocus
|
||||
withArrow
|
||||
withinPortal
|
||||
shadow="md"
|
||||
radius="md"
|
||||
// The field lives inside a Modal's scrollable body while the dropdown is
|
||||
// portaled to <body> — a different scroll container than its reference.
|
||||
// The default `absolute` strategy sums offsets across that scroll chain
|
||||
// and can get it wrong (dropdown flipped off-screen, or not following
|
||||
// the field as the modal scrolls). `fixed` positions purely off the
|
||||
// reference's viewport rect, sidestepping that.
|
||||
floatingStrategy="fixed"
|
||||
// Prevents the dropdown from re-flipping position mid-interaction —
|
||||
// switching months resizes the grid (Pagume has far fewer days), which
|
||||
// otherwise nudges the floating box right as a nav click lands, making
|
||||
// the click miss.
|
||||
preventPositionChangeWhenVisible
|
||||
>
|
||||
<Popover.Target>
|
||||
<TextInput
|
||||
label={label}
|
||||
required={required}
|
||||
value={displayValue}
|
||||
readOnly
|
||||
disabled={disabled}
|
||||
size={size}
|
||||
name={name}
|
||||
w={w}
|
||||
error={error}
|
||||
placeholder={placeholder}
|
||||
onClick={() => !disabled && toggle()}
|
||||
onBlur={onBlur}
|
||||
styles={{
|
||||
input: {
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
},
|
||||
}}
|
||||
leftSection={
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
tabIndex={-1}
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCalendarType((prev) => (prev === 'EN' ? 'AMH' : 'EN'));
|
||||
}}
|
||||
aria-label={t('common.switchCalendar')}
|
||||
styles={{
|
||||
root: {
|
||||
paddingLeft: 6,
|
||||
paddingRight: 6,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{calendarType}
|
||||
</Button>
|
||||
}
|
||||
leftSectionWidth="3.25rem"
|
||||
rightSection={
|
||||
<ActionIcon size="sm" variant="transparent" disabled={disabled} onClick={() => toggle()}>
|
||||
<IconCalendarEvent size={18} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
</Popover.Target>
|
||||
|
||||
{/* Inside a Modal the dropdown is portaled out of the modal body so it positions nicely. */}
|
||||
<Popover.Dropdown className="amharic-daypicker-dropdown" p="sm" style={{ overflow: 'hidden' }}>
|
||||
{calendarType === 'AMH' ? (
|
||||
<EthiopicDayPicker
|
||||
className="amharic-daypicker"
|
||||
mode="single"
|
||||
selected={selected ?? undefined}
|
||||
defaultMonth={selected ?? undefined}
|
||||
startMonth={YEAR_DROPDOWN_START}
|
||||
endMonth={YEAR_DROPDOWN_END}
|
||||
numerals="latn"
|
||||
captionLayout="dropdown"
|
||||
formatters={ETH_FORMATTERS}
|
||||
onSelect={(date: Date | undefined) => {
|
||||
onChange?.(date ? formatWireValue(mergeDateTime(selected, date), dateFormat, withTime) : '');
|
||||
if (!withTime) close();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<GregorianDayPicker
|
||||
className="amharic-daypicker"
|
||||
mode="single"
|
||||
selected={selected ?? undefined}
|
||||
defaultMonth={selected ?? undefined}
|
||||
startMonth={YEAR_DROPDOWN_START}
|
||||
endMonth={YEAR_DROPDOWN_END}
|
||||
captionLayout="dropdown"
|
||||
onSelect={(date: Date | undefined) => {
|
||||
onChange?.(date ? formatWireValue(mergeDateTime(selected, date), dateFormat, withTime) : '');
|
||||
if (!withTime) close();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{withTime && calendarType === 'AMH' && (() => {
|
||||
const { period, hour } = selected ? toEthTime(selected.getHours()) : { period: 'tewat' as EthPeriod, hour: 12 };
|
||||
const minute = selected ? selected.getMinutes() : 0;
|
||||
const periodHours = ETH_PERIODS.find((p) => p.value === period)?.hours ?? ETH_PERIODS[0].hours;
|
||||
const setTime = (h24: number, m: number) =>
|
||||
onChange?.(
|
||||
formatWireValue(mergeDateTime(selected, undefined, { hours: h24, minutes: m }), dateFormat, true),
|
||||
);
|
||||
return (
|
||||
<Stack gap={4} mt="sm">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('common.time')}
|
||||
</Text>
|
||||
{/* Full-width 4-way toggle reads better than a dropdown for 4
|
||||
short Amharic words, and doesn't get squeezed by the
|
||||
calendar's fixed (auto) popover width the way three
|
||||
side-by-side Selects did. */}
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
size="xs"
|
||||
disabled={!selected}
|
||||
value={period}
|
||||
data={ETH_PERIODS.map((p) => ({ value: p.value, label: p.label }))}
|
||||
onChange={(next) => {
|
||||
// Keep the same slot index within the new period so the
|
||||
// hour never lands outside its 6-hour range.
|
||||
const slot = Math.max(periodHours.indexOf(hour), 0);
|
||||
const nextHours = ETH_PERIODS.find((p) => p.value === next)?.hours ?? periodHours;
|
||||
setTime(fromEthTime(next as EthPeriod, nextHours[slot]), minute);
|
||||
}}
|
||||
/>
|
||||
<Group gap={4} justify="center" wrap="nowrap">
|
||||
<Select
|
||||
disabled={!selected}
|
||||
value={String(hour)}
|
||||
data={periodHours.map((h) => ({ value: String(h), label: String(h) }))}
|
||||
allowDeselect={false}
|
||||
w={72}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
onChange={(next) => {
|
||||
if (!next) return;
|
||||
setTime(fromEthTime(period, Number(next)), minute);
|
||||
}}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
:
|
||||
</Text>
|
||||
<Select
|
||||
disabled={!selected}
|
||||
value={String(minute).padStart(2, '0')}
|
||||
data={Array.from({ length: 60 }, (_, m) => ({
|
||||
value: String(m).padStart(2, '0'),
|
||||
label: String(m).padStart(2, '0'),
|
||||
}))}
|
||||
allowDeselect={false}
|
||||
w={72}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
onChange={(next) => {
|
||||
if (next == null) return;
|
||||
setTime(fromEthTime(period, hour), Number(next));
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
})()}
|
||||
|
||||
{withTime && calendarType === 'EN' && (
|
||||
<TimeInput
|
||||
label={t('common.time')}
|
||||
mt="sm"
|
||||
disabled={!selected}
|
||||
value={
|
||||
selected
|
||||
? `${String(selected.getHours()).padStart(2, '0')}:${String(selected.getMinutes()).padStart(2, '0')}`
|
||||
: ''
|
||||
}
|
||||
onChange={(e) => {
|
||||
const [h, m] = e.currentTarget.value.split(':').map(Number);
|
||||
if (Number.isNaN(h) || Number.isNaN(m)) return;
|
||||
onChange?.(
|
||||
formatWireValue(mergeDateTime(selected, undefined, { hours: h, minutes: m }), dateFormat, true),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="sm">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
onChange?.('');
|
||||
close();
|
||||
}}
|
||||
>
|
||||
{calendarType === 'AMH' ? 'አጽዳ' : 'Clear'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
onChange?.(formatWireValue(new Date(), dateFormat, withTime));
|
||||
close();
|
||||
}}
|
||||
>
|
||||
{calendarType === 'AMH' ? 'ዛሬ' : 'Today'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
116
libs/ui/src/lib/input/CountrySelect.tsx
Normal file
116
libs/ui/src/lib/input/CountrySelect.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Group, Select, Text, type ComboboxItem, type SelectProps } from '@mantine/core';
|
||||
import { registerLocale, getNames, getName, getAlpha2Code } from 'i18n-iso-countries';
|
||||
import {
|
||||
registerLocale as registerNationalityLocale,
|
||||
getName as getNationalityNameRaw,
|
||||
} from 'i18n-nationality';
|
||||
import * as Flags from 'country-flag-icons/react/3x2';
|
||||
import en from 'i18n-iso-countries/langs/en.json';
|
||||
import am from 'i18n-iso-countries/langs/am.json';
|
||||
import nationalityEn from 'i18n-nationality/langs/en.json';
|
||||
|
||||
// Registered once at module load — locale data is static.
|
||||
registerLocale(en);
|
||||
registerLocale(am);
|
||||
registerNationalityLocale(nationalityEn);
|
||||
|
||||
type CountryLang = 'en' | 'am';
|
||||
|
||||
function resolveLang(lng: string): CountryLang {
|
||||
return lng === 'am' ? 'am' : 'en';
|
||||
}
|
||||
|
||||
/** Localized country name for an alpha-2 code; '' when unset/unknown. */
|
||||
export function getCountryName(code: string | null | undefined, lang: CountryLang = 'en'): string {
|
||||
return code ? (getName(code, lang) ?? '') : '';
|
||||
}
|
||||
|
||||
/** Alpha-2 code for a stored country name (e.g. "Ethiopian" data); undefined when unmatched. */
|
||||
export function getCountryCode(name: string | null | undefined, lang: CountryLang = 'en'): string | undefined {
|
||||
return name ? getAlpha2Code(name, lang) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Localized demonym for an alpha-2 code (e.g. "ET" -> "Ethiopian"), for
|
||||
* nationality fields as opposed to plain country fields.
|
||||
* ponytail: i18n-nationality only ships an English locale — Amharic falls
|
||||
* back to the country name until an Amharic demonym dataset shows up.
|
||||
*/
|
||||
export function getNationalityName(code: string | null | undefined, lang: CountryLang = 'en'): string {
|
||||
if (!code) return '';
|
||||
if (lang === 'en') return getNationalityNameRaw(code, 'en') ?? getCountryName(code, lang);
|
||||
return getCountryName(code, lang);
|
||||
}
|
||||
|
||||
function CountryFlag({ code }: { code: string }) {
|
||||
const Flag = Flags[code as keyof typeof Flags];
|
||||
return Flag ? <Flag style={{ width: 22, borderRadius: 2, display: 'block' }} /> : null;
|
||||
}
|
||||
|
||||
// Case-insensitive substring on name, plus ISO code prefix ("et" → Ethiopia).
|
||||
const filterCountries: SelectProps['filter'] = ({ options, search }) => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return options;
|
||||
return (options as ComboboxItem[]).filter(
|
||||
(o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().startsWith(q),
|
||||
);
|
||||
};
|
||||
|
||||
// Module scope → stable reference, no re-render churn.
|
||||
const renderCountryOption: SelectProps['renderOption'] = ({ option }) => (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<CountryFlag code={option.value} />
|
||||
<Text fz="sm">{option.label}</Text>
|
||||
</Group>
|
||||
);
|
||||
|
||||
export interface CountrySelectProps {
|
||||
value: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
description?: React.ReactNode;
|
||||
error?: React.ReactNode;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Show nationality labels ("Ethiopian") instead of country names ("Ethiopia"). */
|
||||
demonym?: boolean;
|
||||
}
|
||||
|
||||
export function CountrySelect({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
demonym,
|
||||
...rest
|
||||
}: CountrySelectProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = resolveLang(i18n.language);
|
||||
|
||||
const countries = useMemo(
|
||||
() =>
|
||||
Object.keys(getNames(lang))
|
||||
.map((code) => ({ value: code, label: demonym ? getNationalityName(code, lang) : getCountryName(code, lang) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, lang)),
|
||||
[lang, demonym],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
data={countries}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder ?? t('country.select')}
|
||||
leftSection={value ? <CountryFlag code={value} /> : undefined}
|
||||
renderOption={renderCountryOption}
|
||||
filter={filterCountries}
|
||||
nothingFoundMessage={t('country.notFound')}
|
||||
searchable
|
||||
clearable
|
||||
maxDropdownHeight={320}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
57
libs/ui/src/lib/input/PasswordRequirements.tsx
Normal file
57
libs/ui/src/lib/input/PasswordRequirements.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Stack, Text, Group } from '@mantine/core';
|
||||
import { IconCheck, IconX } from '@tabler/icons-react';
|
||||
import { z } from 'zod';
|
||||
|
||||
export function passwordRules(minLength: number) {
|
||||
return [
|
||||
{ label: `At least ${minLength} characters`, test: (p: string) => p.length >= minLength },
|
||||
{ label: 'One lowercase letter', test: (p: string) => /[a-z]/.test(p) },
|
||||
{ label: 'One uppercase letter', test: (p: string) => /[A-Z]/.test(p) },
|
||||
{ label: 'One number', test: (p: string) => /\d/.test(p) },
|
||||
{ label: 'One special character', test: (p: string) => /[^A-Za-z0-9]/.test(p) },
|
||||
];
|
||||
}
|
||||
|
||||
/** Zod field schema enforcing every rule; unmet rules surface as separate issues. */
|
||||
export const passwordSchema = (minLength: number) =>
|
||||
z.string().superRefine((val, ctx) => {
|
||||
for (const rule of passwordRules(minLength)) {
|
||||
if (!rule.test(val)) {
|
||||
ctx.addIssue({ code: 'custom', message: rule.label });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const passwordMeetsAll = (password: string, minLength: number) =>
|
||||
passwordRules(minLength).every((rule) => rule.test(password));
|
||||
|
||||
interface PasswordRequirementsProps {
|
||||
password: string;
|
||||
minLength: number;
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
/** Live checklist of password requirements, ticking off as the user types. Hidden until the user starts typing. */
|
||||
export function PasswordRequirements({ password, minLength, labels }: PasswordRequirementsProps) {
|
||||
if (!password) return null;
|
||||
const rules = passwordRules(minLength);
|
||||
return (
|
||||
<Stack gap={6} mt={6}>
|
||||
{rules.map((rule, i) => {
|
||||
const met = rule.test(password);
|
||||
return (
|
||||
<Group key={rule.label} gap={6} wrap="nowrap">
|
||||
{met ? (
|
||||
<IconCheck size={14} color="var(--mantine-color-teal-6)" />
|
||||
) : (
|
||||
<IconX size={14} color="var(--mantine-color-gray-5)" />
|
||||
)}
|
||||
<Text fz="xs" c={met ? 'teal' : 'dimmed'}>
|
||||
{labels?.[i] ?? rule.label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
13
libs/ui/src/lib/input/phone.ts
Normal file
13
libs/ui/src/lib/input/phone.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** Accepts `09xxxxxxxx` or `+2519xxxxxxxx`; always yields `+2519xxxxxxxx`. */
|
||||
export const ethiopianPhone = z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((v) => (/^09\d{8}$/.test(v) ? `+251${v.slice(1)}` : v))
|
||||
.refine((v) => /^\+2519\d{8}$/.test(v), {
|
||||
message: 'Enter a valid phone number (+2519xxxxxxxx)',
|
||||
});
|
||||
|
||||
/** Same rules, but blank is allowed. */
|
||||
export const optionalEthiopianPhone = z.union([z.literal(''), ethiopianPhone]).optional();
|
||||
@@ -34,6 +34,8 @@ interface AppHeaderProps {
|
||||
userName?: string;
|
||||
userInitials?: string;
|
||||
supportedLanguages: readonly string[];
|
||||
onNotificationsClick?: () => void;
|
||||
notificationCount?: number;
|
||||
}
|
||||
|
||||
export function AppHeader({
|
||||
@@ -46,6 +48,8 @@ export function AppHeader({
|
||||
userName = 'User',
|
||||
userInitials = '?',
|
||||
supportedLanguages,
|
||||
onNotificationsClick,
|
||||
notificationCount,
|
||||
}: AppHeaderProps) {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
|
||||
@@ -201,9 +205,17 @@ export function AppHeader({
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
|
||||
}}
|
||||
onClick={onNotificationsClick}
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Indicator color="red" size={9} offset={5} withBorder>
|
||||
<Indicator
|
||||
color="red"
|
||||
size={notificationCount ? 16 : 9}
|
||||
offset={notificationCount ? 2 : 5}
|
||||
withBorder
|
||||
disabled={notificationCount === 0}
|
||||
label={notificationCount || undefined}
|
||||
>
|
||||
<IconBell size={19} />
|
||||
</Indicator>
|
||||
</UnstyledButton>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
AppShell,
|
||||
Badge,
|
||||
Group,
|
||||
NavLink,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
@@ -9,8 +11,10 @@ import {
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
rem,
|
||||
useMantineColorScheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
} from '@tabler/icons-react';
|
||||
@@ -64,6 +68,10 @@ interface SidebarItemProps {
|
||||
collapsed: boolean;
|
||||
activePath: string;
|
||||
onNavigate: (item: NavItem) => void;
|
||||
/** Whether this item's children are expanded. Ignored when it has none. */
|
||||
opened: boolean;
|
||||
/** Called with the new expanded state when the header is toggled. */
|
||||
onToggle: (opened: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +83,7 @@ interface SidebarItemProps {
|
||||
* nest, so a parent becomes a hover flyout instead of silently losing its
|
||||
* children.
|
||||
*/
|
||||
function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemProps) {
|
||||
function SidebarItem({ item, collapsed, activePath, onNavigate, opened, onToggle }: SidebarItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const ItemIcon = item.icon;
|
||||
const hasChildren = Boolean(item.children?.length);
|
||||
@@ -101,10 +109,29 @@ function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemPro
|
||||
</Badge>
|
||||
);
|
||||
|
||||
const rightSection = item.soon ? (
|
||||
// Parent headers always carry a chevron so the expand/collapse state is
|
||||
// never ambiguous, even when a badge is also present.
|
||||
const chevron = hasChildren ? (
|
||||
opened ? (
|
||||
<IconChevronDown size={14} stroke={1.8} color="var(--mantine-color-gray-5)" />
|
||||
) : (
|
||||
<IconChevronRight size={14} stroke={1.8} color="var(--mantine-color-gray-5)" />
|
||||
)
|
||||
) : null;
|
||||
|
||||
const soonBadge = (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
{t('nav.soon', 'Soon')}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
const rightSection = hasChildren ? (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{item.soon ? soonBadge : badgeNode}
|
||||
{chevron}
|
||||
</Group>
|
||||
) : item.soon ? (
|
||||
soonBadge
|
||||
) : (
|
||||
badgeNode || undefined
|
||||
);
|
||||
@@ -183,8 +210,11 @@ function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemPro
|
||||
label={t(item.label)}
|
||||
leftSection={<ItemIcon size={19} stroke={1.6} />}
|
||||
rightSection={rightSection}
|
||||
// Auto-expands the group the user is currently inside.
|
||||
defaultOpened={hasChildren ? branchActive : undefined}
|
||||
disableRightSectionRotation
|
||||
// Controlled so the group re-opens if the active route moves inside it
|
||||
// later (see the auto-expand effect in AppSidebar), not just on mount.
|
||||
opened={hasChildren ? opened : undefined}
|
||||
onChange={hasChildren ? onToggle : undefined}
|
||||
onClick={() => !hasChildren && onNavigate(item)}
|
||||
variant="light"
|
||||
styles={{
|
||||
@@ -249,6 +279,55 @@ export function AppSidebar({
|
||||
brandLogo,
|
||||
}: AppSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const hoverBg = colorScheme === 'dark'
|
||||
? 'var(--mantine-color-dark-6)'
|
||||
: 'var(--mantine-color-gray-0)';
|
||||
const hoverColor = colorScheme === 'dark'
|
||||
? 'var(--mantine-color-gray-3)'
|
||||
: 'var(--mantine-color-gray-7)';
|
||||
|
||||
const sections = useMemo(() => toSections(navItems), [navItems]);
|
||||
|
||||
// Expand/collapse state per collapsible header, keyed by section label for
|
||||
// NavSection headings and by item label for a parent item's children.
|
||||
// Sections default open (today every section is always visible); a nested
|
||||
// item's children default to open only when the active route is inside it,
|
||||
// matching the previous `defaultOpened` behaviour.
|
||||
const [openMap, setOpenMap] = useState<Record<string, boolean>>({});
|
||||
|
||||
// If the active route moves into a header the user had collapsed, expand
|
||||
// it back open so the highlighted item stays visible. Never collapses
|
||||
// anything — that stays purely a manual, per-header action.
|
||||
useEffect(() => {
|
||||
setOpenMap((prev) => {
|
||||
let changed = false;
|
||||
const next = { ...prev };
|
||||
sections.forEach((section, sectionIndex) => {
|
||||
const sectionKey = section.label ?? `section-${sectionIndex}`;
|
||||
if (
|
||||
section.label &&
|
||||
next[sectionKey] !== true &&
|
||||
section.items.some((item) => isBranchActive(item, activePath))
|
||||
) {
|
||||
next[sectionKey] = true;
|
||||
changed = true;
|
||||
}
|
||||
section.items.forEach((item) => {
|
||||
if (item.children?.length && next[item.label] !== true && isBranchActive(item, activePath)) {
|
||||
next[item.label] = true;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [activePath, sections]);
|
||||
|
||||
const toggleSection = (key: string) =>
|
||||
setOpenMap((prev) => ({ ...prev, [key]: !(prev[key] ?? true) }));
|
||||
const setItemOpened = (key: string, next: boolean) =>
|
||||
setOpenMap((prev) => ({ ...prev, [key]: next }));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -299,41 +378,64 @@ export function AppSidebar({
|
||||
{/* Navigation items */}
|
||||
<AppShell.Section grow component={ScrollArea} p="md">
|
||||
<Stack gap={2}>
|
||||
{toSections(navItems).map((section, sectionIndex) => (
|
||||
<Stack gap={2} key={section.label ?? `section-${sectionIndex}`}>
|
||||
{/* Headings are noise when only icons are visible. */}
|
||||
{section.label && !collapsed && (
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
c="dimmed"
|
||||
mt={sectionIndex === 0 ? 0 : rem(14)}
|
||||
pl={rem(12)}
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.06em' }}
|
||||
>
|
||||
{t(section.label)}
|
||||
</Text>
|
||||
)}
|
||||
{section.label && collapsed && sectionIndex > 0 && (
|
||||
<div
|
||||
style={{
|
||||
height: 1,
|
||||
margin: `${rem(8)} ${rem(6)}`,
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{section.items.map((item) => (
|
||||
<SidebarItem
|
||||
key={item.label}
|
||||
item={item}
|
||||
collapsed={collapsed}
|
||||
activePath={activePath}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
))}
|
||||
{sections.map((section, sectionIndex) => {
|
||||
const sectionKey = section.label ?? `section-${sectionIndex}`;
|
||||
const sectionOpened = openMap[sectionKey] ?? true;
|
||||
return (
|
||||
<Stack gap={2} key={sectionKey}>
|
||||
{/* Headings are noise when only icons are visible. */}
|
||||
{section.label && !collapsed && (
|
||||
<UnstyledButton
|
||||
onClick={() => toggleSection(sectionKey)}
|
||||
aria-expanded={sectionOpened}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
padding: `0 ${rem(12)}`,
|
||||
marginTop: sectionIndex === 0 ? 0 : rem(14),
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
c="dimmed"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.06em' }}
|
||||
>
|
||||
{t(section.label)}
|
||||
</Text>
|
||||
{sectionOpened ? (
|
||||
<IconChevronDown size={14} stroke={1.8} color="var(--mantine-color-gray-5)" />
|
||||
) : (
|
||||
<IconChevronRight size={14} stroke={1.8} color="var(--mantine-color-gray-5)" />
|
||||
)}
|
||||
</UnstyledButton>
|
||||
)}
|
||||
{section.label && collapsed && sectionIndex > 0 && (
|
||||
<div
|
||||
style={{
|
||||
height: 1,
|
||||
margin: `${rem(8)} ${rem(6)}`,
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(!section.label || sectionOpened || collapsed) &&
|
||||
section.items.map((item) => (
|
||||
<SidebarItem
|
||||
key={item.label}
|
||||
item={item}
|
||||
collapsed={collapsed}
|
||||
activePath={activePath}
|
||||
onNavigate={onNavigate}
|
||||
opened={openMap[item.label] ?? isBranchActive(item, activePath)}
|
||||
onToggle={(next) => setItemOpened(item.label, next)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</AppShell.Section>
|
||||
|
||||
@@ -371,8 +473,8 @@ export function AppSidebar({
|
||||
fontWeight: 500,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-gray-0)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-gray-7)';
|
||||
e.currentTarget.style.background = hoverBg;
|
||||
e.currentTarget.style.color = hoverColor;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Checkbox,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Pagination,
|
||||
Table,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
type MantineSpacing,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconRefresh,
|
||||
IconSelector,
|
||||
} from '@tabler/icons-react';
|
||||
import { EmptyState } from '../feedback/EmptyState';
|
||||
|
||||
export interface AdvancedTableColumn<T> {
|
||||
/** Unique column id; doubles as the sort field sent to `sort.onSort`. */
|
||||
key: string;
|
||||
header: ReactNode;
|
||||
/** Cell renderer. Defaults to reading `row[key]`. */
|
||||
render?: (row: T) => ReactNode;
|
||||
sortable?: boolean;
|
||||
width?: number | string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
export interface AdvancedTableAction<T> {
|
||||
key: string;
|
||||
/** Shown as tooltip and aria-label. */
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
color?: string;
|
||||
hidden?: (row: T) => boolean;
|
||||
disabled?: (row: T) => boolean;
|
||||
onClick: (row: T) => void;
|
||||
}
|
||||
|
||||
export interface AdvancedTableSort {
|
||||
sortBy?: string;
|
||||
sortDir?: 'asc' | 'desc';
|
||||
onSort: (field: string) => void;
|
||||
}
|
||||
|
||||
export interface AdvancedTablePagination {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export interface AdvancedTableSelection {
|
||||
selected: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
export interface AdvancedTableProps<T> {
|
||||
columns: AdvancedTableColumn<T>[];
|
||||
data: T[];
|
||||
rowKey: (row: T) => string;
|
||||
actions?: AdvancedTableAction<T>[];
|
||||
sort?: AdvancedTableSort;
|
||||
pagination?: AdvancedTablePagination;
|
||||
/** Controlled row selection (checkbox column); ids come from `rowKey`. */
|
||||
selection?: AdvancedTableSelection;
|
||||
loading?: boolean;
|
||||
/** Min table width before horizontal scroll kicks in. */
|
||||
minWidth?: number;
|
||||
/** Row density, e.g. 4 (compact) or 'sm' (comfortable). */
|
||||
verticalSpacing?: MantineSpacing;
|
||||
/** Per-row style override (e.g. focused-row highlight). */
|
||||
rowStyle?: (row: T) => CSSProperties | undefined;
|
||||
/** Rendered above the table, left-aligned (filters, search, tabs…). */
|
||||
toolbar?: ReactNode;
|
||||
/** Shows a refresh button above the table, right-aligned. */
|
||||
onRefresh?: () => void;
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
onRowClick?: (row: T) => void;
|
||||
}
|
||||
|
||||
function SortableHeader({
|
||||
column,
|
||||
sort,
|
||||
}: {
|
||||
column: AdvancedTableColumn<never>;
|
||||
sort: AdvancedTableSort;
|
||||
}) {
|
||||
const active = sort.sortBy === column.key;
|
||||
const Icon = active
|
||||
? sort.sortDir === 'desc'
|
||||
? IconChevronDown
|
||||
: IconChevronUp
|
||||
: IconSelector;
|
||||
return (
|
||||
<UnstyledButton onClick={() => sort.onSort(column.key)} fz="sm" fw={700}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{column.header}
|
||||
<Icon size={14} stroke={1.5} />
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic data table: column definitions and row actions come from the
|
||||
* consumer as config (typically `*Columns.tsx` + `*ColumnActions.tsx` files);
|
||||
* filters, search and refresh live in the parent component above the table.
|
||||
* Sorting and pagination are controlled — the parent owns the state (URL,
|
||||
* query params) and refetches.
|
||||
*/
|
||||
export function AdvancedTable<T>({
|
||||
columns,
|
||||
data,
|
||||
rowKey,
|
||||
actions,
|
||||
sort,
|
||||
pagination,
|
||||
selection,
|
||||
loading = false,
|
||||
minWidth = 640,
|
||||
verticalSpacing,
|
||||
rowStyle,
|
||||
toolbar,
|
||||
onRefresh,
|
||||
emptyTitle = 'Nothing here yet',
|
||||
emptyDescription,
|
||||
onRowClick,
|
||||
}: AdvancedTableProps<T>) {
|
||||
const allIds = data.map(rowKey);
|
||||
const allSelected = selection
|
||||
? allIds.length > 0 && allIds.every((id) => selection.selected.includes(id))
|
||||
: false;
|
||||
|
||||
const toolbarRow = (toolbar || onRefresh) && (
|
||||
<Group justify="space-between" mb="sm" wrap="nowrap" align="flex-end">
|
||||
<Box style={{ flex: 1 }}>{toolbar}</Box>
|
||||
{onRefresh && (
|
||||
<Tooltip label="Refresh">
|
||||
<ActionIcon variant="subtle" color="gray" onClick={onRefresh} aria-label="Refresh">
|
||||
<IconRefresh size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
|
||||
if (!loading && data.length === 0) {
|
||||
return (
|
||||
<Box>
|
||||
{toolbarRow}
|
||||
<EmptyState title={emptyTitle} description={emptyDescription} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box pos="relative">
|
||||
{toolbarRow}
|
||||
<LoadingOverlay visible={loading} zIndex={10} />
|
||||
<Table.ScrollContainer minWidth={minWidth}>
|
||||
<Table striped highlightOnHover verticalSpacing={verticalSpacing}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{selection && (
|
||||
<Table.Th w={40}>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={selection.selected.length > 0 && !allSelected}
|
||||
onChange={() => selection.onChange(allSelected ? [] : allIds)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</Table.Th>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<Table.Th key={col.key} w={col.width} ta={col.align}>
|
||||
{col.sortable && sort ? (
|
||||
<SortableHeader column={col as AdvancedTableColumn<never>} sort={sort} />
|
||||
) : (
|
||||
col.header
|
||||
)}
|
||||
</Table.Th>
|
||||
))}
|
||||
{actions && <Table.Th w={1} />}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{data.map((row) => (
|
||||
<Table.Tr
|
||||
key={rowKey(row)}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
style={{
|
||||
...(onRowClick ? { cursor: 'pointer' } : undefined),
|
||||
...rowStyle?.(row),
|
||||
}}
|
||||
>
|
||||
{selection && (
|
||||
<Table.Td onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selection.selected.includes(rowKey(row))}
|
||||
onChange={(e) => {
|
||||
const id = rowKey(row);
|
||||
selection.onChange(
|
||||
e.currentTarget.checked
|
||||
? [...selection.selected, id]
|
||||
: selection.selected.filter((s) => s !== id),
|
||||
);
|
||||
}}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</Table.Td>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<Table.Td key={col.key} ta={col.align}>
|
||||
{col.render
|
||||
? col.render(row)
|
||||
: ((row as Record<string, unknown>)[col.key] as ReactNode)}
|
||||
</Table.Td>
|
||||
))}
|
||||
{actions && (
|
||||
<Table.Td onClick={(e) => e.stopPropagation()}>
|
||||
<Group gap="xs" wrap="nowrap" justify="flex-end">
|
||||
{actions
|
||||
.filter((a) => !a.hidden?.(row))
|
||||
.map((a) => (
|
||||
<Tooltip key={a.key} label={a.label}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={a.color}
|
||||
disabled={a.disabled?.(row)}
|
||||
aria-label={a.label}
|
||||
onClick={() => a.onClick(row)}
|
||||
>
|
||||
{a.icon}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
)}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Pagination
|
||||
value={pagination.page}
|
||||
total={pagination.totalPages}
|
||||
onChange={pagination.onPageChange}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "../../dist/out-tsc" },
|
||||
"compilerOptions": { "outDir": "../../dist/out-tsc", "types": ["vite/client"] },
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user