mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 04:50:56 +00:00
feat(AmharicDatePicker): implement Amharic date picker component with calendar type switching and time input
This commit is contained in:
@@ -27,11 +27,10 @@ import {
|
||||
IconTrash,
|
||||
IconPlus,
|
||||
IconInfoCircle,
|
||||
IconCalendar,
|
||||
IconClipboardList,
|
||||
IconDetails,
|
||||
} from "@tabler/icons-react";
|
||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from "@ema-platform/ui";
|
||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker, type AdvancedColumn } from "@ema-platform/ui";
|
||||
import { useGetCertificationsQuery } from "../../certification/api/certification-api";
|
||||
import {
|
||||
useGetExamsQuery,
|
||||
@@ -194,13 +193,12 @@ function ExamForm({
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<TextInput
|
||||
<AmharicDatePicker
|
||||
label={t("exam.form.examDate")}
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.currentTarget.value)}
|
||||
onChange={setDate}
|
||||
dateFormat="date"
|
||||
size="sm"
|
||||
leftSection={<IconCalendar size={14} />}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
type LicenseStatus,
|
||||
type QueueFilter,
|
||||
} from '@ema-platform/api';
|
||||
import { AdvancedTable, EmptyState, ErrorState, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { AdvancedTable, EmptyState, ErrorState, AmharicDatePicker, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { computeSla } from '../sla';
|
||||
import {
|
||||
DEFAULT_VIEW,
|
||||
@@ -496,17 +496,17 @@ export function LicenseQueuePage() {
|
||||
w={220}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
type="date"
|
||||
<AmharicDatePicker
|
||||
label={t('queue.submittedFrom', 'Submitted from')}
|
||||
value={urlFilter.submittedFrom ?? ''}
|
||||
onChange={(e) => setFacet({ submittedFrom: e.currentTarget.value || undefined })}
|
||||
onChange={(v) => setFacet({ submittedFrom: v || undefined })}
|
||||
dateFormat="date"
|
||||
/>
|
||||
<TextInput
|
||||
type="date"
|
||||
<AmharicDatePicker
|
||||
label={t('queue.submittedTo', 'Submitted to')}
|
||||
value={urlFilter.submittedTo ?? ''}
|
||||
onChange={(e) => setFacet({ submittedTo: e.currentTarget.value || undefined })}
|
||||
onChange={(v) => setFacet({ submittedTo: v || undefined })}
|
||||
dateFormat="date"
|
||||
/>
|
||||
{hasFacets && (
|
||||
<Button
|
||||
|
||||
@@ -57,7 +57,7 @@ import {
|
||||
useScheduleInspectionMutation,
|
||||
type RemarkTargetType,
|
||||
} from '@ema-platform/api';
|
||||
import { ErrorState, ModalFooter } from '@ema-platform/ui';
|
||||
import { ErrorState, ModalFooter, AmharicDatePicker } from '@ema-platform/ui';
|
||||
import { usePermissions } from '@ema-platform/auth';
|
||||
import { useAppSelector } from '../../../store/hooks';
|
||||
import { DecisionBar } from '../components/DecisionBar';
|
||||
@@ -888,11 +888,11 @@ export function LicenseReviewPage() {
|
||||
title={t('review.actions.scheduleInspection', 'Schedule inspection')}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
type="datetime-local"
|
||||
<AmharicDatePicker
|
||||
label={t('review.dateTime', 'Date and time')}
|
||||
value={inspectionDate}
|
||||
onChange={(e) => setInspectionDate(e.currentTarget.value)}
|
||||
onChange={setInspectionDate}
|
||||
withTime
|
||||
/>
|
||||
<ModalFooter>
|
||||
<Tooltip
|
||||
@@ -916,7 +916,7 @@ export function LicenseReviewPage() {
|
||||
run(async () => {
|
||||
await scheduleInspection({
|
||||
applicationId: id,
|
||||
scheduledDate: new Date(inspectionDate).toISOString(),
|
||||
scheduledDate: inspectionDate,
|
||||
}).unwrap();
|
||||
setInspectionOpen(false);
|
||||
}, t('review.done.scheduled', 'Inspection scheduled'))
|
||||
|
||||
@@ -102,6 +102,8 @@ export const am: Translations = {
|
||||
view: 'ይመልከቱ',
|
||||
toggleColumns: 'አምዶችን ቀያይር',
|
||||
noResult: 'ምንም አልተገኘም',
|
||||
switchCalendar: 'የቀን መቁጠሪያ ዓይነት ቀይር',
|
||||
time: 'ሰዓት',
|
||||
},
|
||||
|
||||
breadcrumbs: {
|
||||
|
||||
@@ -100,6 +100,8 @@ export const en = {
|
||||
view: 'View',
|
||||
toggleColumns: 'Toggle columns',
|
||||
noResult: 'No results',
|
||||
switchCalendar: 'Switch calendar type',
|
||||
time: 'Time',
|
||||
},
|
||||
|
||||
breadcrumbs: {
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
/* 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-nav_button-width: 2.75rem;
|
||||
--rdp-nav_button-height: 2.75rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
MantineSize,
|
||||
Popover,
|
||||
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 { EthDateTime } from 'ethiopian-calendar-date-converter';
|
||||
import '@daypicker/react/dist/style.css';
|
||||
import './AmharicDatePicker.css';
|
||||
|
||||
const EC_MONTHS_AM = [
|
||||
'መስከረም', 'ጥቅምት', 'ኅዳር', 'ታህሳስ', 'ጥር', 'የካቲት',
|
||||
'መጋቢት', 'ሚያዝያ', 'ግንቦት', 'ሰኔ', 'ሐምሌ', 'ነሐሴ', 'ጳጉሜ',
|
||||
];
|
||||
|
||||
// EthDateTime.fromEuropeanDate() computes the day from a raw UTC-epoch
|
||||
// difference. A local-midnight Date in any positive-UTC-offset timezone
|
||||
// (e.g. Ethiopia, UTC+3) lands in the previous UTC day and converts to
|
||||
// yesterday's Ethiopian date. Re-embedding the same Y/M/D at UTC noon fixes
|
||||
// the day regardless of the runtime's timezone.
|
||||
function toEthDateTime(date: Date): EthDateTime {
|
||||
const utcNoon = new Date(
|
||||
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12),
|
||||
);
|
||||
return EthDateTime.fromEuropeanDate(utcNoon);
|
||||
}
|
||||
|
||||
function ethMonthName(date: Date): string {
|
||||
try {
|
||||
return EC_MONTHS_AM[toEthDateTime(date).month - 1] ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function toAmharicDisplay(date: Date): string {
|
||||
try {
|
||||
const eth = toEthDateTime(date);
|
||||
return `${ethMonthName(date)} ${eth.date}/${eth.year}`;
|
||||
} catch {
|
||||
return date.toLocaleDateString('en-US');
|
||||
}
|
||||
}
|
||||
|
||||
// react-day-picker calls these with the Gregorian Date it tracks internally;
|
||||
// override so the caption/dropdown show Amharic month names instead of the
|
||||
// library's Latin transliteration (triggered by numerals="latn" below).
|
||||
const ETH_FORMATTERS = {
|
||||
formatCaption: (month: Date) =>
|
||||
`${ethMonthName(month)} ${toEthDateTime(month).year}`,
|
||||
formatMonthDropdown: (month: Date) => ethMonthName(month),
|
||||
};
|
||||
|
||||
// Wire format is a full ISO-8601 instant string (e.g.
|
||||
// "2026-07-31T00:00:00.000Z"), what the backend expects for date fields.
|
||||
// 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 parseISO(value?: string | null, withTime = false): Date | null {
|
||||
if (!value) return null;
|
||||
const d = new Date(value);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return withTime
|
||||
? d
|
||||
: new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
|
||||
}
|
||||
|
||||
function formatISO(date: Date, withTime = false): string {
|
||||
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;
|
||||
}
|
||||
|
||||
export function toEthiopicDateLabel(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? parseISO(date) : date;
|
||||
if (!d) return '';
|
||||
try {
|
||||
const eth = toEthDateTime(d);
|
||||
return `EC ${eth.year}-${String(eth.month).padStart(2, '0')}-${String(eth.date).padStart(2, '0')}`;
|
||||
} catch {
|
||||
return d.toLocaleDateString('en-US');
|
||||
}
|
||||
}
|
||||
|
||||
export function toGregorianDateLabel(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? parseISO(date) : date;
|
||||
if (!d) return '';
|
||||
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
}
|
||||
|
||||
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;
|
||||
/** Show a time-of-day field alongside the calendar. Off by default —
|
||||
* most callers only need a calendar day. */
|
||||
withTime?: boolean;
|
||||
}
|
||||
|
||||
export function AmharicDatePicker({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
required,
|
||||
placeholder,
|
||||
error,
|
||||
disabled,
|
||||
size,
|
||||
name,
|
||||
onBlur,
|
||||
withTime = false,
|
||||
}: 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 = parseISO(value, withTime);
|
||||
|
||||
const dateLabel = selected
|
||||
? calendarType === 'EN'
|
||||
? selected.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
: toAmharicDisplay(selected)
|
||||
: '';
|
||||
const timeLabel =
|
||||
withTime && selected
|
||||
? ` ${selected.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}`
|
||||
: '';
|
||||
const displayValue = dateLabel + timeLabel;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={close}
|
||||
position="bottom"
|
||||
width="auto"
|
||||
trapFocus
|
||||
withArrow
|
||||
>
|
||||
<Popover.Target>
|
||||
<TextInput
|
||||
label={label}
|
||||
required={required}
|
||||
value={displayValue}
|
||||
readOnly
|
||||
disabled={disabled}
|
||||
size={size}
|
||||
name={name}
|
||||
error={error}
|
||||
placeholder={placeholder}
|
||||
onClick={() => !disabled && toggle()}
|
||||
onBlur={onBlur}
|
||||
leftSection={
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
size="compact-xs"
|
||||
tabIndex={0}
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCalendarType((prev) => (prev === 'EN' ? 'AMH' : 'EN'));
|
||||
}}
|
||||
aria-label={t('common.switchCalendar')}
|
||||
>
|
||||
{calendarType}
|
||||
</Button>
|
||||
}
|
||||
leftSectionWidth="calc(4.375rem * var(--mantine-scale))"
|
||||
rightSection={
|
||||
<ActionIcon size="md" variant="transparent" disabled={disabled} onClick={() => toggle()}>
|
||||
<IconCalendarEvent size={20} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown p="md">
|
||||
{calendarType === 'AMH' ? (
|
||||
<EthiopicDayPicker
|
||||
className="amharic-daypicker"
|
||||
mode="single"
|
||||
selected={selected ?? undefined}
|
||||
defaultMonth={selected ?? undefined}
|
||||
endMonth={YEAR_DROPDOWN_END}
|
||||
numerals="latn"
|
||||
captionLayout="dropdown"
|
||||
formatters={ETH_FORMATTERS}
|
||||
onSelect={(date: Date | undefined) => {
|
||||
onChange?.(date ? formatISO(mergeDateTime(selected, date), withTime) : '');
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<GregorianDayPicker
|
||||
className="amharic-daypicker"
|
||||
mode="single"
|
||||
selected={selected ?? undefined}
|
||||
defaultMonth={selected ?? undefined}
|
||||
endMonth={YEAR_DROPDOWN_END}
|
||||
captionLayout="dropdown"
|
||||
onSelect={(date: Date | undefined) => {
|
||||
onChange?.(date ? formatISO(mergeDateTime(selected, date), withTime) : '');
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{withTime && (
|
||||
<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?.(
|
||||
formatISO(mergeDateTime(selected, undefined, { hours: h, minutes: m }), 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?.(formatISO(new Date(), withTime));
|
||||
close();
|
||||
}}
|
||||
>
|
||||
{calendarType === 'AMH' ? 'ዛሬ' : 'Today'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Loader, Select, SimpleGrid, TextInput } from '@mantine/core';
|
||||
import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { AmharicDatePicker } from '../../../components/AmharicDatePicker';
|
||||
import { AmharicDatePicker } from '@ema-platform/ui';
|
||||
|
||||
export const profileSchema = z.object({
|
||||
professionId: z.string().min(1, 'Select your profession'),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AmharicDatePicker } from '../../../components/AmharicDatePicker';
|
||||
import { AmharicDatePicker } from '@ema-platform/ui';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
|
||||
@@ -33,9 +33,15 @@ import {
|
||||
IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { notify, BilingualInput, useErrorHandler } from '@ema-platform/ui';
|
||||
import {
|
||||
notify,
|
||||
BilingualInput,
|
||||
useErrorHandler,
|
||||
AmharicDatePicker,
|
||||
toEthiopicDateLabel,
|
||||
toGregorianDateLabel,
|
||||
} from '@ema-platform/ui';
|
||||
import type { BilingualValue } from '@ema-platform/ui';
|
||||
import { AmharicDatePicker, toEthiopicDateLabel, toGregorianDateLabel } from '../../../components/AmharicDatePicker';
|
||||
import { LocationPicker } from '../../location/components/LocationPicker';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user