Frontend-only application of prod_v2 (7bc6845, a29eafc). Dev docker-compose.yml change (b11cb36) and backups churn excluded.
373 lines
17 KiB
TypeScript
373 lines
17 KiB
TypeScript
import { useMemo, useRef, useState } from "react";
|
|
import { useNavigate, Link } from "react-router-dom";
|
|
import {
|
|
ChevronRight,
|
|
ChevronLeft,
|
|
Calendar as CalendarIcon,
|
|
Building2,
|
|
Plus,
|
|
RotateCcw,
|
|
PackageX,
|
|
Trash2,
|
|
Clock
|
|
} from "lucide-react";
|
|
import { Header, PageLayout } from "../../components/layout/Header";
|
|
import { useAuthStore } from "../../store/auth";
|
|
import { inventorySubheader } from "./panelMenu";
|
|
import { useInventoryData, unitName, whName } from "./useInventoryData";
|
|
import { faNum } from "../../lib/formatters";
|
|
import { gregorianToJalali, jalaliToGregorian, 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 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]);
|
|
|
|
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;
|
|
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 shiftMonth = (delta: number) => {
|
|
let nm = tm + delta;
|
|
let ny = ty;
|
|
if (nm < 1) {
|
|
nm = 12;
|
|
ny -= 1;
|
|
} else if (nm > 12) {
|
|
nm = 1;
|
|
ny += 1;
|
|
}
|
|
const [gy, gm, gd] = jalaliToGregorian(ny, nm, 1);
|
|
setAnchor(new Date(gy, gm - 1, gd));
|
|
};
|
|
|
|
const dayTx = txByDay.get(sel) || [];
|
|
|
|
const navLast = useRef<{ key: string; at: number }>({ key: "", at: 0 });
|
|
const navFire = (key: string, action: () => void) => {
|
|
const now = Date.now();
|
|
if (navLast.current.key === key && now - navLast.current.at < 350) return;
|
|
navLast.current = { key, at: now };
|
|
action();
|
|
};
|
|
const navBtnProps = (key: string, action: () => void) => ({
|
|
type: "button" as const,
|
|
onPointerUp: (e: React.PointerEvent) => {
|
|
e.preventDefault();
|
|
navFire(key, action);
|
|
},
|
|
onClick: () => navFire(key, action),
|
|
onKeyDown: (e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
navFire(key, action);
|
|
}
|
|
},
|
|
});
|
|
|
|
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]));
|
|
const [sy, sm, sd] = gregorianToJalali(selDate.getFullYear(), selDate.getMonth() + 1, selDate.getDate());
|
|
|
|
return (
|
|
<div className="min-h-screen bg-slate-50/60">
|
|
<Header
|
|
title="کنترل روزانه"
|
|
sub={inventorySubheader(user)}
|
|
backTo="/inventory"
|
|
backLabel="بازگشت به داشبورد"
|
|
/>
|
|
<PageLayout className="flex flex-col gap-4">
|
|
{/* نوار پروژه سفید و شفاف */}
|
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white p-3 shadow-xs">
|
|
<div className="flex flex-wrap items-center gap-2.5 text-xs">
|
|
<span className="flex items-center gap-1.5 font-bold text-slate-800">
|
|
<Building2 className="h-4 w-4 text-blue-600" />
|
|
<span>پروژه:</span>
|
|
</span>
|
|
{isAdmin ? (
|
|
<select
|
|
className="h-8 rounded-lg border border-slate-200 bg-white px-2.5 text-xs font-medium text-slate-800 focus:outline-none focus:border-blue-500"
|
|
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="font-semibold text-slate-800">پروژه فعال</span>
|
|
)}
|
|
<span className="text-[11.5px] text-slate-400">
|
|
{projFilter ? "نمایش برای پروژه انتخابی" : "نمایش تمام پروژهها"}
|
|
</span>
|
|
</div>
|
|
|
|
<Link
|
|
to="/inventory/change"
|
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold shadow-xs transition-colors no-underline"
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
<span>ثبت خروج / تغییر موجودی</span>
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="grid gap-4 lg:grid-cols-2 items-start">
|
|
{/* ستون تقویم — سفید، خوانا و بدون هدر تیره */}
|
|
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-xs">
|
|
<div className="mb-4 flex items-center justify-between pb-3 border-b border-slate-100">
|
|
<div className="flex items-center gap-2 text-[14px] font-bold text-slate-800">
|
|
<CalendarIcon className="h-4 w-4 text-blue-600" />
|
|
<span>{monthTitle}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<button
|
|
className="inline-flex items-center gap-1 h-7.5 px-2.5 rounded-md border border-slate-200 bg-white text-xs font-medium text-slate-700 hover:border-slate-300 transition-colors cursor-pointer"
|
|
{...navBtnProps("prev", () => shiftMonth(-1))}
|
|
>
|
|
<ChevronRight className="h-3.5 w-3.5" />
|
|
<span>ماه قبل</span>
|
|
</button>
|
|
<button
|
|
className="h-7.5 px-3 rounded-md border border-blue-200 bg-blue-50 text-xs font-bold text-blue-700 hover:bg-blue-100 transition-colors cursor-pointer"
|
|
{...navBtnProps("today", () => { setAnchor(new Date()); setSel(todayKey()); setActRec(null); })}
|
|
>
|
|
امروز
|
|
</button>
|
|
<button
|
|
className="inline-flex items-center gap-1 h-7.5 px-2.5 rounded-md border border-slate-200 bg-white text-xs font-medium text-slate-700 hover:border-slate-300 transition-colors cursor-pointer"
|
|
{...navBtnProps("next", () => shiftMonth(1))}
|
|
>
|
|
<span>ماه بعد</span>
|
|
<ChevronLeft className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* روزهای هفته */}
|
|
<div className="grid grid-cols-7 gap-1 text-center">
|
|
{["ش", "ی", "د", "س", "چ", "پ", "ج"].map((d) => (
|
|
<div key={d} className="py-1 text-xs font-semibold text-slate-400">{d}</div>
|
|
))}
|
|
{cells.map((c, i) => {
|
|
const [, , jd] = gregorianToJalali(c.d.getFullYear(), c.d.getMonth() + 1, c.d.getDate());
|
|
const k = gregKey(c.d);
|
|
const isSelected = sel === k;
|
|
|
|
return (
|
|
<button
|
|
key={i}
|
|
type="button"
|
|
onClick={() => { setSel(k); setActRec(null); }}
|
|
className={`relative flex flex-col items-center justify-center h-9 rounded-lg text-xs transition-colors cursor-pointer ${
|
|
!c.inMonth ? "text-slate-300" : "text-slate-700 font-medium"
|
|
} ${
|
|
isSelected
|
|
? "bg-blue-600 text-white font-bold"
|
|
: c.isToday
|
|
? "border border-blue-500 font-bold text-blue-600 hover:bg-blue-50/50"
|
|
: "hover:bg-slate-100/70"
|
|
}`}
|
|
>
|
|
<span>{toFa(jd)}</span>
|
|
{c.n > 0 && (
|
|
<span
|
|
className={`absolute bottom-1 h-1 w-1 rounded-full ${
|
|
isSelected ? "bg-white" : "bg-blue-600"
|
|
}`}
|
|
/>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ستون جزئیات روز — سفید یکدست */}
|
|
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-xs">
|
|
<div className="mb-2 flex items-center justify-between pb-3 border-b border-slate-100">
|
|
<div className="flex items-center gap-2">
|
|
<Clock className="h-4 w-4 text-blue-600" />
|
|
<h2 className="text-sm font-bold text-slate-800">
|
|
{isToday ? "امروز — " : "کنترل — "}
|
|
{toFa(sd)} {MONTHS[sm - 1]} {toFa(sy)}
|
|
</h2>
|
|
</div>
|
|
<span
|
|
className={`rounded-md px-2 py-0.5 text-xs font-semibold ${
|
|
wGroups.length > 0
|
|
? "bg-amber-50 text-amber-700 border border-amber-200"
|
|
: "text-slate-400 border border-slate-100"
|
|
}`}
|
|
>
|
|
{faNum(wGroups.length)} برداشت
|
|
</span>
|
|
</div>
|
|
|
|
<p className="mb-3 text-[11.5px] leading-5 text-slate-500">
|
|
اقلام برداشتشده از انبار در این تاریخ:
|
|
</p>
|
|
|
|
{!wGroups.length ? (
|
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-slate-200 py-10 px-4 text-center">
|
|
<span className="text-xs text-slate-400 mb-2">
|
|
{isToday ? "امروز هنوز هیچ برداشتی ثبت نشده است." : "در این تاریخ برداشتی ثبت نشده است."}
|
|
</span>
|
|
<Link
|
|
to="/inventory/change"
|
|
className="inline-flex items-center gap-1 px-3 py-1 rounded text-blue-600 hover:bg-blue-50 text-xs font-semibold transition-colors no-underline"
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
<span>ثبت برداشت جدید</span>
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
{wGroups.map((g) => (
|
|
<div
|
|
key={g.recId}
|
|
className={`rounded-lg border p-3 transition-colors ${
|
|
actRec === g.recId
|
|
? "border-blue-500 bg-blue-50/20"
|
|
: "border-slate-100 bg-white hover:border-slate-200"
|
|
}`}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center gap-3 text-start cursor-pointer"
|
|
onClick={() => setActRec(actRec === g.recId ? null : g.recId)}
|
|
>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-xs font-bold text-slate-800">
|
|
{g.tx.material?.name || "—"}
|
|
</span>
|
|
<span className="block truncate text-[11px] text-slate-400 mt-0.5">
|
|
{[
|
|
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-xs font-bold text-rose-600 tabular-nums">
|
|
{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}
|
|
</span>
|
|
<span className="text-[11px] text-slate-400 tabular-nums">
|
|
{new Date(g.last).toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" })}
|
|
</span>
|
|
</button>
|
|
|
|
{actRec === g.recId && isPM && (
|
|
<div className="mt-2.5 flex flex-wrap gap-2 border-t border-slate-100 pt-2">
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center gap-1 rounded border border-emerald-300 bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-700 hover:bg-emerald-100 transition-colors cursor-pointer"
|
|
onClick={() => navigate(`/inventory/change?type=RETURN&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
|
|
>
|
|
<RotateCcw className="h-3 w-3" />
|
|
<span>برگشت</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center gap-1 rounded border border-rose-300 bg-rose-50 px-2.5 py-1 text-xs font-semibold text-rose-700 hover:bg-rose-100 transition-colors cursor-pointer"
|
|
onClick={() => navigate(`/inventory/change?type=LOSS&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
|
|
>
|
|
<PackageX className="h-3 w-3" />
|
|
<span>مفقودی</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center gap-1 rounded border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-slate-700 hover:bg-slate-50 transition-colors cursor-pointer"
|
|
onClick={() => navigate(`/inventory/change?type=WASTE&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
<span>اتلاف</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</PageLayout>
|
|
</div>
|
|
);
|
|
} |