feat(AmharicDatePicker): implement Amharic date picker component with calendar type switching and time input

This commit is contained in:
estifanos
2026-08-05 10:14:03 +00:00
parent 5fb1926ac0
commit 7ba760d74b
11 changed files with 89 additions and 37 deletions

View File

@@ -27,11 +27,10 @@ import {
IconTrash, IconTrash,
IconPlus, IconPlus,
IconInfoCircle, IconInfoCircle,
IconCalendar,
IconClipboardList, IconClipboardList,
IconDetails, IconDetails,
} from "@tabler/icons-react"; } 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 { useGetCertificationsQuery } from "../../certification/api/certification-api";
import { import {
useGetExamsQuery, useGetExamsQuery,
@@ -194,13 +193,12 @@ function ExamForm({
autosize autosize
minRows={2} minRows={2}
/> />
<TextInput <AmharicDatePicker
label={t("exam.form.examDate")} label={t("exam.form.examDate")}
type="date"
value={date} value={date}
onChange={(e) => setDate(e.currentTarget.value)} onChange={setDate}
dateFormat="date"
size="sm" size="sm"
leftSection={<IconCalendar size={14} />}
required required
/> />
<TextInput <TextInput

View File

@@ -47,7 +47,7 @@ import {
type LicenseStatus, type LicenseStatus,
type QueueFilter, type QueueFilter,
} from '@ema-platform/api'; } 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 { computeSla } from '../sla';
import { import {
DEFAULT_VIEW, DEFAULT_VIEW,
@@ -496,17 +496,17 @@ export function LicenseQueuePage() {
w={220} w={220}
/> />
)} )}
<TextInput <AmharicDatePicker
type="date"
label={t('queue.submittedFrom', 'Submitted from')} label={t('queue.submittedFrom', 'Submitted from')}
value={urlFilter.submittedFrom ?? ''} value={urlFilter.submittedFrom ?? ''}
onChange={(e) => setFacet({ submittedFrom: e.currentTarget.value || undefined })} onChange={(v) => setFacet({ submittedFrom: v || undefined })}
dateFormat="date"
/> />
<TextInput <AmharicDatePicker
type="date"
label={t('queue.submittedTo', 'Submitted to')} label={t('queue.submittedTo', 'Submitted to')}
value={urlFilter.submittedTo ?? ''} value={urlFilter.submittedTo ?? ''}
onChange={(e) => setFacet({ submittedTo: e.currentTarget.value || undefined })} onChange={(v) => setFacet({ submittedTo: v || undefined })}
dateFormat="date"
/> />
{hasFacets && ( {hasFacets && (
<Button <Button

View File

@@ -57,7 +57,7 @@ import {
useScheduleInspectionMutation, useScheduleInspectionMutation,
type RemarkTargetType, type RemarkTargetType,
} from '@ema-platform/api'; } 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 { usePermissions } from '@ema-platform/auth';
import { useAppSelector } from '../../../store/hooks'; import { useAppSelector } from '../../../store/hooks';
import { DecisionBar } from '../components/DecisionBar'; import { DecisionBar } from '../components/DecisionBar';
@@ -888,11 +888,11 @@ export function LicenseReviewPage() {
title={t('review.actions.scheduleInspection', 'Schedule inspection')} title={t('review.actions.scheduleInspection', 'Schedule inspection')}
> >
<Stack> <Stack>
<TextInput <AmharicDatePicker
type="datetime-local"
label={t('review.dateTime', 'Date and time')} label={t('review.dateTime', 'Date and time')}
value={inspectionDate} value={inspectionDate}
onChange={(e) => setInspectionDate(e.currentTarget.value)} onChange={setInspectionDate}
withTime
/> />
<ModalFooter> <ModalFooter>
<Tooltip <Tooltip
@@ -916,7 +916,7 @@ export function LicenseReviewPage() {
run(async () => { run(async () => {
await scheduleInspection({ await scheduleInspection({
applicationId: id, applicationId: id,
scheduledDate: new Date(inspectionDate).toISOString(), scheduledDate: inspectionDate,
}).unwrap(); }).unwrap();
setInspectionOpen(false); setInspectionOpen(false);
}, t('review.done.scheduled', 'Inspection scheduled')) }, t('review.done.scheduled', 'Inspection scheduled'))

View File

@@ -102,6 +102,8 @@ export const am: Translations = {
view: 'ይመልከቱ', view: 'ይመልከቱ',
toggleColumns: 'አምዶችን ቀያይር', toggleColumns: 'አምዶችን ቀያይር',
noResult: 'ምንም አልተገኘም', noResult: 'ምንም አልተገኘም',
switchCalendar: 'የቀን መቁጠሪያ ዓይነት ቀይር',
time: 'ሰዓት',
}, },
breadcrumbs: { breadcrumbs: {

View File

@@ -100,6 +100,8 @@ export const en = {
view: 'View', view: 'View',
toggleColumns: 'Toggle columns', toggleColumns: 'Toggle columns',
noResult: 'No results', noResult: 'No results',
switchCalendar: 'Switch calendar type',
time: 'Time',
}, },
breadcrumbs: { breadcrumbs: {

View File

@@ -1,7 +1,7 @@
import { Loader, Select, SimpleGrid, TextInput } from '@mantine/core'; import { Loader, Select, SimpleGrid, TextInput } from '@mantine/core';
import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form'; import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { AmharicDatePicker } from '../../../components/AmharicDatePicker'; import { AmharicDatePicker } from '@ema-platform/ui';
export const profileSchema = z.object({ export const profileSchema = z.object({
professionId: z.string().min(1, 'Select your profession'), professionId: z.string().min(1, 'Select your profession'),

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { AmharicDatePicker } from '../../../components/AmharicDatePicker'; import { AmharicDatePicker } from '@ema-platform/ui';
import { import {
ActionIcon, ActionIcon,
Alert, Alert,

View File

@@ -33,9 +33,15 @@ import {
IconUser, IconUser,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom'; 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 type { BilingualValue } from '@ema-platform/ui';
import { AmharicDatePicker, toEthiopicDateLabel, toGregorianDateLabel } from '../../../components/AmharicDatePicker';
import { LocationPicker } from '../../location/components/LocationPicker'; import { LocationPicker } from '../../location/components/LocationPicker';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -1,4 +1,5 @@
export * from "./lib/input/BilingualInput"; export * from "./lib/input/BilingualInput";
export * from "./lib/input/AmharicDatePicker";
export * from "./lib/feedback/ConfirmModal"; export * from "./lib/feedback/ConfirmModal";
export * from "./lib/feedback/ModalFooter"; export * from "./lib/feedback/ModalFooter";
export * from "./lib/feedback/ApiErrorAlert"; export * from "./lib/feedback/ApiErrorAlert";

View File

@@ -60,14 +60,39 @@ const ETH_FORMATTERS = {
formatMonthDropdown: (month: Date) => ethMonthName(month), formatMonthDropdown: (month: Date) => ethMonthName(month),
}; };
// Wire format is a full ISO-8601 instant string (e.g. type DateWireFormat = 'iso' | 'date';
// "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 // yyyy-MM-dd, parsed/formatted as a LOCAL calendar date — no Date-object/UTC
// midnight — reading back with getUTC*() (not local get*()) keeps the // round-trip at all, so it can't suffer the timezone off-by-one class of bug
// calendar day stable regardless of the runtime's timezone, avoiding the // the ISO path below works around. This is the shape filter query params and
// same off-by-one class of bug the UTC-noon workaround above exists for. // plain `date: string` DTO fields expect.
function parseISO(value?: string | null, withTime = false): Date | null { 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 (!value) return null;
if (format === 'date') return parsePlainDate(value);
const d = new Date(value); const d = new Date(value);
if (isNaN(d.getTime())) return null; if (isNaN(d.getTime())) return null;
return withTime return withTime
@@ -75,7 +100,12 @@ function parseISO(value?: string | null, withTime = false): Date | null {
: new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); : new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
} }
function formatISO(date: Date, withTime = false): string { function formatWireValue(
date: Date,
format: DateWireFormat,
withTime: boolean,
): string {
if (format === 'date') return formatPlainDate(date);
return withTime return withTime
? date.toISOString() ? date.toISOString()
: new Date( : new Date(
@@ -100,8 +130,15 @@ function mergeDateTime(
return result; return result;
} }
// Accepts either wire shape for display-only formatting — tries the plain
// yyyy-MM-dd shape first, falls back to ISO — so these keep working
// regardless of which `dateFormat` produced the stored string.
function parseAnyDateString(value: string): Date | null {
return parsePlainDate(value) ?? parseWireValue(value, 'iso', false);
}
export function toEthiopicDateLabel(date: Date | string): string { export function toEthiopicDateLabel(date: Date | string): string {
const d = typeof date === 'string' ? parseISO(date) : date; const d = typeof date === 'string' ? parseAnyDateString(date) : date;
if (!d) return ''; if (!d) return '';
try { try {
const eth = toEthDateTime(d); const eth = toEthDateTime(d);
@@ -112,7 +149,7 @@ export function toEthiopicDateLabel(date: Date | string): string {
} }
export function toGregorianDateLabel(date: Date | string): string { export function toGregorianDateLabel(date: Date | string): string {
const d = typeof date === 'string' ? parseISO(date) : date; const d = typeof date === 'string' ? parseAnyDateString(date) : date;
if (!d) return ''; if (!d) return '';
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
} }
@@ -133,6 +170,11 @@ export interface AmharicDatePickerProps {
/** Show a time-of-day field alongside the calendar. Off by default /** Show a time-of-day field alongside the calendar. Off by default
* most callers only need a calendar day. */ * most callers only need a calendar day. */
withTime?: boolean; 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({ export function AmharicDatePicker({
@@ -147,6 +189,7 @@ export function AmharicDatePicker({
name, name,
onBlur, onBlur,
withTime = false, withTime = false,
dateFormat = 'iso',
}: AmharicDatePickerProps) { }: AmharicDatePickerProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const [calendarType, setCalendarType] = useState<'EN' | 'AMH'>(() => const [calendarType, setCalendarType] = useState<'EN' | 'AMH'>(() =>
@@ -154,7 +197,7 @@ export function AmharicDatePicker({
); );
const [opened, { close, toggle }] = useDisclosure(false); const [opened, { close, toggle }] = useDisclosure(false);
const selected = parseISO(value, withTime); const selected = parseWireValue(value, dateFormat, withTime);
const dateLabel = selected const dateLabel = selected
? calendarType === 'EN' ? calendarType === 'EN'
@@ -230,7 +273,7 @@ export function AmharicDatePicker({
captionLayout="dropdown" captionLayout="dropdown"
formatters={ETH_FORMATTERS} formatters={ETH_FORMATTERS}
onSelect={(date: Date | undefined) => { onSelect={(date: Date | undefined) => {
onChange?.(date ? formatISO(mergeDateTime(selected, date), withTime) : ''); onChange?.(date ? formatWireValue(mergeDateTime(selected, date), dateFormat, withTime) : '');
close(); close();
}} }}
/> />
@@ -243,7 +286,7 @@ export function AmharicDatePicker({
endMonth={YEAR_DROPDOWN_END} endMonth={YEAR_DROPDOWN_END}
captionLayout="dropdown" captionLayout="dropdown"
onSelect={(date: Date | undefined) => { onSelect={(date: Date | undefined) => {
onChange?.(date ? formatISO(mergeDateTime(selected, date), withTime) : ''); onChange?.(date ? formatWireValue(mergeDateTime(selected, date), dateFormat, withTime) : '');
close(); close();
}} }}
/> />
@@ -263,7 +306,7 @@ export function AmharicDatePicker({
const [h, m] = e.currentTarget.value.split(':').map(Number); const [h, m] = e.currentTarget.value.split(':').map(Number);
if (Number.isNaN(h) || Number.isNaN(m)) return; if (Number.isNaN(h) || Number.isNaN(m)) return;
onChange?.( onChange?.(
formatISO(mergeDateTime(selected, undefined, { hours: h, minutes: m }), true), formatWireValue(mergeDateTime(selected, undefined, { hours: h, minutes: m }), dateFormat, true),
); );
}} }}
/> />
@@ -284,7 +327,7 @@ export function AmharicDatePicker({
variant="light" variant="light"
size="xs" size="xs"
onClick={() => { onClick={() => {
onChange?.(formatISO(new Date(), withTime)); onChange?.(formatWireValue(new Date(), dateFormat, withTime));
close(); close();
}} }}
> >