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:
Nati
2026-08-13 11:31:27 +00:00
182 changed files with 14181 additions and 8141 deletions

View File

@@ -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";

View 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>

View 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`).

View 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>
);
}

View 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,
};
}

View File

@@ -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>
);
}

View 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>
);
}

View 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 };
}

View 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;
}

View 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>
);
}

View 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}
/>
);
}

View 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>
);
}

View 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();

View File

@@ -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>

View File

@@ -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';

View File

@@ -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>
);
}

View File

@@ -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"]
}