mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-08 03:05:43 +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,
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ export const am: Translations = {
|
|||||||
view: 'ይመልከቱ',
|
view: 'ይመልከቱ',
|
||||||
toggleColumns: 'አምዶችን ቀያይር',
|
toggleColumns: 'አምዶችን ቀያይር',
|
||||||
noResult: 'ምንም አልተገኘም',
|
noResult: 'ምንም አልተገኘም',
|
||||||
|
switchCalendar: 'የቀን መቁጠሪያ ዓይነት ቀይር',
|
||||||
|
time: 'ሰዓት',
|
||||||
},
|
},
|
||||||
|
|
||||||
breadcrumbs: {
|
breadcrumbs: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Reference in New Issue
Block a user