improvement: rtl direction, solar calendar and persian formatting added for persian users.

This commit is contained in:
2026-07-14 01:27:11 +03:30
parent 27eae25f61
commit ec2b23f4b1
50 changed files with 1410 additions and 528 deletions

View File

@@ -72,7 +72,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
return (
<ToastProvider>
<div className="flex h-[100dvh] app-web-bg text-text-primary">
<div className="app-dashboard-shell flex h-[100dvh] app-web-bg text-text-primary">
{sidebarOpen ? (
<button
type="button"

View File

@@ -4,11 +4,12 @@ import { getMessages, setRequestLocale } from 'next-intl/server';
import { hasLocale } from 'next-intl';
import { notFound } from 'next/navigation';
import Script from 'next/script';
import { Noto_Sans_Arabic, Vazirmatn } from 'next/font/google';
import '@/styles/globals.css';
import '@/styles/background-web.css';
import { AuthProvider } from '@/lib/hooks/useAuth';
import { THEME_STORAGE_KEY } from '@/lib/theme';
import { routing, localeHtmlLang } from '@/i18n/routing';
import { routing, isRtlLocale, localeHtmlLang } from '@/i18n/routing';
import { LocaleSync } from '@/components/i18n/LocaleSync';
export const metadata: Metadata = {
@@ -25,6 +26,18 @@ export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
const vazirmatn = Vazirmatn({
subsets: ['arabic'],
variable: '--font-vazirmatn',
display: 'swap',
});
const notoSansArabic = Noto_Sans_Arabic({
subsets: ['arabic'],
variable: '--font-noto-sans-arabic',
display: 'swap',
});
export default async function LocaleLayout({
children,
params,
@@ -42,9 +55,20 @@ export default async function LocaleLayout({
const messages = await getMessages();
const themeInit = `(function(){try{var k=${JSON.stringify(THEME_STORAGE_KEY)};var t=localStorage.getItem(k);document.documentElement.setAttribute('data-theme',t==='light'||t==='dark'?t:'dark');}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();`;
const dir = isRtlLocale(locale) ? 'rtl' : 'ltr';
const fontSans = isRtlLocale(locale)
? 'var(--font-vazirmatn), var(--font-noto-sans-arabic), system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif'
: 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif';
return (
<html lang={localeHtmlLang(locale)} dir="ltr" suppressHydrationWarning>
<html
lang={localeHtmlLang(locale)}
dir={dir}
data-locale={locale}
className={`${vazirmatn.variable} ${notoSansArabic.variable}`}
style={{ ['--font-sans' as never]: fontSans }}
suppressHydrationWarning
>
<body>
<Script id="theme-init" strategy="beforeInteractive">
{themeInit}

View File

@@ -1,3 +1,5 @@
import { formatAppMinuteOfDay } from '@/lib/i18n/format';
/** Normalize to local midnight; invalid input falls back to today. */
export function startOfLocalDay(d: Date): Date {
if (Number.isNaN(d.getTime())) {
@@ -45,9 +47,8 @@ export function isSameLocalCalendarDay(a: Date, b: Date): boolean {
);
}
export function formatHourLabel(hour: number): string {
const d = new Date(2000, 0, 1, hour, 0, 0, 0);
return d.toLocaleTimeString(undefined, { hour: 'numeric', hour12: true });
export function formatHourLabel(hour: number, locale: string): string {
return formatAppMinuteOfDay(hour * 60, locale);
}
/** Local midnight + delta calendar days. */

View File

@@ -1,5 +1,6 @@
import type { LabCaseDetail } from '@/types/cases';
import type { CaseToothChartProsthesisRow } from '@/components/ui/lab/CaseToothChartPanel';
import { formatAppDateTime } from '@/lib/i18n/format';
export function formatPatientName(patient: { firstName: string; lastName: string }) {
return `${patient.firstName} ${patient.lastName}`.trim();
@@ -7,10 +8,7 @@ export function formatPatientName(patient: { firstName: string; lastName: string
export function formatCaseDateTime(value: string | null, locale: string) {
if (!value) return '—';
return new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
return formatAppDateTime(value, locale, { dateStyle: 'medium', timeStyle: 'short' });
}
export function buildCaseProsthesisRows(labCase: LabCaseDetail): CaseToothChartProsthesisRow[] {

View File

@@ -1,4 +1,5 @@
import type { BadgeVariant } from '@/components/ui/shared/Badge';
import { formatAppDate, APP_DATE } from '@/lib/i18n/format';
export function toDateInputValue(iso: string | null | undefined): string {
if (!iso) return '';
@@ -18,13 +19,7 @@ export function formatLabCaseDueDate(
locale: string,
): string | null {
if (!iso) return null;
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return null;
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date);
return formatAppDate(iso, locale, APP_DATE.short);
}
/** Whole calendar days from today (UTC) until due date. Negative = overdue. */

View File

@@ -1,5 +1,12 @@
export const FORM_SELECT_CLASS =
'form-select rounded border border-border bg-background-card text-text-primary px-2 py-1 text-sm disabled:opacity-60 focus:outline-none focus:ring-2 focus:ring-primary/35';
'form-select appearance-none rounded border border-border bg-background-card text-text-primary ps-3 pe-10 py-1 text-sm disabled:opacity-60 focus:outline-none focus:ring-2 focus:ring-primary/35';
/** Same shell as select fields but without the CSS chevron (date picker uses a button icon). */
export const FORM_DATE_INPUT_CLASS = `${FORM_SELECT_CLASS} form-select-no-chevron text-start`;
/** Tiny selects (e.g. sort direction) — symmetric padding, no chevron gutter. */
export const FORM_SELECT_COMPACT_CLASS =
'form-select form-select-no-chevron appearance-none rounded border border-border bg-background-card text-text-primary px-2 py-1 text-sm text-center disabled:opacity-60 focus:outline-none focus:ring-2 focus:ring-primary/35';
/** Lab task status control — larger tap target on small screens. */
export const LAB_TASK_STATUS_SELECT_CLASS = `${FORM_SELECT_CLASS} h-[44px] w-full py-2 text-base font-medium sm:h-9 sm:py-1 sm:text-sm sm:max-w-none`;

View File

@@ -1,3 +1,5 @@
import { formatAppMinuteOfDay } from '@/lib/i18n/format';
export const MINUTES_PER_DAY = 24 * 60;
export const SCHEDULE_SLOT_MINUTES = 15;
@@ -50,9 +52,8 @@ export function timeInputToMinutes(value: string): number | null {
return h * 60 + m;
}
export function formatMinuteLabel(minute: number): string {
const d = new Date(2000, 0, 1, Math.floor(minute / 60), minute % 60, 0, 0);
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true });
export function formatMinuteLabel(minute: number, locale: string): string {
return formatAppMinuteOfDay(minute, locale);
}
export function emptyWorkingHoursEditorDays(): WorkingHoursEditorDay[] {

View File

@@ -1,16 +1,14 @@
'use client';
import { useMemo } from 'react';
import { useLocale } from 'next-intl';
import { APP_DATE, createAppDateFormatter } from '@/lib/i18n/format';
export function useTodayDayLabelFormatter() {
const locale = useLocale();
return useMemo(
() =>
new Intl.DateTimeFormat(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
}),
[],
() => createAppDateFormatter(locale, APP_DATE.chartDay),
[locale],
);
}

View File

@@ -1,4 +1,5 @@
import type { LinkedOrganizationOption, TreatmentCaseSendInfo } from '@/types/treatment';
import { formatAppDateTime } from '@/lib/i18n/format';
export type CaseSendLabelT = (
key: 'sentToAt' | 'fallbackOrgName',
@@ -13,16 +14,17 @@ export function formatCaseSentLines(
orgs?: LinkedOrganizationOption[];
} | undefined,
t: CaseSendLabelT,
locale: string,
): string[] {
if (sends?.length) {
return sends.map((s) => {
const at = new Date(s.sentAt).toLocaleString();
const at = formatAppDateTime(s.sentAt, locale, { dateStyle: 'medium', timeStyle: 'short' });
return t('sentToAt', { orgName: s.organizationName, datetime: at });
});
}
if (fallback?.sentAt && fallback.organizationIds.length > 0) {
const at = new Date(fallback.sentAt).toLocaleString();
const at = formatAppDateTime(fallback.sentAt, locale, { dateStyle: 'medium', timeStyle: 'short' });
const nameById = new Map(fallback.orgs?.map((o) => [o.id, o.name]) ?? []);
return fallback.organizationIds.map((id) => {
const name = nameById.get(id) ?? t('fallbackOrgName');
@@ -41,7 +43,8 @@ export function formatCaseSentSummary(
orgs?: LinkedOrganizationOption[];
} | undefined,
t: CaseSendLabelT,
locale: string,
): string | null {
const lines = formatCaseSentLines(sends, fallback, t);
const lines = formatCaseSentLines(sends, fallback, t, locale);
return lines.length > 0 ? lines.join(' · ') : null;
}

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import {
purposeBannerStyle,
@@ -9,6 +9,7 @@ import {
} from '@/components/appointments/appointmentPurposeStyles';
import type { AppointmentRecord } from '@/types/appointment';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import { formatAppTimeRange } from '@/lib/i18n/format';
type AppointmentOverlapPopoverProps = {
appointments: AppointmentRecord[];
@@ -18,11 +19,8 @@ type AppointmentOverlapPopoverProps = {
onClose: () => void;
};
function formatTimeRange(apt: AppointmentRecord): string {
const start = new Date(apt.startAt);
const end = new Date(apt.endAt);
const opts: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit' };
return `${start.toLocaleTimeString(undefined, opts)} ${end.toLocaleTimeString(undefined, opts)}`;
function formatTimeRange(apt: AppointmentRecord, locale: string): string {
return formatAppTimeRange(apt.startAt, apt.endAt, locale);
}
export function AppointmentOverlapPopover({
@@ -32,6 +30,7 @@ export function AppointmentOverlapPopover({
onSelect,
onClose,
}: AppointmentOverlapPopoverProps) {
const locale = useLocale();
const t = useTranslations('appointments');
const panelRef = useRef<HTMLDivElement>(null);
@@ -99,7 +98,7 @@ export function AppointmentOverlapPopover({
<p className="text-xs font-medium truncate">
{apt.patient.firstName} {apt.patient.lastName}
</p>
<p className="text-[11px] opacity-90 tabular-nums">{formatTimeRange(apt)}</p>
<p className="text-[11px] opacity-90 tabular-nums">{formatTimeRange(apt, locale)}</p>
<p className="text-[10px] opacity-80 truncate">
{purposeLabel(apt.purpose, treatmentCatalog)}
</p>

View File

@@ -1,7 +1,7 @@
'use client';
import { useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
import {
SCHEDULE_SLOT_MINUTES,
@@ -96,6 +96,7 @@ export function AppointmentScheduleGrid({
onAppointmentClick,
onAppointmentOutsideHours,
}: AppointmentScheduleGridProps) {
const locale = useLocale();
const t = useTranslations('appointments');
const [overlapPopover, setOverlapPopover] = useState<OverlapPopoverState | null>(null);
@@ -218,7 +219,7 @@ export function AppointmentScheduleGrid({
className="absolute left-0 right-0 text-[11px] text-text-muted flex items-start justify-end pr-1.5 pt-0.5 border-b border-border/50"
style={{ top, height }}
>
{formatMinuteLabel(hour * 60)}
{formatMinuteLabel(hour * 60, locale)}
</div>
);
})}
@@ -263,7 +264,7 @@ export function AppointmentScheduleGrid({
: slotDisabled
? t('slotCannotCreate')
: t('slotBookAt', {
time: formatMinuteLabel(slotStartMinute),
time: formatMinuteLabel(slotStartMinute, locale),
})
}
className={`absolute left-0 right-0 border-b border-border/50 transition-colors ${

View File

@@ -2,6 +2,7 @@
'use client';
import { useMemo, useState } from 'react';
import { useLocale } from 'next-intl';
import { Pencil } from 'lucide-react';
import { Button } from '@/components/ui/shared/Button';
import { Badge } from '@/components/ui/shared/Badge';
@@ -10,6 +11,7 @@ import { Table } from '@/components/ui/shared/Table';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { useAuth } from '@/lib/hooks/useAuth';
import { hasPermission } from '@/components/shared/permissions';
import { formatAppNumber } from '@/lib/i18n/format';
type InvoiceStatus = 'paid' | 'unpaid' | 'overdue';
@@ -44,9 +46,11 @@ interface StatCardProps {
count: number;
amount: number;
color: StatCardColor;
locale: string;
}
export function BillingPage() {
const locale = useLocale();
const { currentOrganization } = useAuth();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<(typeof statusFilters)[number]>('all');
@@ -89,10 +93,10 @@ export function BillingPage() {
</div>
<div className="grid grid-cols-2 xl:grid-cols-4 gap-3 sm:gap-4">
<StatCard title="Total Invoices" count={stats.total.count} amount={stats.total.amount} color="blue" />
<StatCard title="Unpaid Invoices" count={stats.unpaid.count} amount={stats.unpaid.amount} color="yellow" />
<StatCard title="Paid Invoices" count={stats.paid.count} amount={stats.paid.amount} color="green" />
<StatCard title="Overdue Invoices" count={stats.overdue.count} amount={stats.overdue.amount} color="red" />
<StatCard title="Total Invoices" count={stats.total.count} amount={stats.total.amount} color="blue" locale={locale} />
<StatCard title="Unpaid Invoices" count={stats.unpaid.count} amount={stats.unpaid.amount} color="yellow" locale={locale} />
<StatCard title="Paid Invoices" count={stats.paid.count} amount={stats.paid.amount} color="green" locale={locale} />
<StatCard title="Overdue Invoices" count={stats.overdue.count} amount={stats.overdue.amount} color="red" locale={locale} />
</div>
<SearchBar
@@ -138,28 +142,28 @@ export function BillingPage() {
<Table
headers={
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
<th className="text-xs font-medium text-text-muted uppercase tracking-wider">
Invoice ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
<th className="text-xs font-medium text-text-muted uppercase tracking-wider">
Patient name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
<th className="text-xs font-medium text-text-muted uppercase tracking-wider">
Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
<th className="text-xs font-medium text-text-muted uppercase tracking-wider">
Service
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
<th className="text-xs font-medium text-text-muted uppercase tracking-wider">
Total amount
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
<th className="text-xs font-medium text-text-muted uppercase tracking-wider">
Paid
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-text-muted uppercase tracking-wider">
<th className="text-center text-xs font-medium text-text-muted uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
<th className="text-xs font-medium text-text-muted uppercase tracking-wider">
Action
</th>
</tr>
@@ -168,18 +172,18 @@ export function BillingPage() {
<>
{filteredInvoices.map((invoice) => (
<tr key={invoice.id} className="hover:bg-background-secondary/45">
<td className="px-6 py-1.5 text-sm font-medium text-text-primary">{invoice.id}</td>
<td className="px-6 py-1.5 text-sm text-text-primary">{invoice.patient}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">{invoice.date}</td>
<td className="px-6 py-1.5 text-sm text-text-primary">{invoice.service}</td>
<td className="px-6 py-1.5 text-sm text-text-primary">${invoice.amount}</td>
<td className="px-6 py-1.5 text-sm text-text-primary">${invoice.paid}</td>
<td className="px-6 py-1.5 text-center align-middle">
<td className="text-sm font-medium text-text-primary">{invoice.id}</td>
<td className="text-sm text-text-primary">{invoice.patient}</td>
<td className="text-sm text-text-secondary">{invoice.date}</td>
<td className="text-sm text-text-primary">{invoice.service}</td>
<td className="text-sm text-text-primary">${invoice.amount}</td>
<td className="text-sm text-text-primary">${invoice.paid}</td>
<td className="text-center align-middle">
<Badge variant={statusColors[invoice.status]} className="capitalize">
{invoice.status}
</Badge>
</td>
<td className="px-6 py-1.5">
<td>
<InvoiceEditButton canEditBilling={canEditBilling} />
</td>
</tr>
@@ -276,7 +280,7 @@ function InvoicePagination({ className = '' }: { className?: string }) {
);
}
function StatCard({ title, count, amount, color }: StatCardProps) {
function StatCard({ title, count, amount, color, locale }: StatCardProps) {
const colors: Record<StatCardColor, string> = {
blue: '!bg-purpose-visit-bg !text-purpose-visit-fg !border-purpose-visit-border',
yellow: '!bg-badge-warning-bg !text-badge-warning-fg !border-badge-warning-border',
@@ -287,9 +291,9 @@ function StatCard({ title, count, amount, color }: StatCardProps) {
return (
<Card className={`min-w-0 ${colors[color]}`}>
<p className="text-xs sm:text-sm font-medium leading-snug">{title}</p>
<p className="text-xl sm:text-2xl font-bold mt-1 tabular-nums">{count}</p>
<p className="text-xl sm:text-2xl font-bold mt-1 tabular-nums">{formatAppNumber(count, locale)}</p>
<p className="text-xs sm:text-sm font-medium mt-1 tabular-nums truncate">
${amount.toLocaleString()}
${formatAppNumber(amount, locale)}
</p>
</Card>
);

View File

@@ -247,7 +247,7 @@ export function CaseDetailPanel({
onAssignTask(task.id, e.target.value ? e.target.value : null)
}
aria-label={t('assigneeLabel')}
className={`${FORM_SELECT_CLASS} max-w-[9.5rem] rounded-md px-2 py-0.5 text-xs`}
className={`${FORM_SELECT_CLASS} max-w-[9.5rem] rounded-md py-0.5 text-xs`}
>
<option value="">{t('assigneeUnassigned')}</option>
{assignableStaff.map((staff) => (

View File

@@ -25,6 +25,7 @@ import { Button } from '@/components/ui/shared/Button';
import { MobileDetailBackButton } from '@/components/ui/shared/MobileDetailBackButton';
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { AppDateInput } from '@/components/ui/shared/AppDateInput';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type {
AssignableTaskStaff,
@@ -267,7 +268,7 @@ export function CasesPage() {
}
}
const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-3 py-2`;
const filterSelectClass = `${FORM_SELECT_CLASS} w-full min-w-0 rounded-md py-1.5 text-xs sm:py-2 sm:text-sm`;
return (
<div className="space-y-4">
@@ -333,11 +334,10 @@ export function CasesPage() {
<label className="space-y-1">
<span className="text-xs font-medium text-text-muted">{t('filterSentFrom')}</span>
<input
type="date"
<AppDateInput
value={sentFrom}
onChange={(e) => {
setSentFrom(e.target.value);
onChange={(next) => {
setSentFrom(next);
setPage(1);
}}
className={filterSelectClass}
@@ -346,11 +346,10 @@ export function CasesPage() {
<label className="space-y-1">
<span className="text-xs font-medium text-text-muted">{t('filterSentTo')}</span>
<input
type="date"
<AppDateInput
value={sentTo}
onChange={(e) => {
setSentTo(e.target.value);
onChange={(next) => {
setSentTo(next);
setPage(1);
}}
className={filterSelectClass}

View File

@@ -5,6 +5,7 @@ import { Badge } from '@/components/ui/shared/Badge';
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
import type { CaseTaskGroup } from '@/components/lab/taskListGrouping';
import { countCaseTaskProgress } from '@/components/lab/taskListGrouping';
import { formatAppDate, APP_DATE } from '@/lib/i18n/format';
function formatPatientName(patient: { firstName: string; lastName: string }) {
return `${patient.firstName} ${patient.lastName}`.trim();
@@ -20,11 +21,7 @@ export function TaskCaseGroupHeader({ caseGroup, locale }: TaskCaseGroupHeaderPr
const progress = countCaseTaskProgress(caseGroup);
const sentLabel = caseGroup.caseSentAt
? new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(new Date(caseGroup.caseSentAt))
? formatAppDate(caseGroup.caseSentAt, locale, APP_DATE.short)
: null;
return (

View File

@@ -12,6 +12,7 @@ import {
labTaskStatusVariant,
} from '@/components/lab/labTaskStatusDisplay';
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
import { formatAppDate, APP_DATE } from '@/lib/i18n/format';
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
import {
formatToothList,
@@ -64,11 +65,7 @@ export function TaskRow({
const canEditStatus = canEditLabTaskStatus(task, currentUserId, canEdit);
const assignedToOther =
Boolean(task.assignee) && task.assignee!.id !== currentUserId;
const taskDate = new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(new Date(task.createdAt));
const taskDate = formatAppDate(task.createdAt, locale, APP_DATE.short);
const rowClassName = [
flatMode ? undefined : 'border-b border-border/40 last:border-b-0',

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
import { FORM_SELECT_CLASS, FORM_SELECT_COMPACT_CLASS } from '@/components/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { TaskCaseGroupHeader } from '@/components/ui/lab/TaskCaseGroupHeader';
import { TaskProsthesisGroupHeader } from '@/components/ui/lab/TaskProsthesisGroupHeader';
@@ -286,7 +286,7 @@ export function TasksPage() {
[canEdit, loadTasks, setError, showError, showSuccess, statusFilter, t, tErrors],
);
const filterSelectClass = `${FORM_SELECT_CLASS} w-full min-h-[44px] rounded-md px-2 py-2 text-base sm:min-h-0 sm:py-1.5 sm:text-sm`;
const filterSelectClass = `${FORM_SELECT_CLASS} w-full min-w-0 rounded-md py-1.5 text-xs sm:py-2 sm:text-sm`;
const sortHintKey = useMemo(() => {
switch (sortBy) {
@@ -366,7 +366,8 @@ export function TasksPage() {
onChange={(v) => applyFilterChange(() => setSearch(v))}
placeholder={t('searchPlaceholder')}
/>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
<div className="overflow-x-auto">
<div className="grid min-w-[44rem] grid-cols-4 gap-2">
<label className="space-y-1">
<span className="text-xs text-text-muted">{t('filterClinic')}</span>
<select
@@ -411,16 +412,16 @@ export function TasksPage() {
))}
</select>
</label>
<label className="space-y-1">
<label className="space-y-1 min-w-0">
<span className="text-xs text-text-muted">{t('sortBy')}</span>
<div className="flex gap-1.5">
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-1">
<select
value={sortBy}
onChange={(e) => {
setSortBy(e.target.value as TaskSortField);
clearFocus();
}}
className={`${filterSelectClass} min-w-0 flex-1`}
className={`${filterSelectClass} min-w-0`}
>
<option value="date">{t('sortDate')}</option>
<option value="dueDate">{t('sortDueDate')}</option>
@@ -432,7 +433,7 @@ export function TasksPage() {
<select
value={sortDir}
onChange={(e) => setSortDir(e.target.value as 'asc' | 'desc')}
className={`${FORM_SELECT_CLASS} w-14 shrink-0 rounded-md px-2 py-1.5 text-sm`}
className={`${FORM_SELECT_COMPACT_CLASS} w-11 shrink-0 rounded-md py-1.5 text-sm`}
aria-label={t('sortDirection')}
>
<option value="desc"></option>
@@ -440,6 +441,7 @@ export function TasksPage() {
</select>
</div>
</label>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<Checkbox

View File

@@ -1,6 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { Card } from '@/components/ui/shared/Card';
import {
@@ -12,12 +12,7 @@ import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvi
import type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization';
import { Badge } from '@/components/ui/shared/Badge';
import { organizationConnectionStatusVariant } from '@/components/organizations/connectionStatusVariant';
function formatTableDate(value: string): string {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleDateString();
}
import { formatAppTableDate } from '@/lib/i18n/format';
type InvitationHistoryDialogProps = {
open: boolean;
@@ -38,6 +33,7 @@ export function InvitationHistoryDialog({
copyingInvitationId,
onCopy,
}: InvitationHistoryDialogProps) {
const locale = useLocale();
const t = useTranslations('organizations');
function formatInvitationStatusLabel(
@@ -82,7 +78,7 @@ export function InvitationHistoryDialog({
<div className="min-w-0">
<p className="font-medium text-text-primary truncate">{inv.organizationName}</p>
<p className="text-sm text-text-secondary truncate mt-0.5">{inv.ownerEmail}</p>
<p className="text-xs text-text-muted mt-1">{formatTableDate(inv.createdAt)}</p>
<p className="text-xs text-text-muted mt-1">{formatAppTableDate(inv.createdAt, locale)}</p>
</div>
<Badge variant={organizationConnectionStatusVariant(inv.status)} fixedWidth={false}>
{formatInvitationStatusLabel(inv.status)}
@@ -105,19 +101,19 @@ export function InvitationHistoryDialog({
<Table
headers={
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
<th className="text-xs font-medium text-text-muted uppercase tracking-wider">
{t('tableOrganization')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
<th className="text-xs font-medium text-text-muted uppercase tracking-wider">
{t('tableOwnerEmail')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
<th className="text-xs font-medium text-text-muted uppercase tracking-wider">
{t('tableDate')}
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-text-muted uppercase tracking-wider">
<th className="text-center text-xs font-medium text-text-muted uppercase tracking-wider">
{t('tableStatus')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider">
<th className="text-end text-xs font-medium text-text-muted uppercase tracking-wider">
{t('tableInvitationLink')}
</th>
</tr>
@@ -126,17 +122,17 @@ export function InvitationHistoryDialog({
<>
{items.map((inv) => (
<tr key={inv.id} className="hover:bg-background-secondary/45">
<td className="px-6 py-1.5 text-sm text-text-primary">{inv.organizationName}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">{inv.ownerEmail}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">
{formatTableDate(inv.createdAt)}
<td className="text-sm text-text-primary">{inv.organizationName}</td>
<td className="text-sm text-text-secondary">{inv.ownerEmail}</td>
<td className="text-sm text-text-secondary">
{formatAppTableDate(inv.createdAt, locale)}
</td>
<td className="px-6 py-1.5 text-center align-middle">
<td className="text-center align-middle">
<Badge variant={organizationConnectionStatusVariant(inv.status)} fixedWidth={false}>
{formatInvitationStatusLabel(inv.status)}
</Badge>
</td>
<td className="px-6 py-1.5 text-right align-middle">
<td className="text-end align-middle">
<CopyInvitationLinkButton
invitation={inv}
copied={copiedId === inv.id}

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { useToast } from '@/lib/hooks/useToast';
import { Check, Trash2, UserPlus, X } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
@@ -26,6 +26,7 @@ import { SearchBar } from '@/components/ui/shared/SearchBar';
import { Table } from '@/components/ui/shared/Table';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useRouter } from '@/i18n/navigation';
import { formatAppTableDate } from '@/lib/i18n/format';
function formatOrganizationStatusLabel(status: string): string {
if (!status) return status;
@@ -33,15 +34,10 @@ function formatOrganizationStatusLabel(status: string): string {
return lower.charAt(0).toUpperCase() + lower.slice(1);
}
function formatTableDate(value: string): string {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '\u2014';
return d.toLocaleDateString();
}
type TableMode = 'existing' | 'search';
export function OrganizationsPage() {
const locale = useLocale();
const t = useTranslations('organizations');
const tErrors = useTranslations('errors');
const tNav = useTranslations('nav');
@@ -58,6 +54,11 @@ export function OrganizationsPage() {
[tCommon, tErrors],
);
const formatTableDate = useCallback(
(value: string) => formatAppTableDate(value, locale),
[locale],
);
const formatConnectionStatusLabel = useCallback(
(row: CounterpartItemDto, currentOrganizationId: string): string => {
if (row.status === 'PENDING') {
@@ -389,19 +390,19 @@ export function OrganizationsPage() {
<Table
headers={
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
<th className="text-xs font-medium text-text-muted uppercase tracking-wider">
{t('tableOrganization')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
<th className="text-xs font-medium text-text-muted uppercase tracking-wider">
{t('tableOwnerEmail')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
<th className="text-xs font-medium text-text-muted uppercase tracking-wider">
{t('tableDate')}
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-text-muted uppercase tracking-wider">
<th className="text-center text-xs font-medium text-text-muted uppercase tracking-wider">
{t('tableStatus')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider">
<th className="text-end text-xs font-medium text-text-muted uppercase tracking-wider">
{t('tableAction')}
</th>
</tr>
@@ -410,14 +411,14 @@ export function OrganizationsPage() {
<>
{loading || (mode === 'search' && searching) ? (
<tr>
<td colSpan={5} className="px-6 py-8 text-sm text-text-secondary">
<td colSpan={5} className="py-8 text-sm text-text-secondary">
{tCommon('loadingEllipsis')}
</td>
</tr>
) : mode === 'existing' ? (
existingRows.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-8 text-sm text-text-secondary">
<td colSpan={5} className="py-8 text-sm text-text-secondary">
{t('emptyConnections')}
</td>
</tr>
@@ -434,19 +435,19 @@ export function OrganizationsPage() {
return (
<tr key={row.id} className="hover:bg-background-secondary/45">
<td className="px-6 py-1.5 text-sm font-medium text-text-primary">
<td className="text-sm font-medium text-text-primary">
{row.organizationName}
</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">{row.ownerEmail}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">
<td className="text-sm text-text-secondary">{row.ownerEmail}</td>
<td className="text-sm text-text-secondary">
{formatTableDate(row.createdAt)}
</td>
<td className="px-6 py-1.5 text-center align-middle">
<td className="text-center align-middle">
<Badge variant={organizationConnectionStatusVariant(row.status)} fixedWidth={false}>
{formatConnectionStatusLabel(row, currentOrganization.id)}
</Badge>
</td>
<td className="px-6 py-1.5 text-right">
<td className="text-end">
<div className="inline-flex items-center gap-2">
{invitationTarget && (
<CopyInvitationLinkButton
@@ -503,13 +504,13 @@ export function OrganizationsPage() {
) : searchResults.length > 0 ? (
searchResults.map((r) => (
<tr key={r.id} className="hover:bg-background-secondary/45">
<td className="px-6 py-1.5 text-sm font-medium text-text-primary">{r.name}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">{r.owner.email}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">{t('statusToday')}</td>
<td className="px-6 py-1.5 text-center align-middle">
<td className="text-sm font-medium text-text-primary">{r.name}</td>
<td className="text-sm text-text-secondary">{r.owner.email}</td>
<td className="text-sm text-text-secondary">{t('statusToday')}</td>
<td className="text-center align-middle">
<Badge variant="default" fixedWidth={false}>{t('statusFound')}</Badge>
</td>
<td className="px-6 py-1.5 text-right">
<td className="text-end">
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:text-text-muted disabled:opacity-50"
@@ -527,7 +528,7 @@ export function OrganizationsPage() {
))
) : (
<tr>
<td colSpan={5} className="px-6 py-6">
<td colSpan={5} className="py-6">
<div className="flex flex-col gap-3">
<p className="text-sm text-text-secondary">
{t('noDirectoryResults')}

View File

@@ -1,9 +1,9 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { formatTimeForInput } from '@/components/appointments/appointmentTime';
import { useLocale, useTranslations } from 'next-intl';
import { purposeLabel } from '@/components/appointments/appointmentPurposeStyles';
import { APP_DATE, formatAppDate, formatAppTimeRange } from '@/lib/i18n/format';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
import { patientsApi } from '@/lib/api/patients';
@@ -15,20 +15,12 @@ interface PatientAppointmentHistoryProps {
patientId: string;
}
function formatAppointmentDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleDateString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
});
function formatAppointmentDate(value: string, locale: string): string {
return formatAppDate(value, locale, APP_DATE.withWeekday);
}
export function PatientAppointmentHistory({ patientId }: PatientAppointmentHistoryProps) {
const locale = useLocale();
const t = useTranslations('patients');
const tErrors = useTranslations('errors');
const [items, setItems] = useState<PatientAppointmentHistoryItem[]>([]);
@@ -91,12 +83,10 @@ export function PatientAppointmentHistory({ patientId }: PatientAppointmentHisto
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="text-sm font-medium text-text-primary">
{formatAppointmentDate(item.startAt)}
{formatAppointmentDate(item.startAt, locale)}
</p>
<p className="text-sm text-text-secondary">
{formatTimeForInput(new Date(item.startAt))}
{' '}
{formatTimeForInput(new Date(item.endAt))}
{formatAppTimeRange(item.startAt, item.endAt, locale)}
</p>
</div>
<div className="flex flex-col gap-1.5 sm:items-end">

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -657,12 +657,12 @@ export function StaffPage() {
<Table
headers={
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{t('tableName')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{t('tableEmail')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{t('tableRole')}</th>
<th className="px-6 py-3 text-center text-xs font-medium text-text-muted uppercase tracking-wider">{t('tableStatus')}</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{t('tableAccess')}</th>
<th className="px-6 py-3 text-center text-xs font-medium text-text-muted uppercase tracking-wider w-36">
<th className="text-xs font-medium text-text-muted uppercase tracking-wider">{t('tableName')}</th>
<th className="text-xs font-medium text-text-muted uppercase tracking-wider">{t('tableEmail')}</th>
<th className="text-xs font-medium text-text-muted uppercase tracking-wider">{t('tableRole')}</th>
<th className="text-center text-xs font-medium text-text-muted uppercase tracking-wider">{t('tableStatus')}</th>
<th className="text-xs font-medium text-text-muted uppercase tracking-wider">{t('tableAccess')}</th>
<th className="text-center text-xs font-medium text-text-muted uppercase tracking-wider w-36">
{t('tableAction')}
</th>
</tr>
@@ -671,16 +671,16 @@ export function StaffPage() {
<>
{members.map((m) => (
<tr key={m.id} className="hover:bg-background-secondary/45">
<td className="px-6 py-1.5 text-sm text-text-primary">{m.name}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">{m.email}</td>
<td className="px-6 py-1.5 text-sm">
<td className="text-sm text-text-primary">{m.name}</td>
<td className="text-sm text-text-secondary">{m.email}</td>
<td className="text-sm">
{m.isOwner ? (
<span className="text-primary font-medium">{t('roleOwner')}</span>
) : (
<span className="text-text-secondary">{t('roleStaff')}</span>
)}
</td>
<td className="px-6 py-1.5 align-middle text-center">
<td className="align-middle text-center">
{m.isOwner || m.invitationStatus === 'ACTIVE' ? (
<Badge variant="success">{t('statusActive')}</Badge>
) : m.invitationStatus === 'PENDING' ? (
@@ -691,7 +691,7 @@ export function StaffPage() {
<Badge variant="danger">{t('statusExpired')}</Badge>
)}
</td>
<td className="px-6 py-1.5 text-sm text-text-secondary max-w-md">
<td className="text-sm text-text-secondary max-w-md">
{m.isOwner ? (
<span className="text-text-muted">{t('allFeatures')}</span>
) : (
@@ -700,7 +700,7 @@ export function StaffPage() {
</span>
)}
</td>
<td className="px-6 py-1.5 align-middle text-center">
<td className="align-middle text-center">
{!m.isOwner && (
<div className="flex min-h-[36px] items-center justify-center gap-1 mx-auto w-fit">
{canShareStaffInviteLink(m) && (

View File

@@ -1,7 +1,7 @@
'use client';
import { useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { useAuth } from '@/lib/hooks/useAuth';
import { getUserFacingError } from '@/components/shared/formatApiError';
@@ -10,8 +10,10 @@ import { TodayLoadErrorBanner } from '@/components/ui/today/TodayLoadErrorBanner
import { TodaySectionErrorFallback } from '@/components/ui/today/TodaySectionErrorFallback';
import { TodayWidgetErrorBoundary } from '@/components/ui/today/TodayWidgetErrorBoundary';
import { useTodaySummary } from '@/lib/hooks/useTodaySummary';
import { formatAppTime } from '@/lib/i18n/format';
export function TodayPage() {
const locale = useLocale();
const t = useTranslations('today');
const tErrors = useTranslations('errors');
const { currentOrganization } = useAuth();
@@ -32,10 +34,7 @@ export function TodayPage() {
{data?.generatedAt && !isInitialLoad ? (
<p className="text-xs text-text-muted">
{t('lastUpdated', {
time: new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: '2-digit',
}).format(new Date(data.generatedAt)),
time: formatAppTime(data.generatedAt, locale),
})}
</p>
) : null}

View File

@@ -1,11 +1,11 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { ChevronRight } from 'lucide-react';
import { Link } from '@/i18n/navigation';
import { Card } from '@/components/ui/shared/Card';
import { formatTimeForInput } from '@/components/appointments/appointmentTime';
import { formatAppTimeRange } from '@/lib/i18n/format';
import { purposeLabel } from '@/components/appointments/appointmentPurposeStyles';
import { treatmentAppointmentHref } from '@/components/shared/treatmentSelection';
import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay';
@@ -27,6 +27,7 @@ export function TodayUpcomingAppointments({
loading = false,
isInitialLoad = false,
}: TodayUpcomingAppointmentsProps) {
const locale = useLocale();
const t = useTranslations('today');
const { currentOrganization } = useAuth();
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
@@ -89,9 +90,7 @@ export function TodayUpcomingAppointments({
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
<ul className="divide-y divide-border/40">
{appointments.map((appointment) => {
const start = new Date(appointment.startAt);
const end = new Date(appointment.endAt);
const timeLabel = `${formatTimeForInput(start)} ${formatTimeForInput(end)}`;
const timeLabel = formatAppTimeRange(appointment.startAt, appointment.endAt, locale);
const purposeIndex = treatmentCatalog.findIndex(
(entry) => entry.code === appointment.purpose,
);

View File

@@ -1,6 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { CalendarDays } from 'lucide-react';
import { Card } from '@/components/ui/shared/Card';
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
@@ -11,6 +11,7 @@ import {
} from '@/components/shared/treatmentTypeDisplay';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { TreatmentAppointment } from '@/types/treatment';
import { APP_DATE, formatAppTimeRange } from '@/lib/i18n/format';
interface AppointmentsStripProps {
stripHidden: boolean;
@@ -35,6 +36,7 @@ export function AppointmentsStrip({
treatmentCatalog,
loading = false,
}: AppointmentsStripProps) {
const locale = useLocale();
const t = useTranslations('treatment');
if (stripHidden) {
@@ -84,15 +86,7 @@ export function AppointmentsStrip({
)}
{appointments.map((a) => {
const sel = a.id === selectedAppointmentId;
const start = new Date(a.startAt);
const end = new Date(a.endAt);
const timeLabel = `${start.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
})} ${end.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
})}`;
const timeLabel = formatAppTimeRange(a.startAt, a.endAt, locale);
const purposeLabel = treatmentTypeLabelFromCatalog(a.purpose, treatmentCatalog);
const purposeIndex = treatmentCatalog.findIndex((e) => e.code === a.purpose);
return (
@@ -104,7 +98,7 @@ export function AppointmentsStrip({
padding="none"
style={treatmentTypeBannerStyle(a.purpose, purposeIndex < 0 ? 0 : purposeIndex)}
className={`
text-left rounded-[var(--radius-sm)] px-3 py-2 w-full sm:w-auto sm:min-w-[200px] sm:max-w-[280px] transition-shadow min-h-[52px]
text-start rounded-[var(--radius-sm)] px-3 py-2 w-full sm:w-auto sm:min-w-[200px] sm:max-w-[280px] transition-shadow min-h-[52px]
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
${sel ? 'ring-2 ring-primary ring-offset-2 ring-offset-background-secondary shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]' : 'hover:brightness-110'}
`}

View File

@@ -1,6 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { formatCaseSentLines } from '@/components/treatment/caseSendLabel';
import type { LabCaseSendInfo, LinkedOrganizationOption } from '@/types/treatment';
@@ -16,6 +16,7 @@ interface CaseSentLabelProps {
}
export function CaseSentLabel({ treatmentCase, orgs, className = 'text-xs text-text-muted' }: CaseSentLabelProps) {
const locale = useLocale();
const t = useTranslations('treatment');
const organizationIds =
treatmentCase.sendToOrganizationIds ??
@@ -24,7 +25,7 @@ export function CaseSentLabel({ treatmentCase, orgs, className = 'text-xs text-t
organizationIds,
sentAt: treatmentCase.sentAt ?? null,
orgs,
}, t);
}, t, locale);
if (lines.length === 0) return null;

View File

@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { isDetailReadyForLabDispatch, isLabCaseCompleted } from '@/components/treatment/treatmentDetailRules';
import { AppDateInput } from '@/components/ui/shared/AppDateInput';
import { toDateInputValue } from '@/components/lab/labCaseDueDateDisplay';
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
import { LinkedOrganizationSearchCombobox } from '@/components/ui/treatment/LinkedOrganizationSearchCombobox';
@@ -283,20 +284,17 @@ export function LabCasesDispatchPanel({
{t('dueDateLabel')}{' '}
<span className="font-normal text-text-muted">({t('dueDateOptional')})</span>
</label>
<input
<AppDateInput
id={dueDateInputId}
type="date"
value={inputValue}
disabled={!canEditDueDate}
onChange={(e) => {
if (!sent) {
updateActiveLabCase({ dueDate: e.target.value || null });
}
onChange={(next) => {
updateActiveLabCase({ dueDate: next || null });
}}
onBlur={(e) => {
if (sent) void handleSentDueDateBlur(e.target.value);
onBlur={(committed) => {
if (sent) void handleSentDueDateBlur(committed);
}}
className={`${FORM_SELECT_CLASS} w-full max-w-[11rem] rounded-md px-2 py-1.5 text-sm`}
className={`${FORM_SELECT_CLASS} w-full max-w-[11rem] rounded-md py-1.5 text-sm`}
/>
</div>
{sent && caseFullyComplete && activeLabCase.dueDate ? (

View File

@@ -1,6 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/shared/Button';
import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge';
@@ -9,6 +9,7 @@ import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentType
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import type { LinkedOrganizationOption } from '@/types/treatment';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import { APP_DATE, formatAppDate } from '@/lib/i18n/format';
interface LabDispatchAttentionPanelProps {
items: LabDispatchAttentionItem[];
@@ -27,6 +28,7 @@ export function LabDispatchAttentionPanel({
onGoToDispatch,
compact = false,
}: LabDispatchAttentionPanelProps) {
const locale = useLocale();
const t = useTranslations('treatment');
if (items.length === 0) {
@@ -54,11 +56,7 @@ export function LabDispatchAttentionPanel({
const teeth = item.detail.teeth.length
? [...item.detail.teeth].sort().join(', ')
: t('teethNone');
const dateLabel = new Date(item.treatmentAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
const dateLabel = formatAppDate(item.treatmentAt, locale, APP_DATE.short);
return (
<li

View File

@@ -1,13 +1,15 @@
'use client';
import { useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
import { TreatmentHistoryDetailLine } from '@/components/ui/treatment/TreatmentHistoryDetailLine';
import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge';
import { filterTreatmentHistoryItems } from '@/components/treatment/treatmentHistoryFilters';
import { AppDateInput } from '@/components/ui/shared/AppDateInput';
import { APP_DATE, formatAppDateTime } from '@/lib/i18n/format';
import type { LinkedOrganizationOption, PastTreatment } from '@/types/treatment';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
@@ -25,16 +27,8 @@ interface PastTreatmentsPanelProps {
compact?: boolean;
}
function formatHistoryTimestamp(iso: string): string {
const date = new Date(iso);
return date.toLocaleString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
function formatHistoryTimestamp(iso: string, locale: string): string {
return formatAppDateTime(iso, locale, APP_DATE.history);
}
export function PastTreatmentsPanel({
@@ -50,6 +44,7 @@ export function PastTreatmentsPanel({
onSelectTreatment,
compact = false,
}: PastTreatmentsPanelProps) {
const locale = useLocale();
const t = useTranslations('treatment');
const [notShippedOnly, setNotShippedOnly] = useState(false);
const [filterDate, setFilterDate] = useState('');
@@ -71,7 +66,7 @@ export function PastTreatmentsPanel({
setFilterDate('');
}
const filterInputClass = `${FORM_SELECT_CLASS} rounded-md px-2 py-1.5 text-xs min-w-[9.5rem]`;
const filterInputClass = `${FORM_SELECT_CLASS} rounded-md py-1.5 text-xs min-w-[9.5rem]`;
const filtersBlock = (
<div
@@ -89,10 +84,9 @@ export function PastTreatmentsPanel({
<span className="text-xs font-medium text-text-muted whitespace-nowrap">
{t('historyFilterDate')}
</span>
<input
type="date"
<AppDateInput
value={filterDate}
onChange={(e) => setFilterDate(e.target.value)}
onChange={setFilterDate}
className={filterInputClass}
/>
</label>
@@ -156,7 +150,7 @@ export function PastTreatmentsPanel({
className="text-xs font-medium text-text-primary tabular-nums"
dateTime={treatment.treatmentAt}
>
{formatHistoryTimestamp(treatment.treatmentAt)}
{formatHistoryTimestamp(treatment.treatmentAt, locale)}
</time>
{isLiveDraft ? (
<span className="text-[10px] font-medium uppercase tracking-wide text-primary">

View File

@@ -1,7 +1,8 @@
'use client';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { TreatmentDetailSummaryRow } from '@/components/ui/treatment/TreatmentDetailSummaryRow';
import { APP_DATE, formatAppDate } from '@/lib/i18n/format';
import type { LinkedOrganizationOption, PastTreatment } from '@/types/treatment';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
@@ -23,6 +24,7 @@ export function TreatmentPreviewCard({
orgs,
embedded = false,
}: TreatmentPreviewCardProps) {
const locale = useLocale();
const t = useTranslations('treatment');
return (
@@ -38,12 +40,7 @@ export function TreatmentPreviewCard({
className="text-xs text-text-muted tabular-nums block"
dateTime={treatment.treatmentAt}
>
{new Date(treatment.treatmentAt).toLocaleDateString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
})}
{formatAppDate(treatment.treatmentAt, locale, APP_DATE.withWeekday)}
</time>
) : null}
<div className="space-y-2 max-h-[min(280px,40vh)] overflow-y-auto pr-1">

View File

@@ -28,7 +28,7 @@ export function TreatmentRailSection({
: 'surface-card';
return (
<section className={`${shellClass} overflow-hidden`}>
<section className={`treatment-rail-section ${shellClass} overflow-hidden`}>
<button
type="button"
onClick={() => setExpanded((open) => !open)}

View File

@@ -1,7 +1,7 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from '@/i18n/navigation';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
@@ -46,6 +46,7 @@ import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithi
import { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts';
import { tabBadgesChangedEventName } from '@/lib/tabBadgeUtils';
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
import { APP_DATE, formatAppDate } from '@/lib/i18n/format';
import { useAuth } from '@/lib/hooks/useAuth';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useToast } from '@/lib/hooks/useToast';
@@ -284,13 +285,13 @@ export function TreatmentWorkspace({
currentOrganization,
initialAppointmentId = null,
}: TreatmentWorkspaceProps) {
const locale = useLocale();
const t = useTranslations('treatment');
const tErrors = useTranslations('errors');
const tPatients = useTranslations('patients');
const router = useRouter();
const { user } = useAuth();
const { showError, showSuccess, messages: toastMessages } = useToast();
const locale = user?.language ?? 'en';
const canView = canViewTreatment(currentOrganization);
const canEdit = canEditTreatment(currentOrganization);
useMarkTabReadOnVisit();
@@ -1571,7 +1572,7 @@ export function TreatmentWorkspace({
</p>
)}
<div className="grid grid-cols-1 xl:grid-cols-[minmax(300px,380px)_minmax(0,1fr)] gap-4 items-start">
<div className="treatment-layout-grid grid grid-cols-1 xl:grid-cols-[minmax(300px,380px)_minmax(0,1fr)] gap-4 items-start">
<div className="space-y-3 min-w-0 xl:max-w-[380px]">
<div className="surface-card p-3 space-y-3">
<PatientSearchCombobox
@@ -1619,12 +1620,7 @@ export function TreatmentWorkspace({
<div className="rounded-[var(--radius-md)] border border-primary/40 bg-primary/5 px-3 py-2 space-y-2">
<p className="text-xs text-text-primary">
{t('browseBanner', {
date: new Date(previewTreatment.treatmentAt).toLocaleDateString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
}),
date: formatAppDate(previewTreatment.treatmentAt, locale, APP_DATE.withWeekday),
})}
</p>
<div className="flex flex-wrap gap-2">

View File

@@ -5,6 +5,13 @@ export type AppLocale = (typeof locales)[number];
export const defaultLocale: AppLocale = 'en';
// Include future RTL locales here (e.g. 'ar') when added.
export const rtlLocales = ['fa'] as const satisfies readonly AppLocale[];
export function isRtlLocale(locale: string): boolean {
return (rtlLocales as readonly string[]).includes(locale);
}
export const routing = defineRouting({
locales,
defaultLocale,

View File

@@ -0,0 +1,45 @@
'use client';
import { useMemo } from 'react';
import { useLocale } from 'next-intl';
import {
APP_DATE,
formatAppDate,
formatAppDateTime,
formatAppNumber,
formatAppTableDate,
formatAppTime,
formatAppTimeRange,
createAppDateFormatter,
} from '@/lib/i18n/format';
export function useAppFormatters() {
const locale = useLocale();
return useMemo(
() => ({
locale,
formatDate: (value: Parameters<typeof formatAppDate>[0], options?: Intl.DateTimeFormatOptions) =>
formatAppDate(value, locale, options),
formatTime: (value: Parameters<typeof formatAppTime>[0], options?: Intl.DateTimeFormatOptions) =>
formatAppTime(value, locale, options),
formatDateTime: (
value: Parameters<typeof formatAppDateTime>[0],
options?: Intl.DateTimeFormatOptions,
) => formatAppDateTime(value, locale, options),
formatTimeRange: (
start: Parameters<typeof formatAppTimeRange>[0],
end: Parameters<typeof formatAppTimeRange>[1],
options?: Intl.DateTimeFormatOptions,
) => formatAppTimeRange(start, end, locale, options),
formatTableDate: (value: Parameters<typeof formatAppTableDate>[0]) =>
formatAppTableDate(value, locale),
formatNumber: (value: number, options?: Intl.NumberFormatOptions) =>
formatAppNumber(value, locale, options),
dateFormatter: (options: Intl.DateTimeFormatOptions = APP_DATE.chartDay) =>
createAppDateFormatter(locale, options),
presets: APP_DATE,
}),
[locale],
);
}

View File

@@ -0,0 +1,43 @@
import { toLatinDigits } from '@/lib/i18n/persianCalendar';
/** Force `YYYY-MM-DD` shape while typing (Latin digits). */
export function maskGregorianDateTyping(raw: string): string {
const digits = toLatinDigits(raw).replace(/\D/g, '').slice(0, 8);
const segments: string[] = [];
if (digits.length > 0) segments.push(digits.slice(0, Math.min(4, digits.length)));
if (digits.length > 4) segments.push(digits.slice(4, Math.min(6, digits.length)));
if (digits.length > 6) segments.push(digits.slice(6, 8));
return segments.join('-');
}
/** Parse typed Gregorian `YYYY-MM-DD` (slashes OK) → `YYYY-MM-DD` or null. */
export function parseGregorianDateInputText(raw: string): string | null {
const normalized = toLatinDigits(raw.trim()).replace(/\//g, '-');
const match = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(normalized);
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (month < 1 || month > 12 || day < 1) return null;
const date = new Date(year, month - 1, day, 0, 0, 0, 0);
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
) {
return null;
}
const gm = String(month).padStart(2, '0');
const gd = String(day).padStart(2, '0');
return `${year}-${gm}-${gd}`;
}
/** `YYYY-MM-DD` → typed Gregorian field text. */
export function formatIsoAsGregorianDateInput(iso: string): string {
if (!iso) return '';
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
return match ? iso : '';
}

View File

@@ -0,0 +1,157 @@
import { isAppLocale, localeHtmlLang } from '@/i18n/routing';
import { getLocalPersianParts } from '@/lib/i18n/persianCalendar';
export const FORMAT_EMPTY = '—';
/** BCP 47 tag for Intl APIs (`fa` → `fa-IR`, etc.). */
export function intlLocale(locale: string): string {
return isAppLocale(locale) ? localeHtmlLang(locale) : locale;
}
export function usesPersianCalendar(locale: string): boolean {
return locale === 'fa';
}
function withPersianCalendar(
locale: string,
options: Intl.DateTimeFormatOptions,
): Intl.DateTimeFormatOptions {
if (!usesPersianCalendar(locale)) {
return options;
}
return { calendar: 'persian', numberingSystem: 'arabext', ...options };
}
export function toValidDate(value: Date | string | number | null | undefined): Date | null {
if (value == null) return null;
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
export function createAppDateFormatter(
locale: string,
options: Intl.DateTimeFormatOptions = {},
): Intl.DateTimeFormat {
return new Intl.DateTimeFormat(intlLocale(locale), withPersianCalendar(locale, options));
}
export const APP_DATE = {
short: { year: 'numeric', month: 'short', day: 'numeric' } satisfies Intl.DateTimeFormatOptions,
withWeekday: {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
} satisfies Intl.DateTimeFormatOptions,
dayPicker: {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
} satisfies Intl.DateTimeFormatOptions,
chartDay: { weekday: 'short', month: 'short', day: 'numeric' } satisfies Intl.DateTimeFormatOptions,
history: {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
} satisfies Intl.DateTimeFormatOptions,
activity: {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
} satisfies Intl.DateTimeFormatOptions,
} as const;
export function formatAppDate(
value: Date | string | number | null | undefined,
locale: string,
options: Intl.DateTimeFormatOptions = APP_DATE.short,
): string {
const date = toValidDate(value);
if (!date) return FORMAT_EMPTY;
return createAppDateFormatter(locale, options).format(date);
}
export function formatAppTime(
value: Date | string | number | null | undefined,
locale: string,
options: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit' },
): string {
const date = toValidDate(value);
if (!date) return FORMAT_EMPTY;
const timeOptions: Intl.DateTimeFormatOptions = {
...options,
...(usesPersianCalendar(locale) ? { numberingSystem: 'arabext' } : {}),
};
return new Intl.DateTimeFormat(intlLocale(locale), timeOptions).format(date);
}
export function formatAppDateTime(
value: Date | string | number | null | undefined,
locale: string,
options: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' },
): string {
const date = toValidDate(value);
if (!date) return FORMAT_EMPTY;
return new Intl.DateTimeFormat(intlLocale(locale), withPersianCalendar(locale, options)).format(date);
}
export function formatAppTimeRange(
start: Date | string | number,
end: Date | string | number,
locale: string,
options: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit' },
): string {
return `${formatAppTime(start, locale, options)} ${formatAppTime(end, locale, options)}`;
}
/** Schedule axis labels (hour/minute from midnight). */
export function formatAppMinuteOfDay(minute: number, locale: string): string {
const hours = Math.floor(minute / 60);
const minutes = minute % 60;
const date = new Date(2000, 0, 1, hours, minutes, 0, 0);
return formatAppTime(date, locale, {
hour: 'numeric',
minute: '2-digit',
hour12: !usesPersianCalendar(locale),
});
}
export function formatAppTableDate(
value: Date | string | number | null | undefined,
locale: string,
): string {
return formatAppDate(value, locale, APP_DATE.short);
}
export function formatAppNumber(
value: number,
locale: string,
options?: Intl.NumberFormatOptions,
): string {
return new Intl.NumberFormat(intlLocale(locale), options).format(value);
}
/** Calendar parts (year/day) — no thousands separators. */
export function formatAppInteger(value: number, locale: string): string {
return new Intl.NumberFormat(intlLocale(locale), {
useGrouping: false,
...(usesPersianCalendar(locale) ? { numberingSystem: 'arabext' } : {}),
}).format(value);
}
/** ScheduleDayPicker / calendar field trigger label — year without grouping. */
export function formatAppPickerDateLabel(date: Date, locale: string): string {
const formatter = createAppDateFormatter(locale, APP_DATE.dayPicker);
const yearNum = usesPersianCalendar(locale)
? getLocalPersianParts(date).year
: date.getFullYear();
return formatter
.formatToParts(date)
.map((part) => (part.type === 'year' ? formatAppInteger(yearNum, locale) : part.value))
.join('');
}

View File

@@ -0,0 +1,214 @@
/**
* Jalali (Persian) calendar — Gregorian `Date` values stay the apps internal model.
* Conversion logic ported from jalaali-js (MIT).
*/
export type PersianDateParts = {
year: number;
month: number;
day: number;
};
const BREAKS = [
-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262,
2324, 2394, 2456, 3178,
];
function div(a: number, b: number): number {
return Math.trunc(a / b);
}
function mod(a: number, b: number): number {
return a - Math.trunc(a / b) * b;
}
function g2d(gy: number, gm: number, gd: number): number {
let d =
div((gy + div(gm - 8, 6) + 100100) * 1461, 4) +
div(153 * mod(gm + 9, 12) + 2, 5) +
gd -
34840408;
d = d - div(div(gy + 100100 + div(gm - 8, 6), 100) * 3, 4) + 752;
return d;
}
function d2g(jdn: number): { gy: number; gm: number; gd: number } {
let j = 4 * jdn + 139361631;
j = j + div(div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908;
const i = div(mod(j, 1461), 4) * 5 + 308;
const gd = div(mod(i, 153), 5) + 1;
const gm = mod(div(i, 153), 12) + 1;
const gy = div(j, 1461) - 100100 + div(8 - gm, 6);
return { gy, gm, gd };
}
function jalCal(jy: number, withoutLeap: boolean): { leap?: number; gy: number; march: number } {
const bl = BREAKS.length;
let gy = jy + 621;
let leapJ = -14;
let jp = BREAKS[0];
let jump = 0;
let leap = 0;
let n = 0;
if (jy < jp || jy >= BREAKS[bl - 1]) {
throw new Error(`Invalid Jalaali year ${jy}`);
}
for (let i = 1; i < bl; i += 1) {
const jm = BREAKS[i];
jump = jm - jp;
if (jy < jm) break;
leapJ = leapJ + div(jump, 33) * 8 + div(mod(jump, 33), 4);
jp = jm;
}
n = jy - jp;
leapJ = leapJ + div(n, 33) * 8 + div(mod(n, 33) + 3, 4);
if (mod(jump, 33) === 4 && jump - n === 4) leapJ += 1;
const leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150;
const march = 20 + leapJ - leapG;
if (withoutLeap) return { gy, march };
if (jump - n < 6) n = n - jump + div(jump + 4, 33) * 33;
leap = mod(mod(n + 1, 33) - 1, 4);
if (leap === -1) leap = 4;
return { leap, gy, march };
}
function j2d(jy: number, jm: number, jd: number): number {
const r = jalCal(jy, true);
return g2d(r.gy, 3, r.march) + (jm - 1) * 31 - div(jm, 7) * (jm - 7) + jd - 1;
}
function d2j(jdn: number): { jy: number; jm: number; jd: number } {
const { gy } = d2g(jdn);
let jy = gy - 621;
const r = jalCal(jy, false);
const jdn1f = g2d(gy, 3, r.march);
let k = jdn - jdn1f;
let jm: number;
let jd: number;
if (k >= 0) {
if (k <= 185) {
jm = 1 + div(k, 31);
jd = mod(k, 31) + 1;
return { jy, jm, jd };
}
k -= 186;
} else {
jy -= 1;
k += 179;
if (r.leap === 1) k += 1;
}
jm = 7 + div(k, 30);
jd = mod(k, 30) + 1;
return { jy, jm, jd };
}
export function gregorianToJalali(gy: number, gm: number, gd: number): [number, number, number] {
const { jy, jm, jd } = d2j(g2d(gy, gm, gd));
return [jy, jm, jd];
}
export function jalaliToGregorian(jy: number, jm: number, jd: number): [number, number, number] {
const { gy, gm, gd } = d2g(j2d(jy, jm, jd));
return [gy, gm, gd];
}
export function isJalaliLeapYear(jy: number): boolean {
const r = jalCal(jy, false);
return r.leap === 0;
}
export function jalaliDaysInMonth(jy: number, jm: number): number {
if (jm <= 6) return 31;
if (jm <= 11) return 30;
return isJalaliLeapYear(jy) ? 30 : 29;
}
export function getLocalPersianParts(date: Date): PersianDateParts {
const [year, month, day] = gregorianToJalali(
date.getFullYear(),
date.getMonth() + 1,
date.getDate(),
);
return { year, month, day };
}
export function persianPartsToLocalDate(jy: number, jm: number, jd: number): Date {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
return new Date(gy, gm - 1, gd, 0, 0, 0, 0);
}
export function persianYearRange(anchorYear: number, past = 10, future = 2): number[] {
const years: number[] = [];
for (let y = anchorYear - past; y <= anchorYear + future; y += 1) {
years.push(y);
}
return years;
}
const persianMonthFormatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
month: 'long',
numberingSystem: 'arabext',
});
/** Jalali month name (Farvardin, …) for picker labels. */
export function formatPersianMonthLabel(jy: number, jm: number): string {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, 15);
return persianMonthFormatter.format(new Date(gy, gm - 1, gd));
}
const ARABEXT_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] as const;
export function toLatinDigits(value: string): string {
return value.replace(/[۰-۹]/g, (ch) => {
const index = ARABEXT_DIGITS.indexOf(ch as (typeof ARABEXT_DIGITS)[number]);
return index >= 0 ? String(index) : ch;
});
}
export function toArabextDigits(value: string): string {
return value.replace(/\d/g, (d) => ARABEXT_DIGITS[Number(d)] ?? d);
}
/** Parse typed Jalali `YYYY/MM/DD` (Latin or Persian digits) → `YYYY-MM-DD` or null. */
export function parsePersianDateInputText(raw: string): string | null {
const normalized = toLatinDigits(raw.trim()).replace(/-/g, '/');
const match = /^(\d{4})\/(\d{1,2})\/(\d{1,2})$/.exec(normalized);
if (!match) return null;
const jy = Number(match[1]);
const jm = Number(match[2]);
const jd = Number(match[3]);
if (jm < 1 || jm > 12 || jd < 1 || jd > jalaliDaysInMonth(jy, jm)) return null;
const date = persianPartsToLocalDate(jy, jm, jd);
const gy = date.getFullYear();
const gm = String(date.getMonth() + 1).padStart(2, '0');
const gd = String(date.getDate()).padStart(2, '0');
return `${gy}-${gm}-${gd}`;
}
/** `YYYY-MM-DD` → typed Jalali field text e.g. ۱۴۰۴/۰۴/۲۲ */
export function formatIsoAsPersianDateInput(iso: string): string {
if (!iso) return '';
const [gy, gm, gd] = iso.split('-').map(Number);
if (!gy || !gm || !gd) return '';
const { year, month, day } = getLocalPersianParts(new Date(gy, gm - 1, gd, 0, 0, 0, 0));
const pad2 = (n: number) => toArabextDigits(String(n).padStart(2, '0'));
return `${toArabextDigits(String(year))}/${pad2(month)}/${pad2(day)}`;
}
/** Force `YYYY/MM/DD` shape while typing (Persian digits in output). */
export function maskJalaliDateTyping(raw: string): string {
const digits = toLatinDigits(raw).replace(/\D/g, '').slice(0, 8);
const segments: string[] = [];
if (digits.length > 0) segments.push(digits.slice(0, Math.min(4, digits.length)));
if (digits.length > 4) segments.push(digits.slice(4, Math.min(6, digits.length)));
if (digits.length > 6) segments.push(digits.slice(6, 8));
return toArabextDigits(segments.join('/'));
}

View File

@@ -1,4 +1,5 @@
import type { LabCaseActivityItem } from '@/types/lab-case-activity';
import { APP_DATE, formatAppDateTime } from '@/lib/i18n/format';
type ActivityLabelTranslator = (
key: string,
@@ -11,12 +12,7 @@ export function formatLabCaseActivityLine(
locale: string,
): string {
const actor = activity.actorName ?? t('activityUnknownActor');
const date = new Date(activity.createdAt).toLocaleString(locale, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const date = formatAppDateTime(activity.createdAt, locale, APP_DATE.activity);
switch (activity.type) {
case 'CASE_SENT':

View File

@@ -241,10 +241,34 @@ html[data-theme='light'] {
color-scheme: light;
}
/* Minimal RTL layer — refine incrementally. */
html[dir='rtl'] body {
direction: rtl;
}
/* Sidebar: don't rely on Tailwind rtl: variants. */
html[dir='rtl'] .app-sidebar {
border-right: 0;
border-left: 1px solid var(--color-border);
}
/* Mobile drawer only — desktop sidebar follows flex + document direction. */
@media (max-width: 1023px) {
html[dir='rtl'] .app-sidebar {
left: auto;
right: 0;
}
}
/* Treatment rail: align header text to the right in RTL. */
html[dir='rtl'] .treatment-rail-section > button {
text-align: right;
}
body {
background-color: var(--color-background-primary);
color: var(--color-text-primary);
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
font-family: var(--font-sans, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif);
}
select.form-select,
@@ -254,6 +278,25 @@ select {
font-size: 1rem;
}
/* Shared chevron for all `.form-select` — symmetric inset matches `ps-3` text padding. */
select.form-select:not(.form-select-no-chevron) {
appearance: none;
-webkit-appearance: none;
text-align: start;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-size: 1rem 1rem;
background-position: right 0.75rem center;
}
html[dir='rtl'] select.form-select:not(.form-select-no-chevron) {
background-position: left 0.75rem center;
}
select.form-select.form-select-no-chevron {
background-image: none;
}
@media (min-width: 640px) {
select.form-select,
select {