improvement/ux-overhaul up #61
@@ -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**.
|
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 / appointment colors
|
||||||
|
|
||||||
Treatment-type colors and labels: `components/shared/treatmentTypeDisplay.ts` + `catalog-type-colors.ts`. UI badges: `components/ui/treatment/TreatmentTypeBadge.tsx`.
|
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.
|
1. Check `components/ui/shared/` for an existing primitive.
|
||||||
2. Check the feature's `ui/{feature}/` folder for an existing pattern.
|
2. Check the feature's `ui/{feature}/` folder for an existing pattern.
|
||||||
3. Add i18n keys to en, fa, and nl.
|
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/`.
|
**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 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):**
|
**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/lab-notifications/` | Tab badges: LabCaseActivity, tab-counts API, read cursors |
|
||||||
| `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout |
|
| `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout |
|
||||||
| `.cursor/skills/api-errors/` | New backend errors + frontend translations |
|
| `.cursor/skills/api-errors/` | New backend errors + frontend translations |
|
||||||
|
| `.cursor/skills/i18n-formatting/` | Dates, times, numbers, Jalali picker, RTL formatting |
|
||||||
|
|
||||||
## Subagents (Task tool)
|
## Subagents (Task tool)
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<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 ? (
|
{sidebarOpen ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { getMessages, setRequestLocale } from 'next-intl/server';
|
|||||||
import { hasLocale } from 'next-intl';
|
import { hasLocale } from 'next-intl';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import Script from 'next/script';
|
import Script from 'next/script';
|
||||||
|
import { Noto_Sans_Arabic, Vazirmatn } from 'next/font/google';
|
||||||
import '@/styles/globals.css';
|
import '@/styles/globals.css';
|
||||||
import '@/styles/background-web.css';
|
import '@/styles/background-web.css';
|
||||||
import { AuthProvider } from '@/lib/hooks/useAuth';
|
import { AuthProvider } from '@/lib/hooks/useAuth';
|
||||||
import { THEME_STORAGE_KEY } from '@/lib/theme';
|
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';
|
import { LocaleSync } from '@/components/i18n/LocaleSync';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -25,6 +26,18 @@ export function generateStaticParams() {
|
|||||||
return routing.locales.map((locale) => ({ locale }));
|
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({
|
export default async function LocaleLayout({
|
||||||
children,
|
children,
|
||||||
params,
|
params,
|
||||||
@@ -42,9 +55,20 @@ export default async function LocaleLayout({
|
|||||||
const messages = await getMessages();
|
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 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 (
|
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>
|
<body>
|
||||||
<Script id="theme-init" strategy="beforeInteractive">
|
<Script id="theme-init" strategy="beforeInteractive">
|
||||||
{themeInit}
|
{themeInit}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { formatAppMinuteOfDay } from '@/lib/i18n/format';
|
||||||
|
|
||||||
/** Normalize to local midnight; invalid input falls back to today. */
|
/** Normalize to local midnight; invalid input falls back to today. */
|
||||||
export function startOfLocalDay(d: Date): Date {
|
export function startOfLocalDay(d: Date): Date {
|
||||||
if (Number.isNaN(d.getTime())) {
|
if (Number.isNaN(d.getTime())) {
|
||||||
@@ -45,9 +47,8 @@ export function isSameLocalCalendarDay(a: Date, b: Date): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatHourLabel(hour: number): string {
|
export function formatHourLabel(hour: number, locale: string): string {
|
||||||
const d = new Date(2000, 0, 1, hour, 0, 0, 0);
|
return formatAppMinuteOfDay(hour * 60, locale);
|
||||||
return d.toLocaleTimeString(undefined, { hour: 'numeric', hour12: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Local midnight + delta calendar days. */
|
/** Local midnight + delta calendar days. */
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { LabCaseDetail } from '@/types/cases';
|
import type { LabCaseDetail } from '@/types/cases';
|
||||||
import type { CaseToothChartProsthesisRow } from '@/components/ui/lab/CaseToothChartPanel';
|
import type { CaseToothChartProsthesisRow } from '@/components/ui/lab/CaseToothChartPanel';
|
||||||
|
import { formatAppDateTime } from '@/lib/i18n/format';
|
||||||
|
|
||||||
export function formatPatientName(patient: { firstName: string; lastName: string }) {
|
export function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||||
return `${patient.firstName} ${patient.lastName}`.trim();
|
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) {
|
export function formatCaseDateTime(value: string | null, locale: string) {
|
||||||
if (!value) return '—';
|
if (!value) return '—';
|
||||||
return new Intl.DateTimeFormat(locale, {
|
return formatAppDateTime(value, locale, { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
dateStyle: 'medium',
|
|
||||||
timeStyle: 'short',
|
|
||||||
}).format(new Date(value));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildCaseProsthesisRows(labCase: LabCaseDetail): CaseToothChartProsthesisRow[] {
|
export function buildCaseProsthesisRows(labCase: LabCaseDetail): CaseToothChartProsthesisRow[] {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { BadgeVariant } from '@/components/ui/shared/Badge';
|
import type { BadgeVariant } from '@/components/ui/shared/Badge';
|
||||||
|
import { formatAppDate, APP_DATE } from '@/lib/i18n/format';
|
||||||
|
|
||||||
export function toDateInputValue(iso: string | null | undefined): string {
|
export function toDateInputValue(iso: string | null | undefined): string {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
@@ -18,13 +19,7 @@ export function formatLabCaseDueDate(
|
|||||||
locale: string,
|
locale: string,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
const date = new Date(iso);
|
return formatAppDate(iso, locale, APP_DATE.short);
|
||||||
if (Number.isNaN(date.getTime())) return null;
|
|
||||||
return new Intl.DateTimeFormat(locale, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
}).format(date);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whole calendar days from today (UTC) until due date. Negative = overdue. */
|
/** Whole calendar days from today (UTC) until due date. Negative = overdue. */
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
export const FORM_SELECT_CLASS =
|
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. */
|
/** 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`;
|
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 MINUTES_PER_DAY = 24 * 60;
|
||||||
export const SCHEDULE_SLOT_MINUTES = 15;
|
export const SCHEDULE_SLOT_MINUTES = 15;
|
||||||
|
|
||||||
@@ -50,9 +52,8 @@ export function timeInputToMinutes(value: string): number | null {
|
|||||||
return h * 60 + m;
|
return h * 60 + m;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatMinuteLabel(minute: number): string {
|
export function formatMinuteLabel(minute: number, locale: string): string {
|
||||||
const d = new Date(2000, 0, 1, Math.floor(minute / 60), minute % 60, 0, 0);
|
return formatAppMinuteOfDay(minute, locale);
|
||||||
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptyWorkingHoursEditorDays(): WorkingHoursEditorDay[] {
|
export function emptyWorkingHoursEditorDays(): WorkingHoursEditorDay[] {
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { useLocale } from 'next-intl';
|
||||||
|
import { APP_DATE, createAppDateFormatter } from '@/lib/i18n/format';
|
||||||
|
|
||||||
export function useTodayDayLabelFormatter() {
|
export function useTodayDayLabelFormatter() {
|
||||||
|
const locale = useLocale();
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() =>
|
() => createAppDateFormatter(locale, APP_DATE.chartDay),
|
||||||
new Intl.DateTimeFormat(undefined, {
|
[locale],
|
||||||
weekday: 'short',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { LinkedOrganizationOption, TreatmentCaseSendInfo } from '@/types/treatment';
|
import type { LinkedOrganizationOption, TreatmentCaseSendInfo } from '@/types/treatment';
|
||||||
|
import { formatAppDateTime } from '@/lib/i18n/format';
|
||||||
|
|
||||||
export type CaseSendLabelT = (
|
export type CaseSendLabelT = (
|
||||||
key: 'sentToAt' | 'fallbackOrgName',
|
key: 'sentToAt' | 'fallbackOrgName',
|
||||||
@@ -13,16 +14,17 @@ export function formatCaseSentLines(
|
|||||||
orgs?: LinkedOrganizationOption[];
|
orgs?: LinkedOrganizationOption[];
|
||||||
} | undefined,
|
} | undefined,
|
||||||
t: CaseSendLabelT,
|
t: CaseSendLabelT,
|
||||||
|
locale: string,
|
||||||
): string[] {
|
): string[] {
|
||||||
if (sends?.length) {
|
if (sends?.length) {
|
||||||
return sends.map((s) => {
|
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 });
|
return t('sentToAt', { orgName: s.organizationName, datetime: at });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fallback?.sentAt && fallback.organizationIds.length > 0) {
|
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]) ?? []);
|
const nameById = new Map(fallback.orgs?.map((o) => [o.id, o.name]) ?? []);
|
||||||
return fallback.organizationIds.map((id) => {
|
return fallback.organizationIds.map((id) => {
|
||||||
const name = nameById.get(id) ?? t('fallbackOrgName');
|
const name = nameById.get(id) ?? t('fallbackOrgName');
|
||||||
@@ -41,7 +43,8 @@ export function formatCaseSentSummary(
|
|||||||
orgs?: LinkedOrganizationOption[];
|
orgs?: LinkedOrganizationOption[];
|
||||||
} | undefined,
|
} | undefined,
|
||||||
t: CaseSendLabelT,
|
t: CaseSendLabelT,
|
||||||
|
locale: string,
|
||||||
): string | null {
|
): string | null {
|
||||||
const lines = formatCaseSentLines(sends, fallback, t);
|
const lines = formatCaseSentLines(sends, fallback, t, locale);
|
||||||
return lines.length > 0 ? lines.join(' · ') : null;
|
return lines.length > 0 ? lines.join(' · ') : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||||
import {
|
import {
|
||||||
purposeBannerStyle,
|
purposeBannerStyle,
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '@/components/appointments/appointmentPurposeStyles';
|
} from '@/components/appointments/appointmentPurposeStyles';
|
||||||
import type { AppointmentRecord } from '@/types/appointment';
|
import type { AppointmentRecord } from '@/types/appointment';
|
||||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
|
import { formatAppTimeRange } from '@/lib/i18n/format';
|
||||||
|
|
||||||
type AppointmentOverlapPopoverProps = {
|
type AppointmentOverlapPopoverProps = {
|
||||||
appointments: AppointmentRecord[];
|
appointments: AppointmentRecord[];
|
||||||
@@ -18,11 +19,8 @@ type AppointmentOverlapPopoverProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatTimeRange(apt: AppointmentRecord): string {
|
function formatTimeRange(apt: AppointmentRecord, locale: string): string {
|
||||||
const start = new Date(apt.startAt);
|
return formatAppTimeRange(apt.startAt, apt.endAt, locale);
|
||||||
const end = new Date(apt.endAt);
|
|
||||||
const opts: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit' };
|
|
||||||
return `${start.toLocaleTimeString(undefined, opts)} – ${end.toLocaleTimeString(undefined, opts)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppointmentOverlapPopover({
|
export function AppointmentOverlapPopover({
|
||||||
@@ -32,6 +30,7 @@ export function AppointmentOverlapPopover({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onClose,
|
onClose,
|
||||||
}: AppointmentOverlapPopoverProps) {
|
}: AppointmentOverlapPopoverProps) {
|
||||||
|
const locale = useLocale();
|
||||||
const t = useTranslations('appointments');
|
const t = useTranslations('appointments');
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -99,7 +98,7 @@ export function AppointmentOverlapPopover({
|
|||||||
<p className="text-xs font-medium truncate">
|
<p className="text-xs font-medium truncate">
|
||||||
{apt.patient.firstName} {apt.patient.lastName}
|
{apt.patient.firstName} {apt.patient.lastName}
|
||||||
</p>
|
</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">
|
<p className="text-[10px] opacity-80 truncate">
|
||||||
{purposeLabel(apt.purpose, treatmentCatalog)}
|
{purposeLabel(apt.purpose, treatmentCatalog)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
|
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
|
||||||
import {
|
import {
|
||||||
SCHEDULE_SLOT_MINUTES,
|
SCHEDULE_SLOT_MINUTES,
|
||||||
@@ -96,6 +96,7 @@ export function AppointmentScheduleGrid({
|
|||||||
onAppointmentClick,
|
onAppointmentClick,
|
||||||
onAppointmentOutsideHours,
|
onAppointmentOutsideHours,
|
||||||
}: AppointmentScheduleGridProps) {
|
}: AppointmentScheduleGridProps) {
|
||||||
|
const locale = useLocale();
|
||||||
const t = useTranslations('appointments');
|
const t = useTranslations('appointments');
|
||||||
const [overlapPopover, setOverlapPopover] = useState<OverlapPopoverState | null>(null);
|
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"
|
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 }}
|
style={{ top, height }}
|
||||||
>
|
>
|
||||||
{formatMinuteLabel(hour * 60)}
|
{formatMinuteLabel(hour * 60, locale)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -263,7 +264,7 @@ export function AppointmentScheduleGrid({
|
|||||||
: slotDisabled
|
: slotDisabled
|
||||||
? t('slotCannotCreate')
|
? t('slotCannotCreate')
|
||||||
: t('slotBookAt', {
|
: t('slotBookAt', {
|
||||||
time: formatMinuteLabel(slotStartMinute),
|
time: formatMinuteLabel(slotStartMinute, locale),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className={`absolute left-0 right-0 border-b border-border/50 transition-colors ${
|
className={`absolute left-0 right-0 border-b border-border/50 transition-colors ${
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useLocale } from 'next-intl';
|
||||||
import { Pencil } from 'lucide-react';
|
import { Pencil } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Badge } from '@/components/ui/shared/Badge';
|
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 { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { hasPermission } from '@/components/shared/permissions';
|
import { hasPermission } from '@/components/shared/permissions';
|
||||||
|
import { formatAppNumber } from '@/lib/i18n/format';
|
||||||
|
|
||||||
type InvoiceStatus = 'paid' | 'unpaid' | 'overdue';
|
type InvoiceStatus = 'paid' | 'unpaid' | 'overdue';
|
||||||
|
|
||||||
@@ -44,9 +46,11 @@ interface StatCardProps {
|
|||||||
count: number;
|
count: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
color: StatCardColor;
|
color: StatCardColor;
|
||||||
|
locale: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BillingPage() {
|
export function BillingPage() {
|
||||||
|
const locale = useLocale();
|
||||||
const { currentOrganization } = useAuth();
|
const { currentOrganization } = useAuth();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<(typeof statusFilters)[number]>('all');
|
const [statusFilter, setStatusFilter] = useState<(typeof statusFilters)[number]>('all');
|
||||||
@@ -89,10 +93,10 @@ export function BillingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 xl:grid-cols-4 gap-3 sm:gap-4">
|
<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="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" />
|
<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" />
|
<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" />
|
<StatCard title="Overdue Invoices" count={stats.overdue.count} amount={stats.overdue.amount} color="red" locale={locale} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SearchBar
|
<SearchBar
|
||||||
@@ -138,28 +142,28 @@ export function BillingPage() {
|
|||||||
<Table
|
<Table
|
||||||
headers={
|
headers={
|
||||||
<tr>
|
<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
|
Invoice ID
|
||||||
</th>
|
</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
|
Patient name
|
||||||
</th>
|
</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
|
Date
|
||||||
</th>
|
</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
|
Service
|
||||||
</th>
|
</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
|
Total amount
|
||||||
</th>
|
</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
|
Paid
|
||||||
</th>
|
</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
|
Status
|
||||||
</th>
|
</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
|
Action
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -168,18 +172,18 @@ export function BillingPage() {
|
|||||||
<>
|
<>
|
||||||
{filteredInvoices.map((invoice) => (
|
{filteredInvoices.map((invoice) => (
|
||||||
<tr key={invoice.id} className="hover:bg-background-secondary/45">
|
<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="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="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="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="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="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="text-sm text-text-primary">${invoice.paid}</td>
|
||||||
<td className="px-6 py-1.5 text-center align-middle">
|
<td className="text-center align-middle">
|
||||||
<Badge variant={statusColors[invoice.status]} className="capitalize">
|
<Badge variant={statusColors[invoice.status]} className="capitalize">
|
||||||
{invoice.status}
|
{invoice.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-1.5">
|
<td>
|
||||||
<InvoiceEditButton canEditBilling={canEditBilling} />
|
<InvoiceEditButton canEditBilling={canEditBilling} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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> = {
|
const colors: Record<StatCardColor, string> = {
|
||||||
blue: '!bg-purpose-visit-bg !text-purpose-visit-fg !border-purpose-visit-border',
|
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',
|
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 (
|
return (
|
||||||
<Card className={`min-w-0 ${colors[color]}`}>
|
<Card className={`min-w-0 ${colors[color]}`}>
|
||||||
<p className="text-xs sm:text-sm font-medium leading-snug">{title}</p>
|
<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">
|
<p className="text-xs sm:text-sm font-medium mt-1 tabular-nums truncate">
|
||||||
${amount.toLocaleString()}
|
${formatAppNumber(amount, locale)}
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ export function CaseDetailPanel({
|
|||||||
onAssignTask(task.id, e.target.value ? e.target.value : null)
|
onAssignTask(task.id, e.target.value ? e.target.value : null)
|
||||||
}
|
}
|
||||||
aria-label={t('assigneeLabel')}
|
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>
|
<option value="">{t('assigneeUnassigned')}</option>
|
||||||
{assignableStaff.map((staff) => (
|
{assignableStaff.map((staff) => (
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { Button } from '@/components/ui/shared/Button';
|
|||||||
import { MobileDetailBackButton } from '@/components/ui/shared/MobileDetailBackButton';
|
import { MobileDetailBackButton } from '@/components/ui/shared/MobileDetailBackButton';
|
||||||
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||||
|
import { AppDateInput } from '@/components/ui/shared/AppDateInput';
|
||||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
import type {
|
import type {
|
||||||
AssignableTaskStaff,
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -333,11 +334,10 @@ export function CasesPage() {
|
|||||||
|
|
||||||
<label className="space-y-1">
|
<label className="space-y-1">
|
||||||
<span className="text-xs font-medium text-text-muted">{t('filterSentFrom')}</span>
|
<span className="text-xs font-medium text-text-muted">{t('filterSentFrom')}</span>
|
||||||
<input
|
<AppDateInput
|
||||||
type="date"
|
|
||||||
value={sentFrom}
|
value={sentFrom}
|
||||||
onChange={(e) => {
|
onChange={(next) => {
|
||||||
setSentFrom(e.target.value);
|
setSentFrom(next);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
className={filterSelectClass}
|
className={filterSelectClass}
|
||||||
@@ -346,11 +346,10 @@ export function CasesPage() {
|
|||||||
|
|
||||||
<label className="space-y-1">
|
<label className="space-y-1">
|
||||||
<span className="text-xs font-medium text-text-muted">{t('filterSentTo')}</span>
|
<span className="text-xs font-medium text-text-muted">{t('filterSentTo')}</span>
|
||||||
<input
|
<AppDateInput
|
||||||
type="date"
|
|
||||||
value={sentTo}
|
value={sentTo}
|
||||||
onChange={(e) => {
|
onChange={(next) => {
|
||||||
setSentTo(e.target.value);
|
setSentTo(next);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
className={filterSelectClass}
|
className={filterSelectClass}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Badge } from '@/components/ui/shared/Badge';
|
|||||||
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
||||||
import type { CaseTaskGroup } from '@/components/lab/taskListGrouping';
|
import type { CaseTaskGroup } from '@/components/lab/taskListGrouping';
|
||||||
import { countCaseTaskProgress } 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 }) {
|
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||||
return `${patient.firstName} ${patient.lastName}`.trim();
|
return `${patient.firstName} ${patient.lastName}`.trim();
|
||||||
@@ -20,11 +21,7 @@ export function TaskCaseGroupHeader({ caseGroup, locale }: TaskCaseGroupHeaderPr
|
|||||||
const progress = countCaseTaskProgress(caseGroup);
|
const progress = countCaseTaskProgress(caseGroup);
|
||||||
|
|
||||||
const sentLabel = caseGroup.caseSentAt
|
const sentLabel = caseGroup.caseSentAt
|
||||||
? new Intl.DateTimeFormat(locale, {
|
? formatAppDate(caseGroup.caseSentAt, locale, APP_DATE.short)
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
}).format(new Date(caseGroup.caseSentAt))
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
labTaskStatusVariant,
|
labTaskStatusVariant,
|
||||||
} from '@/components/lab/labTaskStatusDisplay';
|
} from '@/components/lab/labTaskStatusDisplay';
|
||||||
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
||||||
|
import { formatAppDate, APP_DATE } from '@/lib/i18n/format';
|
||||||
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
|
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
|
||||||
import {
|
import {
|
||||||
formatToothList,
|
formatToothList,
|
||||||
@@ -64,11 +65,7 @@ export function TaskRow({
|
|||||||
const canEditStatus = canEditLabTaskStatus(task, currentUserId, canEdit);
|
const canEditStatus = canEditLabTaskStatus(task, currentUserId, canEdit);
|
||||||
const assignedToOther =
|
const assignedToOther =
|
||||||
Boolean(task.assignee) && task.assignee!.id !== currentUserId;
|
Boolean(task.assignee) && task.assignee!.id !== currentUserId;
|
||||||
const taskDate = new Intl.DateTimeFormat(locale, {
|
const taskDate = formatAppDate(task.createdAt, locale, APP_DATE.short);
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
}).format(new Date(task.createdAt));
|
|
||||||
|
|
||||||
const rowClassName = [
|
const rowClassName = [
|
||||||
flatMode ? undefined : 'border-b border-border/40 last:border-b-0',
|
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 { useTranslations } from 'next-intl';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
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 { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||||
import { TaskCaseGroupHeader } from '@/components/ui/lab/TaskCaseGroupHeader';
|
import { TaskCaseGroupHeader } from '@/components/ui/lab/TaskCaseGroupHeader';
|
||||||
import { TaskProsthesisGroupHeader } from '@/components/ui/lab/TaskProsthesisGroupHeader';
|
import { TaskProsthesisGroupHeader } from '@/components/ui/lab/TaskProsthesisGroupHeader';
|
||||||
@@ -286,7 +286,7 @@ export function TasksPage() {
|
|||||||
[canEdit, loadTasks, setError, showError, showSuccess, statusFilter, t, tErrors],
|
[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(() => {
|
const sortHintKey = useMemo(() => {
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
@@ -366,7 +366,8 @@ export function TasksPage() {
|
|||||||
onChange={(v) => applyFilterChange(() => setSearch(v))}
|
onChange={(v) => applyFilterChange(() => setSearch(v))}
|
||||||
placeholder={t('searchPlaceholder')}
|
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">
|
<label className="space-y-1">
|
||||||
<span className="text-xs text-text-muted">{t('filterClinic')}</span>
|
<span className="text-xs text-text-muted">{t('filterClinic')}</span>
|
||||||
<select
|
<select
|
||||||
@@ -411,16 +412,16 @@ export function TasksPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="space-y-1">
|
<label className="space-y-1 min-w-0">
|
||||||
<span className="text-xs text-text-muted">{t('sortBy')}</span>
|
<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
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSortBy(e.target.value as TaskSortField);
|
setSortBy(e.target.value as TaskSortField);
|
||||||
clearFocus();
|
clearFocus();
|
||||||
}}
|
}}
|
||||||
className={`${filterSelectClass} min-w-0 flex-1`}
|
className={`${filterSelectClass} min-w-0`}
|
||||||
>
|
>
|
||||||
<option value="date">{t('sortDate')}</option>
|
<option value="date">{t('sortDate')}</option>
|
||||||
<option value="dueDate">{t('sortDueDate')}</option>
|
<option value="dueDate">{t('sortDueDate')}</option>
|
||||||
@@ -432,7 +433,7 @@ export function TasksPage() {
|
|||||||
<select
|
<select
|
||||||
value={sortDir}
|
value={sortDir}
|
||||||
onChange={(e) => setSortDir(e.target.value as 'asc' | 'desc')}
|
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')}
|
aria-label={t('sortDirection')}
|
||||||
>
|
>
|
||||||
<option value="desc">↓</option>
|
<option value="desc">↓</option>
|
||||||
@@ -441,6 +442,7 @@ export function TasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={importantOnly}
|
checked={importantOnly}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||||
import { Card } from '@/components/ui/shared/Card';
|
import { Card } from '@/components/ui/shared/Card';
|
||||||
import {
|
import {
|
||||||
@@ -12,12 +12,7 @@ import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvi
|
|||||||
import type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization';
|
import type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization';
|
||||||
import { Badge } from '@/components/ui/shared/Badge';
|
import { Badge } from '@/components/ui/shared/Badge';
|
||||||
import { organizationConnectionStatusVariant } from '@/components/organizations/connectionStatusVariant';
|
import { organizationConnectionStatusVariant } from '@/components/organizations/connectionStatusVariant';
|
||||||
|
import { formatAppTableDate } from '@/lib/i18n/format';
|
||||||
function formatTableDate(value: string): string {
|
|
||||||
const d = new Date(value);
|
|
||||||
if (Number.isNaN(d.getTime())) return '—';
|
|
||||||
return d.toLocaleDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
type InvitationHistoryDialogProps = {
|
type InvitationHistoryDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -38,6 +33,7 @@ export function InvitationHistoryDialog({
|
|||||||
copyingInvitationId,
|
copyingInvitationId,
|
||||||
onCopy,
|
onCopy,
|
||||||
}: InvitationHistoryDialogProps) {
|
}: InvitationHistoryDialogProps) {
|
||||||
|
const locale = useLocale();
|
||||||
const t = useTranslations('organizations');
|
const t = useTranslations('organizations');
|
||||||
|
|
||||||
function formatInvitationStatusLabel(
|
function formatInvitationStatusLabel(
|
||||||
@@ -82,7 +78,7 @@ export function InvitationHistoryDialog({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium text-text-primary truncate">{inv.organizationName}</p>
|
<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-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>
|
</div>
|
||||||
<Badge variant={organizationConnectionStatusVariant(inv.status)} fixedWidth={false}>
|
<Badge variant={organizationConnectionStatusVariant(inv.status)} fixedWidth={false}>
|
||||||
{formatInvitationStatusLabel(inv.status)}
|
{formatInvitationStatusLabel(inv.status)}
|
||||||
@@ -105,19 +101,19 @@ export function InvitationHistoryDialog({
|
|||||||
<Table
|
<Table
|
||||||
headers={
|
headers={
|
||||||
<tr>
|
<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')}
|
{t('tableOrganization')}
|
||||||
</th>
|
</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')}
|
{t('tableOwnerEmail')}
|
||||||
</th>
|
</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')}
|
{t('tableDate')}
|
||||||
</th>
|
</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')}
|
{t('tableStatus')}
|
||||||
</th>
|
</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')}
|
{t('tableInvitationLink')}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -126,17 +122,17 @@ export function InvitationHistoryDialog({
|
|||||||
<>
|
<>
|
||||||
{items.map((inv) => (
|
{items.map((inv) => (
|
||||||
<tr key={inv.id} className="hover:bg-background-secondary/45">
|
<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="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="text-sm text-text-secondary">{inv.ownerEmail}</td>
|
||||||
<td className="px-6 py-1.5 text-sm text-text-secondary">
|
<td className="text-sm text-text-secondary">
|
||||||
{formatTableDate(inv.createdAt)}
|
{formatAppTableDate(inv.createdAt, locale)}
|
||||||
</td>
|
</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}>
|
<Badge variant={organizationConnectionStatusVariant(inv.status)} fixedWidth={false}>
|
||||||
{formatInvitationStatusLabel(inv.status)}
|
{formatInvitationStatusLabel(inv.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-1.5 text-right align-middle">
|
<td className="text-end align-middle">
|
||||||
<CopyInvitationLinkButton
|
<CopyInvitationLinkButton
|
||||||
invitation={inv}
|
invitation={inv}
|
||||||
copied={copiedId === inv.id}
|
copied={copiedId === inv.id}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
import { Check, Trash2, UserPlus, X } from 'lucide-react';
|
import { Check, Trash2, UserPlus, X } from 'lucide-react';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
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 { Table } from '@/components/ui/shared/Table';
|
||||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { useRouter } from '@/i18n/navigation';
|
import { useRouter } from '@/i18n/navigation';
|
||||||
|
import { formatAppTableDate } from '@/lib/i18n/format';
|
||||||
|
|
||||||
function formatOrganizationStatusLabel(status: string): string {
|
function formatOrganizationStatusLabel(status: string): string {
|
||||||
if (!status) return status;
|
if (!status) return status;
|
||||||
@@ -33,15 +34,10 @@ function formatOrganizationStatusLabel(status: string): string {
|
|||||||
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
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';
|
type TableMode = 'existing' | 'search';
|
||||||
|
|
||||||
export function OrganizationsPage() {
|
export function OrganizationsPage() {
|
||||||
|
const locale = useLocale();
|
||||||
const t = useTranslations('organizations');
|
const t = useTranslations('organizations');
|
||||||
const tErrors = useTranslations('errors');
|
const tErrors = useTranslations('errors');
|
||||||
const tNav = useTranslations('nav');
|
const tNav = useTranslations('nav');
|
||||||
@@ -58,6 +54,11 @@ export function OrganizationsPage() {
|
|||||||
[tCommon, tErrors],
|
[tCommon, tErrors],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const formatTableDate = useCallback(
|
||||||
|
(value: string) => formatAppTableDate(value, locale),
|
||||||
|
[locale],
|
||||||
|
);
|
||||||
|
|
||||||
const formatConnectionStatusLabel = useCallback(
|
const formatConnectionStatusLabel = useCallback(
|
||||||
(row: CounterpartItemDto, currentOrganizationId: string): string => {
|
(row: CounterpartItemDto, currentOrganizationId: string): string => {
|
||||||
if (row.status === 'PENDING') {
|
if (row.status === 'PENDING') {
|
||||||
@@ -389,19 +390,19 @@ export function OrganizationsPage() {
|
|||||||
<Table
|
<Table
|
||||||
headers={
|
headers={
|
||||||
<tr>
|
<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')}
|
{t('tableOrganization')}
|
||||||
</th>
|
</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')}
|
{t('tableOwnerEmail')}
|
||||||
</th>
|
</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')}
|
{t('tableDate')}
|
||||||
</th>
|
</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')}
|
{t('tableStatus')}
|
||||||
</th>
|
</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')}
|
{t('tableAction')}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -410,14 +411,14 @@ export function OrganizationsPage() {
|
|||||||
<>
|
<>
|
||||||
{loading || (mode === 'search' && searching) ? (
|
{loading || (mode === 'search' && searching) ? (
|
||||||
<tr>
|
<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')}
|
{tCommon('loadingEllipsis')}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : mode === 'existing' ? (
|
) : mode === 'existing' ? (
|
||||||
existingRows.length === 0 ? (
|
existingRows.length === 0 ? (
|
||||||
<tr>
|
<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')}
|
{t('emptyConnections')}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -434,19 +435,19 @@ export function OrganizationsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={row.id} className="hover:bg-background-secondary/45">
|
<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}
|
{row.organizationName}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-1.5 text-sm text-text-secondary">{row.ownerEmail}</td>
|
<td className="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">
|
||||||
{formatTableDate(row.createdAt)}
|
{formatTableDate(row.createdAt)}
|
||||||
</td>
|
</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}>
|
<Badge variant={organizationConnectionStatusVariant(row.status)} fixedWidth={false}>
|
||||||
{formatConnectionStatusLabel(row, currentOrganization.id)}
|
{formatConnectionStatusLabel(row, currentOrganization.id)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-1.5 text-right">
|
<td className="text-end">
|
||||||
<div className="inline-flex items-center gap-2">
|
<div className="inline-flex items-center gap-2">
|
||||||
{invitationTarget && (
|
{invitationTarget && (
|
||||||
<CopyInvitationLinkButton
|
<CopyInvitationLinkButton
|
||||||
@@ -503,13 +504,13 @@ export function OrganizationsPage() {
|
|||||||
) : searchResults.length > 0 ? (
|
) : searchResults.length > 0 ? (
|
||||||
searchResults.map((r) => (
|
searchResults.map((r) => (
|
||||||
<tr key={r.id} className="hover:bg-background-secondary/45">
|
<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="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="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="text-sm text-text-secondary">{t('statusToday')}</td>
|
||||||
<td className="px-6 py-1.5 text-center align-middle">
|
<td className="text-center align-middle">
|
||||||
<Badge variant="default" fixedWidth={false}>{t('statusFound')}</Badge>
|
<Badge variant="default" fixedWidth={false}>{t('statusFound')}</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-1.5 text-right">
|
<td className="text-end">
|
||||||
<button
|
<button
|
||||||
type="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"
|
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>
|
<tr>
|
||||||
<td colSpan={5} className="px-6 py-6">
|
<td colSpan={5} className="py-6">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<p className="text-sm text-text-secondary">
|
<p className="text-sm text-text-secondary">
|
||||||
{t('noDirectoryResults')}
|
{t('noDirectoryResults')}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { formatTimeForInput } from '@/components/appointments/appointmentTime';
|
|
||||||
import { purposeLabel } from '@/components/appointments/appointmentPurposeStyles';
|
import { purposeLabel } from '@/components/appointments/appointmentPurposeStyles';
|
||||||
|
import { APP_DATE, formatAppDate, formatAppTimeRange } from '@/lib/i18n/format';
|
||||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
|
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
|
||||||
import { patientsApi } from '@/lib/api/patients';
|
import { patientsApi } from '@/lib/api/patients';
|
||||||
@@ -15,20 +15,12 @@ interface PatientAppointmentHistoryProps {
|
|||||||
patientId: string;
|
patientId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAppointmentDate(value: string): string {
|
function formatAppointmentDate(value: string, locale: string): string {
|
||||||
const date = new Date(value);
|
return formatAppDate(value, locale, APP_DATE.withWeekday);
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
return date.toLocaleDateString(undefined, {
|
|
||||||
weekday: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PatientAppointmentHistory({ patientId }: PatientAppointmentHistoryProps) {
|
export function PatientAppointmentHistory({ patientId }: PatientAppointmentHistoryProps) {
|
||||||
|
const locale = useLocale();
|
||||||
const t = useTranslations('patients');
|
const t = useTranslations('patients');
|
||||||
const tErrors = useTranslations('errors');
|
const tErrors = useTranslations('errors');
|
||||||
const [items, setItems] = useState<PatientAppointmentHistoryItem[]>([]);
|
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="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium text-text-primary">
|
<p className="text-sm font-medium text-text-primary">
|
||||||
{formatAppointmentDate(item.startAt)}
|
{formatAppointmentDate(item.startAt, locale)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-text-secondary">
|
<p className="text-sm text-text-secondary">
|
||||||
{formatTimeForInput(new Date(item.startAt))}
|
{formatAppTimeRange(item.startAt, item.endAt, locale)}
|
||||||
{' – '}
|
|
||||||
{formatTimeForInput(new Date(item.endAt))}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5 sm:items-end">
|
<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';
|
'use client';
|
||||||
|
|
||||||
import { ChevronDown } from 'lucide-react';
|
|
||||||
import React, { forwardRef, useId } from 'react';
|
import React, { forwardRef, useId } from 'react';
|
||||||
|
|
||||||
interface DropdownProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
interface DropdownProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
@@ -24,7 +23,6 @@ export const Dropdown = forwardRef<HTMLSelectElement, DropdownProps>(
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<select
|
<select
|
||||||
ref={ref}
|
ref={ref}
|
||||||
id={selectId}
|
id={selectId}
|
||||||
@@ -32,7 +30,7 @@ export const Dropdown = forwardRef<HTMLSelectElement, DropdownProps>(
|
|||||||
form-select w-full appearance-none rounded-[var(--radius-md)] border
|
form-select w-full appearance-none rounded-[var(--radius-md)] border
|
||||||
${error ? 'border-red-500' : 'border-border'}
|
${error ? 'border-red-500' : 'border-border'}
|
||||||
bg-background-card text-text-primary
|
bg-background-card text-text-primary
|
||||||
pl-4 pr-14 py-2.5 sm:py-2 text-base sm:text-sm
|
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
|
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
transition-all duration-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]
|
transition-all duration-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]
|
||||||
@@ -43,14 +41,6 @@ export const Dropdown = forwardRef<HTMLSelectElement, DropdownProps>(
|
|||||||
{children}
|
{children}
|
||||||
</select>
|
</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>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="mt-1 text-sm text-red-500">
|
<p className="mt-1 text-sm text-red-500">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useId, useRef, useState } from 'react';
|
import { CalendarDaySelect } from '@/components/ui/shared/CalendarDaySelect';
|
||||||
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';
|
|
||||||
|
|
||||||
interface ScheduleDayPickerProps {
|
interface ScheduleDayPickerProps {
|
||||||
value: Date;
|
value: Date;
|
||||||
@@ -14,47 +10,6 @@ interface ScheduleDayPickerProps {
|
|||||||
showTodayToggle?: boolean;
|
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).
|
* Calendar day navigator (arrows + year/month/day panel).
|
||||||
* Does not restrict past dates — parent pages enforce read-only vs editable for schedule grids/forms.
|
* Does not restrict past dates — parent pages enforce read-only vs editable for schedule grids/forms.
|
||||||
@@ -65,215 +20,14 @@ export function ScheduleDayPicker({
|
|||||||
label,
|
label,
|
||||||
showTodayToggle = true,
|
showTodayToggle = true,
|
||||||
}: ScheduleDayPickerProps) {
|
}: 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 (
|
return (
|
||||||
<div ref={rootRef} className="relative w-full max-w-md">
|
<CalendarDaySelect
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
value={value}
|
||||||
<p className="text-sm font-medium text-text-secondary">{resolvedLabel}</p>
|
onChange={onChange}
|
||||||
{showTodayToggle ? (
|
label={label}
|
||||||
<Checkbox
|
showHeader
|
||||||
checked={isTodaySelected}
|
showTodayToggle={showTodayToggle}
|
||||||
onChange={(checked) => {
|
showNavArrows
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { memo, useMemo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { Link, usePathname } from '@/i18n/navigation';
|
import { Link, usePathname } from '@/i18n/navigation';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
counterpartOrganizationType,
|
counterpartOrganizationType,
|
||||||
organizationTypeIcon,
|
organizationTypeIcon,
|
||||||
} from '@/components/shared/organizationTypeIcon';
|
} from '@/components/shared/organizationTypeIcon';
|
||||||
|
import { isRtlLocale } from '@/i18n/routing';
|
||||||
|
|
||||||
type MenuItem = {
|
type MenuItem = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -46,6 +47,8 @@ type SidebarProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function Sidebar({ mobileOpen = false, onClose }: SidebarProps) {
|
function Sidebar({ mobileOpen = false, onClose }: SidebarProps) {
|
||||||
|
const locale = useLocale();
|
||||||
|
const rtl = isRtlLocale(locale);
|
||||||
const t = useTranslations('nav');
|
const t = useTranslations('nav');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -98,8 +101,8 @@ function Sidebar({ mobileOpen = false, onClose }: SidebarProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<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 ${
|
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' : '-translate-x-full lg:translate-x-0'
|
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">
|
<div className="h-[71px] px-4 flex items-center justify-between gap-2">
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ interface TableProps {
|
|||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shared data table — logical alignment (`text-start` / `text-end`) for LTR and RTL. */
|
||||||
export function Table({ headers, body, footer }: TableProps) {
|
export function Table({ headers, body, footer }: TableProps) {
|
||||||
return (
|
return (
|
||||||
<div className="surface-card overflow-hidden">
|
<div className="surface-card overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<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">
|
<thead className="bg-background-secondary/70 border-b border-border">
|
||||||
{headers}
|
{headers}
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -657,12 +657,12 @@ export function StaffPage() {
|
|||||||
<Table
|
<Table
|
||||||
headers={
|
headers={
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left 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('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="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="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="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="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-center text-xs font-medium text-text-muted uppercase tracking-wider w-36">
|
||||||
{t('tableAction')}
|
{t('tableAction')}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -671,16 +671,16 @@ export function StaffPage() {
|
|||||||
<>
|
<>
|
||||||
{members.map((m) => (
|
{members.map((m) => (
|
||||||
<tr key={m.id} className="hover:bg-background-secondary/45">
|
<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="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="text-sm text-text-secondary">{m.email}</td>
|
||||||
<td className="px-6 py-1.5 text-sm">
|
<td className="text-sm">
|
||||||
{m.isOwner ? (
|
{m.isOwner ? (
|
||||||
<span className="text-primary font-medium">{t('roleOwner')}</span>
|
<span className="text-primary font-medium">{t('roleOwner')}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-text-secondary">{t('roleStaff')}</span>
|
<span className="text-text-secondary">{t('roleStaff')}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-1.5 align-middle text-center">
|
<td className="align-middle text-center">
|
||||||
{m.isOwner || m.invitationStatus === 'ACTIVE' ? (
|
{m.isOwner || m.invitationStatus === 'ACTIVE' ? (
|
||||||
<Badge variant="success">{t('statusActive')}</Badge>
|
<Badge variant="success">{t('statusActive')}</Badge>
|
||||||
) : m.invitationStatus === 'PENDING' ? (
|
) : m.invitationStatus === 'PENDING' ? (
|
||||||
@@ -691,7 +691,7 @@ export function StaffPage() {
|
|||||||
<Badge variant="danger">{t('statusExpired')}</Badge>
|
<Badge variant="danger">{t('statusExpired')}</Badge>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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 ? (
|
{m.isOwner ? (
|
||||||
<span className="text-text-muted">{t('allFeatures')}</span>
|
<span className="text-text-muted">{t('allFeatures')}</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -700,7 +700,7 @@ export function StaffPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-1.5 align-middle text-center">
|
<td className="align-middle text-center">
|
||||||
{!m.isOwner && (
|
{!m.isOwner && (
|
||||||
<div className="flex min-h-[36px] items-center justify-center gap-1 mx-auto w-fit">
|
<div className="flex min-h-[36px] items-center justify-center gap-1 mx-auto w-fit">
|
||||||
{canShareStaffInviteLink(m) && (
|
{canShareStaffInviteLink(m) && (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from '@/i18n/navigation';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
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 { TodaySectionErrorFallback } from '@/components/ui/today/TodaySectionErrorFallback';
|
||||||
import { TodayWidgetErrorBoundary } from '@/components/ui/today/TodayWidgetErrorBoundary';
|
import { TodayWidgetErrorBoundary } from '@/components/ui/today/TodayWidgetErrorBoundary';
|
||||||
import { useTodaySummary } from '@/lib/hooks/useTodaySummary';
|
import { useTodaySummary } from '@/lib/hooks/useTodaySummary';
|
||||||
|
import { formatAppTime } from '@/lib/i18n/format';
|
||||||
|
|
||||||
export function TodayPage() {
|
export function TodayPage() {
|
||||||
|
const locale = useLocale();
|
||||||
const t = useTranslations('today');
|
const t = useTranslations('today');
|
||||||
const tErrors = useTranslations('errors');
|
const tErrors = useTranslations('errors');
|
||||||
const { currentOrganization } = useAuth();
|
const { currentOrganization } = useAuth();
|
||||||
@@ -32,10 +34,7 @@ export function TodayPage() {
|
|||||||
{data?.generatedAt && !isInitialLoad ? (
|
{data?.generatedAt && !isInitialLoad ? (
|
||||||
<p className="text-xs text-text-muted">
|
<p className="text-xs text-text-muted">
|
||||||
{t('lastUpdated', {
|
{t('lastUpdated', {
|
||||||
time: new Intl.DateTimeFormat(undefined, {
|
time: formatAppTime(data.generatedAt, locale),
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
}).format(new Date(data.generatedAt)),
|
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from '@/i18n/navigation';
|
||||||
import { Card } from '@/components/ui/shared/Card';
|
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 { purposeLabel } from '@/components/appointments/appointmentPurposeStyles';
|
||||||
import { treatmentAppointmentHref } from '@/components/shared/treatmentSelection';
|
import { treatmentAppointmentHref } from '@/components/shared/treatmentSelection';
|
||||||
import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay';
|
import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay';
|
||||||
@@ -27,6 +27,7 @@ export function TodayUpcomingAppointments({
|
|||||||
loading = false,
|
loading = false,
|
||||||
isInitialLoad = false,
|
isInitialLoad = false,
|
||||||
}: TodayUpcomingAppointmentsProps) {
|
}: TodayUpcomingAppointmentsProps) {
|
||||||
|
const locale = useLocale();
|
||||||
const t = useTranslations('today');
|
const t = useTranslations('today');
|
||||||
const { currentOrganization } = useAuth();
|
const { currentOrganization } = useAuth();
|
||||||
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
|
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">
|
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
|
||||||
<ul className="divide-y divide-border/40">
|
<ul className="divide-y divide-border/40">
|
||||||
{appointments.map((appointment) => {
|
{appointments.map((appointment) => {
|
||||||
const start = new Date(appointment.startAt);
|
const timeLabel = formatAppTimeRange(appointment.startAt, appointment.endAt, locale);
|
||||||
const end = new Date(appointment.endAt);
|
|
||||||
const timeLabel = `${formatTimeForInput(start)} – ${formatTimeForInput(end)}`;
|
|
||||||
const purposeIndex = treatmentCatalog.findIndex(
|
const purposeIndex = treatmentCatalog.findIndex(
|
||||||
(entry) => entry.code === appointment.purpose,
|
(entry) => entry.code === appointment.purpose,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { CalendarDays } from 'lucide-react';
|
import { CalendarDays } from 'lucide-react';
|
||||||
import { Card } from '@/components/ui/shared/Card';
|
import { Card } from '@/components/ui/shared/Card';
|
||||||
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
|
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from '@/components/shared/treatmentTypeDisplay';
|
} from '@/components/shared/treatmentTypeDisplay';
|
||||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
import type { TreatmentAppointment } from '@/types/treatment';
|
import type { TreatmentAppointment } from '@/types/treatment';
|
||||||
|
import { APP_DATE, formatAppTimeRange } from '@/lib/i18n/format';
|
||||||
|
|
||||||
interface AppointmentsStripProps {
|
interface AppointmentsStripProps {
|
||||||
stripHidden: boolean;
|
stripHidden: boolean;
|
||||||
@@ -35,6 +36,7 @@ export function AppointmentsStrip({
|
|||||||
treatmentCatalog,
|
treatmentCatalog,
|
||||||
loading = false,
|
loading = false,
|
||||||
}: AppointmentsStripProps) {
|
}: AppointmentsStripProps) {
|
||||||
|
const locale = useLocale();
|
||||||
const t = useTranslations('treatment');
|
const t = useTranslations('treatment');
|
||||||
|
|
||||||
if (stripHidden) {
|
if (stripHidden) {
|
||||||
@@ -84,15 +86,7 @@ export function AppointmentsStrip({
|
|||||||
)}
|
)}
|
||||||
{appointments.map((a) => {
|
{appointments.map((a) => {
|
||||||
const sel = a.id === selectedAppointmentId;
|
const sel = a.id === selectedAppointmentId;
|
||||||
const start = new Date(a.startAt);
|
const timeLabel = formatAppTimeRange(a.startAt, a.endAt, locale);
|
||||||
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 purposeLabel = treatmentTypeLabelFromCatalog(a.purpose, treatmentCatalog);
|
const purposeLabel = treatmentTypeLabelFromCatalog(a.purpose, treatmentCatalog);
|
||||||
const purposeIndex = treatmentCatalog.findIndex((e) => e.code === a.purpose);
|
const purposeIndex = treatmentCatalog.findIndex((e) => e.code === a.purpose);
|
||||||
return (
|
return (
|
||||||
@@ -104,7 +98,7 @@ export function AppointmentsStrip({
|
|||||||
padding="none"
|
padding="none"
|
||||||
style={treatmentTypeBannerStyle(a.purpose, purposeIndex < 0 ? 0 : purposeIndex)}
|
style={treatmentTypeBannerStyle(a.purpose, purposeIndex < 0 ? 0 : purposeIndex)}
|
||||||
className={`
|
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
|
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'}
|
${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';
|
'use client';
|
||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { formatCaseSentLines } from '@/components/treatment/caseSendLabel';
|
import { formatCaseSentLines } from '@/components/treatment/caseSendLabel';
|
||||||
import type { LabCaseSendInfo, LinkedOrganizationOption } from '@/types/treatment';
|
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) {
|
export function CaseSentLabel({ treatmentCase, orgs, className = 'text-xs text-text-muted' }: CaseSentLabelProps) {
|
||||||
|
const locale = useLocale();
|
||||||
const t = useTranslations('treatment');
|
const t = useTranslations('treatment');
|
||||||
const organizationIds =
|
const organizationIds =
|
||||||
treatmentCase.sendToOrganizationIds ??
|
treatmentCase.sendToOrganizationIds ??
|
||||||
@@ -24,7 +25,7 @@ export function CaseSentLabel({ treatmentCase, orgs, className = 'text-xs text-t
|
|||||||
organizationIds,
|
organizationIds,
|
||||||
sentAt: treatmentCase.sentAt ?? null,
|
sentAt: treatmentCase.sentAt ?? null,
|
||||||
orgs,
|
orgs,
|
||||||
}, t);
|
}, t, locale);
|
||||||
|
|
||||||
if (lines.length === 0) return null;
|
if (lines.length === 0) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||||
import { isDetailReadyForLabDispatch, isLabCaseCompleted } from '@/components/treatment/treatmentDetailRules';
|
import { isDetailReadyForLabDispatch, isLabCaseCompleted } from '@/components/treatment/treatmentDetailRules';
|
||||||
|
import { AppDateInput } from '@/components/ui/shared/AppDateInput';
|
||||||
import { toDateInputValue } from '@/components/lab/labCaseDueDateDisplay';
|
import { toDateInputValue } from '@/components/lab/labCaseDueDateDisplay';
|
||||||
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||||||
import { LinkedOrganizationSearchCombobox } from '@/components/ui/treatment/LinkedOrganizationSearchCombobox';
|
import { LinkedOrganizationSearchCombobox } from '@/components/ui/treatment/LinkedOrganizationSearchCombobox';
|
||||||
@@ -283,20 +284,17 @@ export function LabCasesDispatchPanel({
|
|||||||
{t('dueDateLabel')}{' '}
|
{t('dueDateLabel')}{' '}
|
||||||
<span className="font-normal text-text-muted">({t('dueDateOptional')})</span>
|
<span className="font-normal text-text-muted">({t('dueDateOptional')})</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<AppDateInput
|
||||||
id={dueDateInputId}
|
id={dueDateInputId}
|
||||||
type="date"
|
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
disabled={!canEditDueDate}
|
disabled={!canEditDueDate}
|
||||||
onChange={(e) => {
|
onChange={(next) => {
|
||||||
if (!sent) {
|
updateActiveLabCase({ dueDate: next || null });
|
||||||
updateActiveLabCase({ dueDate: e.target.value || null });
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onBlur={(e) => {
|
onBlur={(committed) => {
|
||||||
if (sent) void handleSentDueDateBlur(e.target.value);
|
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>
|
</div>
|
||||||
{sent && caseFullyComplete && activeLabCase.dueDate ? (
|
{sent && caseFullyComplete && activeLabCase.dueDate ? (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge';
|
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 { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
|
||||||
import type { LinkedOrganizationOption } from '@/types/treatment';
|
import type { LinkedOrganizationOption } from '@/types/treatment';
|
||||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
|
import { APP_DATE, formatAppDate } from '@/lib/i18n/format';
|
||||||
|
|
||||||
interface LabDispatchAttentionPanelProps {
|
interface LabDispatchAttentionPanelProps {
|
||||||
items: LabDispatchAttentionItem[];
|
items: LabDispatchAttentionItem[];
|
||||||
@@ -27,6 +28,7 @@ export function LabDispatchAttentionPanel({
|
|||||||
onGoToDispatch,
|
onGoToDispatch,
|
||||||
compact = false,
|
compact = false,
|
||||||
}: LabDispatchAttentionPanelProps) {
|
}: LabDispatchAttentionPanelProps) {
|
||||||
|
const locale = useLocale();
|
||||||
const t = useTranslations('treatment');
|
const t = useTranslations('treatment');
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
@@ -54,11 +56,7 @@ export function LabDispatchAttentionPanel({
|
|||||||
const teeth = item.detail.teeth.length
|
const teeth = item.detail.teeth.length
|
||||||
? [...item.detail.teeth].sort().join(', ')
|
? [...item.detail.teeth].sort().join(', ')
|
||||||
: t('teethNone');
|
: t('teethNone');
|
||||||
const dateLabel = new Date(item.treatmentAt).toLocaleDateString(undefined, {
|
const dateLabel = formatAppDate(item.treatmentAt, locale, APP_DATE.short);
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||||
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||||||
import { TreatmentHistoryDetailLine } from '@/components/ui/treatment/TreatmentHistoryDetailLine';
|
import { TreatmentHistoryDetailLine } from '@/components/ui/treatment/TreatmentHistoryDetailLine';
|
||||||
import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge';
|
import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge';
|
||||||
import { filterTreatmentHistoryItems } from '@/components/treatment/treatmentHistoryFilters';
|
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 { LinkedOrganizationOption, PastTreatment } from '@/types/treatment';
|
||||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
|
|
||||||
@@ -25,16 +27,8 @@ interface PastTreatmentsPanelProps {
|
|||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatHistoryTimestamp(iso: string): string {
|
function formatHistoryTimestamp(iso: string, locale: string): string {
|
||||||
const date = new Date(iso);
|
return formatAppDateTime(iso, locale, APP_DATE.history);
|
||||||
return date.toLocaleString(undefined, {
|
|
||||||
weekday: 'short',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PastTreatmentsPanel({
|
export function PastTreatmentsPanel({
|
||||||
@@ -50,6 +44,7 @@ export function PastTreatmentsPanel({
|
|||||||
onSelectTreatment,
|
onSelectTreatment,
|
||||||
compact = false,
|
compact = false,
|
||||||
}: PastTreatmentsPanelProps) {
|
}: PastTreatmentsPanelProps) {
|
||||||
|
const locale = useLocale();
|
||||||
const t = useTranslations('treatment');
|
const t = useTranslations('treatment');
|
||||||
const [notShippedOnly, setNotShippedOnly] = useState(false);
|
const [notShippedOnly, setNotShippedOnly] = useState(false);
|
||||||
const [filterDate, setFilterDate] = useState('');
|
const [filterDate, setFilterDate] = useState('');
|
||||||
@@ -71,7 +66,7 @@ export function PastTreatmentsPanel({
|
|||||||
setFilterDate('');
|
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 = (
|
const filtersBlock = (
|
||||||
<div
|
<div
|
||||||
@@ -89,10 +84,9 @@ export function PastTreatmentsPanel({
|
|||||||
<span className="text-xs font-medium text-text-muted whitespace-nowrap">
|
<span className="text-xs font-medium text-text-muted whitespace-nowrap">
|
||||||
{t('historyFilterDate')}
|
{t('historyFilterDate')}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<AppDateInput
|
||||||
type="date"
|
|
||||||
value={filterDate}
|
value={filterDate}
|
||||||
onChange={(e) => setFilterDate(e.target.value)}
|
onChange={setFilterDate}
|
||||||
className={filterInputClass}
|
className={filterInputClass}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -156,7 +150,7 @@ export function PastTreatmentsPanel({
|
|||||||
className="text-xs font-medium text-text-primary tabular-nums"
|
className="text-xs font-medium text-text-primary tabular-nums"
|
||||||
dateTime={treatment.treatmentAt}
|
dateTime={treatment.treatmentAt}
|
||||||
>
|
>
|
||||||
{formatHistoryTimestamp(treatment.treatmentAt)}
|
{formatHistoryTimestamp(treatment.treatmentAt, locale)}
|
||||||
</time>
|
</time>
|
||||||
{isLiveDraft ? (
|
{isLiveDraft ? (
|
||||||
<span className="text-[10px] font-medium uppercase tracking-wide text-primary">
|
<span className="text-[10px] font-medium uppercase tracking-wide text-primary">
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { TreatmentDetailSummaryRow } from '@/components/ui/treatment/TreatmentDetailSummaryRow';
|
import { TreatmentDetailSummaryRow } from '@/components/ui/treatment/TreatmentDetailSummaryRow';
|
||||||
|
import { APP_DATE, formatAppDate } from '@/lib/i18n/format';
|
||||||
import type { LinkedOrganizationOption, PastTreatment } from '@/types/treatment';
|
import type { LinkedOrganizationOption, PastTreatment } from '@/types/treatment';
|
||||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export function TreatmentPreviewCard({
|
|||||||
orgs,
|
orgs,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
}: TreatmentPreviewCardProps) {
|
}: TreatmentPreviewCardProps) {
|
||||||
|
const locale = useLocale();
|
||||||
const t = useTranslations('treatment');
|
const t = useTranslations('treatment');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -38,12 +40,7 @@ export function TreatmentPreviewCard({
|
|||||||
className="text-xs text-text-muted tabular-nums block"
|
className="text-xs text-text-muted tabular-nums block"
|
||||||
dateTime={treatment.treatmentAt}
|
dateTime={treatment.treatmentAt}
|
||||||
>
|
>
|
||||||
{new Date(treatment.treatmentAt).toLocaleDateString(undefined, {
|
{formatAppDate(treatment.treatmentAt, locale, APP_DATE.withWeekday)}
|
||||||
weekday: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
})}
|
|
||||||
</time>
|
</time>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="space-y-2 max-h-[min(280px,40vh)] overflow-y-auto pr-1">
|
<div className="space-y-2 max-h-[min(280px,40vh)] overflow-y-auto pr-1">
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function TreatmentRailSection({
|
|||||||
: 'surface-card';
|
: 'surface-card';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`${shellClass} overflow-hidden`}>
|
<section className={`treatment-rail-section ${shellClass} overflow-hidden`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setExpanded((open) => !open)}
|
onClick={() => setExpanded((open) => !open)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
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 { useRouter } from '@/i18n/navigation';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
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 { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts';
|
||||||
import { tabBadgesChangedEventName } from '@/lib/tabBadgeUtils';
|
import { tabBadgesChangedEventName } from '@/lib/tabBadgeUtils';
|
||||||
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
|
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
|
||||||
|
import { APP_DATE, formatAppDate } from '@/lib/i18n/format';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
@@ -284,13 +285,13 @@ export function TreatmentWorkspace({
|
|||||||
currentOrganization,
|
currentOrganization,
|
||||||
initialAppointmentId = null,
|
initialAppointmentId = null,
|
||||||
}: TreatmentWorkspaceProps) {
|
}: TreatmentWorkspaceProps) {
|
||||||
|
const locale = useLocale();
|
||||||
const t = useTranslations('treatment');
|
const t = useTranslations('treatment');
|
||||||
const tErrors = useTranslations('errors');
|
const tErrors = useTranslations('errors');
|
||||||
const tPatients = useTranslations('patients');
|
const tPatients = useTranslations('patients');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { showError, showSuccess, messages: toastMessages } = useToast();
|
const { showError, showSuccess, messages: toastMessages } = useToast();
|
||||||
const locale = user?.language ?? 'en';
|
|
||||||
const canView = canViewTreatment(currentOrganization);
|
const canView = canViewTreatment(currentOrganization);
|
||||||
const canEdit = canEditTreatment(currentOrganization);
|
const canEdit = canEditTreatment(currentOrganization);
|
||||||
useMarkTabReadOnVisit();
|
useMarkTabReadOnVisit();
|
||||||
@@ -1571,7 +1572,7 @@ export function TreatmentWorkspace({
|
|||||||
</p>
|
</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="space-y-3 min-w-0 xl:max-w-[380px]">
|
||||||
<div className="surface-card p-3 space-y-3">
|
<div className="surface-card p-3 space-y-3">
|
||||||
<PatientSearchCombobox
|
<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">
|
<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">
|
<p className="text-xs text-text-primary">
|
||||||
{t('browseBanner', {
|
{t('browseBanner', {
|
||||||
date: new Date(previewTreatment.treatmentAt).toLocaleDateString(undefined, {
|
date: formatAppDate(previewTreatment.treatmentAt, locale, APP_DATE.withWeekday),
|
||||||
weekday: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
}),
|
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ export type AppLocale = (typeof locales)[number];
|
|||||||
|
|
||||||
export const defaultLocale: AppLocale = 'en';
|
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({
|
export const routing = defineRouting({
|
||||||
locales,
|
locales,
|
||||||
defaultLocale,
|
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 type { LabCaseActivityItem } from '@/types/lab-case-activity';
|
||||||
|
import { APP_DATE, formatAppDateTime } from '@/lib/i18n/format';
|
||||||
|
|
||||||
type ActivityLabelTranslator = (
|
type ActivityLabelTranslator = (
|
||||||
key: string,
|
key: string,
|
||||||
@@ -11,12 +12,7 @@ export function formatLabCaseActivityLine(
|
|||||||
locale: string,
|
locale: string,
|
||||||
): string {
|
): string {
|
||||||
const actor = activity.actorName ?? t('activityUnknownActor');
|
const actor = activity.actorName ?? t('activityUnknownActor');
|
||||||
const date = new Date(activity.createdAt).toLocaleString(locale, {
|
const date = formatAppDateTime(activity.createdAt, locale, APP_DATE.activity);
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
|
|
||||||
switch (activity.type) {
|
switch (activity.type) {
|
||||||
case 'CASE_SENT':
|
case 'CASE_SENT':
|
||||||
|
|||||||
@@ -241,10 +241,34 @@ html[data-theme='light'] {
|
|||||||
color-scheme: 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 {
|
body {
|
||||||
background-color: var(--color-background-primary);
|
background-color: var(--color-background-primary);
|
||||||
color: var(--color-text-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,
|
select.form-select,
|
||||||
@@ -254,6 +278,25 @@ select {
|
|||||||
font-size: 1rem;
|
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) {
|
@media (min-width: 640px) {
|
||||||
select.form-select,
|
select.form-select,
|
||||||
select {
|
select {
|
||||||
|
|||||||
Reference in New Issue
Block a user