feat: mobile UI optimizations
This commit is contained in:
@@ -8,29 +8,70 @@ export interface StatsBarProps {
|
|||||||
avgLeadTime: number | null; // روز
|
avgLeadTime: number | null; // روز
|
||||||
}
|
}
|
||||||
|
|
||||||
// نوار ۴ ستونهی آمار
|
|
||||||
export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProps) {
|
export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProps) {
|
||||||
const stats: { label: string; value: string; color: string; icon: LucideIcon }[] = [
|
const stats: {
|
||||||
{ label: "کل پیشنهادها", value: fmtNum(total), color: "text-ink", icon: ClipboardList },
|
label: string;
|
||||||
// پیشنهاد باز = نارنجی، پذیرفتهشده = آبی، رسیده = سبز
|
shortLabel: string;
|
||||||
{ label: "پیشنهاد باز", value: fmtNum(offered), color: "text-amber", icon: Inbox },
|
value: string;
|
||||||
{ label: "پذیرفتهشده", value: fmtNum(accepted), color: "text-[#1d4ed8]", icon: CheckCircle2 },
|
color: string;
|
||||||
{ label: "میانگین lead time (روز)", value: avgLeadTime == null ? "—" : fmtNum(avgLeadTime), color: "text-green", icon: Timer },
|
icon: LucideIcon;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
label: "کل پیشنهادها",
|
||||||
|
shortLabel: "کل",
|
||||||
|
value: fmtNum(total),
|
||||||
|
color: "text-ink",
|
||||||
|
icon: ClipboardList,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "پیشنهاد باز",
|
||||||
|
shortLabel: "باز",
|
||||||
|
value: fmtNum(offered),
|
||||||
|
color: "text-amber",
|
||||||
|
icon: Inbox,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "پذیرفتهشده",
|
||||||
|
shortLabel: "پذیرفته",
|
||||||
|
value: fmtNum(accepted),
|
||||||
|
color: "text-[#1d4ed8]",
|
||||||
|
icon: CheckCircle2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "میانگین lead time (روز)",
|
||||||
|
shortLabel: "زمان تحویل",
|
||||||
|
value: avgLeadTime == null ? "—" : fmtNum(avgLeadTime),
|
||||||
|
color: "text-green",
|
||||||
|
icon: Timer,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<section className="grid grid-cols-4 gap-1.5 sm:gap-3">
|
||||||
{stats.map((s) => (
|
{stats.map((s) => (
|
||||||
<div
|
<div
|
||||||
key={s.label}
|
key={s.label}
|
||||||
className="rounded-lg border border-line bg-panel p-3.5 transition-colors hover:border-accent/40"
|
className="rounded-xl border border-line bg-panel p-2 text-center transition-colors hover:border-accent/40 sm:rounded-lg sm:p-3.5 sm:text-right"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
{/* عنوان کارت */}
|
||||||
<div className="text-[11px] font-medium text-muted">{s.label}</div>
|
<div className="flex items-center justify-center sm:justify-between">
|
||||||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-slate-bg text-muted">
|
<span className="truncate text-[10px] font-medium text-muted sm:text-[11px]">
|
||||||
|
<span className="sm:hidden">{s.shortLabel}</span>
|
||||||
|
<span className="hidden sm:inline">{s.label}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* آیکون در موبایل حذف شده تا فضا برای متن باز بماند */}
|
||||||
|
<span className="hidden h-6 w-6 shrink-0 items-center justify-center rounded-md bg-slate-bg text-muted sm:flex">
|
||||||
<s.icon className="h-3.5 w-3.5" strokeWidth={2} />
|
<s.icon className="h-3.5 w-3.5" strokeWidth={2} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`mt-1 text-[22px] font-extrabold leading-8 tabular-nums ${s.color}`}>{s.value}</div>
|
|
||||||
|
{/* مقدار عددی */}
|
||||||
|
<div
|
||||||
|
className={`mt-0.5 text-base font-extrabold leading-5 tabular-nums sm:mt-1 sm:text-[22px] sm:leading-8 ${s.color}`}
|
||||||
|
>
|
||||||
|
{s.value}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function Header({ title, sub, backTo, backLabel = "بازگشت", actions
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={goBack}
|
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 max-md:px-2.5 max-md:py-2"
|
className="hidden md: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" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
{backLabel}
|
{backLabel}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { EditModal } from "./EditModal";
|
|||||||
|
|
||||||
const EMPTY_FILTERS: Filters = { category: "", supplier: "", status: "", search: "" };
|
const EMPTY_FILTERS: Filters = { category: "", supplier: "", status: "", search: "" };
|
||||||
|
|
||||||
// صفحهی اصلی دفتر پیشنهادهای خرید
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
useStoreData();
|
useStoreData();
|
||||||
|
|
||||||
@@ -46,7 +45,6 @@ export function DashboardPage() {
|
|||||||
[quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics],
|
[quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics],
|
||||||
);
|
);
|
||||||
|
|
||||||
// فیلتر + مرتبسازی بر اساس زمان ساخت (created_at) — فیلترها بازنشانی نمیشوند
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const list = filterQuotes(store, filters);
|
const list = filterQuotes(store, filters);
|
||||||
const createdTs = (q: (typeof store.quotes)[number]) => {
|
const createdTs = (q: (typeof store.quotes)[number]) => {
|
||||||
@@ -73,13 +71,13 @@ export function DashboardPage() {
|
|||||||
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
|
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen pb-20 md:pb-6">
|
||||||
<Header
|
<Header
|
||||||
title="دفتر پیشنهادهای خرید"
|
title="دفتر پیشنهادهای خرید"
|
||||||
brand
|
brand
|
||||||
menu={<AppSwitcherMenu activeModule="procurement" />}
|
menu={<AppSwitcherMenu activeModule="procurement" />}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<div className="hidden md:flex items-center gap-2">
|
||||||
<Link to="/suppliers" className="btn-line-file no-underline">
|
<Link to="/suppliers" className="btn-line-file no-underline">
|
||||||
<Users className="h-3.5 w-3.5" />
|
<Users className="h-3.5 w-3.5" />
|
||||||
تأمینکنندهها
|
تأمینکنندهها
|
||||||
@@ -96,10 +94,11 @@ export function DashboardPage() {
|
|||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
افزودن پیشنهاد جدید
|
افزودن پیشنهاد جدید
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<PageLayout className="flex flex-col gap-4">
|
|
||||||
|
<PageLayout className="flex flex-col gap-3 px-3 md:px-6">
|
||||||
<StatsBar
|
<StatsBar
|
||||||
total={stats.total}
|
total={stats.total}
|
||||||
offered={stats.offered}
|
offered={stats.offered}
|
||||||
@@ -124,6 +123,15 @@ export function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|
||||||
|
{/* دکمه شناور اکشن (FAB) مخصوص نسخه موبایل */}
|
||||||
|
<Link
|
||||||
|
to="/wizard"
|
||||||
|
aria-label="افزودن پیشنهاد جدید"
|
||||||
|
className="md:hidden fixed bottom-20 left-4 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-accent text-white shadow-xl shadow-accent/30 active:scale-95 transition-transform"
|
||||||
|
>
|
||||||
|
<Plus className="h-7 w-7" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
<AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} />
|
<AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} />
|
||||||
<EditModal quoteId={editId} onClose={() => setEditId(null)} />
|
<EditModal quoteId={editId} onClose={() => setEditId(null)} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
|
||||||
import { Combobox } from "../../components/Combobox";
|
import { Combobox } from "../../components/Combobox";
|
||||||
import { STATUS_LABEL, STATUS_ORDER } from "../../lib/constants";
|
import { STATUS_LABEL, STATUS_ORDER } from "../../lib/constants";
|
||||||
|
|
||||||
@@ -20,12 +22,156 @@ export interface FilterToolbarProps {
|
|||||||
onToggleSort: () => void;
|
onToggleSort: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// نوار فیلتر دفتر: ۳ کامبوباکس + جستجوی آزاد + دکمهی مرتبسازی ⇅
|
export function FilterToolbar({
|
||||||
export function FilterToolbar({ filters, onChange, categories, suppliers, sortDir, onToggleSort }: FilterToolbarProps) {
|
filters,
|
||||||
|
onChange,
|
||||||
|
categories,
|
||||||
|
suppliers,
|
||||||
|
sortDir,
|
||||||
|
onToggleSort,
|
||||||
|
}: FilterToolbarProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const set = (patch: Partial<Filters>) => onChange({ ...filters, ...patch });
|
const set = (patch: Partial<Filters>) => onChange({ ...filters, ...patch });
|
||||||
|
|
||||||
|
// شمارش تعداد فیلترهای فعال به جز سرچ
|
||||||
|
const activeFilterCount = [filters.category, filters.supplier, filters.status].filter(Boolean).length;
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
onChange({ ...filters, category: "", supplier: "", status: "" });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<>
|
||||||
|
{/* ===== نمای موبایل (صفحهنمایشهای زیر md) ===== */}
|
||||||
|
<div className="flex md:hidden items-center gap-2">
|
||||||
|
{/* اینپوت جستجوی متنی */}
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="h-10 w-full rounded-xl border border-line bg-panel px-3 text-sm shadow-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
|
placeholder="جستجو در نام، برند، پروژه..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => set({ search: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* دکمه فیلترها با بج تعداد انتخابشده */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
className={`relative inline-flex h-10 items-center justify-center gap-1.5 rounded-xl border px-3 text-xs font-semibold shadow-sm transition-colors ${
|
||||||
|
activeFilterCount > 0
|
||||||
|
? "border-accent bg-accent-bg text-accent"
|
||||||
|
: "border-line bg-panel text-ink hover:bg-slate-bg"
|
||||||
|
}`}
|
||||||
|
title="فیلترها"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="h-4 w-4" />
|
||||||
|
<span>فیلتر</span>
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-accent text-[11px] font-bold text-white">
|
||||||
|
{activeFilterCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* دکمه تغییر جهت سورت */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleSort}
|
||||||
|
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-line bg-panel text-ink shadow-sm hover:bg-slate-bg"
|
||||||
|
title={sortDir === "desc" ? "جدیدترین اول" : "قدیمیترین اول"}
|
||||||
|
>
|
||||||
|
<ArrowUpDown className="h-4 w-4 text-accent" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* باتمشیت کشویی فیلترها برای موبایل */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex flex-col justify-end bg-black/40 backdrop-blur-xs md:hidden">
|
||||||
|
<div className="fixed inset-0" onClick={() => setIsOpen(false)} aria-hidden="true" />
|
||||||
|
<div className="relative z-10 max-h-[85vh] w-full overflow-y-auto rounded-t-2xl border-t border-line bg-panel p-4 shadow-2xl">
|
||||||
|
{/* هدر کشو */}
|
||||||
|
<div className="mb-4 flex items-center justify-between border-b border-line pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SlidersHorizontal className="h-4 w-4 text-accent" />
|
||||||
|
<span className="text-sm font-bold text-ink">فیلترهای پیشرفته</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-rose-500 hover:underline"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
پاک کردن
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="rounded-lg p-1 text-muted hover:bg-slate-bg hover:text-ink"
|
||||||
|
aria-label="بستن"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* دراپداونها داخل کشو */}
|
||||||
|
<div className="flex flex-col gap-3.5">
|
||||||
|
<div className="field">
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-muted">دسته</label>
|
||||||
|
<Combobox
|
||||||
|
value={filters.category}
|
||||||
|
onChange={(it: { id: string; label: string }) => set({ category: it.label })}
|
||||||
|
items={categories}
|
||||||
|
allowAdd={false}
|
||||||
|
placeholder="همه دستهها"
|
||||||
|
onInputChange={(v) => set({ category: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-muted">تأمینکننده</label>
|
||||||
|
<Combobox
|
||||||
|
value={filters.supplier}
|
||||||
|
onChange={(it: { id: string; label: string }) => set({ supplier: it.label })}
|
||||||
|
items={suppliers}
|
||||||
|
allowAdd={false}
|
||||||
|
placeholder="همه تأمینکنندگان"
|
||||||
|
onInputChange={(v) => set({ supplier: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-muted">وضعیت</label>
|
||||||
|
<Combobox
|
||||||
|
value={filters.status}
|
||||||
|
onChange={(it: { id: string; label: string }) => set({ status: it.label })}
|
||||||
|
items={STATUS_ITEMS}
|
||||||
|
allowAdd={false}
|
||||||
|
placeholder="همه وضعیتها"
|
||||||
|
onInputChange={(v) => set({ status: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* دکمه تأیید و بستن */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="mt-5 flex h-11 w-full items-center justify-center rounded-xl bg-accent text-sm font-bold text-white shadow-md shadow-accent/20 active:scale-[0.99]"
|
||||||
|
>
|
||||||
|
مشاهده نتایج
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== نمای دسکتاپ (md به بالا) ===== */}
|
||||||
|
<div className="hidden md:flex flex-wrap items-end gap-3">
|
||||||
<div className="field min-w-[150px] flex-1">
|
<div className="field min-w-[150px] flex-1">
|
||||||
<label>دسته</label>
|
<label>دسته</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
@@ -82,5 +228,6 @@ export function FilterToolbar({ filters, onChange, categories, suppliers, sortDi
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ export interface LedgerTableProps {
|
|||||||
onEdit: (id: string) => void;
|
onEdit: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// دفتر پیشنهادها — فهرست کارتها (هر پیشنهاد یک کارت خوانا)
|
// دفتر پیشنهادها — فهرست کارتها (واکنشگرا و بهینه برای موبایل)
|
||||||
export function LedgerTable({
|
export function LedgerTable({
|
||||||
store,
|
store,
|
||||||
quotes,
|
quotes,
|
||||||
@@ -23,14 +23,14 @@ export function LedgerTable({
|
|||||||
}: LedgerTableProps) {
|
}: LedgerTableProps) {
|
||||||
if (!quotes.length) {
|
if (!quotes.length) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-line bg-panel">
|
<div className="rounded-xl border border-line bg-panel p-2 sm:rounded-lg">
|
||||||
<EmptyState icon={Inbox} text="پیشنهادی مطابق فیلترها یافت نشد" />
|
<EmptyState icon={Inbox} text="پیشنهادی مطابق فیلترها یافت نشد" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-2.5 sm:gap-3">
|
||||||
{quotes.map((q) => (
|
{quotes.map((q) => (
|
||||||
<QuoteCard
|
<QuoteCard
|
||||||
key={q.id}
|
key={q.id}
|
||||||
@@ -42,7 +42,7 @@ export function LedgerTable({
|
|||||||
onEdit={() => onEdit(q.id)}
|
onEdit={() => onEdit(q.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div className="py-1 text-center text-[11px] text-muted">
|
<div className="px-2 py-3 text-center text-[10.5px] leading-5 text-muted sm:py-1 sm:text-[11px]">
|
||||||
هشدار «منقضی» = قیمت پیشنهادی از تاریخ انقضا گذشته؛ برای پیشبینی قیمت، شمارش نمیشود.
|
هشدار «منقضی» = قیمت پیشنهادی از تاریخ انقضا گذشته؛ برای پیشبینی قیمت، شمارش نمیشود.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, CreditCard } from "lucide-react";
|
import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, CreditCard, Check } from "lucide-react";
|
||||||
import type { Quote, Store } from "../../lib/types";
|
import type { Quote, Store } from "../../lib/types";
|
||||||
import {
|
import {
|
||||||
isExpired,
|
isExpired,
|
||||||
@@ -27,16 +27,16 @@ export interface QuoteCardProps {
|
|||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoCell({ label, children }: { label: string; children: React.ReactNode }) {
|
function InfoCell({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0">
|
<div className={cn("min-w-0", className)}>
|
||||||
<div className="mb-0.5 text-[10.5px] font-semibold text-muted">{label}</div>
|
<div className="mb-0.5 text-[10px] font-semibold text-muted sm:text-[10.5px]">{label}</div>
|
||||||
<div className="min-w-0 text-[13px] leading-6">{children}</div>
|
<div className="min-w-0 text-xs leading-5 text-ink sm:text-[13px] sm:leading-6">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// کارت یک پیشنهاد در دفتر — جایگزین جدول ۱۱ ستونه
|
// کارت پیشنهاد خرید — بهینهشده برای کاربری سرپا با موبایل
|
||||||
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) {
|
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) {
|
||||||
const sup = supplierById(store.suppliers, q.supplier_id);
|
const sup = supplierById(store.suppliers, q.supplier_id);
|
||||||
const expired = isExpired(q);
|
const expired = isExpired(q);
|
||||||
@@ -46,22 +46,25 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
const unitWarns = unitWarning(store, q);
|
const unitWarns = unitWarning(store, q);
|
||||||
const summary = itemsSummary(store, q);
|
const summary = itemsSummary(store, q);
|
||||||
|
|
||||||
// ستون پذیرش (با ساعت)
|
// ستون / دکمه پذیرش
|
||||||
let acceptedCell: React.ReactNode;
|
let acceptedCell: React.ReactNode;
|
||||||
if (q.status === "offered") {
|
if (q.status === "offered") {
|
||||||
acceptedCell = (
|
acceptedCell = (
|
||||||
<button className="chip chip-act" onClick={onAccept}>
|
<button
|
||||||
پذیرش
|
onClick={onAccept}
|
||||||
|
className="inline-flex h-7 items-center justify-center gap-1 rounded-md bg-emerald-600 px-2.5 text-xs font-bold text-white shadow-xs active:scale-95 transition-transform"
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
<span>پذیرش</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
} else if (q.accepted_date) {
|
} else if (q.accepted_date) {
|
||||||
acceptedCell = <span className="tabular-nums">{fmtDateTime(q.accepted_date)}</span>;
|
acceptedCell = <span className="tabular-nums font-medium">{fmtDateTime(q.accepted_date)}</span>;
|
||||||
} else {
|
} else {
|
||||||
acceptedCell = <span className="text-muted">—</span>;
|
acceptedCell = <span className="text-muted">—</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ستون رسید — v2.6: تأیید رسید فقط در «پنل مدیریت انبار» توسط مدیر پروژه انجام میشود؛
|
// وضعیت رسید کالا
|
||||||
// اینجا فقط وضعیت نمایش داده میشود. با رسید جزیی: «رسیده X از Y» + «در راه Z».
|
|
||||||
const activeItems = (q.line_items || []).filter((li) => !li.rejected);
|
const activeItems = (q.line_items || []).filter((li) => !li.rejected);
|
||||||
const receivedSum = activeItems.reduce((s, li) => s + (li.received_qty || 0), 0);
|
const receivedSum = activeItems.reduce((s, li) => s + (li.received_qty || 0), 0);
|
||||||
const orderedSum = activeItems.reduce((s, li) => s + (li.quantity || 0), 0);
|
const orderedSum = activeItems.reduce((s, li) => s + (li.quantity || 0), 0);
|
||||||
@@ -71,25 +74,24 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
const anyReceived = receivedSum > 0;
|
const anyReceived = receivedSum > 0;
|
||||||
const fullyDelivered = activeItems.length > 0 && fulfilledItems === activeItems.length && anyReceived;
|
const fullyDelivered = activeItems.length > 0 && fulfilledItems === activeItems.length && anyReceived;
|
||||||
|
|
||||||
// نشان پیشرفت تکمیل در هدر کارت (جایگزین نشان وضعیت در حالت پذیرش/رسید):
|
// نشان پیشرفت تکمیل
|
||||||
// در راه (آبی) → رسید: X از Y قلم (کهربایی) → تکمیل شد (سبز)
|
|
||||||
let fulfillmentBadge: React.ReactNode = null;
|
let fulfillmentBadge: React.ReactNode = null;
|
||||||
if (q.status === "accepted" || q.status === "arrived") {
|
if (q.status === "accepted" || q.status === "arrived") {
|
||||||
if (fullyDelivered || q.status === "arrived") {
|
if (fullyDelivered || q.status === "arrived") {
|
||||||
fulfillmentBadge = (
|
fulfillmentBadge = (
|
||||||
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[11px] font-bold text-emerald-700">
|
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[10.5px] font-bold text-emerald-700 sm:text-[11px]">
|
||||||
✓ تکمیل شد
|
✓ تکمیل
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
} else if (anyReceived) {
|
} else if (anyReceived) {
|
||||||
fulfillmentBadge = (
|
fulfillmentBadge = (
|
||||||
<span className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-bold text-amber-700">
|
<span className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10.5px] font-bold text-amber-700 sm:text-[11px]">
|
||||||
رسید: {fmtNum(fulfilledItems)} از {fmtNum(activeItems.length)} قلم
|
رسید: {fmtNum(fulfilledItems)}/{fmtNum(activeItems.length)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
fulfillmentBadge = (
|
fulfillmentBadge = (
|
||||||
<span className="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2 py-0.5 text-[11px] font-bold text-blue-700">
|
<span className="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2 py-0.5 text-[10.5px] font-bold text-blue-700 sm:text-[11px]">
|
||||||
⏳ در راه
|
⏳ در راه
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -97,19 +99,12 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
}
|
}
|
||||||
|
|
||||||
let arrivedCell: React.ReactNode;
|
let arrivedCell: React.ReactNode;
|
||||||
// آخرین لحظهی رسید ثبتشده بین قلمها (رسیدهای جزیی توسط مدیر پروژه)
|
|
||||||
const lastReceiptAt = activeItems.reduce<Date | null>((acc, li) => {
|
const lastReceiptAt = activeItems.reduce<Date | null>((acc, li) => {
|
||||||
if (!li.arrived_date) return acc;
|
if (!li.arrived_date) return acc;
|
||||||
const t = new Date(li.arrived_date).getTime();
|
const t = new Date(li.arrived_date).getTime();
|
||||||
if (Number.isNaN(t)) return acc;
|
if (Number.isNaN(t)) return acc;
|
||||||
return !acc || t > acc.getTime() ? new Date(li.arrived_date) : acc;
|
return !acc || t > acc.getTime() ? new Date(li.arrived_date) : acc;
|
||||||
}, null);
|
}, null);
|
||||||
const receiptTimeLine =
|
|
||||||
lastReceiptAt != null ? (
|
|
||||||
<span className="block text-[10.5px] font-normal text-muted">
|
|
||||||
آخرین رسید: {fmtDateTime(lastReceiptAt.toISOString())}
|
|
||||||
</span>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
if (q.status === "accepted") {
|
if (q.status === "accepted") {
|
||||||
if (q.arrived_date) {
|
if (q.arrived_date) {
|
||||||
@@ -117,12 +112,11 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
} else if (receivedSum > 0) {
|
} else if (receivedSum > 0) {
|
||||||
arrivedCell = (
|
arrivedCell = (
|
||||||
<span className="tabular-nums">
|
<span className="tabular-nums">
|
||||||
رسیده {fmtNum(receivedSum)} از {fmtNum(orderedSum)} — در راه {fmtNum(Math.max(0, orderedSum - receivedSum))}
|
{fmtNum(receivedSum)} از {fmtNum(orderedSum)}
|
||||||
{receiptTimeLine}
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
arrivedCell = <span className="text-muted">در انتظار PM</span>;
|
arrivedCell = <span className="text-muted">در انتظار انبار</span>;
|
||||||
}
|
}
|
||||||
} else if (q.status === "arrived") {
|
} else if (q.status === "arrived") {
|
||||||
arrivedCell = (
|
arrivedCell = (
|
||||||
@@ -137,35 +131,42 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"overflow-hidden rounded-lg border bg-panel transition-colors",
|
"overflow-hidden rounded-xl border bg-panel transition-colors shadow-2xs sm:rounded-lg",
|
||||||
expired ? "border-amber/40" : "border-line hover:border-accent/40",
|
expired ? "border-amber/40" : "border-line hover:border-accent/40",
|
||||||
expanded && "border-accent/60",
|
expanded && "border-accent/60 ring-1 ring-accent/20",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* سربرگ کارت */}
|
{/* سربرگ کارت */}
|
||||||
<div className="flex items-center gap-3 border-b border-line bg-bg/40 px-4 py-2.5">
|
<div className="flex items-center gap-2.5 border-b border-line bg-bg/40 px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5">
|
||||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-sm font-bold text-accent-dark">
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-xs font-bold text-accent-dark sm:h-9 sm:w-9 sm:text-sm">
|
||||||
{sup.name.slice(0, 1)}
|
{sup.name.slice(0, 1)}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<Link
|
<Link
|
||||||
to={`/suppliers/${q.supplier_id}`}
|
to={`/suppliers/${q.supplier_id}`}
|
||||||
className="block truncate text-[13.5px] font-bold text-ink no-underline hover:text-accent"
|
className="block truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]"
|
||||||
>
|
>
|
||||||
{sup.name}
|
{sup.name}
|
||||||
</Link>
|
</Link>
|
||||||
{sup.location && <div className="truncate text-[11px] text-muted">{sup.location}</div>}
|
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="ms-auto flex shrink-0 items-center gap-1.5">
|
<div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
|
||||||
{fulfillmentBadge ?? (
|
{fulfillmentBadge ?? (
|
||||||
<Badge variant={q.status as "offered" | "accepted" | "arrived"}>{STATUS_LABEL[q.status]}</Badge>
|
<Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs">
|
||||||
|
{STATUS_LABEL[q.status]}
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<button className="chip" onClick={onEdit} title="ویرایش پیشنهاد">
|
<button
|
||||||
|
className="chip h-7 px-2 sm:h-auto sm:px-2.5"
|
||||||
|
onClick={onEdit}
|
||||||
|
title="ویرایش پیشنهاد"
|
||||||
|
aria-label="ویرایش"
|
||||||
|
>
|
||||||
<Pencil className="h-3 w-3" />
|
<Pencil className="h-3 w-3" />
|
||||||
ویرایش
|
<span className="hidden sm:inline">ویرایش</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="chip chip-expand"
|
className="chip chip-expand h-7 w-7 p-0 sm:h-auto sm:w-auto sm:p-1.5"
|
||||||
onClick={onToggleExpand}
|
onClick={onToggleExpand}
|
||||||
title={expanded ? "جمع کردن" : "نمایش جریان"}
|
title={expanded ? "جمع کردن" : "نمایش جریان"}
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
@@ -175,35 +176,34 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* اطلاعات کلیدی */}
|
{/* اطلاعات کلیدی (گرید ۲ ستونه موبایل / ۴ ستونه دسکتاپ) */}
|
||||||
<div className="grid grid-cols-2 gap-x-5 gap-y-3 px-4 py-3 md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-x-3 gap-y-2.5 p-3 sm:gap-x-5 sm:gap-y-3 sm:p-4 md:grid-cols-4">
|
||||||
<InfoCell label="اقلام">
|
<InfoCell label="اقلام">
|
||||||
{summary.count > 1 ? (
|
{summary.count > 1 ? (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="font-semibold">{fmtNum(summary.count)} قلم</span>
|
<span className="font-semibold text-xs sm:text-sm">{fmtNum(summary.count)} قلم کالا</span>
|
||||||
<div className="flex flex-col gap-1 text-[11.5px] leading-5 text-muted">
|
<div className="flex flex-col gap-0.5 text-[11px] leading-4 text-muted sm:text-[11.5px] sm:leading-5">
|
||||||
{store.quotes.find((x) => x.id === q.id)?.line_items?.filter((li) => !li.rejected).map((li) => {
|
{store.quotes.find((x) => x.id === q.id)?.line_items?.filter((li) => !li.rejected).slice(0, 3).map((li) => {
|
||||||
const m = materialById(store.materials, li.material_id);
|
const m = materialById(store.materials, li.material_id);
|
||||||
return (
|
return (
|
||||||
<span key={li.id} className="flex items-center gap-1">
|
<span key={li.id} className="truncate">
|
||||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent/40" />
|
• {materialLabel(m)} ({fmtNum(li.quantity)} {li.unit})
|
||||||
{materialLabel(m)}
|
|
||||||
<span className="text-[10px] text-muted/70">
|
|
||||||
{fmtNum(li.quantity)} {li.unit}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{(q.line_items?.length || 0) > 3 && (
|
||||||
|
<span className="text-[10px] text-muted">و {(q.line_items?.length || 0) - 3} قلم دیگر...</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
summary.text
|
<span className="truncate">{summary.text}</span>
|
||||||
)}
|
)}
|
||||||
{unitWarns.map((w) => (
|
{unitWarns.map((w) => (
|
||||||
<Link
|
<Link
|
||||||
key={w.unit}
|
key={w.unit}
|
||||||
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
|
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
|
||||||
className="unit-warn mt-1"
|
className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
|
||||||
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
|
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
|
||||||
>
|
>
|
||||||
<TriangleAlert className="h-2.5 w-2.5" />
|
<TriangleAlert className="h-2.5 w-2.5" />
|
||||||
@@ -213,37 +213,34 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
</InfoCell>
|
</InfoCell>
|
||||||
|
|
||||||
<InfoCell label="پروژه">
|
<InfoCell label="پروژه">
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1 text-xs sm:text-[13px]">
|
||||||
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
|
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||||
{q.projects && q.projects.length > 0 ? (
|
<span className="truncate">
|
||||||
<span className="flex flex-col gap-0.5">
|
{q.projects && q.projects.length > 0 ? q.projects.join("، ") : projectCell(q)}
|
||||||
{q.projects.map((p, i) => (
|
|
||||||
<span key={i}>{p}</span>
|
|
||||||
))}
|
|
||||||
</span>
|
</span>
|
||||||
) : (
|
|
||||||
projectCell(q)
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</InfoCell>
|
</InfoCell>
|
||||||
|
|
||||||
<InfoCell label="مجموع">
|
<InfoCell label="مجموع مبلغ">
|
||||||
{pc ? (
|
{pc ? (
|
||||||
<span className="font-bold tabular-nums">
|
<div>
|
||||||
{pc.num} <span className="num-words">({pc.words} تومان)</span>
|
<div className="text-sm font-extrabold text-ink tabular-nums sm:text-base">
|
||||||
</span>
|
{pc.num} <span className="text-[10px] font-normal text-muted sm:text-xs">تومان</span>
|
||||||
|
</div>
|
||||||
|
<div className="hidden text-[11px] text-muted sm:block">({pc.words})</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"—"
|
||||||
)}
|
)}
|
||||||
</InfoCell>
|
</InfoCell>
|
||||||
|
|
||||||
<InfoCell label="معامله">
|
<InfoCell label="شرایط معامله">
|
||||||
{ts.title !== "—" ? (
|
{ts.title !== "—" ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold" style={ts.color ? { color: ts.color } : undefined}>
|
<div className="font-semibold text-xs sm:text-[13px]" style={ts.color ? { color: ts.color } : undefined}>
|
||||||
{ts.title}
|
{ts.title}
|
||||||
</div>
|
</div>
|
||||||
{ts.sub && <div className="text-[11px] text-muted">{ts.sub}</div>}
|
{ts.sub && <div className="truncate text-[10px] text-muted sm:text-[11px]">{ts.sub}</div>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted">—</span>
|
<span className="text-muted">—</span>
|
||||||
@@ -251,29 +248,35 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
</InfoCell>
|
</InfoCell>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* تاریخها و اقدامات */}
|
{/* تاریخها و اکشنها (۲ ستونه در موبایل، نواری در دسکتاپ) */}
|
||||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-line bg-bg/40 px-4 py-2 text-[11.5px] text-muted">
|
<div className="grid grid-cols-2 gap-2 border-t border-line bg-bg/40 p-2.5 text-[11px] text-muted sm:flex sm:flex-wrap sm:items-center sm:gap-x-5 sm:gap-y-2 sm:px-4 sm:py-2 sm:text-[11.5px]">
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5 truncate">
|
||||||
<Package className="h-3 w-3" />
|
<Package className="h-3 w-3 shrink-0" />
|
||||||
پیشنهاد: <b className="text-ink tabular-nums">{fmtDateTime(q.quote_date)}</b>
|
<span>تاریخ:</span>
|
||||||
|
<b className="text-ink tabular-nums">{fmtDate(q.quote_date)}</b>
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<CreditCard className="h-3 w-3" />
|
<span className="inline-flex items-center gap-1.5 truncate">
|
||||||
معتبر تا:{" "}
|
<CreditCard className="h-3 w-3 shrink-0" />
|
||||||
|
<span>اعتبار:</span>
|
||||||
<b className={cn("tabular-nums", expired ? "text-amber" : "text-ink")}>
|
<b className={cn("tabular-nums", expired ? "text-amber" : "text-ink")}>
|
||||||
{fmtDate(q.expiry_date)}
|
{fmtDate(q.expiry_date)}
|
||||||
</b>
|
</b>
|
||||||
{expired && <AlarmClock className="h-3 w-3 text-amber" aria-label="منقضی" />}
|
{expired && <AlarmClock className="h-3 w-3 text-amber shrink-0" aria-label="منقضی" />}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
پذیرش: {acceptedCell}
|
<span>پذیرش:</span>
|
||||||
|
{acceptedCell}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
رسید: {arrivedCell}
|
<span className="inline-flex items-center gap-1.5 truncate">
|
||||||
|
<span>رسید:</span>
|
||||||
|
{arrivedCell}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* جریان (expand) */}
|
{/* جزئیات تکمیلی بازشونده */}
|
||||||
{expanded && <QuoteDetail store={store} q={q} />}
|
{expanded && <QuoteDetail store={store} q={q} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link, useLocation, useSearchParams } from "react-router-dom";
|
import { Link, useLocation, useSearchParams } from "react-router-dom";
|
||||||
import { Plus, Warehouse as WarehouseIcon } from "lucide-react";
|
import { Plus, Warehouse as WarehouseIcon, SlidersHorizontal, ArrowUpDown, X } from "lucide-react";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { EmptyState } from "../../components/EmptyState";
|
import { EmptyState } from "../../components/EmptyState";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
@@ -12,6 +12,7 @@ import { useInventoryData, brandName, catName, materialName, unitName, whName }
|
|||||||
import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
|
import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
|
||||||
import { faNum } from "../../lib/formatters";
|
import { faNum } from "../../lib/formatters";
|
||||||
import { isoToJalaliStr } from "../../lib/jalali";
|
import { isoToJalaliStr } from "../../lib/jalali";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
// رنگبندی معنایی: سبز زمردی = ورود/افزایش، رز = خروج/کاهش، آبی = خنثی/جابهجایی
|
// رنگبندی معنایی: سبز زمردی = ورود/افزایش، رز = خروج/کاهش، آبی = خنثی/جابهجایی
|
||||||
const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = {
|
const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = {
|
||||||
@@ -47,25 +48,23 @@ function specsHash(specs: Record<string, string> | null | undefined): string {
|
|||||||
return keys.map((k) => `${k}=${s[k]}`).join("|");
|
return keys.map((k) => `${k}=${s[k]}`).join("|");
|
||||||
}
|
}
|
||||||
|
|
||||||
// داشبورد انبار — پورت کامل نسخهی ارسالی (گروهبندی + خط زمانی + فیلترها)
|
|
||||||
export function InventoryPage() {
|
export function InventoryPage() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const { warehouses, inventory, transactions, lookup, loading } = useInventoryData();
|
const { warehouses, inventory, transactions, lookup, loading } = useInventoryData();
|
||||||
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager"; // مدیر تدارکات = ادمین دفتر
|
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
|
||||||
const isPM = user?.role === "pm";
|
const isPM = user?.role === "pm";
|
||||||
// پرش عمیق از «انبارها»: /inventory?warehouse=<id> → داشبورد همان انبار از پیش فیلتر شده
|
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
|
|
||||||
const [filters, setFilters] = useState<{ category: string; warehouse: string; material: string; project: string }>({
|
const [filters, setFilters] = useState<{ category: string; warehouse: string; material: string; project: string }>({
|
||||||
category: "", warehouse: params.get("warehouse") || "", material: "", project: "",
|
category: "", warehouse: params.get("warehouse") || "", material: "", project: "",
|
||||||
});
|
});
|
||||||
const [sort, setSort] = useState<"asc" | "desc" | "">("");
|
const [sort, setSort] = useState<"asc" | "desc" | "">("");
|
||||||
|
const [showMobileFilters, setShowMobileFilters] = useState(false);
|
||||||
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
||||||
const [selTx, setSelTx] = useState<string | null>(null);
|
const [selTx, setSelTx] = useState<string | null>(null);
|
||||||
const [expBrand, setExpBrand] = useState<string | null>(null);
|
const [expBrand, setExpBrand] = useState<string | null>(null);
|
||||||
|
|
||||||
// ادمین: انبارهای همه پروژهها یا پروژهی انتخابی
|
|
||||||
const visibleWarehouses = useMemo(() => {
|
const visibleWarehouses = useMemo(() => {
|
||||||
if (isAdmin && filters.project) return warehouses.filter((w) => w.project_id === filters.project);
|
if (isAdmin && filters.project) return warehouses.filter((w) => w.project_id === filters.project);
|
||||||
if (isAdmin) return warehouses;
|
if (isAdmin) return warehouses;
|
||||||
@@ -120,7 +119,7 @@ export function InventoryPage() {
|
|||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
||||||
|
|
||||||
const hasFilter = !!(filters.category || filters.warehouse || filters.material || (isAdmin && filters.project));
|
const hasFilter = !!(filters.category || filters.warehouse || (isAdmin && filters.project));
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen" />;
|
if (loading) return <div className="min-h-screen" />;
|
||||||
if (!user || (!isAdmin && !isPM)) {
|
if (!user || (!isAdmin && !isPM)) {
|
||||||
@@ -135,34 +134,96 @@ export function InventoryPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen pb-20 md:pb-6">
|
||||||
<Header
|
<Header
|
||||||
title="سامانه مدیریت انبار"
|
title="سامانه مدیریت انبار"
|
||||||
sub={inventorySubheader(user)}
|
sub={inventorySubheader(user)}
|
||||||
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
|
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
|
||||||
menu={<AppSwitcherMenu activeModule="warehouse" canSwitchToProcurement={isAdmin} />}
|
menu={<AppSwitcherMenu activeModule="warehouse" canSwitchToProcurement={isAdmin} />}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<div className="flex items-center gap-2">
|
||||||
|
{/* تبهای تکراری در موبایل مخفی و فقط در دسکتاپ نمایش داده میشوند */}
|
||||||
|
<div className="hidden md:flex items-center gap-2">
|
||||||
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />
|
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />
|
||||||
|
</div>
|
||||||
{isPM && (
|
{isPM && (
|
||||||
<Link
|
<Link
|
||||||
to="/inventory/change"
|
to="/inventory/change"
|
||||||
className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark"
|
className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark h-8 px-2.5 text-xs flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
تغییر موجودی
|
<span>تغییر موجودی</span>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<PageLayout className="flex flex-col gap-3">
|
<PageLayout className="flex flex-col gap-3 px-3 md:px-6">
|
||||||
{/* فیلترها */}
|
{/* نوار فیلتر و جستجوی واکنشگرا */}
|
||||||
<div className="flex flex-wrap items-end gap-2 rounded-xl border border-line bg-panel p-3">
|
<div className="rounded-xl border border-line bg-panel p-2.5 sm:p-3">
|
||||||
<div className="field">
|
<div className="flex items-center gap-2">
|
||||||
<label>دستهبندی</label>
|
<div className="relative flex-1">
|
||||||
|
<input
|
||||||
|
className="h-9 w-full rounded-lg border border-line bg-bg/50 px-3 text-xs focus:border-accent focus:bg-panel focus:outline-none"
|
||||||
|
placeholder="جستجوی نام متریال…"
|
||||||
|
value={filters.material}
|
||||||
|
onChange={(e) => setFilters((f) => ({ ...f, material: e.target.value }))}
|
||||||
|
/>
|
||||||
|
{filters.material && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilters((f) => ({ ...f, material: "" }))}
|
||||||
|
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted hover:text-ink"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowMobileFilters((s) => !s)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-9 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium transition-colors md:hidden",
|
||||||
|
hasFilter ? "border-accent bg-accent/10 text-accent font-bold" : "border-line bg-panel text-ink hover:bg-bg"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="h-3.5 w-3.5" />
|
||||||
|
<span>فیلترها</span>
|
||||||
|
{hasFilter && <span className="h-1.5 w-1.5 rounded-full bg-accent" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-9 items-center gap-1 rounded-lg border px-2.5 text-xs transition-colors",
|
||||||
|
sort ? "border-accent bg-accent/5 text-accent font-semibold" : "border-line bg-panel text-ink hover:bg-bg"
|
||||||
|
)}
|
||||||
|
onClick={() => setSort((s) => (s === "" ? "asc" : s === "asc" ? "desc" : ""))}
|
||||||
|
title="مرتبسازی تعداد"
|
||||||
|
>
|
||||||
|
<ArrowUpDown className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
{sort === "asc" ? "تعداد: صعودی" : sort === "desc" ? "تعداد: نزولی" : "مرتبسازی"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Badge variant="outline" className="hidden sm:inline-flex h-9 items-center px-2.5 text-xs">
|
||||||
|
{faNum(groups.length)} قلم
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* گزینههای فیلتر پیشرفته: در موبایل با دکمه باز میشود و در دسکتاپ همیشه باز است */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-3 grid grid-cols-1 gap-2.5 border-t border-line/60 pt-3 sm:grid-cols-2 md:flex md:flex-wrap md:items-end",
|
||||||
|
!showMobileFilters && "hidden md:flex"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="field flex-1 min-w-[140px]">
|
||||||
|
<label className="text-[11px] font-semibold text-muted">دستهبندی</label>
|
||||||
<select
|
<select
|
||||||
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
|
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-xs"
|
||||||
value={filters.category}
|
value={filters.category}
|
||||||
onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value, material: "" }))}
|
onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value, material: "" }))}
|
||||||
>
|
>
|
||||||
@@ -172,11 +233,12 @@ export function InventoryPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="field">
|
<div className="field flex-1 min-w-[140px]">
|
||||||
<label>پروژه</label>
|
<label className="text-[11px] font-semibold text-muted">پروژه</label>
|
||||||
<select
|
<select
|
||||||
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
|
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-xs"
|
||||||
value={filters.project}
|
value={filters.project}
|
||||||
onChange={(e) => setFilters((f) => ({ ...f, project: e.target.value, warehouse: "", material: "" }))}
|
onChange={(e) => setFilters((f) => ({ ...f, project: e.target.value, warehouse: "", material: "" }))}
|
||||||
>
|
>
|
||||||
@@ -187,10 +249,11 @@ export function InventoryPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="field">
|
|
||||||
<label>انبار</label>
|
<div className="field flex-1 min-w-[140px]">
|
||||||
|
<label className="text-[11px] font-semibold text-muted">انبار</label>
|
||||||
<select
|
<select
|
||||||
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
|
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-xs"
|
||||||
value={filters.warehouse}
|
value={filters.warehouse}
|
||||||
onChange={(e) => setFilters((f) => ({ ...f, warehouse: e.target.value, material: "" }))}
|
onChange={(e) => setFilters((f) => ({ ...f, warehouse: e.target.value, material: "" }))}
|
||||||
>
|
>
|
||||||
@@ -202,52 +265,40 @@ export function InventoryPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
|
||||||
<label>متریال</label>
|
|
||||||
<input
|
|
||||||
className="h-8 w-44 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
|
|
||||||
placeholder="تایپ نام متریال…"
|
|
||||||
value={filters.material}
|
|
||||||
onChange={(e) => setFilters((f) => ({ ...f, material: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="h-8 rounded-lg border border-line bg-panel px-3 text-[12px]"
|
|
||||||
onClick={() => setSort((s) => (s === "" ? "asc" : s === "asc" ? "desc" : ""))}
|
|
||||||
>
|
|
||||||
{sort === "asc" ? "تعداد: صعودی ▲" : sort === "desc" ? "تعداد: نزولی ▼" : "مرتبسازی تعداد"}
|
|
||||||
</button>
|
|
||||||
{hasFilter && (
|
{hasFilter && (
|
||||||
<button
|
<button
|
||||||
className="h-8 rounded-lg border border-red/30 bg-red/5 px-3 text-[12px] text-red"
|
type="button"
|
||||||
|
className="h-8 rounded-lg border border-red/30 bg-red/5 px-3 text-xs text-red hover:bg-red/10 transition-colors"
|
||||||
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })}
|
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })}
|
||||||
>
|
>
|
||||||
✕ پاک کردن فیلترها
|
✕ پاک کردن فیلترها
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{filters.warehouse && (
|
|
||||||
<Badge variant="offered">🏬 {whName(warehouses, filters.warehouse)}</Badge>
|
<div className="text-[11px] text-muted ms-auto sm:hidden">
|
||||||
)}
|
نمایش {faNum(groups.length)} قلم کالا
|
||||||
<Badge variant="outline">{faNum(groups.length)} قلم کالا</Badge>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* جدول گروهبندیشده */}
|
{/* جدول موجودی انبار با اسکرول افقی محافظتشده */}
|
||||||
<div className="overflow-hidden rounded-xl border border-line bg-panel">
|
<div className="overflow-x-auto rounded-xl border border-line bg-panel shadow-2xs [scrollbar-width:thin]">
|
||||||
{!groups.length ? (
|
{!groups.length ? (
|
||||||
<EmptyState emoji="📋" text="هیچ رکورد موجودیای مطابق فیلترهای انتخابی یافت نشد." />
|
<EmptyState emoji="📋" text="هیچ رکورد موجودیای مطابق فیلترهای انتخابی یافت نشد." />
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-[12.5px]">
|
<table className="w-full min-w-[640px] text-[12.5px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-line bg-panel text-right text-muted">
|
<tr className="border-b border-line bg-bg/40 text-right text-muted">
|
||||||
<th className="w-8 py-2" />
|
<th className="w-8 py-2.5" />
|
||||||
<th className="py-2 pe-3">متریال</th>
|
<th className="py-2.5 pe-3 font-semibold">متریال</th>
|
||||||
<th className="py-2 pe-3">دستهبندی</th>
|
<th className="py-2.5 pe-3 font-semibold">دستهبندی</th>
|
||||||
<th className="py-2 pe-3">انبار</th>
|
<th className="py-2.5 pe-3 font-semibold">انبار</th>
|
||||||
<th className="py-2 pe-3">مشخصه فنی</th>
|
<th className="py-2.5 pe-3 font-semibold">مشخصه فنی</th>
|
||||||
<th className="py-2 pe-3">تعداد</th>
|
<th className="py-2.5 pe-3 font-semibold">تعداد</th>
|
||||||
<th className="py-2 pe-3">واحد</th>
|
<th className="py-2.5 pe-3 font-semibold">واحد</th>
|
||||||
<th className="py-2 pe-3">آخرین بهروزرسانی</th>
|
<th className="py-2.5 pe-3 font-semibold">آخرین بهروزرسانی</th>
|
||||||
<th className="w-8 py-2" />
|
<th className="w-8 py-2.5" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -295,7 +346,9 @@ export function InventoryPage() {
|
|||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-muted">
|
|
||||||
|
{/* متن راهنما فقط در دسکتاپ نمایش داده میشود */}
|
||||||
|
<div className="hidden md:block text-[11px] text-muted">
|
||||||
۱۰ نوع تراکنش استاندارد • سبز زمردی = ورود به انبار · رز = خروج/کاهش · آبی = انتقال و شمارش • کلیک روی هر سطر: خط زمانی با «مانده» لحظهبهلحظه و تفکیک برندها
|
۱۰ نوع تراکنش استاندارد • سبز زمردی = ورود به انبار · رز = خروج/کاهش · آبی = انتقال و شمارش • کلیک روی هر سطر: خط زمانی با «مانده» لحظهبهلحظه و تفکیک برندها
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
@@ -317,21 +370,21 @@ function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit,
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr className={`border-b border-line/60 cursor-pointer ${isExp ? "bg-accent/5" : "hover:bg-accent/5"}`} onClick={onToggle}>
|
<tr className={`border-b border-line/60 cursor-pointer transition-colors ${isExp ? "bg-accent/5" : "hover:bg-accent/5"}`} onClick={onToggle}>
|
||||||
<td className="py-2 text-center text-muted">{isExp ? "▾" : "▸"}</td>
|
<td className="py-2.5 text-center text-muted">{isExp ? "▾" : "▸"}</td>
|
||||||
<td className="py-2 pe-3 font-semibold">{matName}</td>
|
<td className="py-2.5 pe-3 font-bold text-ink">{matName}</td>
|
||||||
<td className="py-2 pe-3">{matCatName}</td>
|
<td className="py-2.5 pe-3 text-muted">{matCatName}</td>
|
||||||
<td className="py-2 pe-3">
|
<td className="py-2.5 pe-3">
|
||||||
{wh}
|
<div>{wh}</div>
|
||||||
{isAdmin && <small className="block text-muted">{projName}</small>}
|
{isAdmin && <span className="block text-[10.5px] text-muted">{projName}</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 pe-3 text-muted">
|
<td className="py-2.5 pe-3 text-muted">
|
||||||
{Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}
|
{Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 pe-3 font-bold">{faNum(g.qty)}</td>
|
<td className="py-2.5 pe-3 font-extrabold text-ink tabular-nums">{faNum(g.qty)}</td>
|
||||||
<td className="py-2 pe-3">{unit}</td>
|
<td className="py-2.5 pe-3">{unit}</td>
|
||||||
<td className="py-2 pe-3 text-muted">{isoToJalaliStr(g.updatedAt)}</td>
|
<td className="py-2.5 pe-3 text-muted tabular-nums">{isoToJalaliStr(g.updatedAt)}</td>
|
||||||
<td className="py-2 text-muted">🕘</td>
|
<td className="py-2.5 text-muted">🕘</td>
|
||||||
</tr>
|
</tr>
|
||||||
{isExp && (
|
{isExp && (
|
||||||
<tr className="border-b border-line/40 bg-accent-bg/20">
|
<tr className="border-b border-line/40 bg-accent-bg/20">
|
||||||
@@ -367,14 +420,14 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
|
|||||||
key={t.id}
|
key={t.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelTx(sel ? null : t.id)}
|
onClick={() => setSelTx(sel ? null : t.id)}
|
||||||
className={`flex min-w-[120px] flex-col gap-0.5 rounded-lg border p-2 text-start ${sel ? "border-accent bg-accent/10" : "border-line bg-panel"}`}
|
className={`flex min-w-[120px] flex-col gap-0.5 rounded-lg border p-2 text-start transition-all ${sel ? "border-accent bg-accent/10 shadow-xs" : "border-line bg-panel hover:border-accent/40"}`}
|
||||||
style={{ borderTop: `3px solid ${meta.color}` }}
|
style={{ borderTop: `3px solid ${meta.color}` }}
|
||||||
>
|
>
|
||||||
<span className="text-[11px] font-bold" style={{ color: meta.color }}>{meta.icon} {meta.label}</span>
|
<span className="text-[11px] font-bold" style={{ color: meta.color }}>{meta.icon} {meta.label}</span>
|
||||||
<span className="text-[12px] font-bold" style={{ color: meta.color }}>
|
<span className="text-[12px] font-bold tabular-nums" style={{ color: meta.color }}>
|
||||||
{faNum(Math.abs(t.quantity_number))} {unit}
|
{faNum(Math.abs(t.quantity_number))} {unit}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10.5px] text-muted">مانده: <b>{faNum(bal)}</b> {unit}</span>
|
<span className="text-[10.5px] text-muted">مانده: <b className="tabular-nums">{faNum(bal)}</b> {unit}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -385,7 +438,7 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
|
|||||||
const brandSec = expBrand ? (
|
const brandSec = expBrand ? (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="mb-1 text-[12px] font-semibold">جریان برند: {brandNameFn(expBrand)} ({faNum(brandTxs(g, expBrand).length)} تراکنش)</div>
|
<div className="mb-1 text-[12px] font-semibold">جریان برند: {brandNameFn(expBrand)} ({faNum(brandTxs(g, expBrand).length)} تراکنش)</div>
|
||||||
<div className="flex gap-1.5 overflow-x-auto">{track(brandTxs(g, expBrand))}</div>
|
<div className="flex gap-1.5 overflow-x-auto pb-1 [scrollbar-width:thin]">{track(brandTxs(g, expBrand))}</div>
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
@@ -395,21 +448,21 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
|
|||||||
const meta = TX_META[t.transaction_type] || { label: t.transaction_type, color: "#64748b", icon: "•" };
|
const meta = TX_META[t.transaction_type] || { label: t.transaction_type, color: "#64748b", icon: "•" };
|
||||||
const pairTx = t.transfer_pair_id ? allTxs.find((x) => x.transfer_pair_id === t.transfer_pair_id && x.id !== t.id) : null;
|
const pairTx = t.transfer_pair_id ? allTxs.find((x) => x.transfer_pair_id === t.transfer_pair_id && x.id !== t.id) : null;
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 rounded-lg border p-2 text-[12px]" style={{ borderTop: `3px solid ${meta.color}` }}>
|
<div className="mt-2 rounded-lg border p-2.5 text-[12px] bg-panel shadow-2xs" style={{ borderTop: `3px solid ${meta.color}` }}>
|
||||||
<b style={{ color: meta.color }}>{meta.label}</b>
|
<b style={{ color: meta.color }}>{meta.label}</b>
|
||||||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-muted">
|
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-muted">
|
||||||
<span>تاریخ و ساعت: <b>{isoToJalaliStr(t.transaction_date)}</b></span>
|
<span>تاریخ و ساعت: <b className="text-ink">{isoToJalaliStr(t.transaction_date)}</b></span>
|
||||||
{t.transaction_type === "TRANSFER_OUT" || t.transaction_type === "TRANSFER_IN" ? (
|
{t.transaction_type === "TRANSFER_OUT" || t.transaction_type === "TRANSFER_IN" ? (
|
||||||
<>
|
<>
|
||||||
<span>از انبار: <b>{t.transaction_type === "TRANSFER_OUT" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span>
|
<span>از انبار: <b className="text-ink">{t.transaction_type === "TRANSFER_OUT" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span>
|
||||||
<span>به انبار: <b>{t.transaction_type === "TRANSFER_IN" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span>
|
<span>به انبار: <b className="text-ink">{t.transaction_type === "TRANSFER_IN" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span>انبار: <b>{whNameFn(t.warehouse_id)}</b></span>
|
<span>انبار: <b className="text-ink">{whNameFn(t.warehouse_id)}</b></span>
|
||||||
)}
|
)}
|
||||||
{t.brand?.name && <span>برند: <b>{t.brand.name}</b></span>}
|
{t.brand?.name && <span>برند: <b className="text-ink">{t.brand.name}</b></span>}
|
||||||
{t.performed_by && <span>انجامدهنده: <b>{t.performed_by}</b></span>}
|
{t.performed_by && <span>انجامدهنده: <b className="text-ink">{t.performed_by}</b></span>}
|
||||||
{t.note && <span>یادداشت: <b>{t.note}</b></span>}
|
{t.note && <span>یادداشت: <b className="text-ink">{t.note}</b></span>}
|
||||||
{t.transaction_type === "STOCK_COUNT" && t.recorded_qty != null && t.physical_qty != null && (
|
{t.transaction_type === "STOCK_COUNT" && t.recorded_qty != null && t.physical_qty != null && (
|
||||||
<span>
|
<span>
|
||||||
ثبتشده: {faNum(t.recorded_qty)} • شمارششده: {faNum(t.physical_qty)} • اختلاف: {faNum(t.physical_qty - t.recorded_qty)}
|
ثبتشده: {faNum(t.recorded_qty)} • شمارششده: {faNum(t.physical_qty)} • اختلاف: {faNum(t.physical_qty - t.recorded_qty)}
|
||||||
@@ -437,15 +490,15 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
|
|||||||
key={bid}
|
key={bid}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setExpBrand(expBrand === bid ? null : bid)}
|
onClick={() => setExpBrand(expBrand === bid ? null : bid)}
|
||||||
className={`rounded-lg border px-2 py-1 text-[11.5px] ${expBrand === bid ? "border-accent bg-accent/10 text-accent" : "border-line bg-panel"}`}
|
className={`rounded-lg border px-2 py-1 text-[11.5px] transition-colors ${expBrand === bid ? "border-accent bg-accent/10 text-accent font-bold" : "border-line bg-panel hover:bg-bg"}`}
|
||||||
>
|
>
|
||||||
{brandNameFn(bid)}: <b>{faNum(g.brands[bid])}</b> {unit}{expBrand === bid ? " ✕" : ""}
|
{brandNameFn(bid)}: <b className="tabular-nums">{faNum(g.brands[bid])}</b> {unit}{expBrand === bid ? " ✕" : ""}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{brandSec}
|
{brandSec}
|
||||||
<div className="mb-1 mt-2 text-[12px] font-semibold">جریان کلی رکورد — ترکیب همه برندها ({faNum(txs.length)} تراکنش)</div>
|
<div className="mb-1 mt-2 text-[12px] font-semibold">جریان کلی رکورد — ترکیب همه برندها ({faNum(txs.length)} تراکنش)</div>
|
||||||
<div className="flex gap-1.5 overflow-x-auto pb-1">{track(txs)}</div>
|
<div className="flex gap-1.5 overflow-x-auto pb-1 [scrollbar-width:thin]">{track(txs)}</div>
|
||||||
{selDetail}
|
{selDetail}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user