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>
241 lines
7.7 KiB
TypeScript
241 lines
7.7 KiB
TypeScript
"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>
|
||
);
|
||
}
|