improvement: rtl direction, solar calendar and persian formatting added for persian users.
This commit is contained in:
175
frontend/src/components/ui/shared/AppDateInput.tsx
Normal file
175
frontend/src/components/ui/shared/AppDateInput.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { CalendarDays } from 'lucide-react';
|
||||
import { parseDateInput, startOfLocalDay, toDateInputValue } from '@/components/appointments/appointmentTime';
|
||||
import { FORM_DATE_INPUT_CLASS } from '@/components/shared/formSelectStyles';
|
||||
import { CalendarDayPartsPanel } from '@/components/ui/shared/CalendarDayPartsPanel';
|
||||
import {
|
||||
formatIsoAsGregorianDateInput,
|
||||
maskGregorianDateTyping,
|
||||
parseGregorianDateInputText,
|
||||
} from '@/lib/i18n/dateInputFormat';
|
||||
import { usesPersianCalendar } from '@/lib/i18n/format';
|
||||
import {
|
||||
formatIsoAsPersianDateInput,
|
||||
maskJalaliDateTyping,
|
||||
parsePersianDateInputText,
|
||||
} from '@/lib/i18n/persianCalendar';
|
||||
|
||||
export type AppDateInputProps = {
|
||||
id?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onBlur?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Locale-aware date field — wire value is always `YYYY-MM-DD` or empty.
|
||||
* Visual shell matches native `.form-select` (padding, text alignment, icon inset).
|
||||
*/
|
||||
export function AppDateInput({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled = false,
|
||||
className = '',
|
||||
}: AppDateInputProps) {
|
||||
const locale = useLocale();
|
||||
const persian = usesPersianCalendar(locale);
|
||||
const panelId = useId();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [text, setText] = useState('');
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setText(
|
||||
persian ? formatIsoAsPersianDateInput(value) : formatIsoAsGregorianDateInput(value),
|
||||
);
|
||||
}, [persian, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!panelOpen) return;
|
||||
|
||||
function onPointerDown(event: MouseEvent) {
|
||||
if (!rootRef.current?.contains(event.target as Node)) {
|
||||
setPanelOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
setPanelOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [panelOpen]);
|
||||
|
||||
const panelAnchorDate = value ? parseDateInput(value) : startOfLocalDay(new Date());
|
||||
const placeholder = persian ? '۱۴۰۴/۰۴/۲۲' : '2026-07-13';
|
||||
const fieldClass = `${FORM_DATE_INPUT_CLASS} w-full ${className}`.trim();
|
||||
|
||||
function formatDisplay(iso: string): string {
|
||||
return persian ? formatIsoAsPersianDateInput(iso) : formatIsoAsGregorianDateInput(iso);
|
||||
}
|
||||
|
||||
function maskTyping(raw: string): string {
|
||||
return persian ? maskJalaliDateTyping(raw) : maskGregorianDateTyping(raw);
|
||||
}
|
||||
|
||||
function parseTyping(raw: string): string | null {
|
||||
return persian ? parsePersianDateInputText(raw) : parseGregorianDateInputText(raw);
|
||||
}
|
||||
|
||||
function commitText(nextText: string): string {
|
||||
const trimmed = nextText.trim();
|
||||
if (!trimmed) {
|
||||
onChange('');
|
||||
setText('');
|
||||
return '';
|
||||
}
|
||||
const iso = parseTyping(trimmed);
|
||||
if (iso) {
|
||||
onChange(iso);
|
||||
setText(formatDisplay(iso));
|
||||
return iso;
|
||||
}
|
||||
setText(value ? formatDisplay(value) : '');
|
||||
return value;
|
||||
}
|
||||
|
||||
function handlePanelChange(day: Date) {
|
||||
const iso = toDateInputValue(day);
|
||||
onChange(iso);
|
||||
setText(formatDisplay(iso));
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative w-full">
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
value={text}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setText(maskTyping(e.target.value))}
|
||||
onBlur={() => {
|
||||
const committed = commitText(text);
|
||||
onBlur?.(committed);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const committed = commitText(text);
|
||||
onBlur?.(committed);
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
className={fieldClass}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-expanded={panelOpen}
|
||||
aria-controls={`${panelId}-parts`}
|
||||
aria-label={panelOpen ? undefined : 'Open calendar'}
|
||||
onClick={() => {
|
||||
if (!disabled) setPanelOpen((open) => !open);
|
||||
}}
|
||||
className="pointer-events-auto absolute top-1/2 end-3 flex h-4 w-4 -translate-y-1/2 items-center justify-center text-text-muted hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-60"
|
||||
>
|
||||
<CalendarDays className="h-4 w-4 icon-flat" aria-hidden />
|
||||
</button>
|
||||
|
||||
{panelOpen && !disabled ? (
|
||||
<div
|
||||
id={`${panelId}-parts`}
|
||||
role="dialog"
|
||||
className="absolute left-0 right-0 top-full z-50 mt-1 rounded-[var(--radius-md)] border border-border bg-background-secondary p-3 shadow-lg"
|
||||
>
|
||||
<CalendarDayPartsPanel
|
||||
panelId={panelId}
|
||||
value={panelAnchorDate}
|
||||
onChange={handlePanelChange}
|
||||
closePanelOnDaySelect
|
||||
onAfterSelect={(day) => {
|
||||
setPanelOpen(false);
|
||||
onBlur?.(toDateInputValue(day));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
frontend/src/components/ui/shared/CalendarDayPartsPanel.tsx
Normal file
166
frontend/src/components/ui/shared/CalendarDayPartsPanel.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { formatAppInteger, usesPersianCalendar } from '@/lib/i18n/format';
|
||||
import {
|
||||
formatPersianMonthLabel,
|
||||
getLocalPersianParts,
|
||||
jalaliDaysInMonth,
|
||||
persianPartsToLocalDate,
|
||||
persianYearRange,
|
||||
} from '@/lib/i18n/persianCalendar';
|
||||
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
|
||||
import { CompactSelect } from '@/components/ui/shared/CompactSelect';
|
||||
|
||||
const MONTH_KEYS = [
|
||||
'monthJanuary',
|
||||
'monthFebruary',
|
||||
'monthMarch',
|
||||
'monthApril',
|
||||
'monthMay',
|
||||
'monthJune',
|
||||
'monthJuly',
|
||||
'monthAugust',
|
||||
'monthSeptember',
|
||||
'monthOctober',
|
||||
'monthNovember',
|
||||
'monthDecember',
|
||||
] as const;
|
||||
|
||||
function daysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
}
|
||||
|
||||
function buildLocalDay(year: number, month: number, day: number): Date {
|
||||
return new Date(year, month, day, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
function yearRange(anchor: Date): number[] {
|
||||
const anchorYear = anchor.getFullYear();
|
||||
const years: number[] = [];
|
||||
for (let y = anchorYear - 10; y <= anchorYear + 2; y += 1) {
|
||||
years.push(y);
|
||||
}
|
||||
return years;
|
||||
}
|
||||
|
||||
export type CalendarDayPartsPanelProps = {
|
||||
panelId: string;
|
||||
value: Date;
|
||||
onChange: (day: Date) => void;
|
||||
closePanelOnDaySelect?: boolean;
|
||||
onAfterSelect?: (day: Date) => void;
|
||||
};
|
||||
|
||||
/** Year / month / day dropdown row — shared by schedule picker and date fields. */
|
||||
export function CalendarDayPartsPanel({
|
||||
panelId,
|
||||
value,
|
||||
onChange,
|
||||
closePanelOnDaySelect = false,
|
||||
onAfterSelect,
|
||||
}: CalendarDayPartsPanelProps) {
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('schedule');
|
||||
const normalizedValue = startOfLocalDay(value);
|
||||
const persian = usesPersianCalendar(locale);
|
||||
const jalaliParts = persian ? getLocalPersianParts(normalizedValue) : null;
|
||||
const gregorianYear = normalizedValue.getFullYear();
|
||||
const gregorianMonth = normalizedValue.getMonth();
|
||||
const gregorianDay = normalizedValue.getDate();
|
||||
const years =
|
||||
persian && jalaliParts ? persianYearRange(jalaliParts.year) : yearRange(normalizedValue);
|
||||
const selectedYear = jalaliParts?.year ?? gregorianYear;
|
||||
const selectedMonth = jalaliParts?.month ?? gregorianMonth;
|
||||
const selectedDay = jalaliParts?.day ?? gregorianDay;
|
||||
const dayCount = persian
|
||||
? jalaliDaysInMonth(selectedYear, selectedMonth)
|
||||
: daysInMonth(selectedYear, selectedMonth);
|
||||
|
||||
function applyParts(year: number, month: number, day: number, closePanel = false) {
|
||||
const maxDay = persian ? jalaliDaysInMonth(year, month) : daysInMonth(year, month);
|
||||
const clampedDay = Math.min(Math.max(1, day), maxDay);
|
||||
onChange(
|
||||
persian
|
||||
? persianPartsToLocalDate(year, month, clampedDay)
|
||||
: buildLocalDay(year, month, clampedDay),
|
||||
);
|
||||
if (closePanel) {
|
||||
const nextDay = persian
|
||||
? persianPartsToLocalDate(year, month, clampedDay)
|
||||
: buildLocalDay(year, month, clampedDay);
|
||||
onAfterSelect?.(nextDay);
|
||||
}
|
||||
}
|
||||
|
||||
function formatPanelYear(year: number): string {
|
||||
return persian ? formatAppInteger(year, locale) : String(year);
|
||||
}
|
||||
|
||||
function formatPanelDay(day: number): string {
|
||||
return persian ? formatAppInteger(day, locale) : String(day);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label htmlFor={`${panelId}-year`} className="mb-1 block text-xs font-medium text-text-muted">
|
||||
{t('year')}
|
||||
</label>
|
||||
<CompactSelect
|
||||
id={`${panelId}-year`}
|
||||
value={selectedYear}
|
||||
onChange={(e) => applyParts(Number(e.target.value), selectedMonth, selectedDay)}
|
||||
>
|
||||
{years.map((year) => (
|
||||
<option key={year} value={year}>
|
||||
{formatPanelYear(year)}
|
||||
</option>
|
||||
))}
|
||||
</CompactSelect>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor={`${panelId}-month`} className="mb-1 block text-xs font-medium text-text-muted">
|
||||
{t('month')}
|
||||
</label>
|
||||
<CompactSelect
|
||||
id={`${panelId}-month`}
|
||||
value={selectedMonth}
|
||||
onChange={(e) => applyParts(selectedYear, Number(e.target.value), selectedDay)}
|
||||
>
|
||||
{persian
|
||||
? Array.from({ length: 12 }, (_, i) => i + 1).map((month) => (
|
||||
<option key={month} value={month}>
|
||||
{formatPersianMonthLabel(selectedYear, month)}
|
||||
</option>
|
||||
))
|
||||
: MONTH_KEYS.map((key, index) => (
|
||||
<option key={key} value={index}>
|
||||
{t(key)}
|
||||
</option>
|
||||
))}
|
||||
</CompactSelect>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor={`${panelId}-day`} className="mb-1 block text-xs font-medium text-text-muted">
|
||||
{t('day')}
|
||||
</label>
|
||||
<CompactSelect
|
||||
id={`${panelId}-day`}
|
||||
value={selectedDay}
|
||||
onChange={(e) =>
|
||||
applyParts(selectedYear, selectedMonth, Number(e.target.value), closePanelOnDaySelect)
|
||||
}
|
||||
>
|
||||
{Array.from({ length: dayCount }, (_, i) => i + 1).map((day) => (
|
||||
<option key={day} value={day}>
|
||||
{formatPanelDay(day)}
|
||||
</option>
|
||||
))}
|
||||
</CompactSelect>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
frontend/src/components/ui/shared/CalendarDaySelect.tsx
Normal file
188
frontend/src/components/ui/shared/CalendarDaySelect.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { isRtlLocale } from '@/i18n/routing';
|
||||
import { formatAppPickerDateLabel } from '@/lib/i18n/format';
|
||||
import { CalendarDayPartsPanel } from '@/components/ui/shared/CalendarDayPartsPanel';
|
||||
import { addCalendarDays, compareLocalDayStart, startOfLocalDay } from '@/components/appointments/appointmentTime';
|
||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||
|
||||
export type CalendarDaySelectProps = {
|
||||
value: Date;
|
||||
onChange: (day: Date) => void;
|
||||
label?: string;
|
||||
emptyLabel?: string;
|
||||
isEmpty?: boolean;
|
||||
showHeader?: boolean;
|
||||
showTodayToggle?: boolean;
|
||||
showNavArrows?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
id?: string;
|
||||
onBlur?: () => void;
|
||||
closePanelOnDaySelect?: boolean;
|
||||
};
|
||||
|
||||
export function CalendarDaySelect({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
emptyLabel,
|
||||
isEmpty = false,
|
||||
showHeader = false,
|
||||
showTodayToggle = false,
|
||||
showNavArrows = false,
|
||||
disabled = false,
|
||||
className,
|
||||
triggerClassName,
|
||||
id,
|
||||
onBlur,
|
||||
closePanelOnDaySelect = true,
|
||||
}: CalendarDaySelectProps) {
|
||||
const locale = useLocale();
|
||||
const rtl = isRtlLocale(locale);
|
||||
const t = useTranslations('schedule');
|
||||
const PrevIcon = rtl ? ChevronRight : ChevronLeft;
|
||||
const NextIcon = rtl ? ChevronLeft : ChevronRight;
|
||||
const panelId = useId();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
|
||||
const normalizedValue = startOfLocalDay(value);
|
||||
const today = startOfLocalDay(new Date());
|
||||
const isTodaySelected = !isEmpty && compareLocalDayStart(normalizedValue, today) === 0;
|
||||
const resolvedLabel = label ?? t('defaultLabel');
|
||||
const labelText = isEmpty
|
||||
? (emptyLabel ?? t('chooseDate'))
|
||||
: formatAppPickerDateLabel(normalizedValue, locale);
|
||||
|
||||
useEffect(() => {
|
||||
if (!panelOpen) return;
|
||||
|
||||
function onPointerDown(event: MouseEvent) {
|
||||
if (!rootRef.current?.contains(event.target as Node)) {
|
||||
setPanelOpen(false);
|
||||
onBlur?.();
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
setPanelOpen(false);
|
||||
onBlur?.();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [onBlur, panelOpen]);
|
||||
|
||||
const triggerButton = (
|
||||
<button
|
||||
type="button"
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
setPanelOpen((open) => !open);
|
||||
}}
|
||||
aria-expanded={panelOpen}
|
||||
aria-controls={panelId}
|
||||
aria-haspopup="dialog"
|
||||
className={
|
||||
triggerClassName ??
|
||||
`flex flex-1 min-w-0 items-center justify-center gap-3 rounded-[var(--radius-sm)] px-2 py-1.5 text-sm font-medium text-text-primary tabular-nums hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35 disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||
isEmpty ? 'text-text-muted' : ''
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="truncate">{labelText}</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 shrink-0 text-text-muted icon-flat transition-transform ${panelOpen ? 'rotate-180' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={`relative w-full ${className ?? 'max-w-md'}`}>
|
||||
{showHeader ? (
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-medium text-text-secondary">{resolvedLabel}</p>
|
||||
{showTodayToggle ? (
|
||||
<Checkbox
|
||||
checked={isTodaySelected}
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
onChange(today);
|
||||
setPanelOpen(false);
|
||||
}
|
||||
}}
|
||||
label={t('today')}
|
||||
className="shrink-0"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={`flex items-center gap-1 rounded-[var(--radius-md)] border border-border bg-background-secondary/90 px-1 py-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)] ${
|
||||
showNavArrows ? '' : 'py-0.5'
|
||||
}`}
|
||||
>
|
||||
{showNavArrows ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(addCalendarDays(normalizedValue, -1))}
|
||||
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35 disabled:opacity-60"
|
||||
aria-label={t('previousDay')}
|
||||
>
|
||||
<PrevIcon className="h-4 w-4 icon-flat" />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{triggerButton}
|
||||
|
||||
{showNavArrows ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(addCalendarDays(normalizedValue, 1))}
|
||||
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35 disabled:opacity-60"
|
||||
aria-label={t('nextDay')}
|
||||
>
|
||||
<NextIcon className="h-4 w-4 icon-flat" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{panelOpen && !disabled ? (
|
||||
<div
|
||||
id={panelId}
|
||||
role="dialog"
|
||||
aria-label={t('chooseDate')}
|
||||
className="absolute left-0 right-0 top-full z-50 mt-2 rounded-[var(--radius-md)] border border-border bg-background-secondary p-3 shadow-lg"
|
||||
>
|
||||
<CalendarDayPartsPanel
|
||||
panelId={panelId}
|
||||
value={normalizedValue}
|
||||
onChange={onChange}
|
||||
closePanelOnDaySelect={closePanelOnDaySelect}
|
||||
onAfterSelect={(day) => {
|
||||
setPanelOpen(false);
|
||||
onBlur?.();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
frontend/src/components/ui/shared/CompactSelect.tsx
Normal file
15
frontend/src/components/ui/shared/CompactSelect.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
type CompactSelectProps = React.SelectHTMLAttributes<HTMLSelectElement>;
|
||||
|
||||
/** Compact styled `<select>` — chevron from global `.form-select` styles. */
|
||||
export function CompactSelect({ className = '', children, ...props }: CompactSelectProps) {
|
||||
return (
|
||||
<select
|
||||
className={`form-select w-full appearance-none rounded-[var(--radius-sm)] border border-border bg-background-card/90 text-text-primary text-sm ps-3 pe-10 py-1.5 focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong disabled:cursor-not-allowed disabled:opacity-60 ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import React, { forwardRef, useId } from 'react';
|
||||
|
||||
interface DropdownProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
@@ -24,32 +23,23 @@ export const Dropdown = forwardRef<HTMLSelectElement, DropdownProps>(
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<select
|
||||
ref={ref}
|
||||
id={selectId}
|
||||
className={`
|
||||
form-select w-full appearance-none rounded-[var(--radius-md)] border
|
||||
${error ? 'border-red-500' : 'border-border'}
|
||||
bg-background-card text-text-primary
|
||||
pl-4 pr-14 py-2.5 sm:py-2 text-base sm:text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 right-5 flex items-center text-text-muted"
|
||||
aria-hidden
|
||||
>
|
||||
<ChevronDown className="h-4 w-4 icon-flat" />
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
ref={ref}
|
||||
id={selectId}
|
||||
className={`
|
||||
form-select w-full appearance-none rounded-[var(--radius-md)] border
|
||||
${error ? 'border-red-500' : 'border-border'}
|
||||
bg-background-card text-text-primary
|
||||
ps-3 pe-10 py-2.5 sm:py-2 text-base sm:text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { addCalendarDays, compareLocalDayStart, startOfLocalDay } from '@/components/appointments/appointmentTime';
|
||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||
import { CalendarDaySelect } from '@/components/ui/shared/CalendarDaySelect';
|
||||
|
||||
interface ScheduleDayPickerProps {
|
||||
value: Date;
|
||||
@@ -14,47 +10,6 @@ interface ScheduleDayPickerProps {
|
||||
showTodayToggle?: boolean;
|
||||
}
|
||||
|
||||
const MONTH_KEYS = [
|
||||
'monthJanuary',
|
||||
'monthFebruary',
|
||||
'monthMarch',
|
||||
'monthApril',
|
||||
'monthMay',
|
||||
'monthJune',
|
||||
'monthJuly',
|
||||
'monthAugust',
|
||||
'monthSeptember',
|
||||
'monthOctober',
|
||||
'monthNovember',
|
||||
'monthDecember',
|
||||
] as const;
|
||||
|
||||
function daysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
}
|
||||
|
||||
function buildLocalDay(year: number, month: number, day: number): Date {
|
||||
return new Date(year, month, day, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
function yearRange(anchor: Date): number[] {
|
||||
const anchorYear = anchor.getFullYear();
|
||||
const startYear = anchorYear - 10;
|
||||
const endYear = anchorYear + 2;
|
||||
const years: number[] = [];
|
||||
for (let y = startYear; y <= endYear; y += 1) {
|
||||
years.push(y);
|
||||
}
|
||||
return years;
|
||||
}
|
||||
|
||||
const selectClassName = `
|
||||
w-full appearance-none rounded-[var(--radius-sm)] border border-border
|
||||
bg-background-card/90 text-text-primary text-sm
|
||||
pl-2 pr-7 py-1.5
|
||||
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
|
||||
`;
|
||||
|
||||
/**
|
||||
* Calendar day navigator (arrows + year/month/day panel).
|
||||
* Does not restrict past dates — parent pages enforce read-only vs editable for schedule grids/forms.
|
||||
@@ -65,215 +20,14 @@ export function ScheduleDayPicker({
|
||||
label,
|
||||
showTodayToggle = true,
|
||||
}: ScheduleDayPickerProps) {
|
||||
const t = useTranslations('schedule');
|
||||
const panelId = useId();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
|
||||
const normalizedValue = startOfLocalDay(value);
|
||||
const today = startOfLocalDay(new Date());
|
||||
const isTodaySelected = compareLocalDayStart(normalizedValue, today) === 0;
|
||||
const resolvedLabel = label ?? t('defaultLabel');
|
||||
|
||||
const labelText = normalizedValue.toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const years = yearRange(normalizedValue);
|
||||
const selectedYear = normalizedValue.getFullYear();
|
||||
const selectedMonth = normalizedValue.getMonth();
|
||||
const selectedDay = normalizedValue.getDate();
|
||||
const dayCount = daysInMonth(selectedYear, selectedMonth);
|
||||
|
||||
function applyParts(year: number, month: number, day: number, closePanel = false) {
|
||||
const maxDay = daysInMonth(year, month);
|
||||
onChange(buildLocalDay(year, month, Math.min(Math.max(1, day), maxDay)));
|
||||
if (closePanel) {
|
||||
setPanelOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!panelOpen) return;
|
||||
|
||||
function onPointerDown(event: MouseEvent) {
|
||||
if (!rootRef.current?.contains(event.target as Node)) {
|
||||
setPanelOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
setPanelOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [panelOpen]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative w-full max-w-md">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-medium text-text-secondary">{resolvedLabel}</p>
|
||||
{showTodayToggle ? (
|
||||
<Checkbox
|
||||
checked={isTodaySelected}
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
onChange(today);
|
||||
setPanelOpen(false);
|
||||
}
|
||||
}}
|
||||
label={t('today')}
|
||||
className="shrink-0"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-[var(--radius-md)] border border-border bg-background-secondary/90 px-1 py-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(addCalendarDays(normalizedValue, -1))}
|
||||
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
|
||||
aria-label={t('previousDay')}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 icon-flat" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPanelOpen((open) => !open)}
|
||||
aria-expanded={panelOpen}
|
||||
aria-controls={panelId}
|
||||
aria-haspopup="dialog"
|
||||
className="flex flex-1 min-w-0 items-center justify-center gap-3 rounded-[var(--radius-sm)] px-2 py-1.5 text-sm font-medium text-text-primary tabular-nums hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
|
||||
>
|
||||
<span className="truncate">{labelText}</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 shrink-0 text-text-muted icon-flat transition-transform ${panelOpen ? 'rotate-180' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(addCalendarDays(normalizedValue, 1))}
|
||||
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
|
||||
aria-label={t('nextDay')}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4 icon-flat" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{panelOpen && (
|
||||
<div
|
||||
id={panelId}
|
||||
role="dialog"
|
||||
aria-label={t('chooseDate')}
|
||||
className="absolute left-0 right-0 top-full z-50 mt-2 rounded-[var(--radius-md)] border border-border bg-background-secondary p-3 shadow-lg"
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`${panelId}-year`}
|
||||
className="mb-1 block text-xs font-medium text-text-muted"
|
||||
>
|
||||
{t('year')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id={`${panelId}-year`}
|
||||
value={selectedYear}
|
||||
onChange={(e) =>
|
||||
applyParts(Number(e.target.value), selectedMonth, selectedDay)
|
||||
}
|
||||
className={selectClassName}
|
||||
>
|
||||
{years.map((year) => (
|
||||
<option key={year} value={year}>
|
||||
{year}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`${panelId}-month`}
|
||||
className="mb-1 block text-xs font-medium text-text-muted"
|
||||
>
|
||||
{t('month')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id={`${panelId}-month`}
|
||||
value={selectedMonth}
|
||||
onChange={(e) =>
|
||||
applyParts(selectedYear, Number(e.target.value), selectedDay)
|
||||
}
|
||||
className={selectClassName}
|
||||
>
|
||||
{MONTH_KEYS.map((key, index) => (
|
||||
<option key={key} value={index}>
|
||||
{t(key)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`${panelId}-day`}
|
||||
className="mb-1 block text-xs font-medium text-text-muted"
|
||||
>
|
||||
{t('day')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id={`${panelId}-day`}
|
||||
value={selectedDay}
|
||||
onChange={(e) =>
|
||||
applyParts(
|
||||
selectedYear,
|
||||
selectedMonth,
|
||||
Number(e.target.value),
|
||||
true,
|
||||
)
|
||||
}
|
||||
className={selectClassName}
|
||||
>
|
||||
{Array.from({ length: dayCount }, (_, i) => i + 1).map((day) => (
|
||||
<option key={day} value={day}>
|
||||
{day}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CalendarDaySelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label={label}
|
||||
showHeader
|
||||
showTodayToggle={showTodayToggle}
|
||||
showNavArrows
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Link, usePathname } from '@/i18n/navigation';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
counterpartOrganizationType,
|
||||
organizationTypeIcon,
|
||||
} from '@/components/shared/organizationTypeIcon';
|
||||
import { isRtlLocale } from '@/i18n/routing';
|
||||
|
||||
type MenuItem = {
|
||||
name: string;
|
||||
@@ -46,6 +47,8 @@ type SidebarProps = {
|
||||
};
|
||||
|
||||
function Sidebar({ mobileOpen = false, onClose }: SidebarProps) {
|
||||
const locale = useLocale();
|
||||
const rtl = isRtlLocale(locale);
|
||||
const t = useTranslations('nav');
|
||||
const tCommon = useTranslations('common');
|
||||
const pathname = usePathname();
|
||||
@@ -98,8 +101,8 @@ function Sidebar({ mobileOpen = false, onClose }: SidebarProps) {
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 w-56 min-w-56 shrink-0 bg-background-secondary/95 border-r border-border text-text-primary flex flex-col backdrop-blur-sm transition-transform duration-200 ease-out lg:relative lg:translate-x-0 lg:z-auto ${
|
||||
mobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
className={`app-sidebar fixed inset-y-0 left-0 z-50 w-56 min-w-56 shrink-0 bg-background-secondary/95 border-r border-border text-text-primary flex flex-col backdrop-blur-sm transition-transform duration-200 ease-out lg:relative lg:translate-x-0 lg:z-auto ${
|
||||
mobileOpen ? 'translate-x-0' : rtl ? 'translate-x-full lg:translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
}`}
|
||||
>
|
||||
<div className="h-[71px] px-4 flex items-center justify-between gap-2">
|
||||
|
||||
@@ -6,11 +6,12 @@ interface TableProps {
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
/** Shared data table — logical alignment (`text-start` / `text-end`) for LTR and RTL. */
|
||||
export function Table({ headers, body, footer }: TableProps) {
|
||||
return (
|
||||
<div className="surface-card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[36rem] [&_th]:px-3 sm:[&_th]:px-6 [&_td]:px-3 sm:[&_td]:px-6">
|
||||
<table className="w-full min-w-[36rem] border-collapse [&_th]:px-3 sm:[&_th]:px-6 [&_th]:py-3 [&_th]:text-start [&_td]:px-3 sm:[&_td]:px-6 [&_td]:py-1.5 [&_td]:text-start [&_th.text-center]:text-center [&_td.text-center]:text-center [&_th.text-end]:text-end [&_td.text-end]:text-end">
|
||||
<thead className="bg-background-secondary/70 border-b border-border">
|
||||
{headers}
|
||||
</thead>
|
||||
|
||||
Reference in New Issue
Block a user