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

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