feat(frontend): add React UI — procurement office, wizard, suppliers, inventory panel (dash port), role-based login

This commit is contained in:
ali_mohaghegh
2026-08-25 19:08:15 +03:30
parent 170aed66ea
commit 4e105c5cd5
103 changed files with 16352 additions and 0 deletions
+186
View File
@@ -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>
);
}
+21
View File
@@ -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>
);
}
+73
View File
@@ -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>
);
}
+32
View File
@@ -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>
);
}
+34
View File
@@ -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>
);
}
+98
View File
@@ -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>
);
}
+76
View File
@@ -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>
);
}
+92
View File
@@ -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>
);
}
+38
View File
@@ -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>
);
}
+101
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}
+68
View File
@@ -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>
);
}
+33
View File
@@ -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 };
+47
View File
@@ -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 };
+25
View File
@@ -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 };
+95
View File
@@ -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,
};
+21
View File
@@ -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 };
+20
View File
@@ -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 };
+146
View File
@@ -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,
};
+63
View File
@@ -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 };
+21
View File
@@ -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;
}