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