TorbatYar/frontend/components/ds/DatePicker.tsx
Mortezakoohjani 12c8615615 Ship enterprise Accounting FE/API with CRUD parity and production wiring.
Adds accounting-service PATCH/archive, fiscal helpers, COA templates and setup status, plus SuperApp Accounting UI (DS, scoreboard, masters, vouchers, ledger, ops modules) with session refresh and HTTPS public API URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 15:26:43 +03:30

241 lines
7.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import {
MONTHS,
WEEKDAYS,
formatJalali,
formatJalaliDateTime,
isoToJalaliParts,
jalaliMonthLength,
jalaliPartsToIso,
jalaliWeekdaySat0,
shiftJalaliMonth,
todayIso,
} from "@/lib/jalali";
type DatePickerProps = {
value?: string;
onChange?: (iso: string) => void;
onBlur?: () => void;
name?: string;
id?: string;
disabled?: boolean;
placeholder?: string;
className?: string;
/** Gregorian ISO min/max (optional) */
min?: string;
max?: string;
};
/**
* Shamsi (Jalali) date picker — Design System.
* `value` / `onChange` use Gregorian ISO `YYYY-MM-DD` for backend APIs.
* Calendar UI never opens the native Gregorian picker.
*/
export function DatePicker({
value,
onChange,
onBlur,
name,
id,
disabled,
placeholder = "انتخاب تاریخ",
className,
min,
max,
}: DatePickerProps) {
const autoId = useId();
const inputId = id || autoId;
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const selected = value ? isoToJalaliParts(value) : null;
const initial = selected || isoToJalaliParts(todayIso());
const [view, setView] = useState({ year: initial.year, month: initial.month });
useEffect(() => {
if (!open) return;
const parts = value ? isoToJalaliParts(value) : isoToJalaliParts(todayIso());
setView({ year: parts.year, month: parts.month });
}, [open, value]);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) {
setOpen(false);
onBlur?.();
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
onBlur?.();
}
};
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey);
};
}, [open, onBlur]);
const cells = useMemo(() => {
const len = jalaliMonthLength(view.year, view.month);
const start = jalaliWeekdaySat0(view.year, view.month, 1);
const items: ({ day: number; iso: string } | null)[] = [];
for (let i = 0; i < start; i++) items.push(null);
for (let day = 1; day <= len; day++) {
const iso = jalaliPartsToIso({ year: view.year, month: view.month, day });
items.push({ day, iso });
}
return items;
}, [view.year, view.month]);
const minIso = min || undefined;
const maxIso = max || undefined;
const today = todayIso();
const pick = (iso: string) => {
if (minIso && iso < minIso) return;
if (maxIso && iso > maxIso) return;
onChange?.(iso);
setOpen(false);
onBlur?.();
};
const display = value ? formatJalali(value) : "";
return (
<div ref={rootRef} className={cn("relative", className)}>
<input type="hidden" name={name} value={value || ""} readOnly />
<button
type="button"
id={inputId}
disabled={disabled}
aria-haspopup="dialog"
aria-expanded={open}
aria-label="انتخاب تاریخ شمسی"
onClick={() => !disabled && setOpen((o) => !o)}
className={cn(
"flex h-10 w-full items-center justify-between gap-2 rounded-xl border border-[var(--border)] bg-[var(--surface)] px-3 text-sm text-secondary",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30",
"disabled:cursor-not-allowed disabled:opacity-50",
!display && "text-[var(--muted)]"
)}
>
<span className="font-medium tabular-nums" dir="ltr">
{display || placeholder}
</span>
<CalendarDays className="h-4 w-4 shrink-0 text-[var(--muted)]" />
</button>
{open ? (
<div
role="dialog"
aria-label="تقویم شمسی"
className="absolute z-50 mt-2 w-[288px] rounded-2xl border border-[var(--border)] bg-[var(--surface)] p-3 shadow-[var(--shadow-md)]"
>
<div className="mb-3 flex items-center justify-between gap-2">
<button
type="button"
className="rounded-lg p-1.5 hover:bg-[var(--surface-muted)]"
aria-label="ماه بعد"
onClick={() => setView((v) => shiftJalaliMonth(v.year, v.month, 1))}
>
<ChevronRight className="h-4 w-4" />
</button>
<div className="text-sm font-semibold text-secondary">
{MONTHS[view.month - 1]} {view.year}
</div>
<button
type="button"
className="rounded-lg p-1.5 hover:bg-[var(--surface-muted)]"
aria-label="ماه قبل"
onClick={() => setView((v) => shiftJalaliMonth(v.year, v.month, -1))}
>
<ChevronLeft className="h-4 w-4" />
</button>
</div>
<div className="mb-1 grid grid-cols-7 gap-1 text-center text-[11px] text-[var(--muted)]">
{WEEKDAYS.map((w) => (
<div key={w} className="py-1 font-medium">
{w}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{cells.map((cell, idx) => {
if (!cell) return <div key={`e-${idx}`} />;
const isSelected = value === cell.iso;
const isToday = cell.iso === today;
const disabledDay =
(minIso ? cell.iso < minIso : false) || (maxIso ? cell.iso > maxIso : false);
return (
<button
key={cell.iso}
type="button"
disabled={disabledDay}
onClick={() => pick(cell.iso)}
className={cn(
"flex h-9 items-center justify-center rounded-lg text-sm tabular-nums transition-colors",
isSelected && "bg-primary text-white shadow-sm",
!isSelected && isToday && "ring-1 ring-primary/40 text-primary",
!isSelected && !isToday && "hover:bg-[var(--surface-muted)] text-secondary",
disabledDay && "opacity-30 cursor-not-allowed hover:bg-transparent"
)}
>
{cell.day}
</button>
);
})}
</div>
<div className="mt-3 flex items-center justify-between border-t border-[var(--border)] pt-2">
<button
type="button"
className="text-xs font-medium text-primary hover:underline"
onClick={() => pick(today)}
>
امروز
</button>
<button
type="button"
className="text-xs text-[var(--muted)] hover:text-secondary"
onClick={() => {
setOpen(false);
onBlur?.();
}}
>
بستن
</button>
</div>
</div>
) : null}
</div>
);
}
/** Display-only Jalali date from Gregorian ISO. */
export function JalaliDateText({
value,
className,
withTime,
}: {
value?: string | Date | null;
className?: string;
withTime?: boolean;
}) {
return (
<span className={cn("tabular-nums", className)} dir="ltr">
{withTime ? formatJalaliDateTime(value) : formatJalali(value)}
</span>
);
}