feat(frontend): add React UI — procurement office, wizard, suppliers, inventory panel (dash port), role-based login
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { normalize } from "../lib/normalize";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useClickOutside } from "./useClickOutside";
|
||||
|
||||
export interface ComboboxItem {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// منبع آیتمها: رشته، {id,name} یا {id,label}
|
||||
export type ComboboxSourceItem = string | { id: string; name?: string; label?: string };
|
||||
export type ComboboxSource = ComboboxSourceItem[] | (() => ComboboxSourceItem[]);
|
||||
|
||||
export interface ComboboxProps {
|
||||
value?: string;
|
||||
onChange?: (item: ComboboxItem) => void;
|
||||
items: ComboboxSource; // آرایه از string یا {id, name}
|
||||
allowAdd?: boolean; // دکمهی "+ افزودن به عنوان جدید"
|
||||
addSuffix?: string; // متن بعد از +
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
inputClassName?: string;
|
||||
onAdd?: (val: string) => void;
|
||||
onInputChange?: (val: string) => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
function toItem(x: ComboboxSourceItem): ComboboxItem {
|
||||
if (x == null) return { id: "", label: "" };
|
||||
if (typeof x === "string") return { id: x, label: x };
|
||||
const label = String(x.name != null ? x.name : x.label != null ? x.label : x.id ?? "");
|
||||
return { id: String(x.id ?? label), label };
|
||||
}
|
||||
|
||||
// جستجو-و-انتخاب با تایپ: فیلتر با normalize، ناوبری با کلیدها،
|
||||
// گزینهی "+ افزودن به عنوان جدید" وقتی متنی با هیچ آیتمی match نمیشود
|
||||
export function Combobox({
|
||||
value,
|
||||
onChange,
|
||||
items,
|
||||
allowAdd = true,
|
||||
addSuffix = "به عنوان گزینه جدید",
|
||||
placeholder,
|
||||
className,
|
||||
disabled,
|
||||
inputClassName,
|
||||
onAdd,
|
||||
onInputChange,
|
||||
onBlur,
|
||||
}: ComboboxProps) {
|
||||
const [text, setText] = useState(value ?? "");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlight, setHighlight] = useState(-1);
|
||||
|
||||
useEffect(() => {
|
||||
setText(value ?? "");
|
||||
}, [value]);
|
||||
|
||||
const all = useMemo<ComboboxItem[]>(() => {
|
||||
const src = typeof items === "function" ? items() : items;
|
||||
return src.map(toItem);
|
||||
}, [items]);
|
||||
|
||||
const q = useMemo(() => normalize(text), [text]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!q) return all;
|
||||
return all.filter((x) => normalize(x.label).includes(q));
|
||||
}, [all, q]);
|
||||
|
||||
const showAdd =
|
||||
allowAdd && q.length > 0 && !filtered.some((x) => normalize(x.label) === q);
|
||||
|
||||
const listCount = filtered.length + (showAdd ? 1 : 0);
|
||||
|
||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||||
|
||||
const pick = (it: ComboboxItem) => {
|
||||
setText(it.label);
|
||||
setOpen(false);
|
||||
setHighlight(-1);
|
||||
onChange?.(it);
|
||||
onInputChange?.(it.label);
|
||||
};
|
||||
|
||||
const addCurrent = () => {
|
||||
const val = text.trim();
|
||||
if (!val) return;
|
||||
setOpen(false);
|
||||
setHighlight(-1);
|
||||
onAdd?.(val);
|
||||
onInputChange?.(val);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!open) {
|
||||
if (e.key === "ArrowDown" || e.key === "Enter") setOpen(true);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlight((h) => (h + 1) % Math.max(listCount, 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (highlight >= 0 && highlight < filtered.length) {
|
||||
pick(filtered[highlight]);
|
||||
} else if (highlight === filtered.length && showAdd) {
|
||||
addCurrent();
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("relative", className)}>
|
||||
<input
|
||||
type="text"
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
inputClassName,
|
||||
)}
|
||||
value={text}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
setHighlight(-1);
|
||||
onInputChange?.(e.target.value);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{open && (
|
||||
<div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg">
|
||||
{listCount === 0 && (
|
||||
<div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div>
|
||||
)}
|
||||
{filtered.map((it, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={it.id + "|" + i}
|
||||
className={cn(
|
||||
"block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg",
|
||||
i === highlight && "bg-slate-bg",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // حفظ فوکوس input
|
||||
pick(it);
|
||||
}}
|
||||
onMouseEnter={() => setHighlight(i)}
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
))}
|
||||
{showAdd && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-1 border-t border-line px-3 py-1.5 text-start text-[13px] text-accent hover:bg-slate-bg",
|
||||
highlight === filtered.length && "bg-slate-bg",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
addCurrent();
|
||||
}}
|
||||
onMouseEnter={() => setHighlight(filtered.length)}
|
||||
>
|
||||
<span className="font-bold">+</span>
|
||||
<span>
|
||||
«<b>{text.trim()}</b>» {addSuffix}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
|
||||
export interface ConfirmDeleteDialogProps {
|
||||
open: boolean;
|
||||
itemName: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// پاپآپ تأیید حذف چیپ — متن دقیق طبق درخواست کاربر
|
||||
// دکمهی قرمز (حذف) سمت راست، دکمهی آبی (انصراف) سمت چپ
|
||||
export function ConfirmDeleteDialog({ open, itemName, onConfirm, onCancel }: ConfirmDeleteDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onCancel()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[16px] font-bold">آیا از حذف مورد زیر مطمئن هستید؟</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="rounded-lg border border-line bg-slate-bg px-3 py-2.5 text-center text-[13.5px] font-semibold">
|
||||
{itemName}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-red px-4 py-2 text-sm font-medium text-white transition-colors hover:brightness-95"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
می خواهم حذف کنم
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-[#1d4ed8] px-4 py-2 text-sm font-medium text-white transition-colors hover:brightness-95"
|
||||
onClick={onCancel}
|
||||
>
|
||||
تغییری نمی دهم
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export interface EmptyStateProps {
|
||||
icon?: LucideIcon;
|
||||
emoji?: string; // سازگاری با استفادههای قبلی
|
||||
text: string;
|
||||
}
|
||||
|
||||
// حالت خالی
|
||||
export function EmptyState({ icon: Icon, emoji, text }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="empty">
|
||||
{Icon ? (
|
||||
<Icon className="mx-auto mb-2 h-10 w-10 text-muted/60" strokeWidth={1.5} />
|
||||
) : (
|
||||
emoji && <div className="big">{emoji}</div>
|
||||
)}
|
||||
<div>{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { Attachment } from "../lib/types";
|
||||
|
||||
export interface FileListProps {
|
||||
files: Attachment[];
|
||||
onChange: (files: Attachment[]) => void;
|
||||
// آپلود واقعی هر فایل به سرور (باید Attachment با id برگرداند)
|
||||
upload: (file: File) => Promise<Attachment>;
|
||||
// حذف سمت سرور (اختیاری — بعد از حذف محلی صدا زده میشود)
|
||||
onDelete?: (a: Attachment) => void;
|
||||
// لینک دانلود؛ null یعنی هنوز آپلود نشده
|
||||
urlOf?: (a: Attachment) => string | null;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
// مدیریت چند فاکتور: آپلود مستقیم به سرور (MinIO) + دانلود/حذف فایلهای ذخیرهشده
|
||||
export function FileList({ files, onChange, upload, onDelete, urlOf, label = "افزودن فاکتور" }: FileListProps) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const remove = (i: number) => {
|
||||
const removed = files[i];
|
||||
onChange(files.filter((_, idx) => idx !== i));
|
||||
if (removed) onDelete?.(removed);
|
||||
};
|
||||
|
||||
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const picked = Array.from(e.target.files || []);
|
||||
e.target.value = ""; // اجازهی انتخاب دوبارهی همان فایل
|
||||
if (!picked.length) return;
|
||||
const added: Attachment[] = [];
|
||||
for (const file of picked) {
|
||||
try {
|
||||
added.push(await upload(file));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : `آپلود «${file.name}» ناموفق بود`);
|
||||
}
|
||||
}
|
||||
if (added.length) onChange([...files, ...added]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!files.length && <div className="inv-empty">هنوز فایلی انتخاب نشده است.</div>}
|
||||
{files.map((inv, i) => (
|
||||
<div key={i} className="inv-row">
|
||||
<span className="inv-name">📎 {inv.name}</span>
|
||||
{urlOf?.(inv) && (
|
||||
<a className="inv-open" href={urlOf(inv)!} download={inv.name} title="دانلود">
|
||||
↓
|
||||
</a>
|
||||
)}
|
||||
<button type="button" className="inv-x" title="حذف" onClick={() => remove(i)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*,.pdf,.png,.jpg,.jpeg"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<button type="button" className="btn-line-file" onClick={() => inputRef.current?.click()}>
|
||||
+ {label}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
export interface FileUploadProps {
|
||||
onUpload: (file: File) => void;
|
||||
accept?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
// انتخاب تکفایل — آپلود واقعی به سرور (MinIO) توسط والد انجام میشود
|
||||
export function FileUpload({
|
||||
onUpload,
|
||||
accept = "image/*,.pdf,.png,.jpg,.jpeg",
|
||||
label = "انتخاب فایل",
|
||||
}: FileUploadProps) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
onUpload(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input ref={inputRef} type="file" accept={accept} className="hidden" onChange={handleChange} />
|
||||
<button type="button" className="btn-line-file" onClick={() => inputRef.current?.click()}>
|
||||
+ {label}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
daysInMonth,
|
||||
gregorianToJalali,
|
||||
isoToJalaliStr,
|
||||
jalaliStrToIso,
|
||||
jalaliToGregorian,
|
||||
MONTHS,
|
||||
toFa,
|
||||
WEEKDAYS,
|
||||
} from "../lib/jalali";
|
||||
import { faNum } from "../lib/formatters";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useClickOutside } from "./useClickOutside";
|
||||
|
||||
export interface JalaliDatePickerProps {
|
||||
value: string | null; // ISO date (با time اختیاری)
|
||||
onChange: (iso: string | null) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید)
|
||||
}
|
||||
|
||||
function todayJalali(): [number, number, number] {
|
||||
const now = new Date();
|
||||
return gregorianToJalali(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||
}
|
||||
|
||||
function isoToJalali(iso: string | null): [number, number, number] | null {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||||
}
|
||||
|
||||
// ساخت ISO با زمان محلی (تاریخ شمسی + ساعت:دقیقه)
|
||||
function buildIso(jy: number, jm: number, jd: number, h: number, m: number): string {
|
||||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||||
return new Date(gy, gm - 1, gd, h, m).toISOString();
|
||||
}
|
||||
|
||||
function timeOf(iso: string | null): { h: number; m: number } {
|
||||
if (!iso) return { h: 0, m: 0 };
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return { h: 0, m: 0 };
|
||||
return { h: d.getHours(), m: d.getMinutes() };
|
||||
}
|
||||
|
||||
// تقویم شمسی — پورت Jalali.buildPicker به React (با پشتیبانی ساعت:دقیقه)
|
||||
export function JalaliDatePicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
withTime = false,
|
||||
}: JalaliDatePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
|
||||
const [text, setText] = useState<string>(() => {
|
||||
if (!value) return "";
|
||||
const j = isoToJalaliStr(value);
|
||||
if (!withTime) return j;
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return j;
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${j} ${faNum(hh + ":" + mm)}`;
|
||||
});
|
||||
|
||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||||
|
||||
// تاریخ انتخابشده به صورت محلی (نه UTC) برای مقایسه با سلولهای تقویم
|
||||
function selectedLocalDate(): string | null {
|
||||
if (!value) return null;
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
const selectedIso = selectedLocalDate();
|
||||
const time = timeOf(value);
|
||||
|
||||
const grid = useMemo(() => {
|
||||
const [jy, jm] = curJ;
|
||||
const firstG = jalaliToGregorian(jy, jm, 1);
|
||||
const firstWd = (new Date(firstG[0], firstG[1] - 1, firstG[2]).getDay() + 1) % 7; // شنبه = 0
|
||||
const dim = daysInMonth(jy, jm);
|
||||
const today = new Date();
|
||||
const tIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
const cells: { d: number; iso: string; isToday: boolean }[] = [];
|
||||
for (let i = 0; i < firstWd; i++) cells.push({ d: 0, iso: "", isToday: false });
|
||||
for (let d = 1; d <= dim; d++) {
|
||||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, d);
|
||||
const iso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||
cells.push({ d, iso, isToday: iso === tIso });
|
||||
}
|
||||
return cells;
|
||||
}, [curJ]);
|
||||
|
||||
const nav = (dir: string) => {
|
||||
setCurJ(([jy, jm]) => {
|
||||
let ny = jy;
|
||||
let nm = jm;
|
||||
if (dir === "m-1") nm = nm === 12 ? 1 : nm + 1;
|
||||
if (dir === "m+1") nm = nm === 1 ? 12 : nm - 1;
|
||||
if (dir === "y-1") ny += 1;
|
||||
if (dir === "y+1") ny -= 1;
|
||||
if (nm < 1) {
|
||||
nm = 12;
|
||||
ny -= 1;
|
||||
}
|
||||
if (nm > 12) {
|
||||
nm = 1;
|
||||
ny += 1;
|
||||
}
|
||||
return [ny, nm, 1];
|
||||
});
|
||||
};
|
||||
|
||||
const selectDay = (jy: number, jm: number, jd: number) => {
|
||||
const isoWithTime = buildIso(jy, jm, jd, time.h, time.m);
|
||||
onChange(isoWithTime);
|
||||
setText(`${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}${withTime ? " " + faNum(String(time.h).padStart(2, "0") + ":" + String(time.m).padStart(2, "0")) : ""}`);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const setTimePart = (part: "h" | "m", raw: string) => {
|
||||
const n = parseInt(raw.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))), 10);
|
||||
if (Number.isNaN(n)) return;
|
||||
const h = part === "h" ? Math.min(23, Math.max(0, n)) : time.h;
|
||||
const m = part === "m" ? Math.min(59, Math.max(0, n)) : time.m;
|
||||
const [jy, jm, jd] = curJ;
|
||||
onChange(buildIso(jy, jm, jd, h, m));
|
||||
// بهروزرسانی نمایش
|
||||
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, h, m));
|
||||
setText(`${j} ${faNum(String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"))}`);
|
||||
};
|
||||
|
||||
const pickToday = () => {
|
||||
const now = new Date();
|
||||
const [jy, jm, jd] = todayJalali();
|
||||
// بدون withTime فقط تاریخ امروز ثبت میشود — ساعت همیشه نیمهشب
|
||||
const isoWithTime = withTime
|
||||
? buildIso(jy, jm, jd, now.getHours(), now.getMinutes())
|
||||
: buildIso(jy, jm, jd, 0, 0);
|
||||
onChange(isoWithTime);
|
||||
setText(
|
||||
withTime
|
||||
? `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${faNum(String(now.getHours()).padStart(2, "0") + ":" + String(now.getMinutes()).padStart(2, "0"))}`
|
||||
: `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`,
|
||||
);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
onChange(null);
|
||||
setText("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onInputChange = (v: string) => {
|
||||
setText(v);
|
||||
const iso = jalaliStrToIso(v);
|
||||
if (iso) {
|
||||
onChange(iso);
|
||||
const j = isoToJalali(iso);
|
||||
if (j) setCurJ([j[0], j[1], j[2]]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("relative", className)}>
|
||||
<input
|
||||
type="text"
|
||||
className="flex h-9 w-full rounded-md border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
value={text}
|
||||
placeholder={placeholder ?? "۱۴۰۳/۰۵/۱۲"}
|
||||
autoComplete="off"
|
||||
onFocus={() => setOpen(true)}
|
||||
onClick={() => setOpen(true)}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
/>
|
||||
{open && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-0 top-full z-30 mt-1 rounded-md border border-line bg-panel shadow-md",
|
||||
withTime ? "w-80 p-3" : "w-64 p-2",
|
||||
)}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center justify-between gap-1">
|
||||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}>
|
||||
«
|
||||
</button>
|
||||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("m+1")}>
|
||||
‹
|
||||
</button>
|
||||
<span className="text-[13px] font-bold">
|
||||
{MONTHS[curJ[1] - 1]} {toFa(curJ[0])}
|
||||
</span>
|
||||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("m-1")}>
|
||||
›
|
||||
</button>
|
||||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y-1")}>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-0.5 text-center">
|
||||
{WEEKDAYS.map((w) => (
|
||||
<div key={w} className="py-0.5 text-[10px] font-bold text-muted">
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
{grid.map((cell, i) =>
|
||||
cell.d === 0 ? (
|
||||
<div key={i} />
|
||||
) : (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className={cn(
|
||||
"cursor-pointer rounded py-0.5 text-[12px] hover:bg-slate-bg",
|
||||
cell.isToday && !selectedIso && "bg-blue-bg font-bold text-[#1d4ed8]",
|
||||
cell.isToday && selectedIso && "font-bold text-[#1d4ed8]",
|
||||
cell.iso === selectedIso && "bg-accent text-white hover:bg-accent-dark",
|
||||
)}
|
||||
onClick={() => selectDay(curJ[0], curJ[1], cell.d)}
|
||||
>
|
||||
{toFa(cell.d)}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
{withTime && (
|
||||
<div className="mt-2 flex items-center justify-center gap-4 border-t border-line pt-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
|
||||
<input
|
||||
type="text"
|
||||
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
value={faNum(String(time.h).padStart(2, "0"))}
|
||||
inputMode="numeric"
|
||||
maxLength={2}
|
||||
onChange={(e) => setTimePart("h", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted">:</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11.5px] font-semibold text-muted">دقیقه</span>
|
||||
<input
|
||||
type="text"
|
||||
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
value={faNum(String(time.m).padStart(2, "0"))}
|
||||
inputMode="numeric"
|
||||
maxLength={2}
|
||||
onChange={(e) => setTimePart("m", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex justify-between border-t border-line pt-2">
|
||||
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-accent hover:bg-slate-bg" onClick={pickToday}>
|
||||
امروز
|
||||
</button>
|
||||
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-muted hover:bg-slate-bg" onClick={clear}>
|
||||
پاک کردن
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Milestone } from "../lib/types";
|
||||
import { JalaliDatePicker } from "./JalaliDatePicker";
|
||||
import { NumberInput } from "./NumberInput";
|
||||
|
||||
export interface MilestoneRowProps {
|
||||
milestone: Milestone;
|
||||
onChange: (m: Milestone) => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
// یک ردیف مرحلهی پرداخت: تاریخ + مبلغ + دکمهی حذف
|
||||
export function MilestoneRow({ milestone, onChange, onRemove }: MilestoneRowProps) {
|
||||
return (
|
||||
<div className="milestone-row">
|
||||
<JalaliDatePicker
|
||||
value={milestone.when_iso}
|
||||
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
|
||||
placeholder="تاریخ"
|
||||
className="w-36 shrink-0"
|
||||
/>
|
||||
<div className="ms-amount-wrap">
|
||||
<NumberInput
|
||||
value={milestone.amount}
|
||||
onChange={(amount) => onChange({ ...milestone, amount })}
|
||||
placeholder="مبلغ (تومان)"
|
||||
showWords
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="ms-x" title="حذف" onClick={onRemove}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { normalize } from "../lib/normalize";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useClickOutside } from "./useClickOutside";
|
||||
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||
|
||||
export interface MultiSelectComboboxProps {
|
||||
values: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
options?: string[]; // گزینههای کششده (مشخصههای استفادهشده قبلی)
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
inputClassName?: string;
|
||||
allowCreate?: boolean; // اجازهی تایپ مقدار جدید (پیشفرض true)
|
||||
}
|
||||
|
||||
// چند-انتخابی خلاق (creatable multi-select) با چیپها:
|
||||
// - چیپهای قابل حذف با ×
|
||||
// - input بدون حاشیه بعد از آخرین چیپ (فوکوس خودکار)
|
||||
// - منوی autocomplete با فیلتر زنده + گزینهی «ایجاد '{query}'»
|
||||
// - Enter برای افزودن، Backspace برای حذف آخرین چیپ، جلوگیری از تکراری
|
||||
export function MultiSelectCombobox({
|
||||
values,
|
||||
onChange,
|
||||
options = [],
|
||||
placeholder,
|
||||
className,
|
||||
inputClassName,
|
||||
allowCreate = true,
|
||||
}: MultiSelectComboboxProps) {
|
||||
const [text, setText] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlight, setHighlight] = useState(-1);
|
||||
const [pendingRemove, setPendingRemove] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||||
|
||||
// گزینهها = کش + مقادیر انتخابشده (تا چیپهای قبلی در لیست هم دیده شوند)
|
||||
const all = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
options.forEach((o) => o && set.add(o));
|
||||
values.forEach((v) => v && set.add(v));
|
||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}, [options, values]);
|
||||
|
||||
const q = useMemo(() => normalize(text), [text]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!q) return all;
|
||||
return all.filter((x) => normalize(x).includes(q));
|
||||
}, [all, q]);
|
||||
|
||||
const exact = q.length > 0 && filtered.some((x) => normalize(x) === q);
|
||||
const showCreate = allowCreate && q.length > 0 && !exact;
|
||||
const listCount = filtered.length + (showCreate ? 1 : 0);
|
||||
|
||||
const add = (val: string) => {
|
||||
const v = val.trim();
|
||||
if (!v) return;
|
||||
if (values.some((x) => normalize(x) === normalize(v))) {
|
||||
setText("");
|
||||
setHighlight(-1);
|
||||
inputRef.current?.focus();
|
||||
return; // تکراری — اضافه نمیشود
|
||||
}
|
||||
onChange([...values, v]);
|
||||
setText("");
|
||||
setHighlight(-1);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const remove = (v: string) => {
|
||||
onChange(values.filter((x) => x !== v));
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (highlight >= 0 && highlight < filtered.length) {
|
||||
add(filtered[highlight]);
|
||||
} else if (text.trim()) {
|
||||
add(text.trim());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Backspace" && !text && values.length) {
|
||||
e.preventDefault();
|
||||
remove(values[values.length - 1]);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
setHighlight((h) => (h + 1) % Math.max(listCount, 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1));
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("relative", className)}>
|
||||
<div
|
||||
className="flex min-h-9 w-full flex-wrap items-center gap-1 rounded-lg border border-line bg-panel px-2 py-1 shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/40"
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{values.map((v) => (
|
||||
<span key={v} className="spec-chip">
|
||||
<span className="max-w-[140px] truncate">{v}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="spec-chip-x"
|
||||
title="حذف"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingRemove(v);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={cn(
|
||||
"min-w-[80px] flex-1 border-0 bg-transparent px-0.5 py-0.5 text-[12px] text-ink caret-accent outline-none placeholder:text-muted",
|
||||
inputClassName,
|
||||
)}
|
||||
value={text}
|
||||
placeholder={values.length ? "" : placeholder}
|
||||
autoComplete="off"
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
setHighlight(-1);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg">
|
||||
{listCount === 0 && (
|
||||
<div className="px-3 py-1.5 text-[12px] text-muted">
|
||||
{allowCreate && text.trim() ? (
|
||||
<span>
|
||||
برای افزودن «<b>{text.trim()}</b>» Enter بزنید
|
||||
</span>
|
||||
) : (
|
||||
"موردی یافت نشد"
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((it, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={it}
|
||||
className={cn(
|
||||
"block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg",
|
||||
i === highlight && "bg-slate-bg",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // حفظ فوکوس input
|
||||
add(it);
|
||||
}}
|
||||
onMouseEnter={() => setHighlight(i)}
|
||||
>
|
||||
{it}
|
||||
</button>
|
||||
))}
|
||||
{showCreate && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-1 border-t border-line px-3 py-1.5 text-start text-[13px] text-accent hover:bg-slate-bg",
|
||||
highlight === filtered.length && "bg-slate-bg",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
add(text.trim());
|
||||
}}
|
||||
onMouseEnter={() => setHighlight(filtered.length)}
|
||||
>
|
||||
<span className="font-bold">+</span>
|
||||
<span>
|
||||
افزودن «<b>{text.trim()}</b>»
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
open={pendingRemove !== null}
|
||||
itemName={pendingRemove || ""}
|
||||
onConfirm={() => {
|
||||
if (pendingRemove !== null) remove(pendingRemove);
|
||||
setPendingRemove(null);
|
||||
}}
|
||||
onCancel={() => setPendingRemove(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { groupCommas, parseNum, toWords } from "../lib/numutil";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export interface NumberInputProps {
|
||||
value: number | null;
|
||||
onChange: (n: number | null) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
showWords?: boolean; // نمایش معادل فارسی زیر input
|
||||
inputMode?: "numeric" | "decimal" | undefined;
|
||||
maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد)
|
||||
}
|
||||
|
||||
// ورودی عددی با فرمتدهی خودکار (groupCommas) و نمایش کلمات فارسی
|
||||
export function NumberInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
showWords = false,
|
||||
inputMode = "numeric",
|
||||
maxDecimals,
|
||||
}: NumberInputProps) {
|
||||
// فرمت با رعایت حداکثر اعشار (گرد کردن)
|
||||
const formatValue = (n: number): string => {
|
||||
let v = n;
|
||||
if (maxDecimals != null) {
|
||||
const f = Math.pow(10, maxDecimals);
|
||||
v = Math.round(n * f) / f;
|
||||
}
|
||||
return groupCommas(v, false);
|
||||
};
|
||||
|
||||
const [text, setText] = useState<string>(() => (value != null ? formatValue(value) : ""));
|
||||
|
||||
// همگامسازی وقتی value از بیرون عوض میشود (مثلاً محاسبهی خودکار قیمت کل).
|
||||
// null از بیرون متن کاربر را پاک نمیکند — تا تایپ میانی مثل «0.» از بین نرود.
|
||||
useEffect(() => {
|
||||
const cur = parseNum(text);
|
||||
if (value != null && value !== cur) {
|
||||
setText(formatValue(value));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
const commit = (raw: string) => {
|
||||
const n = parseNum(raw);
|
||||
if (n != null) {
|
||||
const formatted = formatValue(n);
|
||||
setText(formatted);
|
||||
onChange(n);
|
||||
} else {
|
||||
setText(raw);
|
||||
onChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
const n = parseNum(text);
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0", className)}>
|
||||
<input
|
||||
type="text"
|
||||
className="num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
value={text}
|
||||
placeholder={placeholder}
|
||||
inputMode={inputMode}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
// فرمتدهی خودکار هنگام تایپ — اما جداکنندهی اعشارِ وسط تایپ
|
||||
// («0.» یا «1.») دستنخورده میماند تا کاربر بتواند اعشار بزند
|
||||
const parsed = parseNum(raw);
|
||||
const trailingSep = /[.,،/٬٫]$/.test(raw);
|
||||
if (parsed != null && !trailingSep) {
|
||||
setText(formatValue(parsed));
|
||||
onChange(parsed);
|
||||
} else {
|
||||
setText(raw);
|
||||
onChange(parsed);
|
||||
}
|
||||
}}
|
||||
onBlur={() => commit(text)}
|
||||
/>
|
||||
{showWords && (
|
||||
<span className={cn("num-pill", n == null && "empty")}>
|
||||
{n != null ? (
|
||||
<>
|
||||
{groupCommas(n, true)} <span className="num-words">({toWords(n)})</span>
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export interface MenuItem {
|
||||
label?: string;
|
||||
to?: string;
|
||||
active?: boolean;
|
||||
divider?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
// منوی همبرگری — بالای راست صفحات؛ نمایش پنل فعلی + ناوبری بین پنلها
|
||||
export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border border-line bg-panel text-ink hover:bg-slate-bg"
|
||||
aria-label="منو"
|
||||
title="منو"
|
||||
>
|
||||
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute top-11 right-0 z-50 min-w-56 rounded-xl border border-line bg-panel p-1.5 shadow-lg">
|
||||
{title && <div className="px-3 py-1.5 text-[11px] font-bold text-muted">{title}</div>}
|
||||
{items.map((it, i) =>
|
||||
it.divider ? (
|
||||
<div key={i} className="my-1 border-t border-line" />
|
||||
) : it.to ? (
|
||||
<Link
|
||||
key={i}
|
||||
to={it.to}
|
||||
onClick={() => setOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-[13px] no-underline",
|
||||
it.active ? "bg-accent font-bold text-white" : "text-ink hover:bg-slate-bg",
|
||||
)}
|
||||
>
|
||||
{it.label}
|
||||
{it.active && <span className="text-[10px] opacity-80">✓ فعلی</span>}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
it.onClick?.();
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-start text-[13px] text-ink hover:bg-slate-bg"
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState } from "react";
|
||||
import { normalize } from "../lib/normalize";
|
||||
import { faNum } from "../lib/formatters";
|
||||
import { useClickOutside } from "./useClickOutside";
|
||||
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||
|
||||
export interface SpecDrawerProps {
|
||||
specs: string[];
|
||||
onChange: (specs: string[]) => void;
|
||||
}
|
||||
|
||||
// کشوی چیپ مشخصههای فنی: دکمهی «نوع (N)» → کشو با چیپها + input برای افزودن
|
||||
export function SpecDrawer({ specs, onChange }: SpecDrawerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [pendingRemove, setPendingRemove] = useState<string | null>(null);
|
||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||||
|
||||
const addChip = (val?: string) => {
|
||||
const v = String(val ?? draft).trim();
|
||||
if (!v) return;
|
||||
if (specs.some((s) => normalize(s) === normalize(v))) return;
|
||||
onChange([...specs, v]);
|
||||
setDraft("");
|
||||
};
|
||||
|
||||
const removeChip = (v: string) => {
|
||||
onChange(specs.filter((s) => s !== v));
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className="spec-cell">
|
||||
<button
|
||||
type="button"
|
||||
className="spec-trigger"
|
||||
title="مشخصههای فنی"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className={`spec-trigger-label${specs.length ? "" : " muted"}`}>
|
||||
{specs.length ? `مشخصه (${faNum(specs.length)})` : "مشخصه"}
|
||||
</span>
|
||||
<span className="text-muted">▾</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="spec-drawer open">
|
||||
<div className="spec-chips">
|
||||
{specs.map((s) => (
|
||||
<span key={s} className="spec-chip">
|
||||
<span className="spec-chip-t">{s}</span>
|
||||
<button type="button" className="spec-chip-x" title="حذف" onClick={() => setPendingRemove(s)}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="spec-add-row">
|
||||
<input
|
||||
type="text"
|
||||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-[12px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="مشخصه را تایپ کنید..."
|
||||
autoComplete="off"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addChip();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="spec-add-btn" title="افزودن مشخصه" onClick={() => addChip()}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDeleteDialog
|
||||
open={pendingRemove !== null}
|
||||
itemName={pendingRemove || ""}
|
||||
onConfirm={() => {
|
||||
if (pendingRemove !== null) removeChip(pendingRemove);
|
||||
setPendingRemove(null);
|
||||
}}
|
||||
onCancel={() => setPendingRemove(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ClipboardList, Inbox, CheckCircle2, Timer, type LucideIcon } from "lucide-react";
|
||||
import { fmtNum } from "../lib/formatters";
|
||||
|
||||
export interface StatsBarProps {
|
||||
total: number;
|
||||
offered: number;
|
||||
accepted: number;
|
||||
avgLeadTime: number | null; // روز
|
||||
}
|
||||
|
||||
// نوار ۴ ستونهی آمار
|
||||
export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProps) {
|
||||
const stats: { label: string; value: string; color: string; icon: LucideIcon }[] = [
|
||||
{ label: "کل پیشنهادها", value: fmtNum(total), color: "text-ink", icon: ClipboardList },
|
||||
// پیشنهاد باز = نارنجی، پذیرفتهشده = آبی، رسیده = سبز
|
||||
{ label: "پیشنهاد باز", value: fmtNum(offered), color: "text-amber", icon: Inbox },
|
||||
{ label: "پذیرفتهشده", value: fmtNum(accepted), color: "text-[#1d4ed8]", icon: CheckCircle2 },
|
||||
{ label: "میانگین lead time (روز)", value: avgLeadTime == null ? "—" : fmtNum(avgLeadTime), color: "text-green", icon: Timer },
|
||||
];
|
||||
return (
|
||||
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{stats.map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="rounded-lg border border-line bg-panel p-3.5 transition-colors hover:border-accent/40"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[11px] font-medium text-muted">{s.label}</div>
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-slate-bg text-muted">
|
||||
<s.icon className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
</span>
|
||||
</div>
|
||||
<div className={`mt-1 text-[22px] font-extrabold leading-8 tabular-nums ${s.color}`}>{s.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Milestone } from "../lib/types";
|
||||
import { faNum } from "../lib/formatters";
|
||||
import { MilestoneRow } from "./MilestoneRow";
|
||||
|
||||
export interface TermsheetItem {
|
||||
tempId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface TermsheetCardProps {
|
||||
index: number; // 1-based → «گزینه N»
|
||||
milestones: Milestone[];
|
||||
onMilestonesChange: (ms: Milestone[]) => void;
|
||||
// اقلام قابل تیک (مرحله ۲)
|
||||
items?: TermsheetItem[];
|
||||
coveredIds?: string[]; // tempIdهایی که تیک دارند
|
||||
onCoverageChange?: (ids: string[]) => void;
|
||||
onRemove?: () => void;
|
||||
defaultAll?: boolean; // وقتی هیچ تیکی نیست، پیشفرض همه تیک بخورند
|
||||
removable?: boolean;
|
||||
}
|
||||
|
||||
// کارت ترمشیت — هم در ویزارد (مرحله ۳) هم در مودال پذیرش/ویرایش
|
||||
export function TermsheetCard({
|
||||
index,
|
||||
milestones,
|
||||
onMilestonesChange,
|
||||
items = [],
|
||||
coveredIds = [],
|
||||
onCoverageChange,
|
||||
onRemove,
|
||||
defaultAll = false,
|
||||
removable = true,
|
||||
}: TermsheetCardProps) {
|
||||
const covered = new Set(coveredIds);
|
||||
// پیشفرض: وقتی هیچ قلمی تیک نخورده و defaultAll فعال است → همه تیک
|
||||
const effectiveCovered = covered.size === 0 && defaultAll ? items.map((i) => i.tempId) : coveredIds;
|
||||
|
||||
const toggle = (tempId: string, checked: boolean) => {
|
||||
if (!onCoverageChange) return;
|
||||
const next = new Set(covered);
|
||||
if (checked) next.add(tempId);
|
||||
else next.delete(tempId);
|
||||
onCoverageChange([...next]);
|
||||
};
|
||||
|
||||
const addMilestone = () => {
|
||||
onMilestonesChange([...milestones, { when_iso: null, amount: null }]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="variant-card">
|
||||
<div className="variant-head">
|
||||
<span className="variant-num">گزینه {faNum(index)}</span>
|
||||
{removable && onRemove && (
|
||||
<button type="button" className="variant-x" title="حذف" onClick={onRemove}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-1.5 text-[12px] text-muted">مراحل پرداخت:</div>
|
||||
<div className="mb-1 flex flex-col gap-1.5">
|
||||
{milestones.map((m, i) => (
|
||||
<MilestoneRow
|
||||
key={i}
|
||||
milestone={m}
|
||||
onChange={(next) => onMilestonesChange(milestones.map((x, j) => (j === i ? next : x)))}
|
||||
onRemove={() => onMilestonesChange(milestones.filter((_, j) => j !== i))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="btn-line-file mt-1" onClick={addMilestone}>
|
||||
+ مرحله پرداخت
|
||||
</button>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="ts-items-box">
|
||||
<div className="ts-items-title">این termsheet کدام اقلام (مرحله ۲) را پوشش میدهد؟</div>
|
||||
{items.length === 0 ? (
|
||||
<div className="ts-items-empty">هنوز قلمی در مرحلهی ۲ اضافه نشده.</div>
|
||||
) : (
|
||||
<div>
|
||||
{items.map((it) => (
|
||||
<label key={it.tempId} className="ts-item-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="ts-item-cb"
|
||||
data-item-temp-id={it.tempId}
|
||||
checked={effectiveCovered.includes(it.tempId)}
|
||||
onChange={(e) => toggle(it.tempId, e.target.checked)}
|
||||
/>
|
||||
{it.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { TOPICS } from "../lib/constants";
|
||||
import { normalize } from "../lib/normalize";
|
||||
import { faNum } from "../lib/formatters";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export interface TopicGridProps {
|
||||
selectedTopic?: string;
|
||||
onSelect: (topicKey: string) => void;
|
||||
showCounts?: boolean; // تعداد تأمینکنندههای هر تاپیک
|
||||
counts?: Record<string, number>; // topic name → count
|
||||
}
|
||||
|
||||
// گرید ۴ ستونهی ۸ تاپیک
|
||||
export function TopicGrid({ selectedTopic, onSelect, showCounts = false, counts }: TopicGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{TOPICS.map((t) => {
|
||||
const selected = selectedTopic != null && normalize(selectedTopic) === normalize(t.name);
|
||||
const count = counts?.[t.name] ?? 0;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={t.key}
|
||||
className={cn("topic-card", selected && "selected")}
|
||||
onClick={() => onSelect(t.name)}
|
||||
>
|
||||
<img src={t.icon} alt="" />
|
||||
<div className="t-name">{t.name}</div>
|
||||
{showCounts && <div className="t-count">{faNum(count)} تأمینکننده</div>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { ChevronRight, ClipboardList } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface HeaderProps {
|
||||
title: string;
|
||||
sub?: string;
|
||||
backTo?: string; // مسیر بازگشتِ جایگزین (وقتی سابقهای در برنامه نیست)
|
||||
backLabel?: string;
|
||||
actions?: ReactNode; // دکمههای سمت چپ
|
||||
brand?: boolean; // نمایش آیکون دفتر کنار عنوان
|
||||
menu?: ReactNode; // منوی همبرگری بالای راست (ناوبری بین پنلها)
|
||||
}
|
||||
|
||||
// نوار بالای هر صفحه: منو (همبرگری) + دکمهی برگشت (یک مرحلهی قبل) + عنوان + اکشنها
|
||||
export function Header({ title, sub, backTo, backLabel = "بازگشت", actions, brand, menu }: HeaderProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// اگر کاربر از داخل برنامه به این صفحه آمده، یک قدم برمیگردیم؛
|
||||
// اگر مستقیم باز شده (بدون سابقهی داخلی)، به backTo میرویم.
|
||||
const goBack = () => {
|
||||
if (location.key !== "default") navigate(-1);
|
||||
else if (backTo) navigate(backTo);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex flex-wrap items-center gap-3 border-b border-line bg-panel px-5 py-2.5">
|
||||
{menu && <div className="flex shrink-0 items-center">{menu}</div>}
|
||||
{backTo && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
className="inline-flex cursor-pointer items-center gap-0.5 rounded-md px-2 py-1 text-[13px] text-muted transition-colors hover:bg-slate-bg hover:text-ink"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
{backLabel}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
{brand && (
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-accent">
|
||||
<ClipboardList className="h-4.5 w-4.5" strokeWidth={2} />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-[17px] font-extrabold leading-6">{title}</h1>
|
||||
{sub && <div className="text-[11px] text-muted">{sub}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ms-auto flex flex-wrap items-center gap-2">{actions}</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// بدنهی صفحه با عرض محدود و چینش مرکزی
|
||||
export function PageLayout({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("mx-auto w-full max-w-[1200px] px-4 py-4", className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-accent text-white",
|
||||
// پیشنهاد شده = نارنجی، پذیرفته شده = آبی، رسیده = سبز
|
||||
offered: "border-transparent bg-amber-bg text-amber",
|
||||
accepted: "border-transparent bg-blue-bg text-[#1d4ed8]",
|
||||
arrived: "border-transparent bg-green-bg text-green",
|
||||
outline: "border-line text-muted",
|
||||
muted: "border-transparent bg-slate-bg text-muted",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-accent text-white shadow-sm hover:bg-accent-dark",
|
||||
primary: "bg-accent text-white shadow-sm hover:bg-accent-dark",
|
||||
slate: "bg-slate-bg text-ink hover:bg-line",
|
||||
line: "border border-line bg-panel text-ink hover:bg-slate-bg",
|
||||
ghost: "hover:bg-slate-bg text-ink",
|
||||
green: "bg-green text-white hover:brightness-95",
|
||||
danger: "bg-red-bg text-red hover:bg-red hover:text-white",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-line bg-panel shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-accent data-[state=checked]:bg-accent data-[state=checked]:text-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
||||
<Check className="h-3 w-3" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
@@ -0,0 +1,95 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 z-50 grid max-h-[90vh] w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-xl border border-line bg-panel p-6 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-accent/40">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">بستن</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-col space-y-1.5 text-start", className)} {...props} />;
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-bold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none text-ink peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,146 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-lg border border-line bg-panel px-3 py-2 text-sm text-ink shadow-sm focus:outline-none focus:ring-2 focus:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-line bg-panel text-ink shadow-md data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
position === "popper" && "data-[side=bottom]:translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 ps-2 pe-8 text-sm outline-none focus:bg-slate-bg data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute end-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-line", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
);
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn("border-b border-line transition-colors hover:bg-slate-bg/50", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-9 px-3 text-start align-middle text-[11px] font-semibold whitespace-nowrap text-muted/90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn("p-3 align-middle", className)} {...props} />
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm text-ink shadow-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
// صدا زدن callback هنگام کلیک بیرون از عنصر
|
||||
export function useClickOutside<T extends HTMLElement>(onOutside: () => void): React.RefObject<T | null> {
|
||||
const ref = useRef<T | null>(null);
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onOutside();
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [onOutside]);
|
||||
return ref;
|
||||
}
|
||||
Reference in New Issue
Block a user