improvement: rtl direction, solar calendar and persian formatting added for persian users.
This commit is contained in:
@@ -24,6 +24,8 @@ Monorepo: `backend/` (NestJS + Prisma), `frontend/` (Next.js + next-intl), `infr
|
||||
|
||||
All user-visible strings: `frontend/messages/en.json`, `fa.json`, `nl.json` — add keys to **all three**.
|
||||
|
||||
Dates/times/numbers: `frontend/src/lib/i18n/format.ts` + `useLocale()`. **Form date fields:** `AppDateInput` only (no native `<input type="date">`); wire format `YYYY-MM-DD`. **Filter selects:** `FORM_SELECT_CLASS` from `components/shared/formSelectStyles.ts` (chevron via `globals.css`). **Tables:** `components/ui/shared/Table.tsx` — use `text-start`/`text-end`/`text-center`, never physical `text-left`/`text-right`. Appointments: `ScheduleDayPicker`. Skill: `.cursor/skills/i18n-formatting/SKILL.md`.
|
||||
|
||||
## Treatment / appointment colors
|
||||
|
||||
Treatment-type colors and labels: `components/shared/treatmentTypeDisplay.ts` + `catalog-type-colors.ts`. UI badges: `components/ui/treatment/TreatmentTypeBadge.tsx`.
|
||||
|
||||
@@ -51,3 +51,14 @@ Reference: `app/.../treatment/page.tsx` + `components/ui/treatment/TreatmentWork
|
||||
1. Check `components/ui/shared/` for an existing primitive.
|
||||
2. Check the feature's `ui/{feature}/` folder for an existing pattern.
|
||||
3. Add i18n keys to en, fa, and nl.
|
||||
|
||||
## Shared form & table primitives
|
||||
|
||||
| Need | Use |
|
||||
|------|-----|
|
||||
| Date filter / due date field | `AppDateInput` (`ui/shared/`) — not `<input type="date">` |
|
||||
| Filter or inline `<select>` | `FORM_SELECT_CLASS` from `components/shared/formSelectStyles.ts` |
|
||||
| Tiny select (sort dir, etc.) | `FORM_SELECT_COMPACT_CLASS` |
|
||||
| Desktop data table | `Table` (`ui/shared/Table.tsx`) — logical alignment only |
|
||||
|
||||
Styles for `.form-select` chevrons live in `styles/globals.css`. Do not duplicate chevron icons on raw selects.
|
||||
|
||||
80
.cursor/skills/i18n-formatting/SKILL.md
Normal file
80
.cursor/skills/i18n-formatting/SKILL.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# i18n formatting (dates, times, numbers)
|
||||
|
||||
Use when adding or changing user-visible dates/times/numbers, RTL layout, or locale-specific pickers.
|
||||
|
||||
## Display formatting
|
||||
|
||||
- Module: `frontend/src/lib/i18n/format.ts`
|
||||
- Hook: `frontend/src/lib/hooks/useAppFormatters.ts` (`useLocale()` inside)
|
||||
- **Never** use raw `toLocaleDateString` / `toLocaleTimeString` / bare `Intl.DateTimeFormat(undefined, …)` in UI.
|
||||
|
||||
| Helper | Use for |
|
||||
|--------|---------|
|
||||
| `formatAppDate` | Date-only labels |
|
||||
| `formatAppTime` | Time-only labels |
|
||||
| `formatAppDateTime` | Combined stamp |
|
||||
| `formatAppTimeRange` | Appointment ranges |
|
||||
| `formatAppTableDate` | Table cells |
|
||||
| `formatAppNumber` | Counts, amounts (grouped) |
|
||||
| `formatAppInteger` | Calendar year/day — no comma grouping |
|
||||
| `formatAppPickerDateLabel` | Calendar trigger button label |
|
||||
| `APP_DATE.*` | Shared preset option objects |
|
||||
|
||||
## Form controls (dates & selects)
|
||||
|
||||
| Piece | Location / use |
|
||||
|-------|----------------|
|
||||
| **`AppDateInput`** | All form/filter date fields — **every locale**; masked typing + calendar popup; wire value `YYYY-MM-DD` or empty |
|
||||
| **`FORM_DATE_INPUT_CLASS`** | Visual shell for date input (same padding/inset as selects; no CSS chevron) |
|
||||
| **`FORM_SELECT_CLASS`** | Native `<select>` filters/fields — `ps-3 pe-10`, chevron from `globals.css` |
|
||||
| **`FORM_SELECT_COMPACT_CLASS`** | Tiny selects (e.g. sort direction `↓`/`↑`) — symmetric `px-2`, no chevron gutter |
|
||||
| **`Dropdown`** | Labeled form select — only where already used; prefer `FORM_SELECT_CLASS` for new filters |
|
||||
| **`CompactSelect`** | Year/month/day sub-selects inside calendar panels |
|
||||
|
||||
**`AppDateInput` behavior**
|
||||
|
||||
- `fa`: Jalali display `YYYY/MM/DD` (Persian digits), parse/mask in `persianCalendar.ts`
|
||||
- `en` / `nl`: Gregorian display `YYYY-MM-DD`, parse/mask in `dateInputFormat.ts`
|
||||
- Calendar icon at **`end-3`** (matches select chevron inset); text uses **`text-start`** (logical — right in RTL, left in LTR)
|
||||
- Popup: **`CalendarDayPartsPanel`** (shared with schedule picker)
|
||||
- **Do not** add native `<input type="date">` — one component for all locales
|
||||
|
||||
**Select chevron**
|
||||
|
||||
- Defined once in `frontend/src/styles/globals.css` on `.form-select:not(.form-select-no-chevron)`
|
||||
- RTL: `background-position: left 0.75rem center`; LTR: `right 0.75rem center`
|
||||
- `text-align: start` on selects
|
||||
|
||||
## Calendar / appointment pickers
|
||||
|
||||
| Component | Use for |
|
||||
|-----------|---------|
|
||||
| `ScheduleDayPicker` | Appointments strip — nav arrows + today toggle + expandable panel |
|
||||
| `CalendarDaySelect` | Navigator wrapper (arrows + panel) |
|
||||
| `CalendarDayPartsPanel` | Year / month / day row — used by schedule picker and `AppDateInput` |
|
||||
|
||||
Persian (`fa`): Jalali calendar + `arabext` digits via Intl (`usesPersianCalendar`). Internal model stays **`Date` at local midnight** (Gregorian) — APIs unchanged.
|
||||
|
||||
- Conversion: `frontend/src/lib/i18n/persianCalendar.ts`
|
||||
- Gregorian typing: `frontend/src/lib/i18n/dateInputFormat.ts`
|
||||
- Gregorian month labels: `schedule.monthJanuary` … message keys
|
||||
|
||||
## RTL
|
||||
|
||||
- `dir` / `lang` on `<html>` from `app/[locale]/layout.tsx`
|
||||
- `isRtlLocale` in `frontend/src/i18n/routing.ts`
|
||||
- Use **logical** CSS: `text-start`, `text-end`, `ps-*`, `pe-*`, `ms-*`, `me-*`
|
||||
- **`Table`**: default `[&_th]:text-start [&_td]:text-start`; override with `text-center` or `text-end` on cells — **never** `text-left` / `text-right` on headers (causes header/body column drift in RTL)
|
||||
- Minimal overrides in `globals.css` — avoid double-mirroring (no extra `row-reverse` on shells that already inherit `direction: rtl`)
|
||||
|
||||
## i18n strings
|
||||
|
||||
- User-facing copy: `frontend/messages/{en,fa,nl}.json` — all three locales.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit
|
||||
```
|
||||
|
||||
Manual: switch to Persian — Cases date filters, Tasks filter row (single line + sort visible), Staff/Orgs table columns aligned; switch to English — date fields match adjacent dropdown alignment.
|
||||
@@ -38,6 +38,8 @@ frontend/src/
|
||||
|
||||
**Example thin page:** `app/.../treatment/page.tsx` → imports `TreatmentWorkspace` from `components/ui/treatment/`.
|
||||
|
||||
**i18n formatting:** Display dates/times/numbers via `lib/i18n/format.ts` + `useLocale()`. Form dates: **`AppDateInput`** (all locales — same component, masked typing + calendar popup). Appointments strip: **`ScheduleDayPicker`**. Filter `<select>`s: **`FORM_SELECT_CLASS`**; data tables: **`Table`** with logical `text-start`/`text-end` (not `text-left`/`text-right`). Skill: `.cursor/skills/i18n-formatting/SKILL.md`.
|
||||
|
||||
**Treatment tab:** Preview and editable form are **separate** until the user clicks **Load into workspace** on a history item. See `.cursor/skills/treatment-workspace/SKILL.md` before changing that flow.
|
||||
|
||||
**Treatment lab rules (quick ref):**
|
||||
@@ -75,6 +77,7 @@ Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never
|
||||
| `.cursor/skills/lab-notifications/` | Tab badges: LabCaseActivity, tab-counts API, read cursors |
|
||||
| `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout |
|
||||
| `.cursor/skills/api-errors/` | New backend errors + frontend translations |
|
||||
| `.cursor/skills/i18n-formatting/` | Dates, times, numbers, Jalali picker, RTL formatting |
|
||||
|
||||
## Subagents (Task tool)
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<div className="flex h-[100dvh] app-web-bg text-text-primary">
|
||||
<div className="app-dashboard-shell flex h-[100dvh] app-web-bg text-text-primary">
|
||||
{sidebarOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -4,11 +4,12 @@ import { getMessages, setRequestLocale } from 'next-intl/server';
|
||||
import { hasLocale } from 'next-intl';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Script from 'next/script';
|
||||
import { Noto_Sans_Arabic, Vazirmatn } from 'next/font/google';
|
||||
import '@/styles/globals.css';
|
||||
import '@/styles/background-web.css';
|
||||
import { AuthProvider } from '@/lib/hooks/useAuth';
|
||||
import { THEME_STORAGE_KEY } from '@/lib/theme';
|
||||
import { routing, localeHtmlLang } from '@/i18n/routing';
|
||||
import { routing, isRtlLocale, localeHtmlLang } from '@/i18n/routing';
|
||||
import { LocaleSync } from '@/components/i18n/LocaleSync';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -25,6 +26,18 @@ export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
const vazirmatn = Vazirmatn({
|
||||
subsets: ['arabic'],
|
||||
variable: '--font-vazirmatn',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
const notoSansArabic = Noto_Sans_Arabic({
|
||||
subsets: ['arabic'],
|
||||
variable: '--font-noto-sans-arabic',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
@@ -42,9 +55,20 @@ export default async function LocaleLayout({
|
||||
const messages = await getMessages();
|
||||
|
||||
const themeInit = `(function(){try{var k=${JSON.stringify(THEME_STORAGE_KEY)};var t=localStorage.getItem(k);document.documentElement.setAttribute('data-theme',t==='light'||t==='dark'?t:'dark');}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();`;
|
||||
const dir = isRtlLocale(locale) ? 'rtl' : 'ltr';
|
||||
const fontSans = isRtlLocale(locale)
|
||||
? 'var(--font-vazirmatn), var(--font-noto-sans-arabic), system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif'
|
||||
: 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif';
|
||||
|
||||
return (
|
||||
<html lang={localeHtmlLang(locale)} dir="ltr" suppressHydrationWarning>
|
||||
<html
|
||||
lang={localeHtmlLang(locale)}
|
||||
dir={dir}
|
||||
data-locale={locale}
|
||||
className={`${vazirmatn.variable} ${notoSansArabic.variable}`}
|
||||
style={{ ['--font-sans' as never]: fontSans }}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body>
|
||||
<Script id="theme-init" strategy="beforeInteractive">
|
||||
{themeInit}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { formatAppMinuteOfDay } from '@/lib/i18n/format';
|
||||
|
||||
/** Normalize to local midnight; invalid input falls back to today. */
|
||||
export function startOfLocalDay(d: Date): Date {
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
@@ -45,9 +47,8 @@ export function isSameLocalCalendarDay(a: Date, b: Date): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function formatHourLabel(hour: number): string {
|
||||
const d = new Date(2000, 0, 1, hour, 0, 0, 0);
|
||||
return d.toLocaleTimeString(undefined, { hour: 'numeric', hour12: true });
|
||||
export function formatHourLabel(hour: number, locale: string): string {
|
||||
return formatAppMinuteOfDay(hour * 60, locale);
|
||||
}
|
||||
|
||||
/** Local midnight + delta calendar days. */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LabCaseDetail } from '@/types/cases';
|
||||
import type { CaseToothChartProsthesisRow } from '@/components/ui/lab/CaseToothChartPanel';
|
||||
import { formatAppDateTime } from '@/lib/i18n/format';
|
||||
|
||||
export function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||
return `${patient.firstName} ${patient.lastName}`.trim();
|
||||
@@ -7,10 +8,7 @@ export function formatPatientName(patient: { firstName: string; lastName: string
|
||||
|
||||
export function formatCaseDateTime(value: string | null, locale: string) {
|
||||
if (!value) return '—';
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value));
|
||||
return formatAppDateTime(value, locale, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
export function buildCaseProsthesisRows(labCase: LabCaseDetail): CaseToothChartProsthesisRow[] {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BadgeVariant } from '@/components/ui/shared/Badge';
|
||||
import { formatAppDate, APP_DATE } from '@/lib/i18n/format';
|
||||
|
||||
export function toDateInputValue(iso: string | null | undefined): string {
|
||||
if (!iso) return '';
|
||||
@@ -18,13 +19,7 @@ export function formatLabCaseDueDate(
|
||||
locale: string,
|
||||
): string | null {
|
||||
if (!iso) return null;
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
return formatAppDate(iso, locale, APP_DATE.short);
|
||||
}
|
||||
|
||||
/** Whole calendar days from today (UTC) until due date. Negative = overdue. */
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
export const FORM_SELECT_CLASS =
|
||||
'form-select rounded border border-border bg-background-card text-text-primary px-2 py-1 text-sm disabled:opacity-60 focus:outline-none focus:ring-2 focus:ring-primary/35';
|
||||
'form-select appearance-none rounded border border-border bg-background-card text-text-primary ps-3 pe-10 py-1 text-sm disabled:opacity-60 focus:outline-none focus:ring-2 focus:ring-primary/35';
|
||||
|
||||
/** Same shell as select fields but without the CSS chevron (date picker uses a button icon). */
|
||||
export const FORM_DATE_INPUT_CLASS = `${FORM_SELECT_CLASS} form-select-no-chevron text-start`;
|
||||
|
||||
/** Tiny selects (e.g. sort direction) — symmetric padding, no chevron gutter. */
|
||||
export const FORM_SELECT_COMPACT_CLASS =
|
||||
'form-select form-select-no-chevron appearance-none rounded border border-border bg-background-card text-text-primary px-2 py-1 text-sm text-center disabled:opacity-60 focus:outline-none focus:ring-2 focus:ring-primary/35';
|
||||
|
||||
/** Lab task status control — larger tap target on small screens. */
|
||||
export const LAB_TASK_STATUS_SELECT_CLASS = `${FORM_SELECT_CLASS} h-[44px] w-full py-2 text-base font-medium sm:h-9 sm:py-1 sm:text-sm sm:max-w-none`;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { formatAppMinuteOfDay } from '@/lib/i18n/format';
|
||||
|
||||
export const MINUTES_PER_DAY = 24 * 60;
|
||||
export const SCHEDULE_SLOT_MINUTES = 15;
|
||||
|
||||
@@ -50,9 +52,8 @@ export function timeInputToMinutes(value: string): number | null {
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
export function formatMinuteLabel(minute: number): string {
|
||||
const d = new Date(2000, 0, 1, Math.floor(minute / 60), minute % 60, 0, 0);
|
||||
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
export function formatMinuteLabel(minute: number, locale: string): string {
|
||||
return formatAppMinuteOfDay(minute, locale);
|
||||
}
|
||||
|
||||
export function emptyWorkingHoursEditorDays(): WorkingHoursEditorDay[] {
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { APP_DATE, createAppDateFormatter } from '@/lib/i18n/format';
|
||||
|
||||
export function useTodayDayLabelFormatter() {
|
||||
const locale = useLocale();
|
||||
return useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
[],
|
||||
() => createAppDateFormatter(locale, APP_DATE.chartDay),
|
||||
[locale],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LinkedOrganizationOption, TreatmentCaseSendInfo } from '@/types/treatment';
|
||||
import { formatAppDateTime } from '@/lib/i18n/format';
|
||||
|
||||
export type CaseSendLabelT = (
|
||||
key: 'sentToAt' | 'fallbackOrgName',
|
||||
@@ -13,16 +14,17 @@ export function formatCaseSentLines(
|
||||
orgs?: LinkedOrganizationOption[];
|
||||
} | undefined,
|
||||
t: CaseSendLabelT,
|
||||
locale: string,
|
||||
): string[] {
|
||||
if (sends?.length) {
|
||||
return sends.map((s) => {
|
||||
const at = new Date(s.sentAt).toLocaleString();
|
||||
const at = formatAppDateTime(s.sentAt, locale, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
return t('sentToAt', { orgName: s.organizationName, datetime: at });
|
||||
});
|
||||
}
|
||||
|
||||
if (fallback?.sentAt && fallback.organizationIds.length > 0) {
|
||||
const at = new Date(fallback.sentAt).toLocaleString();
|
||||
const at = formatAppDateTime(fallback.sentAt, locale, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
const nameById = new Map(fallback.orgs?.map((o) => [o.id, o.name]) ?? []);
|
||||
return fallback.organizationIds.map((id) => {
|
||||
const name = nameById.get(id) ?? t('fallbackOrgName');
|
||||
@@ -41,7 +43,8 @@ export function formatCaseSentSummary(
|
||||
orgs?: LinkedOrganizationOption[];
|
||||
} | undefined,
|
||||
t: CaseSendLabelT,
|
||||
locale: string,
|
||||
): string | null {
|
||||
const lines = formatCaseSentLines(sends, fallback, t);
|
||||
const lines = formatCaseSentLines(sends, fallback, t, locale);
|
||||
return lines.length > 0 ? lines.join(' · ') : null;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -5,6 +5,13 @@ export type AppLocale = (typeof locales)[number];
|
||||
|
||||
export const defaultLocale: AppLocale = 'en';
|
||||
|
||||
// Include future RTL locales here (e.g. 'ar') when added.
|
||||
export const rtlLocales = ['fa'] as const satisfies readonly AppLocale[];
|
||||
|
||||
export function isRtlLocale(locale: string): boolean {
|
||||
return (rtlLocales as readonly string[]).includes(locale);
|
||||
}
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales,
|
||||
defaultLocale,
|
||||
|
||||
45
frontend/src/lib/hooks/useAppFormatters.ts
Normal file
45
frontend/src/lib/hooks/useAppFormatters.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useLocale } from 'next-intl';
|
||||
import {
|
||||
APP_DATE,
|
||||
formatAppDate,
|
||||
formatAppDateTime,
|
||||
formatAppNumber,
|
||||
formatAppTableDate,
|
||||
formatAppTime,
|
||||
formatAppTimeRange,
|
||||
createAppDateFormatter,
|
||||
} from '@/lib/i18n/format';
|
||||
|
||||
export function useAppFormatters() {
|
||||
const locale = useLocale();
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
locale,
|
||||
formatDate: (value: Parameters<typeof formatAppDate>[0], options?: Intl.DateTimeFormatOptions) =>
|
||||
formatAppDate(value, locale, options),
|
||||
formatTime: (value: Parameters<typeof formatAppTime>[0], options?: Intl.DateTimeFormatOptions) =>
|
||||
formatAppTime(value, locale, options),
|
||||
formatDateTime: (
|
||||
value: Parameters<typeof formatAppDateTime>[0],
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
) => formatAppDateTime(value, locale, options),
|
||||
formatTimeRange: (
|
||||
start: Parameters<typeof formatAppTimeRange>[0],
|
||||
end: Parameters<typeof formatAppTimeRange>[1],
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
) => formatAppTimeRange(start, end, locale, options),
|
||||
formatTableDate: (value: Parameters<typeof formatAppTableDate>[0]) =>
|
||||
formatAppTableDate(value, locale),
|
||||
formatNumber: (value: number, options?: Intl.NumberFormatOptions) =>
|
||||
formatAppNumber(value, locale, options),
|
||||
dateFormatter: (options: Intl.DateTimeFormatOptions = APP_DATE.chartDay) =>
|
||||
createAppDateFormatter(locale, options),
|
||||
presets: APP_DATE,
|
||||
}),
|
||||
[locale],
|
||||
);
|
||||
}
|
||||
43
frontend/src/lib/i18n/dateInputFormat.ts
Normal file
43
frontend/src/lib/i18n/dateInputFormat.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { toLatinDigits } from '@/lib/i18n/persianCalendar';
|
||||
|
||||
/** Force `YYYY-MM-DD` shape while typing (Latin digits). */
|
||||
export function maskGregorianDateTyping(raw: string): string {
|
||||
const digits = toLatinDigits(raw).replace(/\D/g, '').slice(0, 8);
|
||||
const segments: string[] = [];
|
||||
if (digits.length > 0) segments.push(digits.slice(0, Math.min(4, digits.length)));
|
||||
if (digits.length > 4) segments.push(digits.slice(4, Math.min(6, digits.length)));
|
||||
if (digits.length > 6) segments.push(digits.slice(6, 8));
|
||||
return segments.join('-');
|
||||
}
|
||||
|
||||
/** Parse typed Gregorian `YYYY-MM-DD` (slashes OK) → `YYYY-MM-DD` or null. */
|
||||
export function parseGregorianDateInputText(raw: string): string | null {
|
||||
const normalized = toLatinDigits(raw.trim()).replace(/\//g, '-');
|
||||
const match = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(normalized);
|
||||
if (!match) return null;
|
||||
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
if (month < 1 || month > 12 || day < 1) return null;
|
||||
|
||||
const date = new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||
if (
|
||||
date.getFullYear() !== year ||
|
||||
date.getMonth() !== month - 1 ||
|
||||
date.getDate() !== day
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gm = String(month).padStart(2, '0');
|
||||
const gd = String(day).padStart(2, '0');
|
||||
return `${year}-${gm}-${gd}`;
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DD` → typed Gregorian field text. */
|
||||
export function formatIsoAsGregorianDateInput(iso: string): string {
|
||||
if (!iso) return '';
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
||||
return match ? iso : '';
|
||||
}
|
||||
157
frontend/src/lib/i18n/format.ts
Normal file
157
frontend/src/lib/i18n/format.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { isAppLocale, localeHtmlLang } from '@/i18n/routing';
|
||||
import { getLocalPersianParts } from '@/lib/i18n/persianCalendar';
|
||||
|
||||
export const FORMAT_EMPTY = '—';
|
||||
|
||||
/** BCP 47 tag for Intl APIs (`fa` → `fa-IR`, etc.). */
|
||||
export function intlLocale(locale: string): string {
|
||||
return isAppLocale(locale) ? localeHtmlLang(locale) : locale;
|
||||
}
|
||||
|
||||
export function usesPersianCalendar(locale: string): boolean {
|
||||
return locale === 'fa';
|
||||
}
|
||||
|
||||
function withPersianCalendar(
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions,
|
||||
): Intl.DateTimeFormatOptions {
|
||||
if (!usesPersianCalendar(locale)) {
|
||||
return options;
|
||||
}
|
||||
return { calendar: 'persian', numberingSystem: 'arabext', ...options };
|
||||
}
|
||||
|
||||
export function toValidDate(value: Date | string | number | null | undefined): Date | null {
|
||||
if (value == null) return null;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
export function createAppDateFormatter(
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions = {},
|
||||
): Intl.DateTimeFormat {
|
||||
return new Intl.DateTimeFormat(intlLocale(locale), withPersianCalendar(locale, options));
|
||||
}
|
||||
|
||||
export const APP_DATE = {
|
||||
short: { year: 'numeric', month: 'short', day: 'numeric' } satisfies Intl.DateTimeFormatOptions,
|
||||
withWeekday: {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
} satisfies Intl.DateTimeFormatOptions,
|
||||
dayPicker: {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
} satisfies Intl.DateTimeFormatOptions,
|
||||
chartDay: { weekday: 'short', month: 'short', day: 'numeric' } satisfies Intl.DateTimeFormatOptions,
|
||||
history: {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
} satisfies Intl.DateTimeFormatOptions,
|
||||
activity: {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
} satisfies Intl.DateTimeFormatOptions,
|
||||
} as const;
|
||||
|
||||
export function formatAppDate(
|
||||
value: Date | string | number | null | undefined,
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions = APP_DATE.short,
|
||||
): string {
|
||||
const date = toValidDate(value);
|
||||
if (!date) return FORMAT_EMPTY;
|
||||
return createAppDateFormatter(locale, options).format(date);
|
||||
}
|
||||
|
||||
export function formatAppTime(
|
||||
value: Date | string | number | null | undefined,
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit' },
|
||||
): string {
|
||||
const date = toValidDate(value);
|
||||
if (!date) return FORMAT_EMPTY;
|
||||
const timeOptions: Intl.DateTimeFormatOptions = {
|
||||
...options,
|
||||
...(usesPersianCalendar(locale) ? { numberingSystem: 'arabext' } : {}),
|
||||
};
|
||||
return new Intl.DateTimeFormat(intlLocale(locale), timeOptions).format(date);
|
||||
}
|
||||
|
||||
export function formatAppDateTime(
|
||||
value: Date | string | number | null | undefined,
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' },
|
||||
): string {
|
||||
const date = toValidDate(value);
|
||||
if (!date) return FORMAT_EMPTY;
|
||||
return new Intl.DateTimeFormat(intlLocale(locale), withPersianCalendar(locale, options)).format(date);
|
||||
}
|
||||
|
||||
export function formatAppTimeRange(
|
||||
start: Date | string | number,
|
||||
end: Date | string | number,
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit' },
|
||||
): string {
|
||||
return `${formatAppTime(start, locale, options)} – ${formatAppTime(end, locale, options)}`;
|
||||
}
|
||||
|
||||
/** Schedule axis labels (hour/minute from midnight). */
|
||||
export function formatAppMinuteOfDay(minute: number, locale: string): string {
|
||||
const hours = Math.floor(minute / 60);
|
||||
const minutes = minute % 60;
|
||||
const date = new Date(2000, 0, 1, hours, minutes, 0, 0);
|
||||
return formatAppTime(date, locale, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: !usesPersianCalendar(locale),
|
||||
});
|
||||
}
|
||||
|
||||
export function formatAppTableDate(
|
||||
value: Date | string | number | null | undefined,
|
||||
locale: string,
|
||||
): string {
|
||||
return formatAppDate(value, locale, APP_DATE.short);
|
||||
}
|
||||
|
||||
export function formatAppNumber(
|
||||
value: number,
|
||||
locale: string,
|
||||
options?: Intl.NumberFormatOptions,
|
||||
): string {
|
||||
return new Intl.NumberFormat(intlLocale(locale), options).format(value);
|
||||
}
|
||||
|
||||
/** Calendar parts (year/day) — no thousands separators. */
|
||||
export function formatAppInteger(value: number, locale: string): string {
|
||||
return new Intl.NumberFormat(intlLocale(locale), {
|
||||
useGrouping: false,
|
||||
...(usesPersianCalendar(locale) ? { numberingSystem: 'arabext' } : {}),
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/** ScheduleDayPicker / calendar field trigger label — year without grouping. */
|
||||
export function formatAppPickerDateLabel(date: Date, locale: string): string {
|
||||
const formatter = createAppDateFormatter(locale, APP_DATE.dayPicker);
|
||||
const yearNum = usesPersianCalendar(locale)
|
||||
? getLocalPersianParts(date).year
|
||||
: date.getFullYear();
|
||||
return formatter
|
||||
.formatToParts(date)
|
||||
.map((part) => (part.type === 'year' ? formatAppInteger(yearNum, locale) : part.value))
|
||||
.join('');
|
||||
}
|
||||
214
frontend/src/lib/i18n/persianCalendar.ts
Normal file
214
frontend/src/lib/i18n/persianCalendar.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Jalali (Persian) calendar — Gregorian `Date` values stay the app’s internal model.
|
||||
* Conversion logic ported from jalaali-js (MIT).
|
||||
*/
|
||||
|
||||
export type PersianDateParts = {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
};
|
||||
|
||||
const BREAKS = [
|
||||
-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262,
|
||||
2324, 2394, 2456, 3178,
|
||||
];
|
||||
|
||||
function div(a: number, b: number): number {
|
||||
return Math.trunc(a / b);
|
||||
}
|
||||
|
||||
function mod(a: number, b: number): number {
|
||||
return a - Math.trunc(a / b) * b;
|
||||
}
|
||||
|
||||
function g2d(gy: number, gm: number, gd: number): number {
|
||||
let d =
|
||||
div((gy + div(gm - 8, 6) + 100100) * 1461, 4) +
|
||||
div(153 * mod(gm + 9, 12) + 2, 5) +
|
||||
gd -
|
||||
34840408;
|
||||
d = d - div(div(gy + 100100 + div(gm - 8, 6), 100) * 3, 4) + 752;
|
||||
return d;
|
||||
}
|
||||
|
||||
function d2g(jdn: number): { gy: number; gm: number; gd: number } {
|
||||
let j = 4 * jdn + 139361631;
|
||||
j = j + div(div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908;
|
||||
const i = div(mod(j, 1461), 4) * 5 + 308;
|
||||
const gd = div(mod(i, 153), 5) + 1;
|
||||
const gm = mod(div(i, 153), 12) + 1;
|
||||
const gy = div(j, 1461) - 100100 + div(8 - gm, 6);
|
||||
return { gy, gm, gd };
|
||||
}
|
||||
|
||||
function jalCal(jy: number, withoutLeap: boolean): { leap?: number; gy: number; march: number } {
|
||||
const bl = BREAKS.length;
|
||||
let gy = jy + 621;
|
||||
let leapJ = -14;
|
||||
let jp = BREAKS[0];
|
||||
let jump = 0;
|
||||
let leap = 0;
|
||||
let n = 0;
|
||||
|
||||
if (jy < jp || jy >= BREAKS[bl - 1]) {
|
||||
throw new Error(`Invalid Jalaali year ${jy}`);
|
||||
}
|
||||
|
||||
for (let i = 1; i < bl; i += 1) {
|
||||
const jm = BREAKS[i];
|
||||
jump = jm - jp;
|
||||
if (jy < jm) break;
|
||||
leapJ = leapJ + div(jump, 33) * 8 + div(mod(jump, 33), 4);
|
||||
jp = jm;
|
||||
}
|
||||
n = jy - jp;
|
||||
leapJ = leapJ + div(n, 33) * 8 + div(mod(n, 33) + 3, 4);
|
||||
if (mod(jump, 33) === 4 && jump - n === 4) leapJ += 1;
|
||||
|
||||
const leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150;
|
||||
const march = 20 + leapJ - leapG;
|
||||
|
||||
if (withoutLeap) return { gy, march };
|
||||
|
||||
if (jump - n < 6) n = n - jump + div(jump + 4, 33) * 33;
|
||||
leap = mod(mod(n + 1, 33) - 1, 4);
|
||||
if (leap === -1) leap = 4;
|
||||
return { leap, gy, march };
|
||||
}
|
||||
|
||||
function j2d(jy: number, jm: number, jd: number): number {
|
||||
const r = jalCal(jy, true);
|
||||
return g2d(r.gy, 3, r.march) + (jm - 1) * 31 - div(jm, 7) * (jm - 7) + jd - 1;
|
||||
}
|
||||
|
||||
function d2j(jdn: number): { jy: number; jm: number; jd: number } {
|
||||
const { gy } = d2g(jdn);
|
||||
let jy = gy - 621;
|
||||
const r = jalCal(jy, false);
|
||||
const jdn1f = g2d(gy, 3, r.march);
|
||||
let k = jdn - jdn1f;
|
||||
let jm: number;
|
||||
let jd: number;
|
||||
|
||||
if (k >= 0) {
|
||||
if (k <= 185) {
|
||||
jm = 1 + div(k, 31);
|
||||
jd = mod(k, 31) + 1;
|
||||
return { jy, jm, jd };
|
||||
}
|
||||
k -= 186;
|
||||
} else {
|
||||
jy -= 1;
|
||||
k += 179;
|
||||
if (r.leap === 1) k += 1;
|
||||
}
|
||||
jm = 7 + div(k, 30);
|
||||
jd = mod(k, 30) + 1;
|
||||
return { jy, jm, jd };
|
||||
}
|
||||
|
||||
export function gregorianToJalali(gy: number, gm: number, gd: number): [number, number, number] {
|
||||
const { jy, jm, jd } = d2j(g2d(gy, gm, gd));
|
||||
return [jy, jm, jd];
|
||||
}
|
||||
|
||||
export function jalaliToGregorian(jy: number, jm: number, jd: number): [number, number, number] {
|
||||
const { gy, gm, gd } = d2g(j2d(jy, jm, jd));
|
||||
return [gy, gm, gd];
|
||||
}
|
||||
|
||||
export function isJalaliLeapYear(jy: number): boolean {
|
||||
const r = jalCal(jy, false);
|
||||
return r.leap === 0;
|
||||
}
|
||||
|
||||
export function jalaliDaysInMonth(jy: number, jm: number): number {
|
||||
if (jm <= 6) return 31;
|
||||
if (jm <= 11) return 30;
|
||||
return isJalaliLeapYear(jy) ? 30 : 29;
|
||||
}
|
||||
|
||||
export function getLocalPersianParts(date: Date): PersianDateParts {
|
||||
const [year, month, day] = gregorianToJalali(
|
||||
date.getFullYear(),
|
||||
date.getMonth() + 1,
|
||||
date.getDate(),
|
||||
);
|
||||
return { year, month, day };
|
||||
}
|
||||
|
||||
export function persianPartsToLocalDate(jy: number, jm: number, jd: number): Date {
|
||||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||||
return new Date(gy, gm - 1, gd, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
export function persianYearRange(anchorYear: number, past = 10, future = 2): number[] {
|
||||
const years: number[] = [];
|
||||
for (let y = anchorYear - past; y <= anchorYear + future; y += 1) {
|
||||
years.push(y);
|
||||
}
|
||||
return years;
|
||||
}
|
||||
|
||||
const persianMonthFormatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||
month: 'long',
|
||||
numberingSystem: 'arabext',
|
||||
});
|
||||
|
||||
/** Jalali month name (Farvardin, …) for picker labels. */
|
||||
export function formatPersianMonthLabel(jy: number, jm: number): string {
|
||||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, 15);
|
||||
return persianMonthFormatter.format(new Date(gy, gm - 1, gd));
|
||||
}
|
||||
|
||||
const ARABEXT_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] as const;
|
||||
|
||||
export function toLatinDigits(value: string): string {
|
||||
return value.replace(/[۰-۹]/g, (ch) => {
|
||||
const index = ARABEXT_DIGITS.indexOf(ch as (typeof ARABEXT_DIGITS)[number]);
|
||||
return index >= 0 ? String(index) : ch;
|
||||
});
|
||||
}
|
||||
|
||||
export function toArabextDigits(value: string): string {
|
||||
return value.replace(/\d/g, (d) => ARABEXT_DIGITS[Number(d)] ?? d);
|
||||
}
|
||||
|
||||
/** Parse typed Jalali `YYYY/MM/DD` (Latin or Persian digits) → `YYYY-MM-DD` or null. */
|
||||
export function parsePersianDateInputText(raw: string): string | null {
|
||||
const normalized = toLatinDigits(raw.trim()).replace(/-/g, '/');
|
||||
const match = /^(\d{4})\/(\d{1,2})\/(\d{1,2})$/.exec(normalized);
|
||||
if (!match) return null;
|
||||
|
||||
const jy = Number(match[1]);
|
||||
const jm = Number(match[2]);
|
||||
const jd = Number(match[3]);
|
||||
if (jm < 1 || jm > 12 || jd < 1 || jd > jalaliDaysInMonth(jy, jm)) return null;
|
||||
|
||||
const date = persianPartsToLocalDate(jy, jm, jd);
|
||||
const gy = date.getFullYear();
|
||||
const gm = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const gd = String(date.getDate()).padStart(2, '0');
|
||||
return `${gy}-${gm}-${gd}`;
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DD` → typed Jalali field text e.g. ۱۴۰۴/۰۴/۲۲ */
|
||||
export function formatIsoAsPersianDateInput(iso: string): string {
|
||||
if (!iso) return '';
|
||||
const [gy, gm, gd] = iso.split('-').map(Number);
|
||||
if (!gy || !gm || !gd) return '';
|
||||
const { year, month, day } = getLocalPersianParts(new Date(gy, gm - 1, gd, 0, 0, 0, 0));
|
||||
const pad2 = (n: number) => toArabextDigits(String(n).padStart(2, '0'));
|
||||
return `${toArabextDigits(String(year))}/${pad2(month)}/${pad2(day)}`;
|
||||
}
|
||||
|
||||
/** Force `YYYY/MM/DD` shape while typing (Persian digits in output). */
|
||||
export function maskJalaliDateTyping(raw: string): string {
|
||||
const digits = toLatinDigits(raw).replace(/\D/g, '').slice(0, 8);
|
||||
const segments: string[] = [];
|
||||
if (digits.length > 0) segments.push(digits.slice(0, Math.min(4, digits.length)));
|
||||
if (digits.length > 4) segments.push(digits.slice(4, Math.min(6, digits.length)));
|
||||
if (digits.length > 6) segments.push(digits.slice(6, 8));
|
||||
return toArabextDigits(segments.join('/'));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LabCaseActivityItem } from '@/types/lab-case-activity';
|
||||
import { APP_DATE, formatAppDateTime } from '@/lib/i18n/format';
|
||||
|
||||
type ActivityLabelTranslator = (
|
||||
key: string,
|
||||
@@ -11,12 +12,7 @@ export function formatLabCaseActivityLine(
|
||||
locale: string,
|
||||
): string {
|
||||
const actor = activity.actorName ?? t('activityUnknownActor');
|
||||
const date = new Date(activity.createdAt).toLocaleString(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const date = formatAppDateTime(activity.createdAt, locale, APP_DATE.activity);
|
||||
|
||||
switch (activity.type) {
|
||||
case 'CASE_SENT':
|
||||
|
||||
@@ -241,10 +241,34 @@ html[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* Minimal RTL layer — refine incrementally. */
|
||||
html[dir='rtl'] body {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
/* Sidebar: don't rely on Tailwind rtl: variants. */
|
||||
html[dir='rtl'] .app-sidebar {
|
||||
border-right: 0;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Mobile drawer only — desktop sidebar follows flex + document direction. */
|
||||
@media (max-width: 1023px) {
|
||||
html[dir='rtl'] .app-sidebar {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Treatment rail: align header text to the right in RTL. */
|
||||
html[dir='rtl'] .treatment-rail-section > button {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-background-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
||||
font-family: var(--font-sans, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif);
|
||||
}
|
||||
|
||||
select.form-select,
|
||||
@@ -254,6 +278,25 @@ select {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Shared chevron for all `.form-select` — symmetric inset matches `ps-3` text padding. */
|
||||
select.form-select:not(.form-select-no-chevron) {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
text-align: start;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1rem 1rem;
|
||||
background-position: right 0.75rem center;
|
||||
}
|
||||
|
||||
html[dir='rtl'] select.form-select:not(.form-select-no-chevron) {
|
||||
background-position: left 0.75rem center;
|
||||
}
|
||||
|
||||
select.form-select.form-select-no-chevron {
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
select.form-select,
|
||||
select {
|
||||
|
||||
Reference in New Issue
Block a user