The month-shift anchor discarded the day returned by jalaliToGregorian: new Date(gy, gm-1, 1) instead of new Date(gy, gm-1, gd). Jalali months start mid-Gregorian-month (مهر ۱ = 2026-09-23), so the anchor landed on the 1st of the Gregorian month — still inside the SAME Jalali month (شهریور ۱۰) — and «ماه بعد» appeared dead while «ماه قبل» only seemed to work because its wrong target showed a different label. This was also the source of the reported ~22-day date mismatches. - shiftMonth now keeps the converted day (gy, gm, gd) - nav buttons fire on pointerup/click/keyboard with a dedupe guard so the action survives browsers that swallow the click event - buttons got hover feedback (cursor-pointer, hover:bg-slate-100, transition-colors) Verified against the library math: from شهریور ۱۴۰۵, next → مهر ۱۴۰۵, prev → مرداد ۱۴۰۵; confirmed in the deployed bundle.
268 lines
14 KiB
TypeScript
268 lines
14 KiB
TypeScript
import { useMemo, useRef, useState } from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
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]);
|
||
|
||
// 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 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) || [];
|
||
|
||
// فعالسازی ناوبری ماه از چند مسیر (pointerup / click / کیبورد) با حذف
|
||
// اجرای تکراری — اگر مرورگر رویداد click را بخورد، pointerup جواب میدهد.
|
||
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">
|
||
<Header
|
||
title="کنترل روزانه"
|
||
sub={inventorySubheader(user)}
|
||
backTo="/inventory"
|
||
backLabel="بازگشت به داشبورد"
|
||
/>
|
||
<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="cursor-pointer rounded-lg border border-line px-3 py-1 text-[12px] transition-colors hover:bg-slate-100" {...navBtnProps("prev", () => shiftMonth(-1))}>▶ ماه قبل</button>
|
||
<button className="cursor-pointer rounded-lg border border-line px-3 py-1 text-[12px] transition-colors hover:bg-slate-100" {...navBtnProps("today", () => { setAnchor(new Date()); setSel(todayKey()); setActRec(null); })}>امروز</button>
|
||
<button className="cursor-pointer rounded-lg border border-line px-3 py-1 text-[12px] transition-colors hover:bg-slate-100" {...navBtnProps("next", () => shiftMonth(1))}>◀ ماه بعد</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) => {
|
||
// gregorianToJalali → [سال، ماه، روز] — روزِ ماه در خانهی تقویم
|
||
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 font-bold text-white" : ""} ${sel === k ? "ring-2 ring-accent" : "hover:bg-accent/5"}`}
|
||
>
|
||
{toFa(jd)}
|
||
</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 ? "امروز " : ""}{toFa(sd)} {MONTHS[sm - 1]} {toFa(sy)}</h2>
|
||
<span className="rounded-lg bg-rose-50 px-2 py-1 text-[11.5px] font-bold text-rose-600">▼ برداشتها: {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-rose-50 text-rose-600">➖</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-rose-600">{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-emerald-300 bg-emerald-50 px-3 py-1 text-[11.5px] font-semibold text-emerald-700"
|
||
onClick={() => navigate(`/inventory/change?type=RETURN&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
|
||
>
|
||
↩️ برگشت
|
||
</button>
|
||
<button
|
||
className="rounded-lg border border-rose-300 bg-rose-50 px-3 py-1 text-[11.5px] font-semibold text-rose-700"
|
||
onClick={() => navigate(`/inventory/change?type=LOSS&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
|
||
>
|
||
📦 مفقودی
|
||
</button>
|
||
<button
|
||
className="rounded-lg border border-rose-300 bg-rose-50 px-3 py-1 text-[11.5px] font-semibold text-rose-700"
|
||
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>
|
||
);
|
||
}
|