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
@@ -0,0 +1,132 @@
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Package } from "lucide-react";
import type { Quote } from "../../lib/types";
import { useMaterials, useQuotes, useSuppliers, useUpdateQuote } from "../../hooks";
import { activeItems, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils";
import { fmtNum } from "../../lib/formatters";
import { groupCommas } from "../../lib/numutil";
import { Button } from "../../components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog";
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
export interface ArriveModalProps {
quoteId: string | null;
onClose: () => void;
}
// مودال رسید: تیک بزنید کدام قلم‌ها رسیده‌اند؛ تا همه نرسیده باشند quote پذیرفته می‌ماند
export function ArriveModal({ quoteId, onClose }: ArriveModalProps) {
const quotes = useQuotes();
const suppliers = useSuppliers();
const materials = useMaterials();
const updateQuote = useUpdateQuote();
const q = useMemo(() => quotes.find((x) => x.id === quoteId) || null, [quotes, quoteId]);
const [checked, setChecked] = useState<Set<string>>(new Set());
// تاریخ و زمان رسید — پیش‌فرض الان
const [arriveDate, setArriveDate] = useState<string | null>(() => new Date().toISOString());
const [ready, setReady] = useState(false);
useMemo(() => {
if (!q) return;
const set = new Set<string>();
activeItems(q).forEach((li) => {
if (li.arrived_date) set.add(li.id);
});
setChecked(set);
setReady(true);
}, [q]);
if (!q || !ready) return null;
const sup = supplierById(suppliers, q.supplier_id);
const items = activeItems(q);
const confirm = () => {
if (!checked.size) {
toast.error("حداقل یک قلم را برای رسید انتخاب کنید.");
return;
}
const next: Quote = JSON.parse(JSON.stringify(q));
const arrivedIso = arriveDate || new Date().toISOString();
const now = new Date().toISOString();
next.line_items.forEach((li) => {
li.arrived_date = checked.has(li.id) ? arrivedIso : null;
});
if (activeItems(next).every((li) => li.arrived_date)) {
next.status = "arrived";
next.arrived_date = arrivedIso;
next.events.push({ type: "arrived", occurred_at: now });
} else {
next.arrived_date = null;
}
void updateQuote.mutateAsync({ id: q.id, patch: next });
setReady(false);
onClose();
};
return (
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>ثبت رسید — {sup.name}</DialogTitle>
</DialogHeader>
<div className="text-[12px] text-muted">
کدام قلم‌ها رسیده‌اند؟ تیک بزنید و ثبت کنید. تا وقتی همه‌ی قلم‌ها نرسیده‌اند، پیشنهاد
«پذیرفته شده» می‌ماند.
</div>
<div className="flex flex-col gap-1.5">
{items.map((li) => {
const m = materialById(materials, li.material_id);
const specText = materialSpecs(m).join(" · ");
return (
<label key={li.id} className={`arrive-row${li.arrived_date ? " arrived" : ""}`}>
<input
type="checkbox"
className="accent-green"
checked={checked.has(li.id)}
onChange={(e) => {
const next = new Set(checked);
if (e.target.checked) next.add(li.id);
else next.delete(li.id);
setChecked(next);
}}
/>
<span>
<Package className="me-1 inline h-3.5 w-3.5 text-muted" />
{m.category} {m.brand}
{specText ? ` · ${specText}` : ""}
</span>
<span className="arrive-meta">
{fmtNum(li.quantity)} {li.unit || ""} × {groupCommas(li.unit_price, true)}
</span>
</label>
);
})}
</div>
{/* تاریخ و زمان رسید */}
<div className="field max-w-xs">
<label>تاریخ و زمان رسید</label>
<JalaliDatePicker value={arriveDate} onChange={setArriveDate} placeholder="تاریخ و ساعت" withTime />
</div>
<DialogFooter>
<Button variant="slate" onClick={onClose}>
انصراف
</Button>
<Button variant="green" onClick={confirm}>
ثبت رسید
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}