refactor(ui): modernize proposal wizard, dashboard cards and daily control calendar
Frontend-only application of prod_v2 (7bc6845, a29eafc). Dev docker-compose.yml change (b11cb36) and backups churn excluded.
This commit is contained in:
@@ -8,19 +8,17 @@ export interface TermsheetItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TermsheetCardProps {
|
export interface TermsheetCardProps {
|
||||||
index: number; // 1-based → «گزینه N»
|
index: number;
|
||||||
milestones: Milestone[];
|
milestones: Milestone[];
|
||||||
onMilestonesChange: (ms: Milestone[]) => void;
|
onMilestonesChange: (ms: Milestone[]) => void;
|
||||||
// اقلام قابل تیک (مرحله ۲)
|
|
||||||
items?: TermsheetItem[];
|
items?: TermsheetItem[];
|
||||||
coveredIds?: string[]; // tempIdهایی که تیک دارند
|
coveredIds?: string[];
|
||||||
onCoverageChange?: (ids: string[]) => void;
|
onCoverageChange?: (ids: string[]) => void;
|
||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
defaultAll?: boolean; // وقتی هیچ تیکی نیست، پیشفرض همه تیک بخورند
|
defaultAll?: boolean;
|
||||||
removable?: boolean;
|
removable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// کارت ترمشیت — هم در ویزارد (مرحله ۳) هم در مودال پذیرش/ویرایش
|
|
||||||
export function TermsheetCard({
|
export function TermsheetCard({
|
||||||
index,
|
index,
|
||||||
milestones,
|
milestones,
|
||||||
@@ -33,7 +31,6 @@ export function TermsheetCard({
|
|||||||
removable = true,
|
removable = true,
|
||||||
}: TermsheetCardProps) {
|
}: TermsheetCardProps) {
|
||||||
const covered = new Set(coveredIds);
|
const covered = new Set(coveredIds);
|
||||||
// پیشفرض: وقتی هیچ قلمی تیک نخورده و defaultAll فعال است → همه تیک
|
|
||||||
const effectiveCovered = covered.size === 0 && defaultAll ? items.map((i) => i.tempId) : coveredIds;
|
const effectiveCovered = covered.size === 0 && defaultAll ? items.map((i) => i.tempId) : coveredIds;
|
||||||
|
|
||||||
const toggle = (tempId: string, checked: boolean) => {
|
const toggle = (tempId: string, checked: boolean) => {
|
||||||
@@ -49,17 +46,25 @@ export function TermsheetCard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="variant-card">
|
<div className="rounded-xl border border-line bg-panel p-4">
|
||||||
<div className="variant-head">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="variant-num">گزینه {faNum(index)}</span>
|
<span className="font-bold text-[13px]">گزینه {faNum(index)}</span>
|
||||||
{removable && onRemove && (
|
{removable && onRemove && (
|
||||||
<button type="button" className="variant-x" title="حذف" onClick={onRemove}>
|
<button
|
||||||
×
|
type="button"
|
||||||
|
className="text-gray-400 hover:text-red-500 p-1 rounded transition"
|
||||||
|
title="حذف گزینه"
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-1.5 text-[12px] text-muted">مراحل پرداخت:</div>
|
<div className="mb-1.5 text-[12px] text-muted">مراحل پرداخت:</div>
|
||||||
<div className="mb-1 flex flex-col gap-1.5">
|
<div className="mb-2 flex flex-col gap-1.5">
|
||||||
{milestones.map((m, i) => (
|
{milestones.map((m, i) => (
|
||||||
<MilestoneRow
|
<MilestoneRow
|
||||||
key={i}
|
key={i}
|
||||||
@@ -69,31 +74,31 @@ export function TermsheetCard({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn-line-file mt-1" onClick={addMilestone}>
|
|
||||||
+ مرحله پرداخت
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full py-1.5 border border-dashed border-gray-300 hover:border-blue-500 hover:text-blue-600 rounded-lg text-[12px] text-gray-500 transition mb-3"
|
||||||
|
onClick={addMilestone}
|
||||||
|
>
|
||||||
|
+ مرحله پرداخت
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<div className="ts-items-box">
|
<div className="pt-2 border-t border-line/60">
|
||||||
<div className="ts-items-title">این termsheet کدام اقلام (مرحله ۲) را پوشش میدهد؟</div>
|
<div className="text-[12px] text-muted mb-1.5">این termsheet کدام اقلام (مرحله ۲) را پوشش میدهد؟</div>
|
||||||
{items.length === 0 ? (
|
<div className="flex flex-col gap-1 text-[12px]">
|
||||||
<div className="ts-items-empty">هنوز قلمی در مرحلهی ۲ اضافه نشده.</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
{items.map((it) => (
|
{items.map((it) => (
|
||||||
<label key={it.tempId} className="ts-item-label">
|
<label key={it.tempId} className="flex items-center gap-2 cursor-pointer text-gray-700">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="ts-item-cb"
|
className="rounded border-gray-300"
|
||||||
data-item-temp-id={it.tempId}
|
|
||||||
checked={effectiveCovered.includes(it.tempId)}
|
checked={effectiveCovered.includes(it.tempId)}
|
||||||
onChange={(e) => toggle(it.tempId, e.target.checked)}
|
onChange={(e) => toggle(it.tempId, e.target.checked)}
|
||||||
/>
|
/>
|
||||||
{it.label}
|
<span>{it.label}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,12 +26,18 @@ export function TopicGrid({ topics, selectedTopic, onSelect, showCounts = false,
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={t.key}
|
key={t.key}
|
||||||
className={cn("topic-card", selected && "selected")}
|
className={cn("topic-card flex flex-col items-center justify-center p-4", selected && "selected")}
|
||||||
onClick={() => onSelect(t.name)}
|
onClick={() => onSelect(t.name)}
|
||||||
>
|
>
|
||||||
<img src={t.icon} alt="" />
|
{/* استفاده از استایل اینلاین برای تضمین تغییر سایز و دور زدن CSSهای قبلی */}
|
||||||
<div className="t-name">{t.name}</div>
|
<img
|
||||||
{showCounts && <div className="t-count">{faNum(count)} تأمینکننده</div>}
|
src={t.icon}
|
||||||
|
alt=""
|
||||||
|
style={{ width: '130px', height: '80px', objectFit: 'contain' }}
|
||||||
|
className="mb-2"
|
||||||
|
/>
|
||||||
|
<div className="t-name text-[13px] font-semibold">{t.name}</div>
|
||||||
|
{showCounts && <div className="t-count text-[11px] text-muted">{faNum(count)} تأمینکننده</div>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { Users, FolderKanban, Ruler, Plus } from "lucide-react";
|
import { Users, FolderKanban, Ruler, Plus } from "lucide-react";
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
useMaterials,
|
useMaterials,
|
||||||
useProjects,
|
useProjects,
|
||||||
@@ -47,7 +46,7 @@ export function DashboardPage() {
|
|||||||
[quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics],
|
[quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics],
|
||||||
);
|
);
|
||||||
|
|
||||||
// فیلتر + مرتبسازی بر اساس زمان ساخت (created_at) — فیلترها بازنشانی نمیشوند
|
// فیلتر + مرتبسازی بر اساس زمان ساخت (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]) => {
|
||||||
@@ -80,35 +79,43 @@ export function DashboardPage() {
|
|||||||
brand
|
brand
|
||||||
menu={<AppSwitcherMenu activeModule="procurement" />}
|
menu={<AppSwitcherMenu activeModule="procurement" />}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<div className="flex items-center gap-2">
|
||||||
<Button asChild variant="slate">
|
{/* دکمه تأمینکنندهها */}
|
||||||
<Link to="/suppliers" className="no-underline">
|
<Link
|
||||||
<Users className="h-4 w-4" />
|
to="/suppliers"
|
||||||
تأمینکنندهها
|
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline"
|
||||||
|
>
|
||||||
|
<Users className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
|
||||||
|
<span>تأمینکنندهها</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button asChild variant="slate">
|
{/* دکمه پروژهها */}
|
||||||
<Link to="/projects" className="no-underline">
|
<Link
|
||||||
<FolderKanban className="h-4 w-4" />
|
to="/projects"
|
||||||
پروژهها
|
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline"
|
||||||
|
>
|
||||||
|
<FolderKanban className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
|
||||||
|
<span>پروژهها</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button asChild variant="line">
|
{/* دکمه ضرایب تبدیل */}
|
||||||
<Link to="/units" className="no-underline">
|
<Link
|
||||||
<Ruler className="h-4 w-4" />
|
to="/units"
|
||||||
ضرایب تبدیل
|
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline"
|
||||||
|
>
|
||||||
|
<Ruler className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
|
||||||
|
<span>ضرایب تبدیل</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button asChild variant="primary">
|
{/* دکمه اصلی: افزودن پیشنهاد جدید */}
|
||||||
<Link to="/wizard" className="no-underline">
|
<Link
|
||||||
|
to="/wizard"
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs font-semibold shadow-sm hover:shadow transition-all no-underline"
|
||||||
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
افزودن پیشنهاد جدید
|
<span>افزودن پیشنهاد جدید</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</div>
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<PageLayout className="flex flex-col gap-4">
|
<PageLayout className="flex flex-col gap-4">
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function InfoCell({ label, children }: { label: string; children: React.ReactNod
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// کارت یک پیشنهاد در دفتر — جایگزین جدول ۱۱ ستونه
|
// کارت ساده و یکدست یک پیشنهاد در دفتر
|
||||||
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,11 +46,15 @@ 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
|
||||||
|
type="button"
|
||||||
|
onClick={onAccept}
|
||||||
|
className="px-2 py-0.5 rounded text-xs font-semibold bg-emerald-50 text-emerald-700 hover:bg-emerald-100 border border-emerald-200 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
پذیرش
|
پذیرش
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -60,8 +64,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
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,8 +73,6 @@ 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") {
|
||||||
@@ -97,7 +97,6 @@ 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();
|
||||||
@@ -137,37 +136,49 @@ 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-all shadow-xs",
|
||||||
expired ? "border-amber/40" : "border-line hover:border-accent/40",
|
expired ? "border-amber/40" : "border-line hover:border-gray-300",
|
||||||
expanded && "border-accent/60",
|
expanded && "border-blue-500/50 shadow-sm",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* سربرگ کارت */}
|
{/* هدر یکپارچه و تمیز بدون کادر تیره */}
|
||||||
<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-3 border-b border-line/60 px-4 py-3">
|
||||||
<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-gray-100 text-xs font-bold text-gray-700">
|
||||||
{sup.name.slice(0, 1)}
|
{sup.name.slice(0, 1)}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<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-[13.5px] font-bold text-ink no-underline hover:text-blue-600"
|
||||||
>
|
>
|
||||||
{sup.name}
|
{sup.name}
|
||||||
</Link>
|
</Link>
|
||||||
{sup.location && <div className="truncate text-[11px] text-muted">{sup.location}</div>}
|
{sup.location && <div className="truncate text-[11px] text-muted">{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-2">
|
||||||
{fulfillmentBadge ?? (
|
{fulfillmentBadge ?? (
|
||||||
<Badge variant={q.status as "offered" | "accepted" | "arrived"}>{STATUS_LABEL[q.status]}</Badge>
|
<Badge variant={q.status as "offered" | "accepted" | "arrived"}>{STATUS_LABEL[q.status]}</Badge>
|
||||||
)}
|
)}
|
||||||
<button className="chip" onClick={onEdit} title="ویرایش پیشنهاد">
|
|
||||||
<Pencil className="h-3 w-3" />
|
{/* دکمه ویرایش مینیمال */}
|
||||||
ویرایش
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
className="chip chip-expand"
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-gray-500 hover:text-gray-800 hover:bg-gray-100 text-xs transition cursor-pointer"
|
||||||
|
onClick={onEdit}
|
||||||
|
title="ویرایش پیشنهاد"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
<span>ویرایش</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* فلش آکاردئون */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition cursor-pointer"
|
||||||
onClick={onToggleExpand}
|
onClick={onToggleExpand}
|
||||||
title={expanded ? "جمع کردن" : "نمایش جریان"}
|
title={expanded ? "جمع کردن" : "نمایش جزئیات"}
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
>
|
>
|
||||||
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
|
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
|
||||||
@@ -175,18 +186,21 @@ 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-5 gap-y-3 px-4 py-3.5 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">{fmtNum(summary.count)} قلم</span>
|
||||||
<div className="flex flex-col gap-1 text-[11.5px] leading-5 text-muted">
|
<div className="flex flex-col gap-1 text-[11.5px] leading-5 text-muted">
|
||||||
{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)
|
||||||
|
.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="flex items-center gap-1">
|
||||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent/40" />
|
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-blue-500/60" />
|
||||||
{materialLabel(m)}
|
{materialLabel(m)}
|
||||||
<span className="text-[10px] text-muted/70">
|
<span className="text-[10px] text-muted/70">
|
||||||
{fmtNum(li.quantity)} {li.unit}
|
{fmtNum(li.quantity)} {li.unit}
|
||||||
@@ -251,8 +265,8 @@ 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="flex flex-wrap items-center gap-x-5 gap-y-1.5 border-t border-line/60 px-4 py-2 text-[11.5px] text-muted">
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<Package className="h-3 w-3" />
|
<Package className="h-3 w-3" />
|
||||||
پیشنهاد: <b className="text-ink tabular-nums">{fmtDateTime(q.quote_date)}</b>
|
پیشنهاد: <b className="text-ink tabular-nums">{fmtDateTime(q.quote_date)}</b>
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
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 { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { inventorySubheader } from "./panelMenu";
|
import { inventorySubheader } from "./panelMenu";
|
||||||
@@ -16,7 +27,6 @@ function todayKey(): string {
|
|||||||
return gregKey(new Date());
|
return gregKey(new Date());
|
||||||
}
|
}
|
||||||
|
|
||||||
// کنترل روزانه — پورت کامل تقویم شمسی نسخهی ارسالی
|
|
||||||
export function DailyControlPage() {
|
export function DailyControlPage() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const data = useInventoryData();
|
const data = useInventoryData();
|
||||||
@@ -26,7 +36,6 @@ export function DailyControlPage() {
|
|||||||
const [anchor, setAnchor] = useState<Date>(new Date());
|
const [anchor, setAnchor] = useState<Date>(new Date());
|
||||||
const [sel, setSel] = useState<string>(todayKey());
|
const [sel, setSel] = useState<string>(todayKey());
|
||||||
const [actRec, setActRec] = useState<string | null>(null);
|
const [actRec, setActRec] = useState<string | null>(null);
|
||||||
// فیلتر پروژه — فقط برای مدیر تدارکات/ادمین (مدیر پروژه به پروژه خودش محدود است)
|
|
||||||
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
|
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
|
||||||
const [projFilter, setProjFilter] = useState("");
|
const [projFilter, setProjFilter] = useState("");
|
||||||
|
|
||||||
@@ -46,7 +55,6 @@ export function DailyControlPage() {
|
|||||||
return map;
|
return map;
|
||||||
}, [data.transactions, data.warehouses, projFilter]);
|
}, [data.transactions, data.warehouses, projFilter]);
|
||||||
|
|
||||||
// cells: 42 روز از شنبه قبلِ اولین روز ماه جلالی
|
|
||||||
const cells = useMemo(() => {
|
const cells = useMemo(() => {
|
||||||
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
|
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
|
||||||
let first: Date | null = null;
|
let first: Date | null = null;
|
||||||
@@ -59,7 +67,7 @@ export function DailyControlPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!first) first = new Date(anchor.getTime() - 33 * 86400000);
|
if (!first) first = new Date(anchor.getTime() - 33 * 86400000);
|
||||||
const offset = (first.getDay() + 1) % 7; // شنبه = 0
|
const offset = (first.getDay() + 1) % 7;
|
||||||
const gridStart = new Date(first.getTime() - offset * 86400000);
|
const gridStart = new Date(first.getTime() - offset * 86400000);
|
||||||
const out: { d: Date; inMonth: boolean; isToday: boolean; n: number }[] = [];
|
const out: { d: Date; inMonth: boolean; isToday: boolean; n: number }[] = [];
|
||||||
for (let c = 0; c < 42; c++) {
|
for (let c = 0; c < 42; c++) {
|
||||||
@@ -79,7 +87,6 @@ export function DailyControlPage() {
|
|||||||
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
|
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
|
||||||
const monthTitle = `${toFa(ty)} ${MONTHS[tm - 1]}`;
|
const monthTitle = `${toFa(ty)} ${MONTHS[tm - 1]}`;
|
||||||
|
|
||||||
// جابهجایی بین ماههای شمسی (نه ±۳۳ روز) — ماه قبل/بعد با سالشماری درست
|
|
||||||
const shiftMonth = (delta: number) => {
|
const shiftMonth = (delta: number) => {
|
||||||
let nm = tm + delta;
|
let nm = tm + delta;
|
||||||
let ny = ty;
|
let ny = ty;
|
||||||
@@ -90,18 +97,12 @@ export function DailyControlPage() {
|
|||||||
nm = 1;
|
nm = 1;
|
||||||
ny += 1;
|
ny += 1;
|
||||||
}
|
}
|
||||||
// روزِ برگشتی را نگه میداریم (ماه شمسی از میانهی ماه میلادی شروع میشود:
|
|
||||||
// مثلاً مهر ۱ = ۲۳ سپتامبر). اگر بهجای آن روز «۱» استفاده شود، لنگر در
|
|
||||||
// اولِ ماهِ میلادی مینشیند و هنوز در همان ماه شمسی است — دکمه «ماه بعد»
|
|
||||||
// ظاهراً هیچ کاری نمیکند.
|
|
||||||
const [gy, gm, gd] = jalaliToGregorian(ny, nm, 1);
|
const [gy, gm, gd] = jalaliToGregorian(ny, nm, 1);
|
||||||
setAnchor(new Date(gy, gm - 1, gd));
|
setAnchor(new Date(gy, gm - 1, gd));
|
||||||
};
|
};
|
||||||
|
|
||||||
const dayTx = txByDay.get(sel) || [];
|
const dayTx = txByDay.get(sel) || [];
|
||||||
|
|
||||||
// فعالسازی ناوبری ماه از چند مسیر (pointerup / click / کیبورد) با حذف
|
|
||||||
// اجرای تکراری — اگر مرورگر رویداد click را بخورد، pointerup جواب میدهد.
|
|
||||||
const navLast = useRef<{ key: string; at: number }>({ key: "", at: 0 });
|
const navLast = useRef<{ key: string; at: number }>({ key: "", at: 0 });
|
||||||
const navFire = (key: string, action: () => void) => {
|
const navFire = (key: string, action: () => void) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -144,19 +145,24 @@ export function DailyControlPage() {
|
|||||||
const [sy, sm, sd] = gregorianToJalali(selDate.getFullYear(), selDate.getMonth() + 1, selDate.getDate());
|
const [sy, sm, sd] = gregorianToJalali(selDate.getFullYear(), selDate.getMonth() + 1, selDate.getDate());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen bg-slate-50/60">
|
||||||
<Header
|
<Header
|
||||||
title="کنترل روزانه"
|
title="کنترل روزانه"
|
||||||
sub={inventorySubheader(user)}
|
sub={inventorySubheader(user)}
|
||||||
backTo="/inventory"
|
backTo="/inventory"
|
||||||
backLabel="بازگشت به داشبورد"
|
backLabel="بازگشت به داشبورد"
|
||||||
/>
|
/>
|
||||||
<PageLayout className="flex flex-col gap-3">
|
<PageLayout className="flex flex-col gap-4">
|
||||||
{isAdmin && (
|
{/* نوار پروژه سفید و شفاف */}
|
||||||
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-line bg-panel px-3 py-2 text-[12.5px]">
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white p-3 shadow-xs">
|
||||||
<label className="font-bold">🏢 پروژه:</label>
|
<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
|
<select
|
||||||
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
|
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}
|
value={projFilter}
|
||||||
onChange={(e) => setProjFilter(e.target.value)}
|
onChange={(e) => setProjFilter(e.target.value)}
|
||||||
>
|
>
|
||||||
@@ -165,93 +171,193 @@ export function DailyControlPage() {
|
|||||||
<option key={p.id} value={p.id}>{p.name}</option>
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span className="text-[11.5px] text-muted">
|
) : (
|
||||||
{projFilter
|
<span className="font-semibold text-slate-800">پروژه فعال</span>
|
||||||
? "نمایش کنترل روزانه فقط برای پروژه انتخابی"
|
)}
|
||||||
: "نمایش کنترل روزانه همه پروژهها"}
|
<span className="text-[11.5px] text-slate-400">
|
||||||
|
{projFilter ? "نمایش برای پروژه انتخابی" : "نمایش تمام پروژهها"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<Link
|
||||||
{/* تقویم */}
|
to="/inventory/change"
|
||||||
<div className="rounded-xl border border-line bg-panel p-4">
|
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"
|
||||||
<div className="mb-2 flex items-center justify-between">
|
>
|
||||||
<div className="text-[14px] font-bold">📅 {monthTitle}</div>
|
<Plus className="h-3.5 w-3.5" />
|
||||||
<div className="flex gap-1.5">
|
<span>ثبت خروج / تغییر موجودی</span>
|
||||||
<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>
|
</Link>
|
||||||
<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>
|
</div>
|
||||||
<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 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>
|
</div>
|
||||||
<div className="grid grid-cols-7 gap-1">
|
|
||||||
|
{/* روزهای هفته */}
|
||||||
|
<div className="grid grid-cols-7 gap-1 text-center">
|
||||||
{["ش", "ی", "د", "س", "چ", "پ", "ج"].map((d) => (
|
{["ش", "ی", "د", "س", "چ", "پ", "ج"].map((d) => (
|
||||||
<div key={d} className="py-1 text-center text-[11px] font-bold text-muted">{d}</div>
|
<div key={d} className="py-1 text-xs font-semibold text-slate-400">{d}</div>
|
||||||
))}
|
))}
|
||||||
{cells.map((c, i) => {
|
{cells.map((c, i) => {
|
||||||
// gregorianToJalali → [سال، ماه، روز] — روزِ ماه در خانهی تقویم
|
|
||||||
const [, , jd] = gregorianToJalali(c.d.getFullYear(), c.d.getMonth() + 1, c.d.getDate());
|
const [, , jd] = gregorianToJalali(c.d.getFullYear(), c.d.getMonth() + 1, c.d.getDate());
|
||||||
const k = gregKey(c.d);
|
const k = gregKey(c.d);
|
||||||
|
const isSelected = sel === k;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setSel(k); setActRec(null); }}
|
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"}`}
|
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"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{toFa(jd)}
|
<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>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* برداشتهای روز */}
|
{/* ستون جزئیات روز — سفید یکدست */}
|
||||||
<div className="rounded-xl border border-line bg-panel p-4">
|
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-xs">
|
||||||
<div className="mb-1 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between pb-3 border-b border-slate-100">
|
||||||
<h2 className="text-[14px] font-bold">🕘 کنترل روزانه — {isToday ? "امروز " : ""}{toFa(sd)} {MONTHS[sm - 1]} {toFa(sy)}</h2>
|
<div className="flex items-center gap-2">
|
||||||
<span className="rounded-lg bg-rose-50 px-2 py-1 text-[11.5px] font-bold text-rose-600">▼ برداشتها: {faNum(wGroups.length)} قلم</span>
|
<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>
|
</div>
|
||||||
<p className="mb-3 text-[11.5px] leading-5 text-muted">
|
<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>
|
</p>
|
||||||
|
|
||||||
{!wGroups.length ? (
|
{!wGroups.length ? (
|
||||||
<div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-slate-200 py-10 px-4 text-center">
|
||||||
{isToday ? "امروز هنوز برداشتی ثبت نشده است." : "در این روز برداشتی ثبت نشده است."} هر برداشتی که در «تغییر موجودی» ثبت کنید، همینجا نمایش داده میشود.
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-2">
|
||||||
{wGroups.map((g) => (
|
{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"}`}>
|
<div
|
||||||
<button type="button" className="flex w-full items-center gap-2 text-start" onClick={() => setActRec(actRec === g.recId ? null : g.recId)}>
|
key={g.recId}
|
||||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-rose-50 text-rose-600">➖</span>
|
className={`rounded-lg border p-3 transition-colors ${
|
||||||
<span className="min-w-0 flex-1">
|
actRec === g.recId
|
||||||
<span className="block truncate text-[13px] font-semibold">{g.tx.material?.name || "—"}</span>
|
? "border-blue-500 bg-blue-50/20"
|
||||||
<span className="block truncate text-[11px] text-muted">
|
: "border-slate-100 bg-white hover:border-slate-200"
|
||||||
{[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
|
<button
|
||||||
className="rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-1 text-[11.5px] font-semibold text-emerald-700"
|
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 || "")}`)}
|
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>
|
||||||
<button
|
<button
|
||||||
className="rounded-lg border border-rose-300 bg-rose-50 px-3 py-1 text-[11.5px] font-semibold text-rose-700"
|
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 || "")}`)}
|
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>
|
||||||
<button
|
<button
|
||||||
className="rounded-lg border border-rose-300 bg-rose-50 px-3 py-1 text-[11.5px] font-semibold text-rose-700"
|
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 || "")}`)}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -41,9 +41,7 @@ export function itemRowLabel(it: ItemDraft): string {
|
|||||||
return qtyPart ? `${name} -- ${qtyPart}` : name;
|
return qtyPart ? `${name} -- ${qtyPart}` : name;
|
||||||
}
|
}
|
||||||
|
|
||||||
// نام پایهی متریال از نامِ ذخیرهشده: پیشوند برند و پسوند مشخصه حذف میشوند
|
// نام پایهی متریال از نامِ ذخیرهشده
|
||||||
// («راجین سیم مسی» → «سیم») — پیشنهادهای فیلد «متریال» فقط خودِ متریال را نشان
|
|
||||||
// میدهند؛ برند و مشخصه ستونهای جداگانه دارند.
|
|
||||||
function materialBaseName(name: string, brand: string, specs: string[]): string {
|
function materialBaseName(name: string, brand: string, specs: string[]): string {
|
||||||
let base = (name || "").trim();
|
let base = (name || "").trim();
|
||||||
if (!base) return "";
|
if (!base) return "";
|
||||||
@@ -60,9 +58,7 @@ function materialBaseName(name: string, brand: string, specs: string[]): string
|
|||||||
return base || (name || "").trim();
|
return base || (name || "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// مقادیر مجزای category/brand/spec داخل تاپیک — با فیلتر پلهبهپله (تاپیک → دسته → برند)
|
// مقادیر مجزای category/brand/spec داخل تاپیک
|
||||||
// + مقادیر تایپشده در همهی سطرهای قبلی (suggestion از همهی فیلدها)
|
|
||||||
// نکته: برند و واحد از همهی دستهها نمایش داده میشوند (issue #7)
|
|
||||||
export function materialOptions(
|
export function materialOptions(
|
||||||
materials: Material[],
|
materials: Material[],
|
||||||
topic: string,
|
topic: string,
|
||||||
@@ -75,7 +71,6 @@ export function materialOptions(
|
|||||||
const cat = item.cat.trim();
|
const cat = item.cat.trim();
|
||||||
const brand = item.brand.trim();
|
const brand = item.brand.trim();
|
||||||
|
|
||||||
// مقادیر واردشده در سطرهای دیگر — برای پیشنهاد در سطرهای جدید
|
|
||||||
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
|
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
|
||||||
const out = new Set<string>();
|
const out = new Set<string>();
|
||||||
allItems.forEach((it) => {
|
allItems.forEach((it) => {
|
||||||
@@ -94,7 +89,6 @@ export function materialOptions(
|
|||||||
}
|
}
|
||||||
if (cat) list = list.filter((m) => normalize(m.category) === normalize(cat));
|
if (cat) list = list.filter((m) => normalize(m.category) === normalize(cat));
|
||||||
if (field === "brand") {
|
if (field === "brand") {
|
||||||
// همهی برندها از همهی materials + برندهای ثبتشده در بخش برندها (بکاند)
|
|
||||||
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
|
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
|
||||||
apiBrands.forEach((b) => b && allBrands.add(b));
|
apiBrands.forEach((b) => b && allBrands.add(b));
|
||||||
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
|
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
|
||||||
@@ -110,21 +104,18 @@ export function materialOptions(
|
|||||||
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
|
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
|
||||||
const materials = useMaterials();
|
const materials = useMaterials();
|
||||||
|
|
||||||
// واحدها از بکاند خوانده میشوند + واحدهای واردشده در سطرهای قبلی
|
|
||||||
const [unitOptions, setUnitOptions] = useState<string[]>(() => {
|
const [unitOptions, setUnitOptions] = useState<string[]>(() => {
|
||||||
const all = new Set<string>();
|
const all = new Set<string>();
|
||||||
items.forEach((it) => { if (it.unit) all.add(it.unit); });
|
items.forEach((it) => { if (it.unit) all.add(it.unit); });
|
||||||
return [...all];
|
return [...all];
|
||||||
});
|
});
|
||||||
// واحدهای موجود (دیتابیس) + واحدهای تایپشده در سطرهای دیگر
|
|
||||||
const allUnits = useMemo(() => {
|
const allUnits = useMemo(() => {
|
||||||
const out = new Set(unitOptions);
|
const out = new Set(unitOptions);
|
||||||
items.forEach((it) => { if (it.unit) out.add(it.unit); });
|
items.forEach((it) => { if (it.unit) out.add(it.unit); });
|
||||||
return [...out];
|
return [...out];
|
||||||
}, [items, unitOptions]);
|
}, [items, unitOptions]);
|
||||||
|
|
||||||
// نامهای پایهی یکتای همهی متریالهای کاتالوگ سراسری (فقط خود متریال،
|
|
||||||
// بدون برند/مشخصه — آنها ستونهای خودشان را دارند)
|
|
||||||
const allMaterialNames = useMemo(() => {
|
const allMaterialNames = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
materials.forEach((m) => {
|
materials.forEach((m) => {
|
||||||
@@ -134,7 +125,6 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
}, [materials]);
|
}, [materials]);
|
||||||
|
|
||||||
// برندهای ثبتشده در بخش برندها (بکاند) — بهعلاوهی برندهای متریالها
|
|
||||||
const [brandOptions, setBrandOptions] = useState<string[]>([]);
|
const [brandOptions, setBrandOptions] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -146,11 +136,11 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
list.forEach((u) => { if (u.name_fa || u.code) merged.add(u.name_fa || u.code); });
|
list.forEach((u) => { if (u.name_fa || u.code) merged.add(u.name_fa || u.code); });
|
||||||
return [...merged];
|
return [...merged];
|
||||||
}))
|
}))
|
||||||
.catch(() => { /* بکاند در دسترس نیست */ });
|
.catch(() => {});
|
||||||
void api
|
void api
|
||||||
.brandsList()
|
.brandsList()
|
||||||
.then((list) => alive && setBrandOptions(list.map((b) => b.name).filter(Boolean)))
|
.then((list) => alive && setBrandOptions(list.map((b) => b.name).filter(Boolean)))
|
||||||
.catch(() => { /* بکاند در دسترس نیست */ });
|
.catch(() => {});
|
||||||
return () => { alive = false; };
|
return () => { alive = false; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -163,14 +153,12 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
};
|
};
|
||||||
|
|
||||||
const add = () => {
|
const add = () => {
|
||||||
// سطر جدید — مقدارها خالی؛ پیشنهادها از همهی سطرهای قبلی میآیند
|
|
||||||
onItemsChange([
|
onItemsChange([
|
||||||
...items,
|
...items,
|
||||||
{ tempId: "it" + Date.now() + Math.random().toString(36).slice(2, 6), cat: "", brand: "", specs: [], qty: null, unit: "", price: null, total: null },
|
{ tempId: "it" + Date.now() + Math.random().toString(36).slice(2, 6), cat: "", brand: "", specs: [], qty: null, unit: "", price: null, total: null },
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// یادآوری یک فیلد تایپشده برای پیشنهادهای آینده (داخل تاپیک)
|
|
||||||
const rememberField = (field: "i-cat" | "i-brand" | "i-spec", it: ItemDraft) => {
|
const rememberField = (field: "i-cat" | "i-brand" | "i-spec", it: ItemDraft) => {
|
||||||
if (!selectedTopic) return;
|
if (!selectedTopic) return;
|
||||||
let rec: MaterialRec;
|
let rec: MaterialRec;
|
||||||
@@ -198,10 +186,10 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-1 text-[15px] font-bold">
|
<h3 className="mb-1 text-[15px] font-bold">
|
||||||
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۲</span>
|
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-[12px] font-bold text-white">۲</span>
|
||||||
اقلام سفارش
|
اقلام سفارش
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را میتوان از گزینهها انتخاب کرد یا تایپ کرد تا بهعنوان گزینهی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحلهی بعد مشخص میکنید.' }} />
|
<div className="mb-3 text-[12px] text-gray-500" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را میتوان از گزینهها انتخاب کرد یا تایپ کرد تا بهعنوان گزینهی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحلهی بعد مشخص میکنید.' }} />
|
||||||
|
|
||||||
<div className="item-row wizard-item-row item-row-head mb-1 pe-7">
|
<div className="item-row wizard-item-row item-row-head mb-1 pe-7">
|
||||||
<span>متریال</span>
|
<span>متریال</span>
|
||||||
@@ -215,12 +203,10 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{items.map((it) => (
|
{items.map((it) => (
|
||||||
<div key={it.tempId} className="item-row wizard-item-row">
|
<div key={it.tempId} className="item-row wizard-item-row items-center">
|
||||||
<Combobox
|
<Combobox
|
||||||
value={it.cat}
|
value={it.cat}
|
||||||
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
||||||
// پیشنهادها: همهی متریالهای کاتالوگ سراسری با نام واقعیشان
|
|
||||||
// (بدون فیلتر تاپیک) — انتخابِ یک متریال موجود در ذخیره، همان را reuse میکند
|
|
||||||
items={allMaterialNames}
|
items={allMaterialNames}
|
||||||
allowAdd
|
allowAdd
|
||||||
addSuffix="به عنوان متریال جدید"
|
addSuffix="به عنوان متریال جدید"
|
||||||
@@ -263,30 +249,22 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
<Combobox
|
<Combobox
|
||||||
value={it.unit}
|
value={it.unit}
|
||||||
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
||||||
// متنِ خودِ همین سطر از پیشنهادها حذف میشود؛ وگرنه متن تایپشده
|
|
||||||
// «تطبیق دقیق» پیدا میکرد و گزینهی «به عنوان واحد جدید» هرگز ظاهر نمیشد
|
|
||||||
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
|
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
|
||||||
allowAdd
|
allowAdd
|
||||||
addSuffix="به عنوان واحد جدید"
|
addSuffix="به عنوان واحد جدید"
|
||||||
placeholder="واحد"
|
placeholder="واحد"
|
||||||
onInputChange={(v) => patch(it.tempId, { unit: v })}
|
onInputChange={(v) => patch(it.tempId, { unit: v })}
|
||||||
onAdd={(v) => {
|
onAdd={(v) => {
|
||||||
// انتخاب گزینهی «+ [واحد] به عنوان واحد جدید» → ثبت فوری در دیتابیس؛
|
|
||||||
// نامِ تایپشده هم روی همان قلم اعمال میشود (onInputChange بالا)
|
|
||||||
const name = v.trim();
|
const name = v.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
void apiV26.catalog
|
void apiV26.catalog
|
||||||
.createUnit(name)
|
.createUnit(name)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// بعد از ثبت، واحد برای سطرهای بعدی هم پیشنهاد شود
|
|
||||||
setUnitOptions((prev) =>
|
setUnitOptions((prev) =>
|
||||||
prev.some((u) => normalize(u) === normalize(name)) ? prev : [...prev, name],
|
prev.some((u) => normalize(u) === normalize(name)) ? prev : [...prev, name],
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {});
|
||||||
// اگر واحدِ همنام در همین لحظه ساخته شده باشد (تداخل 409) یا شبکه
|
|
||||||
// خطا دهد، هنگام ذخیرهی پیشنهاد دوباره resolve میشود — خطای بلوککننده نیست
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
inputClassName="!text-[12px] !h-9"
|
inputClassName="!text-[12px] !h-9"
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
@@ -315,15 +293,30 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
placeholder="قیمت کل"
|
placeholder="قیمت کل"
|
||||||
showWords
|
showWords
|
||||||
/>
|
/>
|
||||||
<button type="button" className="cursor-pointer text-muted hover:text-red" title="حذف" onClick={() => remove(it.tempId)}>
|
|
||||||
×
|
{/* دکمه حذف مدرن با آیکون سطل زباله */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 hover:bg-red-50 hover:text-red-500 transition-colors"
|
||||||
|
title="حذف قلم"
|
||||||
|
onClick={() => remove(it.tempId)}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" className="btn-line-file mt-2.5" onClick={add}>
|
{/* دکمه افزودن قلم با استایل برجسته و خطچین */}
|
||||||
+ افزودن قلم
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-3 flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-blue-300 bg-blue-50/40 py-2.5 px-4 text-[13px] font-semibold text-blue-600 hover:border-blue-500 hover:bg-blue-100/70 transition-all duration-150"
|
||||||
|
onClick={add}
|
||||||
|
>
|
||||||
|
<span className="text-base font-bold leading-none">+</span>
|
||||||
|
<span>افزودن قلم جدید</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Paperclip } from "lucide-react";
|
|||||||
import { faNum } from "../../lib/formatters";
|
import { faNum } from "../../lib/formatters";
|
||||||
import { groupCommas } from "../../lib/numutil";
|
import { groupCommas } from "../../lib/numutil";
|
||||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||||
import { FileUpload } from "../../components/FileUpload";
|
|
||||||
|
|
||||||
export interface Step5Props {
|
export interface Step5Props {
|
||||||
quoteDate: string | null;
|
quoteDate: string | null;
|
||||||
@@ -52,7 +51,7 @@ export function Step5Review({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-[15px] font-bold">
|
<h3 className="mb-3 text-[15px] font-bold">
|
||||||
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۵</span>
|
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-[12px] font-bold text-white">۵</span>
|
||||||
تاریخ، یادداشت و بازبینی نهایی
|
تاریخ، یادداشت و بازبینی نهایی
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@@ -78,19 +77,43 @@ export function Step5Review({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field col-span-2">
|
<div className="field col-span-2">
|
||||||
<label>پیشفاکتور (اختیاری)</label>
|
<label className="mb-1.5 block text-xs font-medium text-gray-700">پیشفاکتور (اختیاری)</label>
|
||||||
{proforma ? (
|
{proforma ? (
|
||||||
<div className="inv-row">
|
/* وضعیت فایل انتخابشده با دکمه حذف تمیز */
|
||||||
<span className="inv-name">
|
<div className="flex items-center justify-between rounded-xl border border-gray-200 bg-gray-50/70 px-3 py-2 text-xs">
|
||||||
<Paperclip className="me-1 inline h-3.5 w-3.5" />
|
<div className="flex items-center gap-2 truncate text-gray-700">
|
||||||
{proforma.name}
|
<Paperclip className="h-4 w-4 text-blue-600 shrink-0" />
|
||||||
|
<span className="font-medium truncate">{proforma.name}</span>
|
||||||
|
<span className="text-[11px] text-gray-400 shrink-0">
|
||||||
|
({Math.round(proforma.size / 1024)} کیلوبایت)
|
||||||
</span>
|
</span>
|
||||||
<button type="button" className="inv-x" title="حذف" onClick={() => onProforma(null)}>
|
</div>
|
||||||
×
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 hover:bg-red-50 hover:text-red-500 transition-colors"
|
||||||
|
title="حذف فایل"
|
||||||
|
onClick={() => onProforma(null)}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FileUpload onUpload={onProforma} label="انتخاب فایل پیشفاکتور" />
|
/* دکمه آپلود مینیمال با حاشیه خطچین ملایم */
|
||||||
|
<label className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-dashed border-gray-300 bg-gray-50/50 px-4 py-2 text-xs font-medium text-gray-600 hover:border-blue-500 hover:bg-blue-50/40 hover:text-blue-600 transition-all">
|
||||||
|
<Paperclip className="h-4 w-4 text-gray-400" />
|
||||||
|
<span>انتخاب یا پیوست فایل پیشفاکتور</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept=".pdf,.png,.jpg,.jpeg,.xlsx,.xls,.docx"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) onProforma(f);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -73,18 +73,15 @@ export function WizardPage() {
|
|||||||
const [assignment, setAssignment] = useState<Record<string, string[]>>({});
|
const [assignment, setAssignment] = useState<Record<string, string[]>>({});
|
||||||
|
|
||||||
const [quoteDate, setQuoteDate] = useState<string | null>(() => {
|
const [quoteDate, setQuoteDate] = useState<string | null>(() => {
|
||||||
// تاریخ پیشنهاد: همین الان (ساعت و دقیقهی فعلی) — نه نیمهشب
|
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
});
|
});
|
||||||
const [expiryDate, setExpiryDate] = useState<string | null>(null);
|
const [expiryDate, setExpiryDate] = useState<string | null>(null);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [proforma, setProforma] = useState<File | null>(null);
|
const [proforma, setProforma] = useState<File | null>(null);
|
||||||
// دیالوگ ذخیره با فیلدهای خالی (نام فیلد + مرحلهی مربوطه)
|
|
||||||
const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]);
|
const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
|
||||||
// ---------- prefill از لینکها (?topic=&supplier=) ----------
|
// ---------- prefill از لینکها (?topic=&supplier=) ----------
|
||||||
// وقتی suppliers هنوز لود نشده، دوباره اجرا میشود
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = params.get("topic");
|
const t = params.get("topic");
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
@@ -102,23 +99,17 @@ export function WizardPage() {
|
|||||||
}, [params, suppliers, topics]);
|
}, [params, suppliers, topics]);
|
||||||
|
|
||||||
// ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
|
// ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
|
||||||
// مراحل تکمیلشده را بر اساس دادههای فعلی مشخص میکند
|
|
||||||
const completedSteps = useMemo(() => {
|
const completedSteps = useMemo(() => {
|
||||||
const s = new Set<number>();
|
const s = new Set<number>();
|
||||||
// مرحله ۱: تاپیک انتخاب شده + نام تأمینکننده پر شده
|
|
||||||
if (selectedTopic && supplierName.trim()) s.add(1);
|
if (selectedTopic && supplierName.trim()) s.add(1);
|
||||||
// مرحله ۲: حداقل یک قلم با تعداد و قیمت
|
|
||||||
if (items.length > 0 && items.some((it) => it.qty != null && it.qty !== 0 && it.price != null && it.price !== 0)) s.add(2);
|
if (items.length > 0 && items.some((it) => it.qty != null && it.qty !== 0 && it.price != null && it.price !== 0)) s.add(2);
|
||||||
// مرحله ۳: حداقل یک termsheet با مایلستون واقعی (تاریخ یا مبلغ) — نه فقط کارت پیشفرض
|
|
||||||
if (
|
if (
|
||||||
cards.length > 0 &&
|
cards.length > 0 &&
|
||||||
cards.some((card) => (card.milestones || []).some((m) => m.when_iso || (m.amount != null && m.amount !== 0)))
|
cards.some((card) => (card.milestones || []).some((m) => m.when_iso || (m.amount != null && m.amount !== 0)))
|
||||||
) {
|
) {
|
||||||
s.add(3);
|
s.add(3);
|
||||||
}
|
}
|
||||||
// مرحله ۴: همه اقلام حداقل به یک پروژه اختصاص داده شده
|
|
||||||
if (items.length > 0 && items.every((it) => (assignment[it.tempId] || []).length > 0)) s.add(4);
|
if (items.length > 0 && items.every((it) => (assignment[it.tempId] || []).length > 0)) s.add(4);
|
||||||
// مرحله ۵: همیشه بازدیدشده محسوب میشود
|
|
||||||
if (highestVisited >= 5) s.add(5);
|
if (highestVisited >= 5) s.add(5);
|
||||||
return s;
|
return s;
|
||||||
}, [selectedTopic, supplierName, items, cards, coverage, assignment, highestVisited]);
|
}, [selectedTopic, supplierName, items, cards, coverage, assignment, highestVisited]);
|
||||||
@@ -200,16 +191,13 @@ export function WizardPage() {
|
|||||||
}, [items, assignment, cards, coverage, supplierName, selectedTopic, isOfficial]);
|
}, [items, assignment, cards, coverage, supplierName, selectedTopic, isOfficial]);
|
||||||
|
|
||||||
// ---------- ذخیره ----------
|
// ---------- ذخیره ----------
|
||||||
// تابع ذخیرهی اصلی — با یا بدون فیلدهای خالی (انتخاب کاربر در دیالوگ)
|
|
||||||
const doSave = async () => {
|
const doSave = async () => {
|
||||||
// خطای بخش — با دکمهی «رفع خطا» که به همان بخش میبرد
|
|
||||||
const sectionError = (step: number, title: string, err: unknown) => {
|
const sectionError = (step: number, title: string, err: unknown) => {
|
||||||
const msg = err instanceof Error ? err.message : "خطای ناشناخته";
|
const msg = err instanceof Error ? err.message : "خطای ناشناخته";
|
||||||
toast.error(`${title}: ${msg}`, {
|
toast.error(`${title}: ${msg}`, {
|
||||||
action: { label: "رفع خطا", onClick: () => goToStep(step) },
|
action: { label: "رفع خطا", onClick: () => goToStep(step) },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
// مشخص کردن بخش خطا از روی متن پیام
|
|
||||||
const guessStep = (msg: string): number => {
|
const guessStep = (msg: string): number => {
|
||||||
if (/تأمین|تامین|supplier/i.test(msg)) return 1;
|
if (/تأمین|تامین|supplier/i.test(msg)) return 1;
|
||||||
if (/متریال|واحد|قلم|خط آیتم|item|دسته/i.test(msg)) return 2;
|
if (/متریال|واحد|قلم|خط آیتم|item|دسته/i.test(msg)) return 2;
|
||||||
@@ -218,7 +206,6 @@ export function WizardPage() {
|
|||||||
return 5;
|
return 5;
|
||||||
};
|
};
|
||||||
|
|
||||||
// تأمینکننده
|
|
||||||
const name = supplierName.trim();
|
const name = supplierName.trim();
|
||||||
let supplierId = "";
|
let supplierId = "";
|
||||||
if (name) {
|
if (name) {
|
||||||
@@ -244,7 +231,6 @@ export function WizardPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// اقلام
|
|
||||||
const collected: { li: LineItem; tempId: string }[] = [];
|
const collected: { li: LineItem; tempId: string }[] = [];
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
const cat = it.cat.trim();
|
const cat = it.cat.trim();
|
||||||
@@ -256,12 +242,9 @@ export function WizardPage() {
|
|||||||
const total = it.total;
|
const total = it.total;
|
||||||
if (!cat && !brand && qty == null && price == null) continue;
|
if (!cat && !brand && qty == null && price == null) continue;
|
||||||
|
|
||||||
// جستجوی متریال: اول با نامِ کامل سراسری (دیتابیس) — بدون قید دسته/تاپیک،
|
|
||||||
// چون دستهی ذخیرهشدهی متریالها نام تاپیک است نه نام تایپشدهی کاربر
|
|
||||||
const wantName = [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید";
|
const wantName = [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید";
|
||||||
let material = materials.find((m) => normalize(m.name) === normalize(wantName));
|
let material = materials.find((m) => normalize(m.name) === normalize(wantName));
|
||||||
if (!material) {
|
if (!material) {
|
||||||
// جستجوی قبلی (برند و مشخصهی ساختارمند در همین نشست)
|
|
||||||
material = materials.find(
|
material = materials.find(
|
||||||
(m) =>
|
(m) =>
|
||||||
normalize(m.category) === normalize(cat) &&
|
normalize(m.category) === normalize(cat) &&
|
||||||
@@ -283,7 +266,7 @@ export function WizardPage() {
|
|||||||
specs,
|
specs,
|
||||||
base_unit: unit,
|
base_unit: unit,
|
||||||
});
|
});
|
||||||
materialId = created.id; // UUID واقعی از بکاند
|
materialId = created.id;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sectionError(2, "خطا در بخش اقلام (متریال)", err);
|
sectionError(2, "خطا در بخش اقلام (متریال)", err);
|
||||||
return;
|
return;
|
||||||
@@ -309,7 +292,6 @@ export function WizardPage() {
|
|||||||
|
|
||||||
const lineItems = collected.map((c) => c.li);
|
const lineItems = collected.map((c) => c.li);
|
||||||
|
|
||||||
// termsheetها
|
|
||||||
const termsheets = cards
|
const termsheets = cards
|
||||||
.map((card, i) => ({
|
.map((card, i) => ({
|
||||||
id: "new-ts-" + (i + 1),
|
id: "new-ts-" + (i + 1),
|
||||||
@@ -319,7 +301,6 @@ export function WizardPage() {
|
|||||||
}))
|
}))
|
||||||
.filter((t) => t.milestones.length);
|
.filter((t) => t.milestones.length);
|
||||||
|
|
||||||
// اتصال اقلام به termsheetها
|
|
||||||
collected.forEach(({ li, tempId }) => {
|
collected.forEach(({ li, tempId }) => {
|
||||||
const ids = cards
|
const ids = cards
|
||||||
.map((card, i) =>
|
.map((card, i) =>
|
||||||
@@ -330,7 +311,6 @@ export function WizardPage() {
|
|||||||
li.termsheet_ids = valid.length ? valid : termsheets.length ? [termsheets[0].id] : [];
|
li.termsheet_ids = valid.length ? valid : termsheets.length ? [termsheets[0].id] : [];
|
||||||
});
|
});
|
||||||
|
|
||||||
// پروژهها
|
|
||||||
const projSet = new Set<string>();
|
const projSet = new Set<string>();
|
||||||
collected.forEach(({ li, tempId }) => {
|
collected.forEach(({ li, tempId }) => {
|
||||||
const list = assignment[tempId] || [];
|
const list = assignment[tempId] || [];
|
||||||
@@ -361,7 +341,6 @@ export function WizardPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await createQuote.mutateAsync(quote);
|
const created = await createQuote.mutateAsync(quote);
|
||||||
// آپلود پیشفاکتور به MinIO بعد از ساخت پیشنهاد
|
|
||||||
if (proforma) {
|
if (proforma) {
|
||||||
try {
|
try {
|
||||||
await api.files.upload(created.id, "proforma", proforma);
|
await api.files.upload(created.id, "proforma", proforma);
|
||||||
@@ -379,7 +358,6 @@ export function WizardPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ذخیره — اگر بخشی ناقص است، دیالوگ تأیید با لینک فیلدهای خالی باز میشود
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
const incomplete: { label: string; step: number }[] = [];
|
const incomplete: { label: string; step: number }[] = [];
|
||||||
if (!completedSteps.has(1)) incomplete.push({ label: "تأمینکننده", step: 1 });
|
if (!completedSteps.has(1)) incomplete.push({ label: "تأمینکننده", step: 1 });
|
||||||
@@ -475,19 +453,53 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* پاورقی ویزارد */}
|
{/* پاورقی ویزارد — طراحی بازسازیشده با ناوبری استاندارد */}
|
||||||
<div className="mt-4 flex items-center gap-2 rounded-xl border border-line bg-panel p-3 shadow-sm">
|
<div className="mt-5 flex items-center justify-between rounded-xl border border-line bg-panel p-3.5 shadow-sm">
|
||||||
|
{/* دکمه مرحله قبلی */}
|
||||||
|
<div>
|
||||||
{currentStep > 1 && (
|
{currentStep > 1 && (
|
||||||
<Button variant="slate" onClick={() => goToStep(currentStep - 1)}>
|
<button
|
||||||
‹ قبلی
|
type="button"
|
||||||
</Button>
|
onClick={() => goToStep(currentStep - 1)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 hover:bg-gray-50 dark:hover:bg-zinc-700 text-sm font-medium transition-colors shadow-xs"
|
||||||
|
>
|
||||||
|
<span>›</span>
|
||||||
|
<span>قبلی</span>
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
</div>
|
||||||
<Link to="/" className="btn-line-file no-underline">
|
|
||||||
|
{/* دکمههای اقدام (انصراف + بعدی / ذخیره) */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="inline-flex items-center justify-center px-4 py-2 rounded-xl text-sm font-medium text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800 transition-colors no-underline"
|
||||||
|
>
|
||||||
انصراف
|
انصراف
|
||||||
</Link>
|
</Link>
|
||||||
{currentStep < TOTAL_STEPS && <Button onClick={next}>بعدی ›</Button>}
|
|
||||||
{currentStep === TOTAL_STEPS && <Button onClick={() => void save()}>ذخیره پیشنهاد</Button>}
|
{currentStep < TOTAL_STEPS && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={next}
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150"
|
||||||
|
>
|
||||||
|
<span>بعدی</span>
|
||||||
|
<span className="text-xs">‹</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === TOTAL_STEPS && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void save()}
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150"
|
||||||
|
>
|
||||||
|
<span>ذخیره پیشنهاد</span>
|
||||||
|
<span className="text-xs">✓</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* دیالوگ ذخیره با فیلدهای خالی */}
|
{/* دیالوگ ذخیره با فیلدهای خالی */}
|
||||||
|
|||||||
Reference in New Issue
Block a user