Files
ERP/frontend/src/pages/Inventory/DailyControlPage.tsx
T

231 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth";
import { PanelMenu } from "../../components/PanelMenu";
import { accountMenuItems, useLogout } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav";
import { useInventoryData, unitName, whName } from "./useInventoryData";
import { faNum } from "../../lib/formatters";
import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali";
import type { InventoryTransaction } from "../../lib/types";
function gregKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function todayKey(): string {
return gregKey(new Date());
}
// کنترل روزانه — پورت کامل تقویم شمسی نسخه‌ی ارسالی
export function DailyControlPage() {
const location = useLocation();
const logout = useLogout();
const user = useAuthStore((s) => s.user);
const data = useInventoryData();
const navigate = useNavigate();
const isPM = user?.role === "pm";
const [anchor, setAnchor] = useState<Date>(new Date());
const [sel, setSel] = useState<string>(todayKey());
const [actRec, setActRec] = useState<string | null>(null);
// فیلتر پروژه — فقط برای مدیر تدارکات/ادمین (مدیر پروژه به پروژه خودش محدود است)
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
const [projFilter, setProjFilter] = useState("");
const txByDay = useMemo(() => {
const map = new Map<string, InventoryTransaction[]>();
const projWhIds = projFilter
? new Set(data.warehouses.filter((w) => String(w.project_id) === String(projFilter)).map((w) => w.id))
: null;
data.transactions.forEach((t) => {
if (projWhIds && !projWhIds.has(t.warehouse_id)) return;
const k = gregKey(new Date(t.transaction_date));
const arr = map.get(k) || [];
arr.push(t);
map.set(k, arr);
});
map.forEach((arr) => arr.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()));
return map;
}, [data.transactions, data.warehouses, projFilter]);
// cells: 42 روز از شنبه قبلِ اولین روز ماه جلالی
const cells = useMemo(() => {
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
let first: Date | null = null;
for (let i = 0; i <= 33; i++) {
const d = new Date(anchor.getTime() - i * 86400000);
const [py, pm] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
if (py !== ty || pm !== tm) {
first = new Date(anchor.getTime() - (i - 1) * 86400000);
break;
}
}
if (!first) first = new Date(anchor.getTime() - 33 * 86400000);
const offset = (first.getDay() + 1) % 7; // شنبه = 0
const gridStart = new Date(first.getTime() - offset * 86400000);
const out: { d: Date; inMonth: boolean; isToday: boolean; n: number }[] = [];
for (let c = 0; c < 42; c++) {
const day = new Date(gridStart.getTime() + c * 86400000);
const [jy, jm] = gregorianToJalali(day.getFullYear(), day.getMonth() + 1, day.getDate());
const k = gregKey(day);
out.push({
d: day,
inMonth: jy === ty && jm === tm,
isToday: k === todayKey(),
n: (txByDay.get(k) || []).length,
});
}
return out;
}, [anchor, txByDay]);
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
const monthTitle = `${toFa(ty)} ${MONTHS[tm - 1]}`;
const dayTx = txByDay.get(sel) || [];
const withdrawals = dayTx.filter((t) => t.transaction_type === "WITHDRAWAL");
const wGroups = useMemo(() => {
const gmap = new Map<string, { recId: string; q: number; last: string; tx: InventoryTransaction }>();
withdrawals.forEach((t) => {
const g = gmap.get(t.inventory_id);
if (!g) {
gmap.set(t.inventory_id, { recId: t.inventory_id, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t });
} else {
g.q += Math.abs(t.quantity_number || 0);
if (t.transaction_date > g.last) g.last = t.transaction_date;
}
});
return [...gmap.values()];
}, [withdrawals]);
const isToday = sel === todayKey();
const selDate = new Date(Number(sel.split("-")[0]), Number(sel.split("-")[1]) - 1, Number(sel.split("-")[2]));
return (
<div className="min-h-screen">
<Header
title="کنترل روزانه"
sub={user?.project_name || ""}
menu={isAdmin ? <PanelMenu title="پنل مدیریت انبار" items={accountMenuItems(logout)} /> : undefined}
actions={isAdmin || isPM ? <InventoryTopNav pathname={location.pathname} onLogout={logout} variant={isAdmin ? "admin" : "pm"} /> : undefined}
/>
<PageLayout className="flex flex-col gap-3">
{isAdmin && (
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-line bg-panel px-3 py-2 text-[12.5px]">
<label className="font-bold">🏢 پروژه:</label>
<select
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
value={projFilter}
onChange={(e) => setProjFilter(e.target.value)}
>
<option value="">همه پروژه‌ها</option>
{data.lookup.projects.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<span className="text-[11.5px] text-muted">
{projFilter
? "نمایش کنترل روزانه فقط برای پروژه انتخابی"
: "نمایش کنترل روزانه همه پروژه‌ها"}
</span>
</div>
)}
<div className="grid gap-4 lg:grid-cols-2">
{/* تقویم */}
<div className="rounded-xl border border-line bg-panel p-4">
<div className="mb-2 flex items-center justify-between">
<div className="text-[14px] font-bold">📅 {monthTitle}</div>
<div className="flex gap-1.5">
<button className="rounded-lg border border-line px-3 py-1 text-[12px]" onClick={() => setAnchor(new Date(anchor.getTime() - 33 * 86400000))}>▶ ماه قبل</button>
<button className="rounded-lg border border-line px-3 py-1 text-[12px]" onClick={() => { setAnchor(new Date()); setSel(todayKey()); setActRec(null); }}>امروز</button>
<button className="rounded-lg border border-line px-3 py-1 text-[12px]" onClick={() => setAnchor(new Date(anchor.getTime() + 33 * 86400000))}>◀ ماه بعد</button>
</div>
</div>
<div className="grid grid-cols-7 gap-1">
{["ش", "ی", "د", "س", "چ", "پ", "ج"].map((d) => (
<div key={d} className="py-1 text-center text-[11px] font-bold text-muted">{d}</div>
))}
{cells.map((c, i) => {
const [jd] = gregorianToJalali(c.d.getFullYear(), c.d.getMonth() + 1, c.d.getDate());
const k = gregKey(c.d);
return (
<button
key={i}
type="button"
onClick={() => { setSel(k); setActRec(null); }}
className={`relative rounded-lg py-2 text-[12px] ${!c.inMonth ? "text-muted/40" : ""} ${c.isToday ? "bg-accent/15 font-bold text-accent" : ""} ${sel === k ? "ring-2 ring-accent" : "hover:bg-accent/5"}`}
>
{toFa(jd)}
{c.n > 0 && (
<span className="absolute -top-0.5 -left-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-red text-[9px] font-bold text-white">
{toFa(c.n)}
</span>
)}
</button>
);
})}
</div>
</div>
{/* برداشت‌های روز */}
<div className="rounded-xl border border-line bg-panel p-4">
<div className="mb-1 flex items-center justify-between">
<h2 className="text-[14px] font-bold">🕘 کنترل روزانه — {isToday ? "امروز" : ""} {faNum(selDate.getDate())} {MONTHS[selDate.getMonth()]} {toFa(selDate.getFullYear())}</h2>
<span className="rounded-lg bg-blue/10 px-2 py-1 text-[11.5px] font-bold text-blue">▼ برداشت‌ها: {faNum(wGroups.length)} قلم</span>
</div>
<p className="mb-3 text-[11.5px] leading-5 text-muted">
مواردی که این روز از انبار برداشت و به ساختمان برده شده‌اند. روی هر مورد بزنید و برگشت / مفقودی / اتلاف را انتخاب کنید.
</p>
{!wGroups.length ? (
<div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">
{isToday ? "امروز هنوز برداشتی ثبت نشده است." : "در این روز برداشتی ثبت نشده است."} هر برداشتی که در «تغییر موجودی» ثبت کنید، همین‌جا نمایش داده می‌شود.
</div>
) : (
<div className="flex flex-col gap-1.5">
{wGroups.map((g) => (
<div key={g.recId} className={`rounded-lg border p-2.5 ${actRec === g.recId ? "border-accent bg-accent/5" : "border-line bg-panel"}`}>
<button type="button" className="flex w-full items-center gap-2 text-start" onClick={() => setActRec(actRec === g.recId ? null : g.recId)}>
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-blue/10 text-blue">➖</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-semibold">{g.tx.material?.name || "—"}</span>
<span className="block truncate text-[11px] text-muted">
{[g.tx.brand?.name, Object.entries(g.tx.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · "), whName(data.warehouses, g.tx.warehouse_id)].filter(Boolean).join(" · ")}
</span>
</span>
<span className="text-[13px] font-bold text-blue">−{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}</span>
<span className="text-[10.5px] text-muted">{new Date(g.last).toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" })}</span>
</button>
{actRec === g.recId && isPM && (
<div className="mt-2 flex gap-1.5 border-t border-line pt-2">
<button
className="rounded-lg border border-green/30 bg-green/5 px-3 py-1 text-[11.5px] text-green"
onClick={() => navigate(`/inventory/change?type=RETURN&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
>
↩️ برگشت
</button>
<button
className="rounded-lg border border-blue/30 bg-blue/5 px-3 py-1 text-[11.5px] text-blue"
onClick={() => navigate(`/inventory/change?type=LOSS&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
>
📦 مفقودی
</button>
<button
className="rounded-lg border border-blue/30 bg-blue/5 px-3 py-1 text-[11.5px] text-blue"
onClick={() => navigate(`/inventory/change?type=WASTE&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
>
🗑️ اتلاف
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
</PageLayout>
</div>
);
}