feat(frontend): add React UI — procurement office, wizard, suppliers, inventory panel (dash port), role-based login
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
*.log
|
||||
build-out.txt
|
||||
tsc-out.txt
|
||||
@@ -0,0 +1,28 @@
|
||||
# Frontend image — multi-stage build
|
||||
# Stage 1: build the Vite app with Node.js
|
||||
# Stage 2: serve static files with nginx (which also proxies /api -> api:8000)
|
||||
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Dependencies first (layer caching)
|
||||
# NPM_REGISTRY overridable; default points at npmmirror because
|
||||
# registry.npmjs.org is unreachable from this network.
|
||||
ARG NPM_REGISTRY=https://registry.npmmirror.com
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm config set registry ${NPM_REGISTRY} && npm ci
|
||||
|
||||
# Source code + build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ---------- runtime ----------
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>دفتر پیشنهادهای خرید — دفترچه تأمین</title>
|
||||
<script src="/casdoor-config.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
# Frontend nginx config
|
||||
# - Serves the built SPA from /usr/share/nginx/html
|
||||
# - Proxies /api/* to the backend service (Docker compose network name: api)
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# حداکثر حجم فایلهای آپلودی (فاکتور/پیشفاکتور) — پیشفرض nginx فقط ۱ مگابایت
|
||||
# است و آپلودهای بزرگتر با خطای 413 رد میشوند.
|
||||
client_max_body_size 50m;
|
||||
|
||||
# API reverse proxy -> FastAPI backend (port 8000)
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# SPA fallback — all client-side routes go to index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Static assets cache
|
||||
location /assets/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "procurement-ui-v2",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "3.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.11",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@radix-ui/react-label": "^2.1.15",
|
||||
"@radix-ui/react-select": "^2.3.7",
|
||||
"@radix-ui/react-slot": "^1.3.3",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.30.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.85.0",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"zod": "3.25",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"oxlint": "^1.75.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// تنظیمات اتصال به Casdoor — v2.6 (مقادیر همان نسخهی ارسالی داشبورد)
|
||||
window.CASDOOR_CONFIG = {
|
||||
serverUrl: "http://localhost:8080",
|
||||
clientId: "inventory-app-client",
|
||||
clientSecret: "inventory-app-secret-2026",
|
||||
organization: "inventory",
|
||||
appName: "inventory-app",
|
||||
redirectUri: "http://localhost:3000/login"
|
||||
};
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 254 KiB |
@@ -0,0 +1,185 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { useAuthStore } from "./store/auth";
|
||||
|
||||
// هر صفحه با React.lazy لود میشود (code splitting)
|
||||
const DashboardPage = lazy(() =>
|
||||
import("./pages/Dashboard/DashboardPage").then((m) => ({ default: m.DashboardPage })),
|
||||
);
|
||||
const WizardPage = lazy(() => import("./pages/Wizard/WizardPage").then((m) => ({ default: m.WizardPage })));
|
||||
const SuppliersPage = lazy(() =>
|
||||
import("./pages/Suppliers/SuppliersPage").then((m) => ({ default: m.SuppliersPage })),
|
||||
);
|
||||
const TopicPage = lazy(() => import("./pages/Suppliers/TopicPage").then((m) => ({ default: m.TopicPage })));
|
||||
const CategoryPage = lazy(() =>
|
||||
import("./pages/Suppliers/CategoryPage").then((m) => ({ default: m.CategoryPage })),
|
||||
);
|
||||
const SupplierDetailPage = lazy(() =>
|
||||
import("./pages/Suppliers/SupplierDetailPage").then((m) => ({ default: m.SupplierDetailPage })),
|
||||
);
|
||||
const ProjectsPage = lazy(() =>
|
||||
import("./pages/Projects/ProjectsPage").then((m) => ({ default: m.ProjectsPage })),
|
||||
);
|
||||
const UnitsPage = lazy(() => import("./pages/Units/UnitsPage").then((m) => ({ default: m.UnitsPage })));
|
||||
const LoginPage = lazy(() => import("./pages/Auth/LoginPage").then((m) => ({ default: m.LoginPage })));
|
||||
const InventoryPage = lazy(() =>
|
||||
import("./pages/Inventory/InventoryPage").then((m) => ({ default: m.InventoryPage })),
|
||||
);
|
||||
const ArrivalsPage = lazy(() => import("./pages/Inventory/ArrivalsPage").then((m) => ({ default: m.ArrivalsPage })));
|
||||
const ChangeInventoryPage = lazy(() =>
|
||||
import("./pages/Inventory/ChangeInventoryPage").then((m) => ({ default: m.ChangeInventoryPage })),
|
||||
);
|
||||
const DailyControlPage = lazy(() =>
|
||||
import("./pages/Inventory/DailyControlPage").then((m) => ({ default: m.DailyControlPage })),
|
||||
);
|
||||
const WarehousesPage = lazy(() =>
|
||||
import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })),
|
||||
);
|
||||
const OrganizationPage = lazy(() =>
|
||||
import("./pages/Inventory/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
|
||||
);
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center text-sm text-muted">
|
||||
در حال بارگذاری...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// همهی صفحات پشت ورود هستند — بدون نشست، به صفحهی ورود میرویم.
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// صفحهی اصلی بر اساس نقش: مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر
|
||||
function HomeRoute() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
if (user?.role === "pm") return <Navigate to="/inventory" replace />;
|
||||
return <DashboardPage />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<HomeRoute />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/wizard"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<WizardPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/suppliers"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<SuppliersPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/topics/:topic"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<TopicPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/topics/:topic/:category"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<CategoryPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/suppliers/:id"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<SupplierDetailPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/projects"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<ProjectsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/units"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<UnitsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/inventory"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<InventoryPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/inventory/arrivals"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<ArrivalsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/inventory/change"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<ChangeInventoryPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/inventory/daily"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<DailyControlPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/inventory/warehouses"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<WarehousesPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/inventory/org"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<OrganizationPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { normalize } from "../lib/normalize";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useClickOutside } from "./useClickOutside";
|
||||
|
||||
export interface ComboboxItem {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// منبع آیتمها: رشته، {id,name} یا {id,label}
|
||||
export type ComboboxSourceItem = string | { id: string; name?: string; label?: string };
|
||||
export type ComboboxSource = ComboboxSourceItem[] | (() => ComboboxSourceItem[]);
|
||||
|
||||
export interface ComboboxProps {
|
||||
value?: string;
|
||||
onChange?: (item: ComboboxItem) => void;
|
||||
items: ComboboxSource; // آرایه از string یا {id, name}
|
||||
allowAdd?: boolean; // دکمهی "+ افزودن به عنوان جدید"
|
||||
addSuffix?: string; // متن بعد از +
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
inputClassName?: string;
|
||||
onAdd?: (val: string) => void;
|
||||
onInputChange?: (val: string) => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
function toItem(x: ComboboxSourceItem): ComboboxItem {
|
||||
if (x == null) return { id: "", label: "" };
|
||||
if (typeof x === "string") return { id: x, label: x };
|
||||
const label = String(x.name != null ? x.name : x.label != null ? x.label : x.id ?? "");
|
||||
return { id: String(x.id ?? label), label };
|
||||
}
|
||||
|
||||
// جستجو-و-انتخاب با تایپ: فیلتر با normalize، ناوبری با کلیدها،
|
||||
// گزینهی "+ افزودن به عنوان جدید" وقتی متنی با هیچ آیتمی match نمیشود
|
||||
export function Combobox({
|
||||
value,
|
||||
onChange,
|
||||
items,
|
||||
allowAdd = true,
|
||||
addSuffix = "به عنوان گزینه جدید",
|
||||
placeholder,
|
||||
className,
|
||||
disabled,
|
||||
inputClassName,
|
||||
onAdd,
|
||||
onInputChange,
|
||||
onBlur,
|
||||
}: ComboboxProps) {
|
||||
const [text, setText] = useState(value ?? "");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlight, setHighlight] = useState(-1);
|
||||
|
||||
useEffect(() => {
|
||||
setText(value ?? "");
|
||||
}, [value]);
|
||||
|
||||
const all = useMemo<ComboboxItem[]>(() => {
|
||||
const src = typeof items === "function" ? items() : items;
|
||||
return src.map(toItem);
|
||||
}, [items]);
|
||||
|
||||
const q = useMemo(() => normalize(text), [text]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!q) return all;
|
||||
return all.filter((x) => normalize(x.label).includes(q));
|
||||
}, [all, q]);
|
||||
|
||||
const showAdd =
|
||||
allowAdd && q.length > 0 && !filtered.some((x) => normalize(x.label) === q);
|
||||
|
||||
const listCount = filtered.length + (showAdd ? 1 : 0);
|
||||
|
||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||||
|
||||
const pick = (it: ComboboxItem) => {
|
||||
setText(it.label);
|
||||
setOpen(false);
|
||||
setHighlight(-1);
|
||||
onChange?.(it);
|
||||
onInputChange?.(it.label);
|
||||
};
|
||||
|
||||
const addCurrent = () => {
|
||||
const val = text.trim();
|
||||
if (!val) return;
|
||||
setOpen(false);
|
||||
setHighlight(-1);
|
||||
onAdd?.(val);
|
||||
onInputChange?.(val);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!open) {
|
||||
if (e.key === "ArrowDown" || e.key === "Enter") setOpen(true);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlight((h) => (h + 1) % Math.max(listCount, 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (highlight >= 0 && highlight < filtered.length) {
|
||||
pick(filtered[highlight]);
|
||||
} else if (highlight === filtered.length && showAdd) {
|
||||
addCurrent();
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("relative", className)}>
|
||||
<input
|
||||
type="text"
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
inputClassName,
|
||||
)}
|
||||
value={text}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
setHighlight(-1);
|
||||
onInputChange?.(e.target.value);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{open && (
|
||||
<div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg">
|
||||
{listCount === 0 && (
|
||||
<div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div>
|
||||
)}
|
||||
{filtered.map((it, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={it.id + "|" + i}
|
||||
className={cn(
|
||||
"block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg",
|
||||
i === highlight && "bg-slate-bg",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // حفظ فوکوس input
|
||||
pick(it);
|
||||
}}
|
||||
onMouseEnter={() => setHighlight(i)}
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
))}
|
||||
{showAdd && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-1 border-t border-line px-3 py-1.5 text-start text-[13px] text-accent hover:bg-slate-bg",
|
||||
highlight === filtered.length && "bg-slate-bg",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
addCurrent();
|
||||
}}
|
||||
onMouseEnter={() => setHighlight(filtered.length)}
|
||||
>
|
||||
<span className="font-bold">+</span>
|
||||
<span>
|
||||
«<b>{text.trim()}</b>» {addSuffix}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
|
||||
export interface ConfirmDeleteDialogProps {
|
||||
open: boolean;
|
||||
itemName: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// پاپآپ تأیید حذف چیپ — متن دقیق طبق درخواست کاربر
|
||||
// دکمهی قرمز (حذف) سمت راست، دکمهی آبی (انصراف) سمت چپ
|
||||
export function ConfirmDeleteDialog({ open, itemName, onConfirm, onCancel }: ConfirmDeleteDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onCancel()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[16px] font-bold">آیا از حذف مورد زیر مطمئن هستید؟</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="rounded-lg border border-line bg-slate-bg px-3 py-2.5 text-center text-[13.5px] font-semibold">
|
||||
{itemName}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-red px-4 py-2 text-sm font-medium text-white transition-colors hover:brightness-95"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
می خواهم حذف کنم
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-[#1d4ed8] px-4 py-2 text-sm font-medium text-white transition-colors hover:brightness-95"
|
||||
onClick={onCancel}
|
||||
>
|
||||
تغییری نمی دهم
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export interface EmptyStateProps {
|
||||
icon?: LucideIcon;
|
||||
emoji?: string; // سازگاری با استفادههای قبلی
|
||||
text: string;
|
||||
}
|
||||
|
||||
// حالت خالی
|
||||
export function EmptyState({ icon: Icon, emoji, text }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="empty">
|
||||
{Icon ? (
|
||||
<Icon className="mx-auto mb-2 h-10 w-10 text-muted/60" strokeWidth={1.5} />
|
||||
) : (
|
||||
emoji && <div className="big">{emoji}</div>
|
||||
)}
|
||||
<div>{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { Attachment } from "../lib/types";
|
||||
|
||||
export interface FileListProps {
|
||||
files: Attachment[];
|
||||
onChange: (files: Attachment[]) => void;
|
||||
// آپلود واقعی هر فایل به سرور (باید Attachment با id برگرداند)
|
||||
upload: (file: File) => Promise<Attachment>;
|
||||
// حذف سمت سرور (اختیاری — بعد از حذف محلی صدا زده میشود)
|
||||
onDelete?: (a: Attachment) => void;
|
||||
// لینک دانلود؛ null یعنی هنوز آپلود نشده
|
||||
urlOf?: (a: Attachment) => string | null;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
// مدیریت چند فاکتور: آپلود مستقیم به سرور (MinIO) + دانلود/حذف فایلهای ذخیرهشده
|
||||
export function FileList({ files, onChange, upload, onDelete, urlOf, label = "افزودن فاکتور" }: FileListProps) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const remove = (i: number) => {
|
||||
const removed = files[i];
|
||||
onChange(files.filter((_, idx) => idx !== i));
|
||||
if (removed) onDelete?.(removed);
|
||||
};
|
||||
|
||||
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const picked = Array.from(e.target.files || []);
|
||||
e.target.value = ""; // اجازهی انتخاب دوبارهی همان فایل
|
||||
if (!picked.length) return;
|
||||
const added: Attachment[] = [];
|
||||
for (const file of picked) {
|
||||
try {
|
||||
added.push(await upload(file));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : `آپلود «${file.name}» ناموفق بود`);
|
||||
}
|
||||
}
|
||||
if (added.length) onChange([...files, ...added]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!files.length && <div className="inv-empty">هنوز فایلی انتخاب نشده است.</div>}
|
||||
{files.map((inv, i) => (
|
||||
<div key={i} className="inv-row">
|
||||
<span className="inv-name">📎 {inv.name}</span>
|
||||
{urlOf?.(inv) && (
|
||||
<a className="inv-open" href={urlOf(inv)!} download={inv.name} title="دانلود">
|
||||
↓
|
||||
</a>
|
||||
)}
|
||||
<button type="button" className="inv-x" title="حذف" onClick={() => remove(i)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*,.pdf,.png,.jpg,.jpeg"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<button type="button" className="btn-line-file" onClick={() => inputRef.current?.click()}>
|
||||
+ {label}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
export interface FileUploadProps {
|
||||
onUpload: (file: File) => void;
|
||||
accept?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
// انتخاب تکفایل — آپلود واقعی به سرور (MinIO) توسط والد انجام میشود
|
||||
export function FileUpload({
|
||||
onUpload,
|
||||
accept = "image/*,.pdf,.png,.jpg,.jpeg",
|
||||
label = "انتخاب فایل",
|
||||
}: FileUploadProps) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
onUpload(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input ref={inputRef} type="file" accept={accept} className="hidden" onChange={handleChange} />
|
||||
<button type="button" className="btn-line-file" onClick={() => inputRef.current?.click()}>
|
||||
+ {label}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
daysInMonth,
|
||||
gregorianToJalali,
|
||||
isoToJalaliStr,
|
||||
jalaliStrToIso,
|
||||
jalaliToGregorian,
|
||||
MONTHS,
|
||||
toFa,
|
||||
WEEKDAYS,
|
||||
} from "../lib/jalali";
|
||||
import { faNum } from "../lib/formatters";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useClickOutside } from "./useClickOutside";
|
||||
|
||||
export interface JalaliDatePickerProps {
|
||||
value: string | null; // ISO date (با time اختیاری)
|
||||
onChange: (iso: string | null) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید)
|
||||
}
|
||||
|
||||
function todayJalali(): [number, number, number] {
|
||||
const now = new Date();
|
||||
return gregorianToJalali(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||
}
|
||||
|
||||
function isoToJalali(iso: string | null): [number, number, number] | null {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||||
}
|
||||
|
||||
// ساخت ISO با زمان محلی (تاریخ شمسی + ساعت:دقیقه)
|
||||
function buildIso(jy: number, jm: number, jd: number, h: number, m: number): string {
|
||||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||||
return new Date(gy, gm - 1, gd, h, m).toISOString();
|
||||
}
|
||||
|
||||
function timeOf(iso: string | null): { h: number; m: number } {
|
||||
if (!iso) return { h: 0, m: 0 };
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return { h: 0, m: 0 };
|
||||
return { h: d.getHours(), m: d.getMinutes() };
|
||||
}
|
||||
|
||||
// تقویم شمسی — پورت Jalali.buildPicker به React (با پشتیبانی ساعت:دقیقه)
|
||||
export function JalaliDatePicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
withTime = false,
|
||||
}: JalaliDatePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
|
||||
const [text, setText] = useState<string>(() => {
|
||||
if (!value) return "";
|
||||
const j = isoToJalaliStr(value);
|
||||
if (!withTime) return j;
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return j;
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${j} ${faNum(hh + ":" + mm)}`;
|
||||
});
|
||||
|
||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||||
|
||||
// تاریخ انتخابشده به صورت محلی (نه UTC) برای مقایسه با سلولهای تقویم
|
||||
function selectedLocalDate(): string | null {
|
||||
if (!value) return null;
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
const selectedIso = selectedLocalDate();
|
||||
const time = timeOf(value);
|
||||
|
||||
const grid = useMemo(() => {
|
||||
const [jy, jm] = curJ;
|
||||
const firstG = jalaliToGregorian(jy, jm, 1);
|
||||
const firstWd = (new Date(firstG[0], firstG[1] - 1, firstG[2]).getDay() + 1) % 7; // شنبه = 0
|
||||
const dim = daysInMonth(jy, jm);
|
||||
const today = new Date();
|
||||
const tIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
const cells: { d: number; iso: string; isToday: boolean }[] = [];
|
||||
for (let i = 0; i < firstWd; i++) cells.push({ d: 0, iso: "", isToday: false });
|
||||
for (let d = 1; d <= dim; d++) {
|
||||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, d);
|
||||
const iso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||
cells.push({ d, iso, isToday: iso === tIso });
|
||||
}
|
||||
return cells;
|
||||
}, [curJ]);
|
||||
|
||||
const nav = (dir: string) => {
|
||||
setCurJ(([jy, jm]) => {
|
||||
let ny = jy;
|
||||
let nm = jm;
|
||||
if (dir === "m-1") nm = nm === 12 ? 1 : nm + 1;
|
||||
if (dir === "m+1") nm = nm === 1 ? 12 : nm - 1;
|
||||
if (dir === "y-1") ny += 1;
|
||||
if (dir === "y+1") ny -= 1;
|
||||
if (nm < 1) {
|
||||
nm = 12;
|
||||
ny -= 1;
|
||||
}
|
||||
if (nm > 12) {
|
||||
nm = 1;
|
||||
ny += 1;
|
||||
}
|
||||
return [ny, nm, 1];
|
||||
});
|
||||
};
|
||||
|
||||
const selectDay = (jy: number, jm: number, jd: number) => {
|
||||
const isoWithTime = buildIso(jy, jm, jd, time.h, time.m);
|
||||
onChange(isoWithTime);
|
||||
setText(`${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}${withTime ? " " + faNum(String(time.h).padStart(2, "0") + ":" + String(time.m).padStart(2, "0")) : ""}`);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const setTimePart = (part: "h" | "m", raw: string) => {
|
||||
const n = parseInt(raw.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))), 10);
|
||||
if (Number.isNaN(n)) return;
|
||||
const h = part === "h" ? Math.min(23, Math.max(0, n)) : time.h;
|
||||
const m = part === "m" ? Math.min(59, Math.max(0, n)) : time.m;
|
||||
const [jy, jm, jd] = curJ;
|
||||
onChange(buildIso(jy, jm, jd, h, m));
|
||||
// بهروزرسانی نمایش
|
||||
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, h, m));
|
||||
setText(`${j} ${faNum(String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"))}`);
|
||||
};
|
||||
|
||||
const pickToday = () => {
|
||||
const now = new Date();
|
||||
const [jy, jm, jd] = todayJalali();
|
||||
// بدون withTime فقط تاریخ امروز ثبت میشود — ساعت همیشه نیمهشب
|
||||
const isoWithTime = withTime
|
||||
? buildIso(jy, jm, jd, now.getHours(), now.getMinutes())
|
||||
: buildIso(jy, jm, jd, 0, 0);
|
||||
onChange(isoWithTime);
|
||||
setText(
|
||||
withTime
|
||||
? `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${faNum(String(now.getHours()).padStart(2, "0") + ":" + String(now.getMinutes()).padStart(2, "0"))}`
|
||||
: `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`,
|
||||
);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
onChange(null);
|
||||
setText("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onInputChange = (v: string) => {
|
||||
setText(v);
|
||||
const iso = jalaliStrToIso(v);
|
||||
if (iso) {
|
||||
onChange(iso);
|
||||
const j = isoToJalali(iso);
|
||||
if (j) setCurJ([j[0], j[1], j[2]]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("relative", className)}>
|
||||
<input
|
||||
type="text"
|
||||
className="flex h-9 w-full rounded-md border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
value={text}
|
||||
placeholder={placeholder ?? "۱۴۰۳/۰۵/۱۲"}
|
||||
autoComplete="off"
|
||||
onFocus={() => setOpen(true)}
|
||||
onClick={() => setOpen(true)}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
/>
|
||||
{open && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-0 top-full z-30 mt-1 rounded-md border border-line bg-panel shadow-md",
|
||||
withTime ? "w-80 p-3" : "w-64 p-2",
|
||||
)}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center justify-between gap-1">
|
||||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}>
|
||||
«
|
||||
</button>
|
||||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("m+1")}>
|
||||
‹
|
||||
</button>
|
||||
<span className="text-[13px] font-bold">
|
||||
{MONTHS[curJ[1] - 1]} {toFa(curJ[0])}
|
||||
</span>
|
||||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("m-1")}>
|
||||
›
|
||||
</button>
|
||||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y-1")}>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-0.5 text-center">
|
||||
{WEEKDAYS.map((w) => (
|
||||
<div key={w} className="py-0.5 text-[10px] font-bold text-muted">
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
{grid.map((cell, i) =>
|
||||
cell.d === 0 ? (
|
||||
<div key={i} />
|
||||
) : (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className={cn(
|
||||
"cursor-pointer rounded py-0.5 text-[12px] hover:bg-slate-bg",
|
||||
cell.isToday && !selectedIso && "bg-blue-bg font-bold text-[#1d4ed8]",
|
||||
cell.isToday && selectedIso && "font-bold text-[#1d4ed8]",
|
||||
cell.iso === selectedIso && "bg-accent text-white hover:bg-accent-dark",
|
||||
)}
|
||||
onClick={() => selectDay(curJ[0], curJ[1], cell.d)}
|
||||
>
|
||||
{toFa(cell.d)}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
{withTime && (
|
||||
<div className="mt-2 flex items-center justify-center gap-4 border-t border-line pt-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
|
||||
<input
|
||||
type="text"
|
||||
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
value={faNum(String(time.h).padStart(2, "0"))}
|
||||
inputMode="numeric"
|
||||
maxLength={2}
|
||||
onChange={(e) => setTimePart("h", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted">:</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11.5px] font-semibold text-muted">دقیقه</span>
|
||||
<input
|
||||
type="text"
|
||||
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
value={faNum(String(time.m).padStart(2, "0"))}
|
||||
inputMode="numeric"
|
||||
maxLength={2}
|
||||
onChange={(e) => setTimePart("m", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex justify-between border-t border-line pt-2">
|
||||
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-accent hover:bg-slate-bg" onClick={pickToday}>
|
||||
امروز
|
||||
</button>
|
||||
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-muted hover:bg-slate-bg" onClick={clear}>
|
||||
پاک کردن
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Milestone } from "../lib/types";
|
||||
import { JalaliDatePicker } from "./JalaliDatePicker";
|
||||
import { NumberInput } from "./NumberInput";
|
||||
|
||||
export interface MilestoneRowProps {
|
||||
milestone: Milestone;
|
||||
onChange: (m: Milestone) => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
// یک ردیف مرحلهی پرداخت: تاریخ + مبلغ + دکمهی حذف
|
||||
export function MilestoneRow({ milestone, onChange, onRemove }: MilestoneRowProps) {
|
||||
return (
|
||||
<div className="milestone-row">
|
||||
<JalaliDatePicker
|
||||
value={milestone.when_iso}
|
||||
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
|
||||
placeholder="تاریخ"
|
||||
className="w-36 shrink-0"
|
||||
/>
|
||||
<div className="ms-amount-wrap">
|
||||
<NumberInput
|
||||
value={milestone.amount}
|
||||
onChange={(amount) => onChange({ ...milestone, amount })}
|
||||
placeholder="مبلغ (تومان)"
|
||||
showWords
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="ms-x" title="حذف" onClick={onRemove}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { normalize } from "../lib/normalize";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useClickOutside } from "./useClickOutside";
|
||||
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||
|
||||
export interface MultiSelectComboboxProps {
|
||||
values: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
options?: string[]; // گزینههای کششده (مشخصههای استفادهشده قبلی)
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
inputClassName?: string;
|
||||
allowCreate?: boolean; // اجازهی تایپ مقدار جدید (پیشفرض true)
|
||||
}
|
||||
|
||||
// چند-انتخابی خلاق (creatable multi-select) با چیپها:
|
||||
// - چیپهای قابل حذف با ×
|
||||
// - input بدون حاشیه بعد از آخرین چیپ (فوکوس خودکار)
|
||||
// - منوی autocomplete با فیلتر زنده + گزینهی «ایجاد '{query}'»
|
||||
// - Enter برای افزودن، Backspace برای حذف آخرین چیپ، جلوگیری از تکراری
|
||||
export function MultiSelectCombobox({
|
||||
values,
|
||||
onChange,
|
||||
options = [],
|
||||
placeholder,
|
||||
className,
|
||||
inputClassName,
|
||||
allowCreate = true,
|
||||
}: MultiSelectComboboxProps) {
|
||||
const [text, setText] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlight, setHighlight] = useState(-1);
|
||||
const [pendingRemove, setPendingRemove] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||||
|
||||
// گزینهها = کش + مقادیر انتخابشده (تا چیپهای قبلی در لیست هم دیده شوند)
|
||||
const all = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
options.forEach((o) => o && set.add(o));
|
||||
values.forEach((v) => v && set.add(v));
|
||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}, [options, values]);
|
||||
|
||||
const q = useMemo(() => normalize(text), [text]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!q) return all;
|
||||
return all.filter((x) => normalize(x).includes(q));
|
||||
}, [all, q]);
|
||||
|
||||
const exact = q.length > 0 && filtered.some((x) => normalize(x) === q);
|
||||
const showCreate = allowCreate && q.length > 0 && !exact;
|
||||
const listCount = filtered.length + (showCreate ? 1 : 0);
|
||||
|
||||
const add = (val: string) => {
|
||||
const v = val.trim();
|
||||
if (!v) return;
|
||||
if (values.some((x) => normalize(x) === normalize(v))) {
|
||||
setText("");
|
||||
setHighlight(-1);
|
||||
inputRef.current?.focus();
|
||||
return; // تکراری — اضافه نمیشود
|
||||
}
|
||||
onChange([...values, v]);
|
||||
setText("");
|
||||
setHighlight(-1);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const remove = (v: string) => {
|
||||
onChange(values.filter((x) => x !== v));
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (highlight >= 0 && highlight < filtered.length) {
|
||||
add(filtered[highlight]);
|
||||
} else if (text.trim()) {
|
||||
add(text.trim());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Backspace" && !text && values.length) {
|
||||
e.preventDefault();
|
||||
remove(values[values.length - 1]);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
setHighlight((h) => (h + 1) % Math.max(listCount, 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1));
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("relative", className)}>
|
||||
<div
|
||||
className="flex min-h-9 w-full flex-wrap items-center gap-1 rounded-lg border border-line bg-panel px-2 py-1 shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/40"
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{values.map((v) => (
|
||||
<span key={v} className="spec-chip">
|
||||
<span className="max-w-[140px] truncate">{v}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="spec-chip-x"
|
||||
title="حذف"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingRemove(v);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={cn(
|
||||
"min-w-[80px] flex-1 border-0 bg-transparent px-0.5 py-0.5 text-[12px] text-ink caret-accent outline-none placeholder:text-muted",
|
||||
inputClassName,
|
||||
)}
|
||||
value={text}
|
||||
placeholder={values.length ? "" : placeholder}
|
||||
autoComplete="off"
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
setHighlight(-1);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg">
|
||||
{listCount === 0 && (
|
||||
<div className="px-3 py-1.5 text-[12px] text-muted">
|
||||
{allowCreate && text.trim() ? (
|
||||
<span>
|
||||
برای افزودن «<b>{text.trim()}</b>» Enter بزنید
|
||||
</span>
|
||||
) : (
|
||||
"موردی یافت نشد"
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((it, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={it}
|
||||
className={cn(
|
||||
"block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg",
|
||||
i === highlight && "bg-slate-bg",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // حفظ فوکوس input
|
||||
add(it);
|
||||
}}
|
||||
onMouseEnter={() => setHighlight(i)}
|
||||
>
|
||||
{it}
|
||||
</button>
|
||||
))}
|
||||
{showCreate && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-1 border-t border-line px-3 py-1.5 text-start text-[13px] text-accent hover:bg-slate-bg",
|
||||
highlight === filtered.length && "bg-slate-bg",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
add(text.trim());
|
||||
}}
|
||||
onMouseEnter={() => setHighlight(filtered.length)}
|
||||
>
|
||||
<span className="font-bold">+</span>
|
||||
<span>
|
||||
افزودن «<b>{text.trim()}</b>»
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
open={pendingRemove !== null}
|
||||
itemName={pendingRemove || ""}
|
||||
onConfirm={() => {
|
||||
if (pendingRemove !== null) remove(pendingRemove);
|
||||
setPendingRemove(null);
|
||||
}}
|
||||
onCancel={() => setPendingRemove(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { groupCommas, parseNum, toWords } from "../lib/numutil";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export interface NumberInputProps {
|
||||
value: number | null;
|
||||
onChange: (n: number | null) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
showWords?: boolean; // نمایش معادل فارسی زیر input
|
||||
inputMode?: "numeric" | "decimal" | undefined;
|
||||
maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد)
|
||||
}
|
||||
|
||||
// ورودی عددی با فرمتدهی خودکار (groupCommas) و نمایش کلمات فارسی
|
||||
export function NumberInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
showWords = false,
|
||||
inputMode = "numeric",
|
||||
maxDecimals,
|
||||
}: NumberInputProps) {
|
||||
// فرمت با رعایت حداکثر اعشار (گرد کردن)
|
||||
const formatValue = (n: number): string => {
|
||||
let v = n;
|
||||
if (maxDecimals != null) {
|
||||
const f = Math.pow(10, maxDecimals);
|
||||
v = Math.round(n * f) / f;
|
||||
}
|
||||
return groupCommas(v, false);
|
||||
};
|
||||
|
||||
const [text, setText] = useState<string>(() => (value != null ? formatValue(value) : ""));
|
||||
|
||||
// همگامسازی وقتی value از بیرون عوض میشود (مثلاً محاسبهی خودکار قیمت کل).
|
||||
// null از بیرون متن کاربر را پاک نمیکند — تا تایپ میانی مثل «0.» از بین نرود.
|
||||
useEffect(() => {
|
||||
const cur = parseNum(text);
|
||||
if (value != null && value !== cur) {
|
||||
setText(formatValue(value));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
const commit = (raw: string) => {
|
||||
const n = parseNum(raw);
|
||||
if (n != null) {
|
||||
const formatted = formatValue(n);
|
||||
setText(formatted);
|
||||
onChange(n);
|
||||
} else {
|
||||
setText(raw);
|
||||
onChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
const n = parseNum(text);
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0", className)}>
|
||||
<input
|
||||
type="text"
|
||||
className="num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
value={text}
|
||||
placeholder={placeholder}
|
||||
inputMode={inputMode}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
// فرمتدهی خودکار هنگام تایپ — اما جداکنندهی اعشارِ وسط تایپ
|
||||
// («0.» یا «1.») دستنخورده میماند تا کاربر بتواند اعشار بزند
|
||||
const parsed = parseNum(raw);
|
||||
const trailingSep = /[.,،/٬٫]$/.test(raw);
|
||||
if (parsed != null && !trailingSep) {
|
||||
setText(formatValue(parsed));
|
||||
onChange(parsed);
|
||||
} else {
|
||||
setText(raw);
|
||||
onChange(parsed);
|
||||
}
|
||||
}}
|
||||
onBlur={() => commit(text)}
|
||||
/>
|
||||
{showWords && (
|
||||
<span className={cn("num-pill", n == null && "empty")}>
|
||||
{n != null ? (
|
||||
<>
|
||||
{groupCommas(n, true)} <span className="num-words">({toWords(n)})</span>
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export interface MenuItem {
|
||||
label?: string;
|
||||
to?: string;
|
||||
active?: boolean;
|
||||
divider?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
// منوی همبرگری — بالای راست صفحات؛ نمایش پنل فعلی + ناوبری بین پنلها
|
||||
export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border border-line bg-panel text-ink hover:bg-slate-bg"
|
||||
aria-label="منو"
|
||||
title="منو"
|
||||
>
|
||||
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute top-11 right-0 z-50 min-w-56 rounded-xl border border-line bg-panel p-1.5 shadow-lg">
|
||||
{title && <div className="px-3 py-1.5 text-[11px] font-bold text-muted">{title}</div>}
|
||||
{items.map((it, i) =>
|
||||
it.divider ? (
|
||||
<div key={i} className="my-1 border-t border-line" />
|
||||
) : it.to ? (
|
||||
<Link
|
||||
key={i}
|
||||
to={it.to}
|
||||
onClick={() => setOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-[13px] no-underline",
|
||||
it.active ? "bg-accent font-bold text-white" : "text-ink hover:bg-slate-bg",
|
||||
)}
|
||||
>
|
||||
{it.label}
|
||||
{it.active && <span className="text-[10px] opacity-80">✓ فعلی</span>}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
it.onClick?.();
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-start text-[13px] text-ink hover:bg-slate-bg"
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState } from "react";
|
||||
import { normalize } from "../lib/normalize";
|
||||
import { faNum } from "../lib/formatters";
|
||||
import { useClickOutside } from "./useClickOutside";
|
||||
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||
|
||||
export interface SpecDrawerProps {
|
||||
specs: string[];
|
||||
onChange: (specs: string[]) => void;
|
||||
}
|
||||
|
||||
// کشوی چیپ مشخصههای فنی: دکمهی «نوع (N)» → کشو با چیپها + input برای افزودن
|
||||
export function SpecDrawer({ specs, onChange }: SpecDrawerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [pendingRemove, setPendingRemove] = useState<string | null>(null);
|
||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||||
|
||||
const addChip = (val?: string) => {
|
||||
const v = String(val ?? draft).trim();
|
||||
if (!v) return;
|
||||
if (specs.some((s) => normalize(s) === normalize(v))) return;
|
||||
onChange([...specs, v]);
|
||||
setDraft("");
|
||||
};
|
||||
|
||||
const removeChip = (v: string) => {
|
||||
onChange(specs.filter((s) => s !== v));
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className="spec-cell">
|
||||
<button
|
||||
type="button"
|
||||
className="spec-trigger"
|
||||
title="مشخصههای فنی"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className={`spec-trigger-label${specs.length ? "" : " muted"}`}>
|
||||
{specs.length ? `مشخصه (${faNum(specs.length)})` : "مشخصه"}
|
||||
</span>
|
||||
<span className="text-muted">▾</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="spec-drawer open">
|
||||
<div className="spec-chips">
|
||||
{specs.map((s) => (
|
||||
<span key={s} className="spec-chip">
|
||||
<span className="spec-chip-t">{s}</span>
|
||||
<button type="button" className="spec-chip-x" title="حذف" onClick={() => setPendingRemove(s)}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="spec-add-row">
|
||||
<input
|
||||
type="text"
|
||||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-[12px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="مشخصه را تایپ کنید..."
|
||||
autoComplete="off"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addChip();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="spec-add-btn" title="افزودن مشخصه" onClick={() => addChip()}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDeleteDialog
|
||||
open={pendingRemove !== null}
|
||||
itemName={pendingRemove || ""}
|
||||
onConfirm={() => {
|
||||
if (pendingRemove !== null) removeChip(pendingRemove);
|
||||
setPendingRemove(null);
|
||||
}}
|
||||
onCancel={() => setPendingRemove(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ClipboardList, Inbox, CheckCircle2, Timer, type LucideIcon } from "lucide-react";
|
||||
import { fmtNum } from "../lib/formatters";
|
||||
|
||||
export interface StatsBarProps {
|
||||
total: number;
|
||||
offered: number;
|
||||
accepted: number;
|
||||
avgLeadTime: number | null; // روز
|
||||
}
|
||||
|
||||
// نوار ۴ ستونهی آمار
|
||||
export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProps) {
|
||||
const stats: { label: string; value: string; color: string; icon: LucideIcon }[] = [
|
||||
{ label: "کل پیشنهادها", value: fmtNum(total), color: "text-ink", icon: ClipboardList },
|
||||
// پیشنهاد باز = نارنجی، پذیرفتهشده = آبی، رسیده = سبز
|
||||
{ label: "پیشنهاد باز", value: fmtNum(offered), color: "text-amber", icon: Inbox },
|
||||
{ label: "پذیرفتهشده", value: fmtNum(accepted), color: "text-[#1d4ed8]", icon: CheckCircle2 },
|
||||
{ label: "میانگین lead time (روز)", value: avgLeadTime == null ? "—" : fmtNum(avgLeadTime), color: "text-green", icon: Timer },
|
||||
];
|
||||
return (
|
||||
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{stats.map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="rounded-lg border border-line bg-panel p-3.5 transition-colors hover:border-accent/40"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[11px] font-medium text-muted">{s.label}</div>
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-slate-bg text-muted">
|
||||
<s.icon className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
</span>
|
||||
</div>
|
||||
<div className={`mt-1 text-[22px] font-extrabold leading-8 tabular-nums ${s.color}`}>{s.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Milestone } from "../lib/types";
|
||||
import { faNum } from "../lib/formatters";
|
||||
import { MilestoneRow } from "./MilestoneRow";
|
||||
|
||||
export interface TermsheetItem {
|
||||
tempId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface TermsheetCardProps {
|
||||
index: number; // 1-based → «گزینه N»
|
||||
milestones: Milestone[];
|
||||
onMilestonesChange: (ms: Milestone[]) => void;
|
||||
// اقلام قابل تیک (مرحله ۲)
|
||||
items?: TermsheetItem[];
|
||||
coveredIds?: string[]; // tempIdهایی که تیک دارند
|
||||
onCoverageChange?: (ids: string[]) => void;
|
||||
onRemove?: () => void;
|
||||
defaultAll?: boolean; // وقتی هیچ تیکی نیست، پیشفرض همه تیک بخورند
|
||||
removable?: boolean;
|
||||
}
|
||||
|
||||
// کارت ترمشیت — هم در ویزارد (مرحله ۳) هم در مودال پذیرش/ویرایش
|
||||
export function TermsheetCard({
|
||||
index,
|
||||
milestones,
|
||||
onMilestonesChange,
|
||||
items = [],
|
||||
coveredIds = [],
|
||||
onCoverageChange,
|
||||
onRemove,
|
||||
defaultAll = false,
|
||||
removable = true,
|
||||
}: TermsheetCardProps) {
|
||||
const covered = new Set(coveredIds);
|
||||
// پیشفرض: وقتی هیچ قلمی تیک نخورده و defaultAll فعال است → همه تیک
|
||||
const effectiveCovered = covered.size === 0 && defaultAll ? items.map((i) => i.tempId) : coveredIds;
|
||||
|
||||
const toggle = (tempId: string, checked: boolean) => {
|
||||
if (!onCoverageChange) return;
|
||||
const next = new Set(covered);
|
||||
if (checked) next.add(tempId);
|
||||
else next.delete(tempId);
|
||||
onCoverageChange([...next]);
|
||||
};
|
||||
|
||||
const addMilestone = () => {
|
||||
onMilestonesChange([...milestones, { when_iso: null, amount: null }]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="variant-card">
|
||||
<div className="variant-head">
|
||||
<span className="variant-num">گزینه {faNum(index)}</span>
|
||||
{removable && onRemove && (
|
||||
<button type="button" className="variant-x" title="حذف" onClick={onRemove}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-1.5 text-[12px] text-muted">مراحل پرداخت:</div>
|
||||
<div className="mb-1 flex flex-col gap-1.5">
|
||||
{milestones.map((m, i) => (
|
||||
<MilestoneRow
|
||||
key={i}
|
||||
milestone={m}
|
||||
onChange={(next) => onMilestonesChange(milestones.map((x, j) => (j === i ? next : x)))}
|
||||
onRemove={() => onMilestonesChange(milestones.filter((_, j) => j !== i))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="btn-line-file mt-1" onClick={addMilestone}>
|
||||
+ مرحله پرداخت
|
||||
</button>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="ts-items-box">
|
||||
<div className="ts-items-title">این termsheet کدام اقلام (مرحله ۲) را پوشش میدهد؟</div>
|
||||
{items.length === 0 ? (
|
||||
<div className="ts-items-empty">هنوز قلمی در مرحلهی ۲ اضافه نشده.</div>
|
||||
) : (
|
||||
<div>
|
||||
{items.map((it) => (
|
||||
<label key={it.tempId} className="ts-item-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="ts-item-cb"
|
||||
data-item-temp-id={it.tempId}
|
||||
checked={effectiveCovered.includes(it.tempId)}
|
||||
onChange={(e) => toggle(it.tempId, e.target.checked)}
|
||||
/>
|
||||
{it.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { TOPICS } from "../lib/constants";
|
||||
import { normalize } from "../lib/normalize";
|
||||
import { faNum } from "../lib/formatters";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export interface TopicGridProps {
|
||||
selectedTopic?: string;
|
||||
onSelect: (topicKey: string) => void;
|
||||
showCounts?: boolean; // تعداد تأمینکنندههای هر تاپیک
|
||||
counts?: Record<string, number>; // topic name → count
|
||||
}
|
||||
|
||||
// گرید ۴ ستونهی ۸ تاپیک
|
||||
export function TopicGrid({ selectedTopic, onSelect, showCounts = false, counts }: TopicGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{TOPICS.map((t) => {
|
||||
const selected = selectedTopic != null && normalize(selectedTopic) === normalize(t.name);
|
||||
const count = counts?.[t.name] ?? 0;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={t.key}
|
||||
className={cn("topic-card", selected && "selected")}
|
||||
onClick={() => onSelect(t.name)}
|
||||
>
|
||||
<img src={t.icon} alt="" />
|
||||
<div className="t-name">{t.name}</div>
|
||||
{showCounts && <div className="t-count">{faNum(count)} تأمینکننده</div>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { ChevronRight, ClipboardList } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface HeaderProps {
|
||||
title: string;
|
||||
sub?: string;
|
||||
backTo?: string; // مسیر بازگشتِ جایگزین (وقتی سابقهای در برنامه نیست)
|
||||
backLabel?: string;
|
||||
actions?: ReactNode; // دکمههای سمت چپ
|
||||
brand?: boolean; // نمایش آیکون دفتر کنار عنوان
|
||||
menu?: ReactNode; // منوی همبرگری بالای راست (ناوبری بین پنلها)
|
||||
}
|
||||
|
||||
// نوار بالای هر صفحه: منو (همبرگری) + دکمهی برگشت (یک مرحلهی قبل) + عنوان + اکشنها
|
||||
export function Header({ title, sub, backTo, backLabel = "بازگشت", actions, brand, menu }: HeaderProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// اگر کاربر از داخل برنامه به این صفحه آمده، یک قدم برمیگردیم؛
|
||||
// اگر مستقیم باز شده (بدون سابقهی داخلی)، به backTo میرویم.
|
||||
const goBack = () => {
|
||||
if (location.key !== "default") navigate(-1);
|
||||
else if (backTo) navigate(backTo);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex flex-wrap items-center gap-3 border-b border-line bg-panel px-5 py-2.5">
|
||||
{menu && <div className="flex shrink-0 items-center">{menu}</div>}
|
||||
{backTo && (
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
{backLabel}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
{brand && (
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-accent">
|
||||
<ClipboardList className="h-4.5 w-4.5" strokeWidth={2} />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-[17px] font-extrabold leading-6">{title}</h1>
|
||||
{sub && <div className="text-[11px] text-muted">{sub}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ms-auto flex flex-wrap items-center gap-2">{actions}</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// بدنهی صفحه با عرض محدود و چینش مرکزی
|
||||
export function PageLayout({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("mx-auto w-full max-w-[1200px] px-4 py-4", className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-accent text-white",
|
||||
// پیشنهاد شده = نارنجی، پذیرفته شده = آبی، رسیده = سبز
|
||||
offered: "border-transparent bg-amber-bg text-amber",
|
||||
accepted: "border-transparent bg-blue-bg text-[#1d4ed8]",
|
||||
arrived: "border-transparent bg-green-bg text-green",
|
||||
outline: "border-line text-muted",
|
||||
muted: "border-transparent bg-slate-bg text-muted",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-accent text-white shadow-sm hover:bg-accent-dark",
|
||||
primary: "bg-accent text-white shadow-sm hover:bg-accent-dark",
|
||||
slate: "bg-slate-bg text-ink hover:bg-line",
|
||||
line: "border border-line bg-panel text-ink hover:bg-slate-bg",
|
||||
ghost: "hover:bg-slate-bg text-ink",
|
||||
green: "bg-green text-white hover:brightness-95",
|
||||
danger: "bg-red-bg text-red hover:bg-red hover:text-white",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-line bg-panel shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-accent data-[state=checked]:bg-accent data-[state=checked]:text-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
||||
<Check className="h-3 w-3" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
@@ -0,0 +1,95 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 z-50 grid max-h-[90vh] w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-xl border border-line bg-panel p-6 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-accent/40">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">بستن</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-col space-y-1.5 text-start", className)} {...props} />;
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-bold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none text-ink peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,146 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-lg border border-line bg-panel px-3 py-2 text-sm text-ink shadow-sm focus:outline-none focus:ring-2 focus:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-line bg-panel text-ink shadow-md data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
position === "popper" && "data-[side=bottom]:translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 ps-2 pe-8 text-sm outline-none focus:bg-slate-bg data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute end-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-line", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
);
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn("border-b border-line transition-colors hover:bg-slate-bg/50", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-9 px-3 text-start align-middle text-[11px] font-semibold whitespace-nowrap text-muted/90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn("p-3 align-middle", className)} {...props} />
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm text-ink shadow-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
// صدا زدن callback هنگام کلیک بیرون از عنصر
|
||||
export function useClickOutside<T extends HTMLElement>(onOutside: () => void): React.RefObject<T | null> {
|
||||
const ref = useRef<T | null>(null);
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onOutside();
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [onOutside]);
|
||||
return ref;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export { useStoreData, useInvalidateStore } from "./useStoreData";
|
||||
export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./useQuotes";
|
||||
export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers";
|
||||
export { useMaterials, useAddMaterial } from "./useMaterials";
|
||||
export { useProjects, useAddProject } from "./useProjects";
|
||||
export {
|
||||
useUnits,
|
||||
useSaveRefUnit,
|
||||
useSaveConversion,
|
||||
useDeleteConversion,
|
||||
useSaveStandardConversion,
|
||||
useDeleteStandardConversion,
|
||||
} from "./useUnits";
|
||||
@@ -0,0 +1,23 @@
|
||||
// ============================================================
|
||||
// hooks مواد (کالاها) — دستهی جدید هم یک material است
|
||||
// ============================================================
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { Material } from "../lib/types";
|
||||
import { api } from "../lib/api";
|
||||
import { useAppStore } from "../store";
|
||||
import { useInvalidateStore } from "./useStoreData";
|
||||
|
||||
export function useMaterials(): Material[] {
|
||||
return useAppStore((s) => s.materials);
|
||||
}
|
||||
|
||||
export function useAddMaterial() {
|
||||
const addMaterial = useAppStore((s) => s.addMaterial);
|
||||
const invalidate = useInvalidateStore();
|
||||
return useMutation({
|
||||
mutationFn: (m: Material) => api.materials.create(m),
|
||||
onMutate: (m) => addMaterial(m),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// ============================================================
|
||||
// hooks پروژهها
|
||||
// ============================================================
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { Project } from "../lib/types";
|
||||
import { api } from "../lib/api";
|
||||
import { useAppStore } from "../store";
|
||||
import { useInvalidateStore } from "./useStoreData";
|
||||
|
||||
export function useProjects(): Project[] {
|
||||
return useAppStore((s) => s.projects);
|
||||
}
|
||||
|
||||
export function useAddProject() {
|
||||
const addProject = useAppStore((s) => s.addProject);
|
||||
const invalidate = useInvalidateStore();
|
||||
return useMutation({
|
||||
mutationFn: (p: Project) => api.projects.create(p),
|
||||
onMutate: (p) => addProject(p),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// ============================================================
|
||||
// hooks پیشنهادها — خواندن + mutationهای خوشبینانه
|
||||
// ============================================================
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { Quote } from "../lib/types";
|
||||
import { api } from "../lib/api";
|
||||
import { useAppStore } from "../store";
|
||||
import { useInvalidateStore } from "./useStoreData";
|
||||
|
||||
export function useQuotes(): Quote[] {
|
||||
return useAppStore((s) => s.quotes);
|
||||
}
|
||||
|
||||
export function useCreateQuote() {
|
||||
const addQuote = useAppStore((s) => s.addQuote);
|
||||
const invalidate = useInvalidateStore();
|
||||
return useMutation({
|
||||
mutationFn: (q: Quote) => api.quotes.create(q),
|
||||
onMutate: (q) => addQuote(q),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateQuote() {
|
||||
const updateQuote = useAppStore((s) => s.updateQuote);
|
||||
const invalidate = useInvalidateStore();
|
||||
return useMutation({
|
||||
mutationFn: (args: { id: string; patch: Partial<Quote> }) =>
|
||||
api.quotes.update(args.id, args.patch),
|
||||
onMutate: (args) => updateQuote(args.id, args.patch),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteQuote() {
|
||||
const deleteQuote = useAppStore((s) => s.deleteQuote);
|
||||
const invalidate = useInvalidateStore();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.quotes.delete(id),
|
||||
onMutate: (id) => deleteQuote(id),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// ============================================================
|
||||
// پایهی داده: یک query واحد که کل store را لود میکند
|
||||
// ============================================================
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../lib/api";
|
||||
import { useAppStore } from "../store";
|
||||
|
||||
export const STORE_KEY = ["store"] as const;
|
||||
|
||||
// یک بار در ریشهی برنامه صدا زده میشود: لود + mirror در Zustand
|
||||
export function useStoreData(): void {
|
||||
const setData = useAppStore((s) => s.setData);
|
||||
const { data } = useQuery({
|
||||
queryKey: STORE_KEY,
|
||||
queryFn: () => api.listAll(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (data) setData(data);
|
||||
}, [data, setData]);
|
||||
}
|
||||
|
||||
// بعد از هر mutation برای همگرایی با حقیقت
|
||||
export function useInvalidateStore(): () => void {
|
||||
const qc = useQueryClient();
|
||||
return useCallback(() => {
|
||||
void qc.invalidateQueries({ queryKey: STORE_KEY });
|
||||
}, [qc]);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// ============================================================
|
||||
// hooks تأمینکنندهها و دستهها
|
||||
// ============================================================
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { Supplier } from "../lib/types";
|
||||
import { api } from "../lib/api";
|
||||
import { useAppStore } from "../store";
|
||||
import { useInvalidateStore } from "./useStoreData";
|
||||
|
||||
export function useSuppliers(): Supplier[] {
|
||||
return useAppStore((s) => s.suppliers);
|
||||
}
|
||||
|
||||
export function useAddSupplier() {
|
||||
const addSupplier = useAppStore((s) => s.addSupplier);
|
||||
const invalidate = useInvalidateStore();
|
||||
return useMutation({
|
||||
mutationFn: (s: Supplier) => api.suppliers.create(s),
|
||||
onMutate: (s) => addSupplier(s),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSupplier() {
|
||||
const updateSupplier = useAppStore((s) => s.updateSupplier);
|
||||
const invalidate = useInvalidateStore();
|
||||
return useMutation({
|
||||
mutationFn: (args: { id: string; patch: Partial<Supplier> }) => api.suppliers.update(args.id, args.patch),
|
||||
onMutate: (args) => updateSupplier(args.id, args.patch),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveSupplier() {
|
||||
const deleteSupplier = useAppStore((s) => s.deleteSupplier);
|
||||
const invalidate = useInvalidateStore();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.suppliers.delete(id),
|
||||
onMutate: (id) => deleteSupplier(id),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// ============================================================
|
||||
// hooks واحدها — واحد مرجع، ضرایب دستهای و ثابت
|
||||
// ============================================================
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { CategoryUnit, StandardConversion, UnitConversion } from "../lib/types";
|
||||
import { api } from "../lib/api";
|
||||
import { useAppStore } from "../store";
|
||||
import { useInvalidateStore } from "./useStoreData";
|
||||
|
||||
export function useUnits(): {
|
||||
categoryUnits: CategoryUnit[];
|
||||
unitConversions: UnitConversion[];
|
||||
standardConversions: StandardConversion[];
|
||||
} {
|
||||
const categoryUnits = useAppStore((s) => s.categoryUnits);
|
||||
const unitConversions = useAppStore((s) => s.unitConversions);
|
||||
const standardConversions = useAppStore((s) => s.standardConversions);
|
||||
return { categoryUnits, unitConversions, standardConversions };
|
||||
}
|
||||
|
||||
export function useSaveRefUnit() {
|
||||
const setCategoryUnit = useAppStore((s) => s.setCategoryUnit);
|
||||
const invalidate = useInvalidateStore();
|
||||
return useMutation({
|
||||
mutationFn: (cu: CategoryUnit) => api.units.saveCategoryUnit(cu),
|
||||
onMutate: (cu) => setCategoryUnit(cu),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveConversion() {
|
||||
const setConversion = useAppStore((s) => s.setConversion);
|
||||
const invalidate = useInvalidateStore();
|
||||
return useMutation({
|
||||
mutationFn: (c: UnitConversion) => api.units.saveConversion(c),
|
||||
onMutate: (c) => setConversion(c),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteConversion() {
|
||||
const deleteConversion = useAppStore((s) => s.deleteConversion);
|
||||
const invalidate = useInvalidateStore();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.units.deleteConversion(id),
|
||||
onMutate: (id) => deleteConversion(id),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveStandardConversion() {
|
||||
const setStandardConversion = useAppStore((s) => s.setStandardConversion);
|
||||
const invalidate = useInvalidateStore();
|
||||
return useMutation({
|
||||
mutationFn: (sc: StandardConversion) => api.units.saveStandardConversion(sc),
|
||||
onMutate: (sc) => setStandardConversion(sc),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteStandardConversion() {
|
||||
const deleteStandardConversion = useAppStore((s) => s.deleteStandardConversion);
|
||||
const invalidate = useInvalidateStore();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.units.deleteStandardConversion(id),
|
||||
onMutate: (id) => deleteStandardConversion(id),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// ============================================================
|
||||
// Casdoor OIDC + PKCE login — v2.6
|
||||
// Config lives in /casdoor-config.js (window.CASDOOR_CONFIG), served
|
||||
// statically next to index.html (same values as the shipped dash).
|
||||
// ============================================================
|
||||
|
||||
const CASDOOR_CONFIG = (window as unknown as { CASDOOR_CONFIG?: CasdoorConfig }).CASDOOR_CONFIG;
|
||||
|
||||
export interface CasdoorConfig {
|
||||
serverUrl: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
organization: string;
|
||||
appName: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
function b64url(bytes: Uint8Array): string {
|
||||
let s = "";
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function randomBytes(n: number): Uint8Array {
|
||||
const arr = new Uint8Array(n);
|
||||
crypto.getRandomValues(arr);
|
||||
return arr;
|
||||
}
|
||||
|
||||
async function sha256b64url(text: string): Promise<string> {
|
||||
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
||||
return b64url(new Uint8Array(buf));
|
||||
}
|
||||
|
||||
export function startCasdoorLogin(): void {
|
||||
if (!CASDOOR_CONFIG) {
|
||||
alert("تنظیمات Casdoor یافت نشد — فایل casdoor-config.js را بررسی کنید");
|
||||
return;
|
||||
}
|
||||
const verifier = b64url(randomBytes(64));
|
||||
const state = b64url(randomBytes(16));
|
||||
sessionStorage.setItem("casdoor-pkce", JSON.stringify({ verifier, state }));
|
||||
void sha256b64url(verifier).then((challenge) => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: CASDOOR_CONFIG.clientId,
|
||||
redirect_uri: CASDOOR_CONFIG.redirectUri,
|
||||
response_type: "code",
|
||||
scope: "read",
|
||||
state,
|
||||
organization: CASDOOR_CONFIG.organization,
|
||||
application: CASDOOR_CONFIG.appName,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
window.location.href = `${CASDOOR_CONFIG.serverUrl}/login/oauth/authorize?${params.toString()}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Exchange the OIDC authorization code for a token (called on /callback). */
|
||||
export async function exchangeCodeForToken(code: string): Promise<{ access_token: string; id_token?: string }> {
|
||||
if (!CASDOOR_CONFIG) throw new Error("تنظیمات Casdoor یافت نشد");
|
||||
const pkce = JSON.parse(sessionStorage.getItem("casdoor-pkce") || "{}") as { verifier?: string };
|
||||
const resp = await fetch(`${CASDOOR_CONFIG.serverUrl}/api/login/oauth/access_token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: CASDOOR_CONFIG.clientId,
|
||||
client_secret: CASDOOR_CONFIG.clientSecret,
|
||||
code,
|
||||
redirect_uri: CASDOOR_CONFIG.redirectUri,
|
||||
code_verifier: pkce.verifier || "",
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error("خطا در دریافت توکن از Casdoor");
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export { CASDOOR_CONFIG };
|
||||
@@ -0,0 +1,52 @@
|
||||
// ============================================================
|
||||
// ثابتهای سراسری
|
||||
// ============================================================
|
||||
|
||||
import { normalize } from "./normalize";
|
||||
|
||||
export interface Topic {
|
||||
key: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
// ۸ تاپیک ثابت — HVAC و لولهکشی ادغام شدند؛ ابزار و تجهیزات ساخت اضافه شد
|
||||
export const TOPICS: Topic[] = [
|
||||
{ key: "hvac-plumb", name: "تاسیسات مکانیکی و آبی", icon: "/icons/topic-1.png" },
|
||||
{ key: "electric", name: "تجهیزات و لوازم برقی", icon: "/icons/topic-2.png" },
|
||||
{ key: "door-win", name: "درب، پنجره و شیشه", icon: "/icons/topic-3.png" },
|
||||
{ key: "metal", name: "فلزات و مقاطع فولادی", icon: "/icons/topic-4.png" },
|
||||
{ key: "build-mat", name: "مصالح ساختمانی", icon: "/icons/topic-6.png" },
|
||||
{ key: "chem", name: "مواد شیمیایی ساختمانی", icon: "/icons/topic-7.png" },
|
||||
{ key: "finish", name: "نازککاری و دکوراسیون داخلی", icon: "/icons/topic-8.png" },
|
||||
{ key: "tools", name: "ابزار و تجهیزات ساخت", icon: "/icons/topic-9.png" },
|
||||
];
|
||||
|
||||
function topicEq(a: string, b: string): boolean {
|
||||
return normalize(a) === normalize(b);
|
||||
}
|
||||
|
||||
// یافتن تاپیک بر اساس نام (نرمالشده)
|
||||
export function topicByName(name: string | null | undefined): Topic | null {
|
||||
if (!name) return null;
|
||||
return TOPICS.find((t) => topicEq(t.name, name)) || null;
|
||||
}
|
||||
|
||||
export const STATUS_LABEL: Record<string, string> = {
|
||||
offered: "پیشنهاد شده",
|
||||
accepted: "پذیرفته شده",
|
||||
arrived: "رسیده",
|
||||
rejected: "رد شده",
|
||||
expired: "منقضی شده",
|
||||
};
|
||||
|
||||
export const STATUS_ORDER = ["offered", "accepted", "arrived"];
|
||||
|
||||
// در ویرایش، وضعیت فقط میتواند به مراحل قبلی برگردد
|
||||
export function statusOptionsFor(current: string): string[] {
|
||||
const idx = STATUS_ORDER.indexOf(current);
|
||||
return idx < 0 ? STATUS_ORDER : STATUS_ORDER.slice(0, idx + 1);
|
||||
}
|
||||
|
||||
// واحدهای شناختهشده از بکاند خوانده میشوند — این لیست حذف شد
|
||||
// (واحدها فقط در دیتابیس تعریف میشوند و واحد جدید هنگام تایپ ساخته میشود)
|
||||
@@ -0,0 +1,68 @@
|
||||
// ============================================================
|
||||
// فرمتکنندههای نمایشی
|
||||
// ============================================================
|
||||
|
||||
import { parseNum, groupCommas, toWords } from "./numutil";
|
||||
import { isoToJalaliStr } from "./jalali";
|
||||
|
||||
// عدد → "۵۲٬۸۰۰" یا "—" برای null
|
||||
export function fmtNum(n: unknown): string {
|
||||
if (n == null || n === "") return "—";
|
||||
const num = parseNum(n);
|
||||
if (num == null) return "—";
|
||||
return groupCommas(num, true);
|
||||
}
|
||||
|
||||
// ISO → تاریخ شمسی یا "—"
|
||||
export function fmtDate(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
const j = isoToJalaliStr(iso);
|
||||
if (j) return j;
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("fa-IR");
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
// ISO → تاریخ شمسی + ساعت:دقیقه (مثلاً «۱۴۰۵/۰۴/۲۹ ۲۲:۱۵»)
|
||||
export function fmtDateTime(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return fmtDate(iso);
|
||||
const j = isoToJalaliStr(iso);
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${j} ${faNum(hh + ":" + mm)}`;
|
||||
}
|
||||
|
||||
// HTML escape (برای جاهایی که dangerouslySetInnerHTML لازم است)
|
||||
export function esc(s: unknown): string {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// ارقام انگلیسی → فارسی
|
||||
export function faNum(n: unknown): string {
|
||||
return String(n).replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[Number(d)]);
|
||||
}
|
||||
|
||||
// نمایش قیمت با کلمات
|
||||
export function priceCell(v: unknown): { num: string; words: string } | null {
|
||||
if (v == null || v === "") return null;
|
||||
const n = parseNum(v);
|
||||
if (n == null) return null;
|
||||
return { num: groupCommas(n, true), words: toWords(n) };
|
||||
}
|
||||
|
||||
// فاصلهی روز بین دو تاریخ ISO
|
||||
export function daysBetween(aIso: string | null, bIso: string | null): number | null {
|
||||
if (!aIso || !bIso) return null;
|
||||
const ms = new Date(aIso).getTime() - new Date(bIso).getTime();
|
||||
if (Number.isNaN(ms)) return null;
|
||||
return Math.round(ms / 86400000);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// ============================================================
|
||||
// ابزار تقویم شمسی — پورت jalali.js به TypeScript
|
||||
// تبدیلها با الگوریتم استاندارد Kazimierz M. Borkowski
|
||||
// ============================================================
|
||||
|
||||
const FA_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
|
||||
|
||||
export function div(a: number, b: number): number {
|
||||
return Math.floor(a / b);
|
||||
}
|
||||
|
||||
export function jalaliToGregorian(jy: number, jm: number, jd: number): [number, number, number] {
|
||||
jy = +jy;
|
||||
jm = +jm;
|
||||
jd = +jd;
|
||||
let gy = jy <= 979 ? 621 : 1600;
|
||||
jy -= jy <= 979 ? 0 : 979;
|
||||
let days =
|
||||
365 * jy +
|
||||
div(jy, 33) * 8 +
|
||||
div((jy % 33) + 3, 4) +
|
||||
78 +
|
||||
jd +
|
||||
(jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186);
|
||||
gy += 400 * div(days, 146097);
|
||||
days %= 146097;
|
||||
if (days > 36524) {
|
||||
gy += 100 * div(--days, 36524);
|
||||
if (days === 36524) days = 0;
|
||||
days++;
|
||||
}
|
||||
gy += 4 * div(days, 1461);
|
||||
days %= 1461;
|
||||
if (days > 365) {
|
||||
gy += div(days - 1, 365);
|
||||
days = (days - 1) % 365;
|
||||
}
|
||||
let gd = days + 1;
|
||||
const sal_a = [0, 31, (gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0 ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
let gm = 0;
|
||||
for (; gm < 13 && gd > sal_a[gm]; gm++) gd -= sal_a[gm];
|
||||
return [gy, gm, gd];
|
||||
}
|
||||
|
||||
export function gregorianToJalali(gy: number, gm: number, gd: number): [number, number, number] {
|
||||
gy = +gy;
|
||||
gm = +gm;
|
||||
gd = +gd;
|
||||
const g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
|
||||
let jy = gy <= 1600 ? 0 : 979;
|
||||
gy -= gy <= 1600 ? 621 : 1600;
|
||||
let days =
|
||||
365 * gy +
|
||||
div(gy + 3, 4) -
|
||||
div(gy + 99, 100) +
|
||||
div(gy + 399, 400) -
|
||||
80 +
|
||||
gd +
|
||||
g_d_m[gm - 1];
|
||||
jy += 33 * div(days, 12053);
|
||||
days %= 12053;
|
||||
jy += 4 * div(days, 1461);
|
||||
days %= 1461;
|
||||
if (days > 365) {
|
||||
jy += div(days - 1, 365);
|
||||
days = (days - 1) % 365;
|
||||
}
|
||||
const jm = days < 186 ? 1 + div(days, 31) : 7 + div(days - 186, 30);
|
||||
const jd = 1 + (days < 186 ? days % 31 : (days - 186) % 30);
|
||||
return [jy, jm, jd];
|
||||
}
|
||||
|
||||
export function isLeapJalali(jy: number): boolean {
|
||||
const breaks = [-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178];
|
||||
const jp = breaks[0];
|
||||
const jm = breaks[1];
|
||||
let jump = 0;
|
||||
let jp2 = jp;
|
||||
for (const jl of breaks) {
|
||||
jump = jl - jp2;
|
||||
if (jy <= jl) break;
|
||||
jp2 = jl;
|
||||
}
|
||||
let n = jy - jm;
|
||||
if (n < jump) n = n + jump + 1;
|
||||
return (n % 33) % 4 === 1;
|
||||
}
|
||||
|
||||
// ---------- formatting (Persian digits) ----------
|
||||
export function toFa(s: string | number): string {
|
||||
return String(s).replace(/[0-9]/g, (d) => FA_DIGITS[Number(d)]);
|
||||
}
|
||||
|
||||
// ماههای شمسی (نسخهی درستشده — «مهر» بهجای «آذر» تکراری)
|
||||
export const MONTHS = [
|
||||
"فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور",
|
||||
"مهر", "آبان", "آذر", "دی", "بهمن", "اسفند",
|
||||
];
|
||||
|
||||
export const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
|
||||
|
||||
function toEnSafe(s: string): string {
|
||||
return String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)));
|
||||
}
|
||||
|
||||
// format an ISO (gregorian) string to "۱۴۰۳/۰۵/۱۲"
|
||||
export function isoToJalaliStr(iso: string | null | undefined): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const [jy, jm, jd] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||||
return `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`;
|
||||
}
|
||||
|
||||
// format with month name: "۱۸ مرداد ۱۴۰۴"
|
||||
export function isoToJalaliLong(iso: string | null | undefined): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const [jy, jm, jd] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||||
return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`;
|
||||
}
|
||||
|
||||
// parse a jalali "yyyy/m/d" string to a JS Date (gregorian) at local midnight
|
||||
export function jalaliStrToIso(str: string | null | undefined): string | null {
|
||||
if (!str) return null;
|
||||
const nums = String(str)
|
||||
.split(/[/\-.]/)
|
||||
.map((x) => parseInt(toEnSafe(x), 10));
|
||||
if (nums.length < 3 || nums.some((n) => Number.isNaN(n))) return null;
|
||||
const [jy, jm, jd] = nums;
|
||||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||||
const dt = new Date(gy, gm - 1, gd);
|
||||
return Number.isNaN(dt.getTime()) ? null : dt.toISOString();
|
||||
}
|
||||
|
||||
export function daysInMonth(jy: number, jm: number): number {
|
||||
if (jm <= 6) return 31;
|
||||
if (jm <= 11) return 30;
|
||||
return isLeapJalali(jy) ? 30 : 29;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// ============================================================
|
||||
// نرمالسازی متن برای جستجو/مقایسهی بخشنده
|
||||
// ============================================================
|
||||
|
||||
const FA_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
|
||||
|
||||
function toEn(s: string): string {
|
||||
return String(s).replace(/[۰-۹]/g, (d) => String(FA_DIGITS.indexOf(d)));
|
||||
}
|
||||
|
||||
// trim, lowercase, یکسانسازی ی/ک فارسی و عربی، حذف zero-width non-joiner
|
||||
export function normalize(s: unknown): string {
|
||||
return toEn(String(s == null ? "" : s))
|
||||
.replace(/\u200c/g, "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/ي/g, "ی")
|
||||
.replace(/ك/g, "ک");
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// ============================================================
|
||||
// ابزار اعداد فارسی — پورت numutil.js به TypeScript
|
||||
// ============================================================
|
||||
|
||||
const FA_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
|
||||
|
||||
export function toFa(s: string | number): string {
|
||||
return String(s).replace(/[0-9]/g, (d) => FA_DIGITS[Number(d)]);
|
||||
}
|
||||
|
||||
export function toEn(s: string | number): string {
|
||||
return String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)));
|
||||
}
|
||||
|
||||
// strip commas / spaces / Persian digits and parse as a clean number
|
||||
export function parseNum(v: unknown): number | null {
|
||||
if (v == null) return null;
|
||||
const cleaned = toEn(String(v)).replace(/[,،\s]/g, "");
|
||||
if (cleaned === "") return null;
|
||||
const n = Number(cleaned);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
// groupCommas with full decimal support (up to 4 decimal places)
|
||||
export function groupCommas(n: number | string | null | undefined, fa = false): string {
|
||||
if (n == null || (typeof n !== "number" && parseNum(n) == null)) return "";
|
||||
const num = typeof n === "number" ? n : (parseNum(n) as number);
|
||||
const sep = fa ? "٬" : ",";
|
||||
const parts = num.toString().split(".");
|
||||
const intPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, sep);
|
||||
const decPart = parts.length > 1 ? "." + parts[1].slice(0, 4) : "";
|
||||
const out = intPart + decPart;
|
||||
return fa ? toFa(out) : out;
|
||||
}
|
||||
|
||||
// ---------- Persian number to words ----------
|
||||
const ONES = ["", "یک", "دو", "سه", "چهار", "پنج", "شش", "هفت", "هشت", "نه"];
|
||||
const TEN = ["ده", "یازده", "دوازده", "سیزده", "چهارده", "پانزده", "شانزده", "هفده", "هجده", "نوزده"];
|
||||
const TENS = ["", "", "بیست", "سی", "چهل", "پنجاه", "شصت", "هفتاد", "هشتاد", "نود"];
|
||||
const HUNDREDS = ["", "صد", "دویست", "سیصد", "چهارصد", "پانصد", "ششصد", "هفتصد", "هشتصد", "نهصد"];
|
||||
const SCALES = [
|
||||
{ n: 1, s: "" },
|
||||
{ n: 1e3, s: " هزار" },
|
||||
{ n: 1e6, s: " میلیون" },
|
||||
{ n: 1e9, s: " میلیارد" },
|
||||
{ n: 1e12, s: " تریلیون" },
|
||||
];
|
||||
|
||||
function threeDigitToWords(n: number): string {
|
||||
// n: 0..999
|
||||
const parts: string[] = [];
|
||||
const h = Math.floor(n / 100);
|
||||
const rem = n % 100;
|
||||
if (h) parts.push(HUNDREDS[h]);
|
||||
if (rem >= 10 && rem < 20) {
|
||||
parts.push(TEN[rem - 10]);
|
||||
} else {
|
||||
const t = Math.floor(rem / 10);
|
||||
const o = rem % 10;
|
||||
if (t) parts.push(TENS[t]);
|
||||
if (o) parts.push(ONES[o]);
|
||||
}
|
||||
return parts.join(" و ");
|
||||
}
|
||||
|
||||
export function numToWordsFa(n: number): string {
|
||||
n = Math.round(n);
|
||||
if (n === 0) return "صفر";
|
||||
if (n < 0) return "منفی " + numToWordsFa(-n);
|
||||
|
||||
// break into 3-digit groups, smallest first
|
||||
const groups: number[] = [];
|
||||
let w = n;
|
||||
while (w > 0) {
|
||||
groups.push(w % 1000);
|
||||
w = Math.floor(w / 1000);
|
||||
}
|
||||
const words: string[] = [];
|
||||
for (let i = groups.length - 1; i >= 0; i--) {
|
||||
if (groups[i] === 0) continue;
|
||||
const sc = SCALES[i] || { s: "" };
|
||||
words.push(threeDigitToWords(groups[i]) + sc.s);
|
||||
}
|
||||
return words.join(" و ");
|
||||
}
|
||||
|
||||
// "52800" -> "پنجاه و دو هزار و هشتصد"
|
||||
export function toWords(v: unknown): string {
|
||||
const n = parseNum(v);
|
||||
if (n == null) return "";
|
||||
return numToWordsFa(n);
|
||||
}
|
||||
|
||||
// full price label: comma number + words in parentheses
|
||||
// "52800" -> "۵۲٬۸۰۰ (پنجاه و دو هزار و هشتصد)"
|
||||
export function priceLabel(v: unknown, unit?: string): string {
|
||||
const n = parseNum(v);
|
||||
if (n == null) return "";
|
||||
const num = groupCommas(n, true);
|
||||
const words = toWords(n);
|
||||
const suffix = unit ? ` ${unit}` : "";
|
||||
return `${num}${suffix} (${words})`;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// ============================================================
|
||||
// منطق بیزنس مشترک پیشنهادها — پورت از app.js
|
||||
// ============================================================
|
||||
|
||||
import type { LineItem, Material, Quote, StandardConversion, Store, Supplier, UnitConversion } from "./types";
|
||||
import { normalize } from "./normalize";
|
||||
import { groupCommas } from "./numutil";
|
||||
import { fmtDate } from "./formatters";
|
||||
|
||||
export function supplierById(suppliers: Supplier[], id: string): Supplier {
|
||||
return suppliers.find((x) => x.id === id) || { id: "", name: "؟", location: "", topic: "", categories: [], phone: "", contact: "" };
|
||||
}
|
||||
|
||||
export function materialById(materials: Material[], id: string): Material {
|
||||
return (
|
||||
materials.find((x) => x.id === id) || {
|
||||
id: "",
|
||||
name: "",
|
||||
topic: "",
|
||||
category: "؟",
|
||||
brand: "",
|
||||
specs: [],
|
||||
base_unit: "",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// spec chips یک material (دادههای قدیمی ممکن است spec رشتهای داشته باشند)
|
||||
export function materialSpecs(m: Material | null | undefined): string[] {
|
||||
return (m && m.specs) || [];
|
||||
}
|
||||
|
||||
// کلید متعارف یک مجموعه spec — ترتیب مهم نیست، متن نرمالشده
|
||||
export function specsKey(specs: string[]): string {
|
||||
return (specs || []).map((s) => normalize(s)).filter(Boolean).sort().join("|");
|
||||
}
|
||||
|
||||
// specها با جداکنندهی « · »
|
||||
export function specsLabel(specs: string[]): string {
|
||||
return specs.join(" · ");
|
||||
}
|
||||
|
||||
// آرایهی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید)
|
||||
export function itemTermsheetIds(li: LineItem): string[] {
|
||||
if (li.termsheet_ids) return li.termsheet_ids;
|
||||
return [];
|
||||
}
|
||||
|
||||
// اقلام داخل معامله (ردشدهها نگه داشته میشوند ولی هرگز شمرده نمیشوند)
|
||||
export function activeItems(q: Quote): LineItem[] {
|
||||
return (q.line_items || []).filter((li) => !li.rejected);
|
||||
}
|
||||
|
||||
// جمع کل اقلام فعال (total_price اگر هست، وگرنه unit*qty)
|
||||
export function quoteTotal(q: Quote): number {
|
||||
return activeItems(q).reduce((sum, li) => {
|
||||
const t =
|
||||
li.total_price != null
|
||||
? li.total_price
|
||||
: li.unit_price != null && li.quantity != null
|
||||
? li.unit_price * li.quantity
|
||||
: 0;
|
||||
return sum + (t || 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// ترمشیت مؤثر: پذیرفتهشده، وگرنه اولین offered
|
||||
export function effectiveTermsheet(q: Quote): Quote["termsheets"][number] | null {
|
||||
if (!q.termsheets || !q.termsheets.length) return null;
|
||||
return q.termsheets.find((t) => t.status === "accepted") || q.termsheets[0];
|
||||
}
|
||||
|
||||
// id ترمشیتهایی که واقعاً توسط اقلام فعال ارجاع شدهاند
|
||||
export function usedTermsheetIds(q: Quote): string[] {
|
||||
const ids = new Set<string>();
|
||||
activeItems(q).forEach((li) => {
|
||||
itemTermsheetIds(li).forEach((tid) => {
|
||||
if (tid) ids.add(tid);
|
||||
});
|
||||
});
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
// مجموع هر قلم
|
||||
export function lineItemTotal(li: LineItem): number | null {
|
||||
if (li.total_price != null) return li.total_price;
|
||||
if (li.unit_price != null && li.quantity != null) return li.unit_price * li.quantity;
|
||||
return null;
|
||||
}
|
||||
|
||||
// پروژههای یک قلم — فقط پروژههای تخصیصیافته به همین قلم (بدون fallback به سطح پیشنهاد)
|
||||
export function itemProjects(_q: Quote, li: LineItem): string[] {
|
||||
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name));
|
||||
}
|
||||
|
||||
// ---------- واحد مرجع و ضریب تبدیل ----------
|
||||
|
||||
// واحد مرجع یک دسته
|
||||
export function refUnitForCategory(store: Pick<Store, "categoryUnits">, category: string): string {
|
||||
const cu = (store.categoryUnits || []).find((c) => normalize(c.category) === normalize(category));
|
||||
return cu ? cu.ref_unit : "";
|
||||
}
|
||||
|
||||
// ضریب تبدیل با اولویت: دستهای (برند مشخص > همه برندها) > تبدیل ثابت جهانی
|
||||
export function findConversion(
|
||||
store: Pick<Store, "unitConversions" | "standardConversions">,
|
||||
category: string,
|
||||
brand: string,
|
||||
fromUnit: string,
|
||||
toUnit: string,
|
||||
): UnitConversion | StandardConversion | null {
|
||||
const list = (store.unitConversions || []).filter(
|
||||
(c) =>
|
||||
normalize(c.category) === normalize(category) &&
|
||||
normalize(c.from_unit) === normalize(fromUnit) &&
|
||||
normalize(c.to_unit) === normalize(toUnit) &&
|
||||
(!c.brand || normalize(c.brand) === normalize(brand)),
|
||||
);
|
||||
list.sort((a, b) => (b.brand ? 1 : 0) - (a.brand ? 1 : 0));
|
||||
if (list[0]) return list[0];
|
||||
return (
|
||||
(store.standardConversions || []).find(
|
||||
(c) => normalize(c.from_unit) === normalize(fromUnit) && normalize(c.to_unit) === normalize(toUnit),
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
// تبدیل یک قلم به واحد مرجع دسته؛ null وقتی ضریبی نیست یا واحد از قبل مرجع است
|
||||
export function refConverted(
|
||||
store: Pick<Store, "categoryUnits" | "unitConversions" | "standardConversions" | "materials">,
|
||||
li: LineItem,
|
||||
): { qty: number; unit: string; perRef: number | null } | null {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
const ref = refUnitForCategory(store, m.category);
|
||||
if (!ref || !li.unit || normalize(ref) === normalize(li.unit)) return null;
|
||||
const conv = findConversion(store, m.category, m.brand, li.unit, ref);
|
||||
if (!conv) return null;
|
||||
const qty = (li.quantity || 0) * conv.factor;
|
||||
const total = li.total_price != null ? li.total_price : (li.unit_price || 0) * (li.quantity || 0);
|
||||
const perRef = qty > 0 ? Math.round(total / qty) : null;
|
||||
return { qty, unit: ref, perRef };
|
||||
}
|
||||
|
||||
// واحدهای ناشناختهی استفادهشده در یک quote → لینک به داشبورد واحدها
|
||||
// واحدها دیگر hardcode نیستند — همه از بکاند میآیند؛ هشداری نمایش داده نمیشود
|
||||
export function unitWarning(_store: Store, _q: Quote): { unit: string; cat: string }[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
// ستون پروژه
|
||||
export function projectCell(q: Quote): string {
|
||||
if (q.projects && q.projects.length) return q.projects.join("، ");
|
||||
return q.project || "—";
|
||||
}
|
||||
|
||||
// خلاصهی اقلام (ردیف جمعشده)
|
||||
export function itemsSummary(store: Store, q: Quote): { text: string; count: number } {
|
||||
const items = activeItems(q).map((li) => {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
const specs = materialSpecs(m);
|
||||
return `${m.category} ${m.brand}${specs.length ? " " + specs.join(" · ") : ""}`;
|
||||
});
|
||||
return { text: items.join("، "), count: items.length };
|
||||
}
|
||||
|
||||
// ستون معامله — نمایش فشرده برای ردیف جمعشده
|
||||
export function termsheetSummary(store: Store, q: Quote): { title: string; sub: string; color: string } {
|
||||
if (!q.termsheets || !q.termsheets.length) return { title: "—", sub: "", color: "" };
|
||||
const used = usedTermsheetIds(q);
|
||||
const accepted = q.termsheets.find((t) => t.status === "accepted");
|
||||
if (accepted) {
|
||||
const items = activeItems(q).filter((li) => itemTermsheetIds(li).includes(accepted.id));
|
||||
const itemsDesc =
|
||||
items.length === q.line_items.length
|
||||
? "همه اقلام"
|
||||
: items
|
||||
.map((li) => {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
const specText = specsLabel(materialSpecs(m));
|
||||
return specText || m.brand;
|
||||
})
|
||||
.join("، ");
|
||||
return {
|
||||
title: accepted.label,
|
||||
sub: `${itemsDesc} · ${accepted.milestones.length} مرحله`,
|
||||
color: "var(--green)",
|
||||
};
|
||||
}
|
||||
if (used.length === 1) {
|
||||
return { title: q.termsheets[0].label, sub: `${q.termsheets.length} گزینه`, color: "" };
|
||||
}
|
||||
return { title: `${used.length} termsheet جدا · ${q.termsheets.length} گزینه`, sub: "", color: "" };
|
||||
}
|
||||
|
||||
// فیلترهای دفتر
|
||||
export function filterQuotes(
|
||||
store: Store,
|
||||
filters: { category: string; supplier: string; status: string; search: string },
|
||||
): Quote[] {
|
||||
const catVal = filters.category.trim();
|
||||
const supVal = filters.supplier.trim();
|
||||
const stVal = filters.status.trim();
|
||||
const q = filters.search.trim().toLowerCase();
|
||||
|
||||
const STATUS_BY_LABEL: Record<string, string> = {
|
||||
"پیشنهاد شده": "offered",
|
||||
"پذیرفته شده": "accepted",
|
||||
"رسیده": "arrived",
|
||||
};
|
||||
const stKey = STATUS_BY_LABEL[stVal] || "";
|
||||
|
||||
return store.quotes.filter((quote) => {
|
||||
const supObj = supplierById(store.suppliers, quote.supplier_id);
|
||||
if (supVal && supObj.name !== supVal) return false;
|
||||
if (stKey && quote.status !== stKey) return false;
|
||||
|
||||
const matInfo = activeItems(quote).map((li) => materialById(store.materials, li.material_id));
|
||||
if (catVal && !matInfo.some((m) => m.category === catVal)) return false;
|
||||
|
||||
if (q) {
|
||||
const hay =
|
||||
`${supObj.name} ${quote.project || ""} ` +
|
||||
matInfo.map((m) => `${m.category} ${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
|
||||
if (!hay.toLowerCase().includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// وضعیت منقضی
|
||||
export function isExpired(q: Quote): boolean {
|
||||
return (
|
||||
q.status === "offered" &&
|
||||
!!q.expiry_date &&
|
||||
new Date(q.expiry_date) < new Date(new Date().toDateString())
|
||||
);
|
||||
}
|
||||
|
||||
// آمادهسازی نمایش ترمشیت در حالت expanded
|
||||
export function termSheetBlocks(
|
||||
store: Store,
|
||||
q: Quote,
|
||||
): { ts: Quote["termsheets"][number]; isAccepted: boolean; isUsed: boolean; itemsDesc: string; milestones: string[] }[] {
|
||||
const used = usedTermsheetIds(q);
|
||||
const active = activeItems(q);
|
||||
return q.termsheets.map((ts) => {
|
||||
const isAccepted = ts.status === "accepted";
|
||||
const isUsed = used.includes(ts.id);
|
||||
const items = active.filter((li) => itemTermsheetIds(li).includes(ts.id));
|
||||
const itemsDesc =
|
||||
items.length === active.length
|
||||
? "همه اقلام"
|
||||
: items.length
|
||||
? items
|
||||
.map((li) => {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
return specsLabel(materialSpecs(m)) || m.brand;
|
||||
})
|
||||
.join("، ")
|
||||
: "بدون قلم";
|
||||
const milestones = ts.milestones.map((m) => {
|
||||
const dateLabel = m.when_iso ? fmtDate(m.when_iso) : "?";
|
||||
const amt = m.amount != null ? groupCommas(m.amount, true) : "?";
|
||||
return `${dateLabel} · ${amt}`;
|
||||
});
|
||||
return { ts, isAccepted, isUsed, itemsDesc, milestones };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// ============================================================
|
||||
// انواع دادهی برنامه — مدل پیشنهاد خرید
|
||||
// (همان شکلهایی که بکاند واقعی برمیگرداند)
|
||||
// ============================================================
|
||||
|
||||
// وضعیتهای یک پیشنهاد — بکاند rejected/expired را هم برمیگرداند
|
||||
export type QuoteStatus = "offered" | "accepted" | "arrived" | "rejected" | "expired";
|
||||
|
||||
// تأمینکننده
|
||||
export interface Supplier {
|
||||
id: string; // "s1", "s2", ...
|
||||
topic: string; // نام یکی از ۸ تاپیک
|
||||
categories: string[]; // دستهها (مثلاً ["میلگرد", "پروفیل"])
|
||||
name: string;
|
||||
location: string;
|
||||
phone: string;
|
||||
contact: string; // نام شخص تماس
|
||||
}
|
||||
|
||||
// ماده/کالا
|
||||
export interface Material {
|
||||
id: string; // "m1", "m2", ...
|
||||
name: string; // نام کامل ذخیرهشده در بکاند (برند + دسته + مشخصه)
|
||||
topic: string;
|
||||
category: string; // مثلاً "میلگرد"
|
||||
brand: string; // مثلاً "ذوب آهن"
|
||||
specs: string[]; // آرایهای از مشخصهها (مثلاً ["A2"])
|
||||
base_unit: string; // واحد پایه
|
||||
}
|
||||
|
||||
// پروژه
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// مرحلهی پرداخت
|
||||
export interface Milestone {
|
||||
when_iso: string | null; // تاریخ ISO
|
||||
amount: number | null; // مبلغ
|
||||
}
|
||||
|
||||
// شرایط پرداخت (ترمشیت)
|
||||
export interface Termsheet {
|
||||
id: string;
|
||||
label: string; // "گزینه ۱", "گزینه ۲", ...
|
||||
milestones: Milestone[];
|
||||
status: "offered" | "accepted";
|
||||
}
|
||||
|
||||
// فایل پیوست — روی سرور (MinIO) ذخیره میشود؛ داشتن id یعنی آپلود انجام شده
|
||||
export interface Attachment {
|
||||
id?: string;
|
||||
name: string;
|
||||
mime_type?: string;
|
||||
size_bytes?: number;
|
||||
uploaded_at?: string;
|
||||
}
|
||||
|
||||
// قلم سفارش
|
||||
export interface LineItem {
|
||||
id: string;
|
||||
material_id: string;
|
||||
quantity: number | null;
|
||||
unit: string;
|
||||
unit_price: number | null;
|
||||
total_price: number | null;
|
||||
termsheet_ids: string[]; // به کدوم ترمشیتها وصله
|
||||
accepted_ts_id: string | null; // با کدوم ترمشیت پذیرش شده
|
||||
rejected: boolean; // از معامله حذف شده؟
|
||||
arrived_date: string | null; // تاریخ رسید
|
||||
projects?: { id: string; name: string }[]; // پروژههای تخصیصیافته به این قلم
|
||||
}
|
||||
|
||||
// پیشنهاد خرید (اصلیترین entity)
|
||||
export interface Quote {
|
||||
id: string;
|
||||
supplier_id: string;
|
||||
project: string; // legacy
|
||||
projects: string[]; // آرایهی جدید
|
||||
created_at?: string | null; // زمان ساخت (برای مرتبسازی)
|
||||
quote_date: string; // ISO
|
||||
expiry_date: string | null; // ISO, null = بدون انقضا
|
||||
accepted_date: string | null;
|
||||
arrived_date: string | null;
|
||||
status: QuoteStatus;
|
||||
is_official: boolean;
|
||||
proformas: Attachment[]; // پیشفاکتورها — چند فایل
|
||||
invoices: Attachment[]; // فاکتورهای اصلی — چند فایل
|
||||
events: { type: string; occurred_at: string }[];
|
||||
notes: string;
|
||||
line_items: LineItem[];
|
||||
termsheets: Termsheet[];
|
||||
}
|
||||
|
||||
// واحد مرجع هر دسته
|
||||
export interface CategoryUnit {
|
||||
id: string;
|
||||
category: string;
|
||||
ref_unit: string;
|
||||
}
|
||||
|
||||
// ضریب تبدیل (دستهای)
|
||||
export interface UnitConversion {
|
||||
id: string;
|
||||
category: string;
|
||||
brand: string; // خالی = همه برندها
|
||||
from_unit: string;
|
||||
to_unit: string;
|
||||
factor: number; // 1 from_unit = factor * to_unit
|
||||
}
|
||||
|
||||
// ضریب تبدیل استاندارد (جهانی)
|
||||
export interface StandardConversion {
|
||||
id: string;
|
||||
from_unit: string;
|
||||
to_unit: string;
|
||||
factor: number;
|
||||
}
|
||||
|
||||
// کل store
|
||||
export interface Store {
|
||||
suppliers: Supplier[];
|
||||
materials: Material[];
|
||||
projects: Project[];
|
||||
quotes: Quote[];
|
||||
categoryUnits: CategoryUnit[];
|
||||
unitConversions: UnitConversion[];
|
||||
standardConversions: StandardConversion[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v2.6 — inventory domain (React port of the shipped dash)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Warehouse {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
project?: { id: string; name: string } | null;
|
||||
items_count?: number;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface InventoryRow {
|
||||
id: string;
|
||||
warehouse: Warehouse;
|
||||
material: { id: string; name: string; category: { id: string; name: string } | null };
|
||||
brand: { id: string; name: string } | null;
|
||||
quantity: string;
|
||||
quantity_number: number;
|
||||
unit: { id: number; code: string; name_fa: string | null } | null;
|
||||
specs: Record<string, string>;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type TransactionType =
|
||||
| "PURCHASE" | "WITHDRAWAL" | "RETURN" | "TRANSFER_IN" | "TRANSFER_OUT"
|
||||
| "DAMAGE" | "WASTE" | "LOSS" | "FOUND" | "STOCK_COUNT";
|
||||
|
||||
export interface InventoryTransaction {
|
||||
id: string;
|
||||
inventory_id: string;
|
||||
warehouse_id: string;
|
||||
warehouse?: { id: string; name: string } | null;
|
||||
transaction_type: TransactionType;
|
||||
quantity: string;
|
||||
quantity_number: number;
|
||||
recorded_qty?: number | null;
|
||||
physical_qty?: number | null;
|
||||
transfer_pair_id?: string | null;
|
||||
performed_by?: string | null;
|
||||
transaction_date: string;
|
||||
note?: string | null;
|
||||
material?: { id: string; name: string } | null;
|
||||
brand?: { id: string; name: string } | null;
|
||||
specs?: Record<string, string>;
|
||||
unit?: { id: number; code: string; name_fa: string | null } | null;
|
||||
}
|
||||
|
||||
export interface PendingArrival {
|
||||
item_decision_id: string;
|
||||
line_item_id: string;
|
||||
quote_id: string;
|
||||
quote_status: string;
|
||||
material: { id: string; name: string; category: { id: string; name: string } | null };
|
||||
unit: { id: number; code: string; name_fa: string | null } | null;
|
||||
ordered_qty: string;
|
||||
received_qty: string;
|
||||
state: "on_the_way" | "partially_received" | "arrived";
|
||||
}
|
||||
|
||||
export interface ConfirmedReceipt {
|
||||
receipt_id: string;
|
||||
line_item_id: string;
|
||||
material: { id: string; name: string };
|
||||
received_at: string;
|
||||
received_qty: string;
|
||||
on_time: boolean | null;
|
||||
quality_note: string | null;
|
||||
allocations: { warehouse_id: string; warehouse_name: string; quantity: string }[];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "sonner";
|
||||
import App from "./App";
|
||||
import "./styles/globals.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<Toaster position="top-center" richColors closeButton />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { startCasdoorLogin } from "../../lib/casdoor";
|
||||
import { api } from "../../lib/api";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import type { Project } from "../../lib/types";
|
||||
|
||||
// ورود به سامانه — Casdoor (OIDC + PKCE) + ورود آزمایشی برای توسعه
|
||||
export function LoginPage() {
|
||||
const setToken = useAuthStore((s) => s.setToken);
|
||||
const setUser = useAuthStore((s) => s.setUser);
|
||||
const setDevUser = useAuthStore((s) => s.setDevUser);
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
|
||||
// ورود آزمایشی: پروژهها از بکاند خوانده میشوند
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [devRole, setDevRole] = useState<"pm" | "admin" | "procurement_manager">("pm");
|
||||
const [devProjectId, setDevProjectId] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
void api.projects.list().then(setProjects).catch(() => setProjects([]));
|
||||
}, []);
|
||||
|
||||
const devProject = useMemo(
|
||||
() => projects.find((p) => p.id === devProjectId) || null,
|
||||
[projects, devProjectId],
|
||||
);
|
||||
|
||||
// Callback: ?code=...&state=...
|
||||
useEffect(() => {
|
||||
const code = params.get("code");
|
||||
if (!code) return;
|
||||
void (async () => {
|
||||
try {
|
||||
const { exchangeCodeForToken } = await import("../../lib/casdoor");
|
||||
const tok = await exchangeCodeForToken(code);
|
||||
setToken(tok.access_token);
|
||||
// fetch profile from our backend
|
||||
const { apiV26 } = await import("../../lib/api");
|
||||
const profile = await apiV26.users.me();
|
||||
setUser(profile);
|
||||
toast.success(`خوش آمدید، ${profile.display_name}`);
|
||||
// مدیر پروژه → پنل انبار؛ بقیه → دفتر پیشنهادهای خرید
|
||||
navigate(profile.role === "pm" ? "/inventory" : "/");
|
||||
} catch (err) {
|
||||
toast.error(`خطا در ورود: ${err instanceof Error ? err.message : "خطای ناشناخته"}`);
|
||||
}
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [params]);
|
||||
|
||||
const devLogin = () => {
|
||||
setDevUser(devRole, devRole === "pm" ? devProjectId || null : null, devProject?.name || null);
|
||||
toast.success("ورود آزمایشی انجام شد — نقش: " + (devRole === "pm" ? "مدیر پروژه" : devRole === "procurement_manager" ? "مدیر تدارکات" : "ادمین"));
|
||||
// مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید
|
||||
navigate(devRole === "pm" ? "/inventory" : "/");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="ورود به سامانه" sub="احراز هویت با حساب سازمانی (Casdoor)" />
|
||||
<PageLayout className="flex max-w-md flex-col items-start gap-4">
|
||||
<div className="rounded-xl border border-line bg-panel p-4 shadow-sm">
|
||||
<h3 className="mb-1 text-[14px] font-bold">ورود با حساب سازمانی</h3>
|
||||
<p className="mb-3 text-[12.5px] leading-6 text-muted">
|
||||
برای ورود به سامانه تدارکات و انبار، با حساب سازمانی خود وارد شوید. پس از ورود، نقش شما
|
||||
(مدیر پروژه / مدیر تدارکات / ادمین) مشخص و دسترسیها بر اساس آن فعال میشود.
|
||||
</p>
|
||||
<Button onClick={() => void startCasdoorLogin()}>ورود با Casdoor</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-dashed border-accent/40 bg-blue-bg/20 p-4">
|
||||
<h3 className="mb-1 text-[14px] font-bold">ورود آزمایشی (توسعه)</h3>
|
||||
<p className="mb-3 text-[12.5px] leading-6 text-muted">
|
||||
بدون Casdoor وارد شوید — نقش و پروژه از طریق هدرهای توسعه به بکاند ارسال میشود
|
||||
(فقط برای توسعه و تست محلی).
|
||||
</p>
|
||||
<div className="grid gap-3">
|
||||
<div className="field">
|
||||
<label>نقش</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
value={devRole}
|
||||
onChange={(e) => setDevRole(e.target.value as "pm" | "admin")}
|
||||
>
|
||||
<option value="pm">مدیر پروژه (PM)</option>
|
||||
<option value="procurement_manager">مدیر تدارکات</option>
|
||||
<option value="admin">ادمین (فقطخواندنی)</option>
|
||||
</select>
|
||||
</div>
|
||||
{devRole === "pm" && (
|
||||
<div className="field">
|
||||
<label>پروژه</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
value={devProjectId}
|
||||
onChange={(e) => setDevProjectId(e.target.value)}
|
||||
>
|
||||
<option value="">— انتخاب پروژه —</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<Button variant="green" onClick={devLogin} disabled={devRole === "pm" && !devProjectId}>
|
||||
ورود آزمایشی
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { Attachment, Milestone, Quote } from "../../lib/types";
|
||||
import { api, nextDashedId } from "../../lib/api";
|
||||
import { useMaterials, useProjects, useQuotes, useUpdateQuote, useSuppliers } from "../../hooks";
|
||||
import {
|
||||
activeItems,
|
||||
itemTermsheetIds,
|
||||
materialById,
|
||||
materialSpecs,
|
||||
specsLabel,
|
||||
supplierById,
|
||||
} from "../../lib/quoteUtils";
|
||||
import { faNum, fmtNum } from "../../lib/formatters";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog";
|
||||
import { MilestoneRow } from "../../components/MilestoneRow";
|
||||
import { FileList } from "../../components/FileList";
|
||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||
|
||||
interface CardDraft {
|
||||
domId: string;
|
||||
isNew: boolean;
|
||||
milestones: Milestone[];
|
||||
label: string | null; // null = پیشفرض «گزینه N»
|
||||
}
|
||||
|
||||
export interface AcceptModalProps {
|
||||
quoteId: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// مودال پذیرش: انتخاب/ویرایش شرایط پرداخت + تخصیص قلمها + فاکتورها
|
||||
export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
const quotes = useQuotes();
|
||||
const suppliers = useSuppliers();
|
||||
const materials = useMaterials();
|
||||
const projects = useProjects();
|
||||
const updateQuote = useUpdateQuote();
|
||||
|
||||
const q = useMemo(() => quotes.find((x) => x.id === quoteId) || null, [quotes, quoteId]);
|
||||
|
||||
const [cards, setCards] = useState<CardDraft[]>([]);
|
||||
const [invoices, setInvoices] = useState<Attachment[]>([]);
|
||||
const [proformas, setProformas] = useState<Attachment[]>([]);
|
||||
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
|
||||
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
||||
// تخصیص هر قلم به پروژهها (نامها)
|
||||
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string[]>>({});
|
||||
// تاریخ و زمان پذیرش — پیشفرض الان
|
||||
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
// وقتی quote باز شد، state اولیه را بساز
|
||||
useMemo(() => {
|
||||
if (!q) return;
|
||||
const offered = q.termsheets.filter((t) => t.status === "offered");
|
||||
setCards(
|
||||
offered.map((t) => ({ domId: t.id, isNew: false, milestones: JSON.parse(JSON.stringify(t.milestones)), label: t.label })),
|
||||
);
|
||||
const rem = new Set<string>();
|
||||
q.line_items.forEach((li) => {
|
||||
if (li.rejected) rem.add(li.id);
|
||||
});
|
||||
setRemovedItems(rem);
|
||||
setAssignments({});
|
||||
// پروژههای هر قلم — از تخصیصهای قبلی، وگرنه پروژههای سطح پیشنهاد
|
||||
const projSel: Record<string, string[]> = {};
|
||||
q.line_items.forEach((li) => {
|
||||
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
|
||||
projSel[li.id] = names.length
|
||||
? names
|
||||
: q.projects && q.projects.length
|
||||
? [...q.projects]
|
||||
: q.project
|
||||
? [q.project]
|
||||
: [];
|
||||
});
|
||||
setItemProjectsSel(projSel);
|
||||
setReady(true);
|
||||
}, [q]);
|
||||
|
||||
// پیوستها مستقیماً از سرور (MinIO) خوانده میشوند — نه از state محلی
|
||||
useEffect(() => {
|
||||
if (!quoteId) return;
|
||||
let alive = true;
|
||||
void api.files
|
||||
.list(quoteId, "invoice")
|
||||
.then((list) => alive && setInvoices(list))
|
||||
.catch(() => alive && setInvoices([]));
|
||||
void api.files
|
||||
.list(quoteId, "proforma")
|
||||
.then((list) => alive && setProformas(list))
|
||||
.catch(() => alive && setProformas([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [quoteId]);
|
||||
|
||||
if (!q || !ready) return null;
|
||||
|
||||
const sup = supplierById(suppliers, q.supplier_id);
|
||||
const allItems = q.line_items || [];
|
||||
const active = activeItems(q);
|
||||
|
||||
// گزینههای پروژه — پروژههای ثبتشده + پروژههای این پیشنهاد + تخصیصهای قبلی قلمها
|
||||
const projectOptions = (() => {
|
||||
const set = new Set<string>();
|
||||
projects.forEach((p) => p.name && set.add(p.name));
|
||||
(q.projects || []).forEach((n) => set.add(n));
|
||||
if (q.project) set.add(q.project);
|
||||
allItems.forEach((li) => (li.projects || []).forEach((p) => p.name && set.add(p.name)));
|
||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
})();
|
||||
|
||||
const cardLabel = (c: CardDraft, idx: number) => c.label || `گزینه ${faNum(idx + 1)}`;
|
||||
|
||||
const updateCard = (domId: string, patch: Partial<CardDraft>) => {
|
||||
setCards((cs) => cs.map((c) => (c.domId === domId ? { ...c, ...patch } : c)));
|
||||
};
|
||||
|
||||
const addNewCard = () => {
|
||||
setCards((cs) => [...cs, { domId: "new-ts-" + Date.now(), isNew: true, milestones: [{ when_iso: null, amount: null }], label: null }]);
|
||||
};
|
||||
|
||||
const removeCard = (domId: string) => {
|
||||
setCards((cs) => cs.filter((c) => c.domId !== domId));
|
||||
setAssignments((a) => {
|
||||
const next: Record<string, string> = {};
|
||||
Object.entries(a).forEach(([liId, dom]) => {
|
||||
if (dom !== domId) next[liId] = dom;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const assign = (liId: string, domId: string) => {
|
||||
setAssignments((a) => ({ ...a, [liId]: domId }));
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
if (!q) return;
|
||||
|
||||
// پچ نهایی — همهی تغییرات روی همین کلون اعمال میشود (نه روی آبجکت store)
|
||||
const next: Quote = JSON.parse(JSON.stringify(q));
|
||||
|
||||
// فقط کارتهایی با حداقل یک مرحلهی پرداخت
|
||||
const cardMeta = new Map<string, { milestones: Milestone[]; label: string; existing: Quote["termsheets"][number] | undefined }>();
|
||||
cards.forEach((c, idx) => {
|
||||
const milestones = c.milestones.filter((m) => m.when_iso || m.amount != null);
|
||||
if (!milestones.length) return;
|
||||
const existing = next.termsheets.find((t) => t.id === c.domId);
|
||||
cardMeta.set(c.domId, {
|
||||
milestones,
|
||||
label: c.label || `گزینه ${faNum(idx + 1)}`,
|
||||
existing,
|
||||
});
|
||||
});
|
||||
|
||||
if (!cardMeta.size) {
|
||||
toast.error("حداقل یک conditions با حداقل یک مرحله پرداخت لازم است.");
|
||||
return;
|
||||
}
|
||||
|
||||
// رد کردن قلمها — در داده میمانند تا با بازگشت وضعیت برگردند
|
||||
next.line_items.forEach((li) => {
|
||||
if (removedItems.has(li.id)) li.rejected = true;
|
||||
});
|
||||
const active = activeItems(next);
|
||||
if (!active.length) {
|
||||
toast.error("همهی قلمها رد شدهاند؛ حداقل یک قلم لازم است.");
|
||||
return;
|
||||
}
|
||||
|
||||
// فقط termsheetهایی که حداقل یک قلم انتخاب کرده پذیرفته میشوند
|
||||
const fallbackDom = [...cardMeta.keys()][0];
|
||||
const chosenDomIds = new Set<string>();
|
||||
active.forEach((li) => {
|
||||
const dom = assignments[li.id] || fallbackDom;
|
||||
if (dom && cardMeta.has(dom)) chosenDomIds.add(dom);
|
||||
});
|
||||
|
||||
const domToFinal: Record<string, string> = {};
|
||||
chosenDomIds.forEach((dom) => {
|
||||
const meta = cardMeta.get(dom)!;
|
||||
if (meta.existing) {
|
||||
meta.existing.milestones = meta.milestones;
|
||||
meta.existing.status = "accepted";
|
||||
domToFinal[dom] = dom;
|
||||
} else {
|
||||
const newId = nextDashedId("ts", next.termsheets);
|
||||
next.termsheets.push({ id: newId, label: meta.label, milestones: meta.milestones, status: "accepted" });
|
||||
domToFinal[dom] = newId;
|
||||
}
|
||||
});
|
||||
|
||||
// قلمها: اتصالهای offered حفظ میشوند + جفت پذیرفتهشده ثبت میشود
|
||||
active.forEach((li) => {
|
||||
const dom = assignments[li.id] || fallbackDom;
|
||||
const finalId = domToFinal[dom];
|
||||
const current = itemTermsheetIds(li).filter((tid) => next.termsheets.some((t) => t.id === tid));
|
||||
if (finalId && !current.includes(finalId)) current.push(finalId);
|
||||
li.termsheet_ids = current.length ? current : finalId ? [finalId] : current;
|
||||
li.accepted_ts_id = finalId || null;
|
||||
});
|
||||
|
||||
// termsheet پذیرفتهشدهای که هیچ قلمی انتخابش نکرده → برمیگردد به offered
|
||||
const usedIds = new Set<string>();
|
||||
active.forEach((li) => {
|
||||
if (li.accepted_ts_id) usedIds.add(li.accepted_ts_id);
|
||||
});
|
||||
next.termsheets.forEach((t) => {
|
||||
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
|
||||
});
|
||||
|
||||
// پروژههای هر قلم + همگامسازی پروژههای سطح پیشنهاد
|
||||
const projUnion = new Set<string>();
|
||||
next.line_items.forEach((li) => {
|
||||
if (li.rejected) return;
|
||||
const names = itemProjectsSel[li.id];
|
||||
if (names === undefined) return;
|
||||
li.projects = names.map((n) => ({ id: "", name: n }));
|
||||
li.projects.forEach((p) => projUnion.add(p.name));
|
||||
});
|
||||
next.projects = [...projUnion];
|
||||
next.project = next.projects[0] || "";
|
||||
|
||||
const now = new Date().toISOString();
|
||||
next.accepted_date = acceptDate || now;
|
||||
next.status = "accepted";
|
||||
next.events.push({ type: "accepted", occurred_at: now });
|
||||
next.invoices = invoices;
|
||||
next.proformas = proformas;
|
||||
|
||||
void updateQuote.mutateAsync({ id: q.id, patch: next });
|
||||
setReady(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>پذیرش پیشنهاد — {sup.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="text-[12px] text-muted">
|
||||
شرایط پرداخت را انتخاب یا ویرایش کنید. سپس هر قلم را به conditions دلخواه تخصیص دهید.
|
||||
</div>
|
||||
|
||||
{/* کارتهای termsheet */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{cards.map((c, idx) => (
|
||||
<div key={c.domId} className="modal-variant">
|
||||
<div className="modal-variant-head">
|
||||
<span className="variant-num">{cardLabel(c, idx)}</span>
|
||||
<button type="button" className="variant-x" title="حذف" onClick={() => removeCard(c.domId)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{c.milestones.map((m, i) => (
|
||||
<MilestoneRow
|
||||
key={i}
|
||||
milestone={m}
|
||||
onChange={(nextM) => {
|
||||
const ms = [...c.milestones];
|
||||
ms[i] = nextM;
|
||||
updateCard(c.domId, { milestones: ms });
|
||||
}}
|
||||
onRemove={() => updateCard(c.domId, { milestones: c.milestones.filter((_, j) => j !== i) })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="line" size="sm" className="mt-1.5" onClick={() => updateCard(c.domId, { milestones: [...c.milestones, { when_iso: null, amount: null }] })}>
|
||||
+ مرحله پرداخت
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="line" size="sm" className="self-start" onClick={addNewCard}>
|
||||
+ نوشتن conditions جدید
|
||||
</Button>
|
||||
|
||||
{/* تخصیص قلمها */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-1 text-[13px] font-bold">تخصیص قلمها به conditions:</div>
|
||||
<div className="mb-2 text-[11.5px] text-muted">
|
||||
قلمی که میخرید را تیک بزنید؛ قلمی که نمیخرید را بدون تیک رها کنید تا از معامله حذف شود.
|
||||
</div>
|
||||
<div>
|
||||
{allItems.map((li) => {
|
||||
const removed = removedItems.has(li.id);
|
||||
const m = materialById(materials, li.material_id);
|
||||
const specText = specsLabel(materialSpecs(m));
|
||||
return (
|
||||
<div key={li.id} className={`accept-item-assign-row ${removed ? "rejected" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-accent"
|
||||
checked={!removed}
|
||||
title="تیک = در معامله میماند"
|
||||
onChange={(e) => {
|
||||
const next = new Set(removedItems);
|
||||
if (e.target.checked) next.delete(li.id);
|
||||
else next.add(li.id);
|
||||
setRemovedItems(next);
|
||||
}}
|
||||
/>
|
||||
<span className="flex-1 text-[13px]">
|
||||
{m.category} {m.brand}
|
||||
{specText ? ` · ${specText}` : ""}
|
||||
</span>
|
||||
<span className="text-[12px] text-muted">
|
||||
{fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)}
|
||||
</span>
|
||||
<select
|
||||
className="min-w-[140px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]"
|
||||
value={assignments[li.id] || cards[0]?.domId || ""}
|
||||
onChange={(e) => assign(li.id, e.target.value)}
|
||||
>
|
||||
{cards.map((c, idx) => (
|
||||
<option key={c.domId} value={c.domId}>
|
||||
{cardLabel(c, idx)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{active.length === 0 && (
|
||||
<div className="mb-1 text-[12px] text-red">همهی قلمها رد شدهاند — حداقل یک قلم را تیک بزنید.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* تخصیص قلمها به پروژهها */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-1 text-[13px] font-bold">تخصیص قلمها به پروژهها:</div>
|
||||
<div className="mb-2 text-[11.5px] text-muted">
|
||||
برای هر متریال مشخص کنید به کدام پروژه (ها) تعلق میگیرد.
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{allItems
|
||||
.filter((li) => !removedItems.has(li.id))
|
||||
.map((li) => {
|
||||
const m = materialById(materials, li.material_id);
|
||||
const specText = specsLabel(materialSpecs(m));
|
||||
return (
|
||||
<div key={li.id} className="rounded-lg border border-line p-2.5">
|
||||
<div className="mb-1.5 text-[12.5px]">
|
||||
<span className="font-semibold">
|
||||
{m.category} {m.brand}
|
||||
</span>
|
||||
{specText ? <span className="text-muted"> · {specText}</span> : ""}
|
||||
<span className="ms-2 text-[11px] text-muted">
|
||||
{fmtNum(li.quantity)} {li.unit}
|
||||
</span>
|
||||
</div>
|
||||
<MultiSelectCombobox
|
||||
values={itemProjectsSel[li.id] || []}
|
||||
onChange={(names) => setItemProjectsSel((s) => ({ ...s, [li.id]: names }))}
|
||||
options={projectOptions}
|
||||
placeholder="انتخاب پروژه..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* فاکتورهای اصلی */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
|
||||
<div className="mb-2 text-[11.5px] text-muted">
|
||||
چند فاکتور را با هم انتخاب کنید؛ هر فایل همان لحظه روی سرور (MinIO) ذخیره میشود.
|
||||
</div>
|
||||
<FileList
|
||||
files={invoices}
|
||||
onChange={setInvoices}
|
||||
upload={(f) => api.files.upload(q.id, "invoice", f)}
|
||||
onDelete={(a) =>
|
||||
a.id &&
|
||||
void api.files.remove(q.id, "invoice", a.id).catch((err) =>
|
||||
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
|
||||
)
|
||||
}
|
||||
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* پیشفاکتورها */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-1 text-[13px] font-bold">پیشفاکتورها (چند فایل):</div>
|
||||
<div className="mb-2 text-[11.5px] text-muted">
|
||||
پیشفاکتورهای دریافتی از تأمینکننده؛ هر فایل همان لحظه روی سرور (MinIO) ذخیره میشود.
|
||||
</div>
|
||||
<FileList
|
||||
files={proformas}
|
||||
onChange={setProformas}
|
||||
upload={(f) => api.files.upload(q.id, "proforma", f)}
|
||||
onDelete={(a) =>
|
||||
a.id &&
|
||||
void api.files.remove(q.id, "proforma", a.id).catch((err) =>
|
||||
toast.error(err instanceof Error ? err.message : "حذف پیشفاکتور ناموفق بود"),
|
||||
)
|
||||
}
|
||||
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "proforma", a.id) : null)}
|
||||
label="افزودن پیشفاکتور"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* تاریخ و زمان پذیرش */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="field max-w-xs">
|
||||
<label>تاریخ و زمان پذیرش</label>
|
||||
<JalaliDatePicker value={acceptDate} onChange={setAcceptDate} placeholder="تاریخ و ساعت" withTime />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="slate" onClick={onClose}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button onClick={confirm}>ثبت معامله بستهشده</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Package } from "lucide-react";
|
||||
import type { Quote } from "../../lib/types";
|
||||
import { useMaterials, useQuotes, useSuppliers, useUpdateQuote } from "../../hooks";
|
||||
import { activeItems, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils";
|
||||
import { fmtNum } from "../../lib/formatters";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog";
|
||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||
|
||||
export interface ArriveModalProps {
|
||||
quoteId: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// مودال رسید: تیک بزنید کدام قلمها رسیدهاند؛ تا همه نرسیده باشند quote پذیرفته میماند
|
||||
export function ArriveModal({ quoteId, onClose }: ArriveModalProps) {
|
||||
const quotes = useQuotes();
|
||||
const suppliers = useSuppliers();
|
||||
const materials = useMaterials();
|
||||
const updateQuote = useUpdateQuote();
|
||||
|
||||
const q = useMemo(() => quotes.find((x) => x.id === quoteId) || null, [quotes, quoteId]);
|
||||
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set());
|
||||
// تاریخ و زمان رسید — پیشفرض الان
|
||||
const [arriveDate, setArriveDate] = useState<string | null>(() => new Date().toISOString());
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useMemo(() => {
|
||||
if (!q) return;
|
||||
const set = new Set<string>();
|
||||
activeItems(q).forEach((li) => {
|
||||
if (li.arrived_date) set.add(li.id);
|
||||
});
|
||||
setChecked(set);
|
||||
setReady(true);
|
||||
}, [q]);
|
||||
|
||||
if (!q || !ready) return null;
|
||||
|
||||
const sup = supplierById(suppliers, q.supplier_id);
|
||||
const items = activeItems(q);
|
||||
|
||||
const confirm = () => {
|
||||
if (!checked.size) {
|
||||
toast.error("حداقل یک قلم را برای رسید انتخاب کنید.");
|
||||
return;
|
||||
}
|
||||
const next: Quote = JSON.parse(JSON.stringify(q));
|
||||
const arrivedIso = arriveDate || new Date().toISOString();
|
||||
const now = new Date().toISOString();
|
||||
next.line_items.forEach((li) => {
|
||||
li.arrived_date = checked.has(li.id) ? arrivedIso : null;
|
||||
});
|
||||
if (activeItems(next).every((li) => li.arrived_date)) {
|
||||
next.status = "arrived";
|
||||
next.arrived_date = arrivedIso;
|
||||
next.events.push({ type: "arrived", occurred_at: now });
|
||||
} else {
|
||||
next.arrived_date = null;
|
||||
}
|
||||
void updateQuote.mutateAsync({ id: q.id, patch: next });
|
||||
setReady(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>ثبت رسید — {sup.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-[12px] text-muted">
|
||||
کدام قلمها رسیدهاند؟ تیک بزنید و ثبت کنید. تا وقتی همهی قلمها نرسیدهاند، پیشنهاد
|
||||
«پذیرفته شده» میماند.
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{items.map((li) => {
|
||||
const m = materialById(materials, li.material_id);
|
||||
const specText = materialSpecs(m).join(" · ");
|
||||
return (
|
||||
<label key={li.id} className={`arrive-row${li.arrived_date ? " arrived" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-green"
|
||||
checked={checked.has(li.id)}
|
||||
onChange={(e) => {
|
||||
const next = new Set(checked);
|
||||
if (e.target.checked) next.add(li.id);
|
||||
else next.delete(li.id);
|
||||
setChecked(next);
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<Package className="me-1 inline h-3.5 w-3.5 text-muted" />
|
||||
{m.category} {m.brand}
|
||||
{specText ? ` · ${specText}` : ""}
|
||||
</span>
|
||||
<span className="arrive-meta">
|
||||
{fmtNum(li.quantity)} {li.unit || ""} × {groupCommas(li.unit_price, true)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* تاریخ و زمان رسید */}
|
||||
<div className="field max-w-xs">
|
||||
<label>تاریخ و زمان رسید</label>
|
||||
<JalaliDatePicker value={arriveDate} onChange={setArriveDate} placeholder="تاریخ و ساعت" withTime />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="slate" onClick={onClose}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button variant="green" onClick={confirm}>
|
||||
ثبت رسید
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Users, FolderKanban, Ruler, Plus } from "lucide-react";
|
||||
import {
|
||||
useMaterials,
|
||||
useProjects,
|
||||
useQuotes,
|
||||
useStoreData,
|
||||
useSuppliers,
|
||||
useUnits,
|
||||
} from "../../hooks";
|
||||
import { useAppStore } from "../../store";
|
||||
import { filterQuotes } from "../../lib/quoteUtils";
|
||||
import { daysBetween } from "../../lib/formatters";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { StatsBar } from "../../components/StatsBar";
|
||||
import { FilterToolbar, type Filters } from "./FilterToolbar";
|
||||
import { LedgerTable } from "./LedgerTable";
|
||||
import { PanelMenu } from "../../components/PanelMenu";
|
||||
import { useLogout } from "../Inventory/panelMenu";
|
||||
import { AcceptModal } from "./AcceptModal";
|
||||
import { EditModal } from "./EditModal";
|
||||
|
||||
const EMPTY_FILTERS: Filters = { category: "", supplier: "", status: "", search: "" };
|
||||
|
||||
// صفحهی اصلی دفتر پیشنهادهای خرید
|
||||
export function DashboardPage() {
|
||||
useStoreData();
|
||||
const logout = useLogout();
|
||||
|
||||
const quotes = useQuotes();
|
||||
const suppliers = useSuppliers();
|
||||
const materials = useMaterials();
|
||||
const projects = useProjects();
|
||||
const { categoryUnits, unitConversions, standardConversions } = useUnits();
|
||||
|
||||
const expandedQuoteIds = useAppStore((s) => s.expandedQuoteIds);
|
||||
const toggleExpand = useAppStore((s) => s.toggleExpand);
|
||||
|
||||
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
|
||||
const [sortDir, setSortDir] = useState<"desc" | "asc">("desc");
|
||||
const [acceptId, setAcceptId] = useState<string | null>(null);
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
|
||||
const store = useMemo(
|
||||
() => ({ quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions }),
|
||||
[quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions],
|
||||
);
|
||||
|
||||
// فیلتر + مرتبسازی بر اساس زمان ساخت (created_at) — فیلترها بازنشانی نمیشوند
|
||||
const filtered = useMemo(() => {
|
||||
const list = filterQuotes(store, filters);
|
||||
const createdTs = (q: (typeof store.quotes)[number]) => {
|
||||
const iso = q.created_at || q.quote_date;
|
||||
const ts = iso ? new Date(iso).getTime() : 0;
|
||||
return Number.isFinite(ts) ? ts : 0;
|
||||
};
|
||||
return [...list].sort((a, b) => (sortDir === "desc" ? createdTs(b) - createdTs(a) : createdTs(a) - createdTs(b)));
|
||||
}, [store, filters, sortDir]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = quotes.length;
|
||||
const offered = quotes.filter((q) => q.status === "offered").length;
|
||||
const accepted = quotes.filter((q) => q.status === "accepted").length;
|
||||
const leadTimes = quotes
|
||||
.filter((q) => q.status === "arrived" && q.accepted_date && q.arrived_date)
|
||||
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
|
||||
.filter((x): x is number => x != null);
|
||||
const avgLead = leadTimes.length ? Math.round(leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length) : null;
|
||||
return { total, offered, accepted, avgLead };
|
||||
}, [quotes]);
|
||||
|
||||
const categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]);
|
||||
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header
|
||||
title="دفتر پیشنهادهای خرید"
|
||||
sub="دادهها در پایگاه داده ذخیره میشوند"
|
||||
brand
|
||||
menu={
|
||||
<PanelMenu
|
||||
title="پنلها"
|
||||
items={[
|
||||
{ label: "مدیریت انبار", to: "/inventory" },
|
||||
{ label: "مدیریت تدارکات", to: "/", active: true },
|
||||
{ divider: true },
|
||||
{ label: "خروج از حساب", onClick: logout },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Link to="/suppliers" className="btn-line-file no-underline">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
تأمینکنندهها
|
||||
</Link>
|
||||
<Link to="/projects" className="btn-line-file no-underline">
|
||||
<FolderKanban className="h-3.5 w-3.5" />
|
||||
پروژهها
|
||||
</Link>
|
||||
<Link to="/units" className="btn-line-file no-underline">
|
||||
<Ruler className="h-3.5 w-3.5" />
|
||||
ضرایب تبدیل
|
||||
</Link>
|
||||
<Link to="/wizard" className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
افزودن پیشنهاد جدید
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-4">
|
||||
<StatsBar
|
||||
total={stats.total}
|
||||
offered={stats.offered}
|
||||
accepted={stats.accepted}
|
||||
avgLeadTime={stats.avgLead}
|
||||
/>
|
||||
<FilterToolbar
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
categories={categoryOptions}
|
||||
suppliers={supplierOptions}
|
||||
sortDir={sortDir}
|
||||
onToggleSort={() => setSortDir((d) => (d === "desc" ? "asc" : "desc"))}
|
||||
/>
|
||||
<LedgerTable
|
||||
store={store}
|
||||
quotes={filtered}
|
||||
expanded={expandedQuoteIds}
|
||||
onToggleExpand={toggleExpand}
|
||||
onAccept={setAcceptId}
|
||||
onEdit={setEditId}
|
||||
/>
|
||||
</PageLayout>
|
||||
|
||||
<AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} />
|
||||
<EditModal quoteId={editId} onClose={() => setEditId(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { Attachment, LineItem, Milestone, Quote, Termsheet } from "../../lib/types";
|
||||
import { api, nextDashedId, nextId } from "../../lib/api";
|
||||
import {
|
||||
useMaterials,
|
||||
useProjects,
|
||||
useQuotes,
|
||||
useSuppliers,
|
||||
useUpdateQuote,
|
||||
useAddSupplier,
|
||||
useAddMaterial,
|
||||
} from "../../hooks";
|
||||
import { itemTermsheetIds, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils";
|
||||
import { STATUS_LABEL, statusOptionsFor } from "../../lib/constants";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { specsKey } from "../../lib/quoteUtils";
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||
import { NumberInput } from "../../components/NumberInput";
|
||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||
import { FileList } from "../../components/FileList";
|
||||
import { MilestoneRow } from "../../components/MilestoneRow";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog";
|
||||
|
||||
interface EditItemRow {
|
||||
key: string;
|
||||
origLi?: LineItem;
|
||||
cat: string;
|
||||
brand: string;
|
||||
specs: string[];
|
||||
qty: number | null;
|
||||
unit: string;
|
||||
price: number | null;
|
||||
total: number | null;
|
||||
tsDomIds: string[];
|
||||
acceptedTsId: string | null;
|
||||
rejected: boolean;
|
||||
restore: boolean;
|
||||
}
|
||||
|
||||
interface EditTsCard {
|
||||
key: string;
|
||||
domId: string; // "" یا "new-ts-..." برای کارت جدید
|
||||
milestones: Milestone[];
|
||||
}
|
||||
|
||||
export interface EditModalProps {
|
||||
quoteId: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// مودال ویرایش کامل پیشنهاد
|
||||
export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
const quotes = useQuotes();
|
||||
const suppliers = useSuppliers();
|
||||
const materials = useMaterials();
|
||||
const projects = useProjects();
|
||||
const updateQuote = useUpdateQuote();
|
||||
const addSupplier = useAddSupplier();
|
||||
const addMaterial = useAddMaterial();
|
||||
|
||||
const q = useMemo(() => quotes.find((x) => x.id === quoteId) || null, [quotes, quoteId]);
|
||||
|
||||
const [ready, setReady] = useState(false);
|
||||
const [supplierName, setSupplierName] = useState("");
|
||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [invoices, setInvoices] = useState<Attachment[]>([]);
|
||||
const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
|
||||
const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
|
||||
const [quoteDate, setQuoteDate] = useState<string | null>(null);
|
||||
const [expiryDate, setExpiryDate] = useState<string | null>(null);
|
||||
const [acceptedDate, setAcceptedDate] = useState<string | null>(null);
|
||||
const [arrivedDate, setArrivedDate] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState("offered");
|
||||
|
||||
// گزینههای مشخصه — همهی مشخصههای ثبتشده (کش)
|
||||
const specOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
materials.forEach((m) => (m.specs || []).forEach((s) => s && set.add(s)));
|
||||
itemRows.forEach((r) => r.specs.forEach((s) => s && set.add(s)));
|
||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}, [materials, itemRows]);
|
||||
|
||||
// گزینههای پروژه — پروژههای ثبتشده + پروژههای خود پیشنهاد
|
||||
const projectOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
projects.forEach((p) => p.name && set.add(p.name));
|
||||
if (q) {
|
||||
(q.projects || []).forEach((n) => set.add(n));
|
||||
if (q.project) set.add(q.project);
|
||||
}
|
||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}, [projects, q]);
|
||||
|
||||
useMemo(() => {
|
||||
if (!q) return;
|
||||
const sup = supplierById(suppliers, q.supplier_id);
|
||||
setSupplierName(sup.name);
|
||||
setSelectedProjects(q.projects && q.projects.length ? [...q.projects] : q.project ? [q.project] : []);
|
||||
setNotes(q.notes || "");
|
||||
setItemRows(
|
||||
q.line_items.map((li) => {
|
||||
const m = materialById(materials, li.material_id);
|
||||
return {
|
||||
key: li.id,
|
||||
origLi: li,
|
||||
cat: m.category || "",
|
||||
brand: m.brand || "",
|
||||
specs: materialSpecs(m),
|
||||
qty: li.quantity,
|
||||
unit: li.unit || "",
|
||||
price: li.unit_price,
|
||||
total: li.total_price,
|
||||
tsDomIds: itemTermsheetIds(li).filter((tid) => q.termsheets.some((t) => t.id === tid)),
|
||||
acceptedTsId: li.accepted_ts_id,
|
||||
rejected: !!li.rejected,
|
||||
restore: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
setTsCards(
|
||||
q.termsheets.map((t) => ({
|
||||
key: t.id,
|
||||
domId: t.id,
|
||||
milestones: t.milestones.map((m) => ({ ...m })),
|
||||
})),
|
||||
);
|
||||
setQuoteDate(q.quote_date || null);
|
||||
setExpiryDate(q.expiry_date || null);
|
||||
setAcceptedDate(q.accepted_date || null);
|
||||
setArrivedDate(q.arrived_date || null);
|
||||
setStatus(q.status);
|
||||
setReady(true);
|
||||
}, [q, suppliers, materials]);
|
||||
|
||||
// فاکتورها مستقیماً از سرور (MinIO) خوانده میشوند — نه از state محلی
|
||||
useEffect(() => {
|
||||
if (!quoteId) return;
|
||||
let alive = true;
|
||||
void api.files
|
||||
.list(quoteId, "invoice")
|
||||
.then((list) => alive && setInvoices(list))
|
||||
.catch(() => alive && setInvoices([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [quoteId]);
|
||||
|
||||
if (!q || !ready) return null;
|
||||
|
||||
const tsLabel = (card: EditTsCard, idx: number) => {
|
||||
const orig = q.termsheets.find((t) => t.id === card.domId);
|
||||
return orig?.label || `گزینه ${faNum(idx + 1)}`;
|
||||
};
|
||||
|
||||
const patchItem = (key: string, patch: Partial<EditItemRow>) => {
|
||||
setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
|
||||
};
|
||||
|
||||
const addItemRow = () => {
|
||||
setItemRows((rows) => [
|
||||
...rows,
|
||||
{
|
||||
key: "new-" + Math.random().toString(36).slice(2, 9),
|
||||
cat: "",
|
||||
brand: "",
|
||||
specs: [],
|
||||
qty: null,
|
||||
unit: "",
|
||||
price: null,
|
||||
total: null,
|
||||
tsDomIds: [],
|
||||
acceptedTsId: null,
|
||||
rejected: false,
|
||||
restore: true,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const updateTs = (key: string, patch: Partial<EditTsCard>) => {
|
||||
setTsCards((cards) => cards.map((c) => (c.key === key ? { ...c, ...patch } : c)));
|
||||
};
|
||||
|
||||
const addTsCard = () => {
|
||||
setTsCards((cards) => [
|
||||
...cards,
|
||||
{ key: "new-" + Math.random().toString(36).slice(2, 9), domId: "", milestones: [{ when_iso: null, amount: null }] },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeTsCard = (key: string) => {
|
||||
setTsCards((cards) => cards.filter((c) => c.key !== key));
|
||||
};
|
||||
|
||||
const confirm = async () => {
|
||||
const next: Quote = JSON.parse(JSON.stringify(q));
|
||||
|
||||
// تأمینکننده — موجود یا جدید
|
||||
if (supplierName.trim()) {
|
||||
const norm = normalize(supplierName);
|
||||
const exist = suppliers.find((s) => normalize(s.name) === norm);
|
||||
if (exist) {
|
||||
next.supplier_id = exist.id;
|
||||
} else {
|
||||
const newSup = { id: nextId("s", suppliers), name: supplierName.trim(), location: "", phone: "", contact: "", topic: "", categories: [] };
|
||||
const created = await addSupplier.mutateAsync(newSup);
|
||||
next.supplier_id = created.id; // UUID واقعی از بکاند
|
||||
}
|
||||
}
|
||||
|
||||
next.project = selectedProjects[0] || "";
|
||||
next.projects = [...selectedProjects];
|
||||
next.notes = notes.trim();
|
||||
next.invoices = invoices;
|
||||
|
||||
// termsheetها — id موجود حفظ میشود، جدیدها ساخته میشوند
|
||||
const tsIdMap: Record<string, string> = {};
|
||||
const newTermsheets: Termsheet[] = [];
|
||||
tsCards.forEach((card, i) => {
|
||||
const domId = card.domId || "new-ts-" + i;
|
||||
const realId =
|
||||
domId && !domId.startsWith("new-ts-")
|
||||
? domId
|
||||
: nextDashedId("ts", newTermsheets.length ? newTermsheets : next.termsheets);
|
||||
tsIdMap[domId] = realId;
|
||||
const orig = next.termsheets.find((t) => t.id === realId);
|
||||
const label = orig?.label || `گزینه ${faNum(i + 1)}`;
|
||||
const st = orig?.status || "offered";
|
||||
newTermsheets.push({ id: realId, label, milestones: card.milestones, status: st });
|
||||
});
|
||||
next.termsheets = newTermsheets;
|
||||
|
||||
// اقلام
|
||||
const newItems: LineItem[] = [];
|
||||
const materialsCopy = [...materials];
|
||||
for (const row of itemRows) {
|
||||
const cat = row.cat.trim();
|
||||
const brand = row.brand.trim();
|
||||
const specs = row.specs;
|
||||
const unit = row.unit.trim();
|
||||
const qty = row.qty;
|
||||
const price = row.price;
|
||||
const total = row.total;
|
||||
if (!cat && !brand && qty == null && price == null) continue; // ردیف خالی
|
||||
|
||||
let material = materialsCopy.find(
|
||||
(m) =>
|
||||
normalize(m.category) === normalize(cat) &&
|
||||
normalize(m.brand) === normalize(brand) &&
|
||||
specsKey(m.specs) === specsKey(specs),
|
||||
);
|
||||
let materialId: string;
|
||||
if (material) {
|
||||
materialId = material.id;
|
||||
} else {
|
||||
const nm = {
|
||||
id: nextId("m", materialsCopy),
|
||||
name: [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید",
|
||||
topic: materialById(materials, row.origLi?.material_id || "").topic || "",
|
||||
category: cat,
|
||||
brand,
|
||||
specs,
|
||||
base_unit: unit,
|
||||
};
|
||||
materialsCopy.push(nm);
|
||||
// منتظر ساخت متریال میمانیم تا id واقعی (UUID) به قلم وصل شود
|
||||
const created = await addMaterial.mutateAsync(nm);
|
||||
materialId = created.id;
|
||||
}
|
||||
|
||||
const wasRejected = row.rejected;
|
||||
const rejected = wasRejected && !row.restore;
|
||||
|
||||
const termsheet_ids: string[] = [];
|
||||
let acceptedTsId = row.acceptedTsId;
|
||||
if (!rejected) {
|
||||
row.tsDomIds.forEach((domId) => {
|
||||
const realId = tsIdMap[domId] || domId;
|
||||
if (realId) termsheet_ids.push(realId);
|
||||
});
|
||||
if (!termsheet_ids.length && next.termsheets.length) {
|
||||
termsheet_ids.push(next.termsheets[0].id);
|
||||
}
|
||||
if (wasRejected) {
|
||||
const acc = next.termsheets.find((t) => t.status === "accepted");
|
||||
if (acc) {
|
||||
termsheet_ids.length = 0;
|
||||
termsheet_ids.push(acc.id);
|
||||
}
|
||||
acceptedTsId = acc ? acc.id : null;
|
||||
}
|
||||
} else {
|
||||
acceptedTsId = null;
|
||||
}
|
||||
|
||||
newItems.push({
|
||||
id: "li-" + (newItems.length + 1),
|
||||
material_id: materialId,
|
||||
quantity: qty,
|
||||
unit,
|
||||
unit_price: price,
|
||||
total_price: total,
|
||||
termsheet_ids,
|
||||
accepted_ts_id: acceptedTsId,
|
||||
rejected,
|
||||
arrived_date: row.origLi?.arrived_date ?? null,
|
||||
projects: row.origLi?.projects ?? [],
|
||||
});
|
||||
}
|
||||
next.line_items = newItems.length ? newItems : next.line_items;
|
||||
|
||||
// ترمشیت بدون قلم بیمعنی است — حذف
|
||||
const referencedTs = new Set<string>();
|
||||
next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid)));
|
||||
next.termsheets = next.termsheets.filter((t) => referencedTs.has(t.id));
|
||||
next.line_items.forEach((li) => {
|
||||
if (li.accepted_ts_id && !next.termsheets.some((t) => t.id === li.accepted_ts_id)) {
|
||||
li.accepted_ts_id = null;
|
||||
}
|
||||
});
|
||||
|
||||
// تاریخها و وضعیت
|
||||
next.quote_date = quoteDate || q.quote_date;
|
||||
next.expiry_date = expiryDate || "";
|
||||
next.accepted_date = acceptedDate;
|
||||
next.arrived_date = arrivedDate;
|
||||
next.status = status as Quote["status"];
|
||||
|
||||
// پاکسازی خودکار در بازگشت وضعیت
|
||||
if (next.status === "offered") {
|
||||
next.accepted_date = null;
|
||||
next.arrived_date = null;
|
||||
next.termsheets.forEach((t) => {
|
||||
t.status = "offered";
|
||||
});
|
||||
next.line_items.forEach((li) => {
|
||||
li.rejected = false;
|
||||
li.arrived_date = null;
|
||||
li.accepted_ts_id = null;
|
||||
});
|
||||
} else if (next.status === "accepted") {
|
||||
next.arrived_date = null;
|
||||
next.line_items.forEach((li) => {
|
||||
li.arrived_date = null;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await updateQuote.mutateAsync({ id: q.id, patch: next });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "ذخیرهی تغییرات ناموفق بود");
|
||||
return;
|
||||
}
|
||||
setReady(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>ویرایش پیشنهاد</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="field">
|
||||
<label>تأمینکننده</label>
|
||||
<Combobox
|
||||
value={supplierName}
|
||||
onChange={(it: { id: string; label: string }) => setSupplierName(it.label)}
|
||||
items={suppliers.map((s) => ({ id: s.id, name: s.name }))}
|
||||
allowAdd
|
||||
addSuffix="به عنوان تأمینکنندهٔ جدید"
|
||||
placeholder="نام تأمینکننده"
|
||||
onInputChange={(v) => setSupplierName(v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>پروژه</label>
|
||||
<MultiSelectCombobox
|
||||
values={selectedProjects}
|
||||
onChange={setSelectedProjects}
|
||||
options={projectOptions}
|
||||
placeholder="انتخاب پروژه..."
|
||||
/>
|
||||
</div>
|
||||
<div className="field col-span-2">
|
||||
<label>یادداشت</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* اقلام */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-2 text-[13px] font-bold">اقلام سفارش:</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{itemRows.map((row) => (
|
||||
<div key={row.key} className={`rounded-lg border p-3 ${row.rejected ? "border-dashed border-line opacity-60" : "border-line"}`}>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<div className="field">
|
||||
<label>متریال</label>
|
||||
<input
|
||||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="متریال"
|
||||
value={row.cat}
|
||||
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>برند</label>
|
||||
<input
|
||||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="برند"
|
||||
value={row.brand}
|
||||
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>مشخصه</label>
|
||||
<MultiSelectCombobox
|
||||
values={row.specs}
|
||||
onChange={(specs) => patchItem(row.key, { specs })}
|
||||
options={specOptions}
|
||||
placeholder="مشخصه"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>واحد</label>
|
||||
<input
|
||||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="واحد"
|
||||
value={row.unit}
|
||||
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تعداد</label>
|
||||
<NumberInput
|
||||
value={row.qty}
|
||||
onChange={(qty) => {
|
||||
patchItem(row.key, {
|
||||
qty,
|
||||
total: qty != null && row.price != null ? Math.round(qty * row.price * 100) / 100 : row.total,
|
||||
});
|
||||
}}
|
||||
placeholder="تعداد"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>قیمت واحد</label>
|
||||
<NumberInput
|
||||
value={row.price}
|
||||
onChange={(price) => {
|
||||
patchItem(row.key, {
|
||||
price,
|
||||
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت واحد"
|
||||
maxDecimals={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>قیمت کل</label>
|
||||
<NumberInput
|
||||
value={row.total}
|
||||
onChange={(t) => {
|
||||
patchItem(row.key, {
|
||||
total: t,
|
||||
price:
|
||||
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت کل"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[12px] text-muted transition-colors hover:bg-red-bg hover:text-red"
|
||||
title="حذف قلم"
|
||||
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* تخصیص termsheet / بازگرداندن */}
|
||||
<div className="mt-2.5 border-t border-line pt-2">
|
||||
{row.rejected ? (
|
||||
<label className="ts-cb-label e-restore">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.restore}
|
||||
onChange={(e) => patchItem(row.key, { restore: e.target.checked })}
|
||||
/>
|
||||
بازگرداندن به معامله
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-1 text-[10.5px] font-semibold text-muted">این قلم تحت کدام termsheet هاست؟</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tsCards.map((card, idx) => (
|
||||
<label key={card.key} className="chip cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="me-1 accent-accent"
|
||||
checked={row.tsDomIds.includes(card.domId || card.key)}
|
||||
onChange={(e) => {
|
||||
const next = new Set(row.tsDomIds);
|
||||
const domId = card.domId || card.key;
|
||||
if (e.target.checked) next.add(domId);
|
||||
else next.delete(domId);
|
||||
patchItem(row.key, { tsDomIds: [...next] });
|
||||
}}
|
||||
/>
|
||||
{tsLabel(card, idx)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="line" size="sm" className="mt-2" onClick={addItemRow}>
|
||||
+ افزودن قلم
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* termsheetها */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-2 text-[13px] font-bold">termsheet ها (شرایط پرداخت):</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{tsCards.map((card, idx) => (
|
||||
<div key={card.key} className="variant-card">
|
||||
<div className="variant-head">
|
||||
<span className="variant-num">{tsLabel(card, idx)}</span>
|
||||
<button type="button" className="variant-x" title="حذف" onClick={() => removeTsCard(card.key)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-1 flex flex-col gap-1.5">
|
||||
{card.milestones.map((ms, i) => (
|
||||
<MilestoneRow
|
||||
key={i}
|
||||
milestone={ms}
|
||||
onChange={(nextMs) =>
|
||||
updateTs(card.key, { milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)) })
|
||||
}
|
||||
onRemove={() => updateTs(card.key, { milestones: card.milestones.filter((_, j) => j !== i) })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="line"
|
||||
size="sm"
|
||||
className="mt-1"
|
||||
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
|
||||
>
|
||||
+ مرحله پرداخت
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="line" size="sm" className="mt-2" onClick={addTsCard}>
|
||||
+ افزودن termsheet
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* فاکتورها */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
|
||||
<div className="mb-2 text-[11.5px] text-muted">
|
||||
چند فاکتور را با هم انتخاب کنید؛ هر فایل همان لحظه روی سرور (MinIO) ذخیره میشود.
|
||||
</div>
|
||||
<FileList
|
||||
files={invoices}
|
||||
onChange={setInvoices}
|
||||
upload={(f) => api.files.upload(q.id, "invoice", f)}
|
||||
onDelete={(a) =>
|
||||
a.id &&
|
||||
void api.files.remove(q.id, "invoice", a.id).catch((err) =>
|
||||
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
|
||||
)
|
||||
}
|
||||
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* تاریخها و وضعیت */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-2 text-[12px] text-muted">تاریخها و وضعیت:</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="field">
|
||||
<label>تاریخ پیشنهاد</label>
|
||||
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تاریخ انقضا</label>
|
||||
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" withTime />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تاریخ پذیرش</label>
|
||||
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تاریخ رسید</label>
|
||||
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
|
||||
</div>
|
||||
<div className="field col-span-2">
|
||||
<label>وضعیت (فقط بازگشت به مرحلههای قبل)</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
{statusOptionsFor(q.status).map((st) => (
|
||||
<option key={st} value={st}>
|
||||
{STATUS_LABEL[st]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="slate" onClick={onClose}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button onClick={() => void confirm()}>ذخیره تغییرات</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
|
||||
export interface Filters {
|
||||
category: string;
|
||||
supplier: string;
|
||||
status: string;
|
||||
search: string;
|
||||
}
|
||||
|
||||
export interface FilterToolbarProps {
|
||||
filters: Filters;
|
||||
onChange: (f: Filters) => void;
|
||||
categories: string[];
|
||||
suppliers: { id: string; name: string }[];
|
||||
sortDir: "desc" | "asc";
|
||||
onToggleSort: () => void;
|
||||
}
|
||||
|
||||
// نوار فیلتر دفتر: ۳ کامبوباکس + جستجوی آزاد + دکمهی مرتبسازی ⇅
|
||||
export function FilterToolbar({ filters, onChange, categories, suppliers, sortDir, onToggleSort }: FilterToolbarProps) {
|
||||
const set = (patch: Partial<Filters>) => onChange({ ...filters, ...patch });
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="field min-w-[150px] flex-1">
|
||||
<label>دسته</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 min-w-[170px] flex-1">
|
||||
<label>تأمینکننده</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 min-w-[150px] flex-1">
|
||||
<label>وضعیت</label>
|
||||
<Combobox
|
||||
value={filters.status}
|
||||
onChange={(it: { id: string; label: string }) => set({ status: it.label })}
|
||||
items={["پیشنهاد شده", "پذیرفته شده", "رسیده"]}
|
||||
allowAdd={false}
|
||||
placeholder="همه"
|
||||
onInputChange={(v) => set({ status: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field min-w-[200px] flex-1">
|
||||
<label>جستجو</label>
|
||||
<input
|
||||
type="search"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 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>
|
||||
<div className="field min-w-[140px]">
|
||||
<label>ترتیب</label>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 w-full items-center justify-center gap-1.5 rounded-lg border border-line bg-panel px-3 text-sm shadow-sm hover:bg-slate-bg"
|
||||
onClick={onToggleSort}
|
||||
title={sortDir === "desc" ? "تغییر به قدیمیترین اول" : "تغییر به جدیدترین اول"}
|
||||
>
|
||||
<span aria-hidden className="text-accent">⇅</span>
|
||||
<span>{sortDir === "desc" ? "جدیدترین اول" : "قدیمیترین اول"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useCallback, useLayoutEffect, useState } from "react";
|
||||
|
||||
export interface FlowRefs {
|
||||
items: Map<string, HTMLDivElement>;
|
||||
termsheets: Map<string, HTMLDivElement>;
|
||||
}
|
||||
|
||||
export interface FlowDiagramProps {
|
||||
flowEl: HTMLDivElement | null;
|
||||
refs: FlowRefs;
|
||||
redrawKey?: string; // وقتی دادهی quote عوض شد (پذیرش/رسید/ویرایش) خطوط دوباره رسم شوند
|
||||
}
|
||||
|
||||
interface PathSpec {
|
||||
d: string;
|
||||
accepted: boolean;
|
||||
}
|
||||
|
||||
// خطوط اتصال SVG بین جعبههای اقلام و ترمشیتها — موقعیت جعبهها با
|
||||
// getBoundingClientRect خوانده میشود و با ResizeObserver در جریان میماند.
|
||||
// خاکستری = باز، آبی = جفت پذیرفتهشدهی دقیق.
|
||||
export function FlowDiagram({ flowEl, refs, redrawKey = "" }: FlowDiagramProps) {
|
||||
const [paths, setPaths] = useState<PathSpec[]>([]);
|
||||
|
||||
const draw = useCallback(() => {
|
||||
if (!flowEl) return;
|
||||
const crect = flowEl.getBoundingClientRect();
|
||||
if (!crect.width) return;
|
||||
|
||||
const out: PathSpec[] = [];
|
||||
refs.items.forEach((item) => {
|
||||
const ir = item.getBoundingClientRect();
|
||||
const x1 = ir.left - crect.left; // لبهی چپ جعبهی قلم (رو به فاصله)
|
||||
const y1 = ir.top + ir.height / 2 - crect.top;
|
||||
const isRejected = item.classList.contains("rejected");
|
||||
const acceptedTsId = item.dataset.acceptedTs || "";
|
||||
const tsIds = (item.dataset.tsIds || "").split(",").filter(Boolean);
|
||||
tsIds.forEach((tsId) => {
|
||||
const t = refs.termsheets.get(tsId);
|
||||
if (!t) return;
|
||||
const tr = t.getBoundingClientRect();
|
||||
const x2 = tr.right - crect.left; // لبهی راست جعبهی termsheet
|
||||
const y2 = tr.top + tr.height / 2 - crect.top;
|
||||
const cx = (x1 + x2) / 2;
|
||||
// آبی فقط برای جفت دقیقی که قلم پذیرفته — بقیه خاکستری
|
||||
const accepted = !isRejected && acceptedTsId === tsId;
|
||||
out.push({
|
||||
d: `M ${x1} ${y1} C ${cx} ${y1}, ${cx} ${y2}, ${x2} ${y2}`,
|
||||
accepted,
|
||||
});
|
||||
});
|
||||
});
|
||||
setPaths(out);
|
||||
}, [flowEl, refs, redrawKey]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
draw();
|
||||
if (!flowEl) return;
|
||||
const ro = new ResizeObserver(draw);
|
||||
ro.observe(flowEl);
|
||||
window.addEventListener("resize", draw);
|
||||
if (typeof document !== "undefined" && document.fonts && document.fonts.ready) {
|
||||
void document.fonts.ready.then(draw);
|
||||
}
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener("resize", draw);
|
||||
};
|
||||
}, [draw, flowEl]);
|
||||
|
||||
return (
|
||||
<svg className="flow-lines" aria-hidden="true">
|
||||
{paths.map((p, i) => (
|
||||
<path key={i} className={`flow-line${p.accepted ? " accepted" : ""}`} d={p.d} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Inbox } from "lucide-react";
|
||||
import type { Quote, Store } from "../../lib/types";
|
||||
import { QuoteCard } from "./QuoteCard";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
|
||||
export interface LedgerTableProps {
|
||||
store: Store;
|
||||
quotes: Quote[];
|
||||
expanded: Set<string>;
|
||||
onToggleExpand: (id: string) => void;
|
||||
onAccept: (id: string) => void;
|
||||
onEdit: (id: string) => void;
|
||||
}
|
||||
|
||||
// دفتر پیشنهادها — فهرست کارتها (هر پیشنهاد یک کارت خوانا)
|
||||
export function LedgerTable({
|
||||
store,
|
||||
quotes,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
onAccept,
|
||||
onEdit,
|
||||
}: LedgerTableProps) {
|
||||
if (!quotes.length) {
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-panel">
|
||||
<EmptyState icon={Inbox} text="پیشنهادی مطابق فیلترها یافت نشد" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{quotes.map((q) => (
|
||||
<QuoteCard
|
||||
key={q.id}
|
||||
store={store}
|
||||
q={q}
|
||||
expanded={expanded.has(q.id)}
|
||||
onToggleExpand={() => onToggleExpand(q.id)}
|
||||
onAccept={() => onAccept(q.id)}
|
||||
onEdit={() => onEdit(q.id)}
|
||||
/>
|
||||
))}
|
||||
<div className="py-1 text-center text-[11px] text-muted">
|
||||
هشدار «منقضی» = قیمت پیشنهادی از تاریخ انقضا گذشته؛ برای پیشبینی قیمت، شمارش نمیشود.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, CreditCard } from "lucide-react";
|
||||
import type { Quote, Store } from "../../lib/types";
|
||||
import {
|
||||
isExpired,
|
||||
itemsSummary,
|
||||
materialById,
|
||||
materialSpecs,
|
||||
projectCell,
|
||||
quoteTotal,
|
||||
supplierById,
|
||||
termsheetSummary,
|
||||
unitWarning,
|
||||
} from "../../lib/quoteUtils";
|
||||
import { STATUS_LABEL } from "../../lib/constants";
|
||||
import { esc, fmtDate, fmtDateTime, fmtNum, priceCell } from "../../lib/formatters";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { QuoteDetail } from "./QuoteDetail";
|
||||
|
||||
export interface QuoteCardProps {
|
||||
store: Store;
|
||||
q: Quote;
|
||||
expanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onAccept: () => void;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
function InfoCell({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="mb-0.5 text-[10.5px] font-semibold text-muted">{label}</div>
|
||||
<div className="min-w-0 text-[13px] leading-6">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// کارت یک پیشنهاد در دفتر — جایگزین جدول ۱۱ ستونه
|
||||
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) {
|
||||
const sup = supplierById(store.suppliers, q.supplier_id);
|
||||
const expired = isExpired(q);
|
||||
const total = quoteTotal(q);
|
||||
const pc = priceCell(total);
|
||||
const ts = termsheetSummary(store, q);
|
||||
const unitWarns = unitWarning(store, q);
|
||||
const summary = itemsSummary(store, q);
|
||||
|
||||
// ستون پذیرش (با ساعت)
|
||||
let acceptedCell: React.ReactNode;
|
||||
if (q.status === "offered") {
|
||||
acceptedCell = (
|
||||
<button className="chip chip-act" onClick={onAccept}>
|
||||
پذیرش
|
||||
</button>
|
||||
);
|
||||
} else if (q.accepted_date) {
|
||||
acceptedCell = <span className="tabular-nums">{fmtDateTime(q.accepted_date)}</span>;
|
||||
} else {
|
||||
acceptedCell = <span className="text-muted">—</span>;
|
||||
}
|
||||
|
||||
// ستون رسید — v2.6: تأیید رسید فقط در «پنل مدیریت انبار» توسط مدیر پروژه انجام میشود؛
|
||||
// اینجا فقط وضعیت نمایش داده میشود.
|
||||
let arrivedCell: React.ReactNode;
|
||||
if (q.status === "accepted") {
|
||||
if (q.arrived_date) {
|
||||
arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>;
|
||||
} else {
|
||||
const items = (q.line_items || []).filter((li) => !li.rejected);
|
||||
const arrivedCount = items.filter((li) => li.arrived_date).length;
|
||||
arrivedCell = arrivedCount ? (
|
||||
<span className="tabular-nums">{fmtNum(arrivedCount)}/{fmtNum(items.length)}</span>
|
||||
) : (
|
||||
<span className="text-muted">در انتظار PM</span>
|
||||
);
|
||||
}
|
||||
} else if (q.arrived_date) {
|
||||
arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>;
|
||||
} else {
|
||||
arrivedCell = <span className="text-muted">—</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-lg border bg-panel transition-colors",
|
||||
expired ? "border-amber/40" : "border-line hover:border-accent/40",
|
||||
expanded && "border-accent/60",
|
||||
)}
|
||||
>
|
||||
{/* سربرگ کارت */}
|
||||
<div className="flex items-center gap-3 border-b border-line bg-bg/40 px-4 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">
|
||||
{sup.name.slice(0, 1)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
to={`/suppliers/${q.supplier_id}`}
|
||||
className="block truncate text-[13.5px] font-bold text-ink no-underline hover:text-accent"
|
||||
>
|
||||
{sup.name}
|
||||
</Link>
|
||||
{sup.location && <div className="truncate text-[11px] text-muted">{sup.location}</div>}
|
||||
</div>
|
||||
<div className="ms-auto flex shrink-0 items-center gap-1.5">
|
||||
<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
|
||||
className="chip chip-expand"
|
||||
onClick={onToggleExpand}
|
||||
title={expanded ? "جمع کردن" : "نمایش جریان"}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* اطلاعات کلیدی */}
|
||||
<div className="grid grid-cols-2 gap-x-5 gap-y-3 px-4 py-3 md:grid-cols-4">
|
||||
<InfoCell label="اقلام">
|
||||
{summary.count > 1 ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-semibold">{fmtNum(summary.count)} قلم</span>
|
||||
<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) => {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
const specs = materialSpecs(m);
|
||||
return (
|
||||
<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" />
|
||||
{m.category} {m.brand}{specs.length ? ` · ${specs.join(" · ")}` : ""}
|
||||
<span className="text-[10px] text-muted/70">
|
||||
{fmtNum(li.quantity)} {li.unit}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
summary.text
|
||||
)}
|
||||
{unitWarns.map((w) => (
|
||||
<Link
|
||||
key={w.unit}
|
||||
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
|
||||
className="unit-warn mt-1"
|
||||
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
|
||||
>
|
||||
<TriangleAlert className="h-2.5 w-2.5" />
|
||||
واحد جدید: {w.unit}
|
||||
</Link>
|
||||
))}
|
||||
</InfoCell>
|
||||
|
||||
<InfoCell label="پروژه">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
{q.projects && q.projects.length > 0 ? (
|
||||
<span className="flex flex-col gap-0.5">
|
||||
{q.projects.map((p, i) => (
|
||||
<span key={i}>{p}</span>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
projectCell(q)
|
||||
)}
|
||||
</span>
|
||||
</InfoCell>
|
||||
|
||||
<InfoCell label="مجموع">
|
||||
{pc ? (
|
||||
<span className="font-bold tabular-nums">
|
||||
{pc.num} <span className="num-words">تومان</span> <span className="num-words">({pc.words})</span>
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</InfoCell>
|
||||
|
||||
<InfoCell label="معامله">
|
||||
{ts.title !== "—" ? (
|
||||
<div>
|
||||
<div className="font-semibold" style={ts.color ? { color: ts.color } : undefined}>
|
||||
{ts.title}
|
||||
</div>
|
||||
{ts.sub && <div className="text-[11px] text-muted">{ts.sub}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</InfoCell>
|
||||
</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">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Package className="h-3 w-3" />
|
||||
پیشنهاد: <b className="text-ink tabular-nums">{fmtDateTime(q.quote_date)}</b>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CreditCard className="h-3 w-3" />
|
||||
معتبر تا:{" "}
|
||||
<b className={cn("tabular-nums", expired ? "text-amber" : "text-ink")}>
|
||||
{fmtDate(q.expiry_date)}
|
||||
</b>
|
||||
{expired && <AlarmClock className="h-3 w-3 text-amber" aria-label="منقضی" />}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
پذیرش: {acceptedCell}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
رسید: {arrivedCell}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* جریان (expand) */}
|
||||
{expanded && <QuoteDetail store={store} q={q} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Package, Scale, CreditCard, CalendarDays, Paperclip, Building2, Ban } from "lucide-react";
|
||||
import type { Attachment, Quote, Store } from "../../lib/types";
|
||||
import { api } from "../../lib/api";
|
||||
import {
|
||||
itemTermsheetIds,
|
||||
lineItemTotal,
|
||||
materialById,
|
||||
materialSpecs,
|
||||
refConverted,
|
||||
specsLabel,
|
||||
} from "../../lib/quoteUtils";
|
||||
import { fmtDate, fmtNum } from "../../lib/formatters";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { FlowDiagram, type FlowRefs } from "./FlowDiagram";
|
||||
import { itemProjects } from "../../lib/quoteUtils";
|
||||
|
||||
export interface QuoteDetailProps {
|
||||
store: Store;
|
||||
q: Quote;
|
||||
}
|
||||
|
||||
// بخش expanded یک پیشنهاد: اقلام (راست) ↔ ترمشیتها (چپ) با خطوط SVG رنگی
|
||||
export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
||||
const [flowEl, setFlowEl] = useState<HTMLDivElement | null>(null);
|
||||
const [flowRefs] = useState<FlowRefs>(() => ({ items: new Map(), termsheets: new Map() }));
|
||||
const [bump, setBump] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// بعد از نشستن layout (فونت/تصویر) خطوط دوباره رسم شوند
|
||||
const t = setTimeout(() => setBump((b) => b + 1), 350);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
void bump;
|
||||
|
||||
// پیوستها (فاکتور/پیشفاکتور) مستقیماً از سرور (MinIO) خوانده میشوند
|
||||
const [attachments, setAttachments] = useState<{ invoices: Attachment[]; proformas: Attachment[] }>({
|
||||
invoices: [],
|
||||
proformas: [],
|
||||
});
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void Promise.all([
|
||||
api.files.list(q.id, "invoice").catch(() => [] as Attachment[]),
|
||||
api.files.list(q.id, "proforma").catch(() => [] as Attachment[]),
|
||||
]).then(([invoices, proformas]) => {
|
||||
if (alive) setAttachments({ invoices, proformas });
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [q.id]);
|
||||
|
||||
const tsList = q.termsheets || [];
|
||||
const allItems = q.line_items || [];
|
||||
const items = allItems.filter((li) => !li.rejected);
|
||||
const rejectedItems = allItems.filter((li) => li.rejected);
|
||||
|
||||
// وقتی دادهی جریان عوض شد (پذیرش/رسید/ویرایش) خطوط دوباره رسم شوند
|
||||
const redrawKey = JSON.stringify({
|
||||
items: allItems.map((li) => [li.id, li.accepted_ts_id, !!li.rejected, !!li.arrived_date]),
|
||||
ts: tsList.map((t) => [t.id, t.status]),
|
||||
});
|
||||
|
||||
const itemBox = (li: (typeof allItems)[number], isRejected: boolean) => {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
const tsIds = itemTermsheetIds(li).filter((tid) => tsList.some((t) => t.id === tid));
|
||||
const total = lineItemTotal(li);
|
||||
const projs = itemProjects(q, li);
|
||||
const conv = isRejected ? null : refConverted(store, li);
|
||||
const specText = specsLabel(materialSpecs(m));
|
||||
return (
|
||||
<div
|
||||
key={li.id}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
flowRefs.items.set(li.id, el);
|
||||
el.dataset.tsIds = tsIds.join(",");
|
||||
el.dataset.acceptedTs = li.accepted_ts_id || "";
|
||||
} else {
|
||||
flowRefs.items.delete(li.id);
|
||||
}
|
||||
}}
|
||||
className={`flow-box flow-item${isRejected ? " rejected" : ""}${li.arrived_date ? " deal" : ""}`}
|
||||
>
|
||||
<div className="fb-name">
|
||||
<Package className="me-1 inline h-3.5 w-3.5 text-muted" />
|
||||
{m.category} {m.brand}
|
||||
{specText ? ` · ${specText}` : ""}
|
||||
</div>
|
||||
<div className="fb-meta">
|
||||
<Building2 className="me-1 inline h-3 w-3" /> {projs.length ? projs.join("، ") : "—"}
|
||||
</div>
|
||||
<div className="fb-nums">
|
||||
<span>
|
||||
{fmtNum(li.quantity)} {li.unit || ""}
|
||||
</span>
|
||||
<span>× {groupCommas(li.unit_price, true)}</span>
|
||||
<span className="fb-total">= {total != null ? groupCommas(total, true) + " تومان" : "—"}</span>
|
||||
</div>
|
||||
{conv && (
|
||||
<div className="fb-conv">
|
||||
<Scale className="me-1 inline h-3 w-3" /> ≈ {groupCommas(conv.qty, true)} {conv.unit}
|
||||
{conv.perRef != null ? ` · ${groupCommas(conv.perRef, true)} تومان/${conv.unit}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const attachParts: React.ReactNode[] = [];
|
||||
attachments.proformas.forEach((pf, i) => {
|
||||
if (!pf.id) return;
|
||||
attachParts.push(
|
||||
<a key={"pf" + i} className="detail-link" href={api.files.downloadUrl(q.id, "proforma", pf.id)} download={pf.name}>
|
||||
<Paperclip className="me-1 inline h-3 w-3" />
|
||||
پیشفاکتور: {pf.name}
|
||||
</a>,
|
||||
);
|
||||
});
|
||||
attachments.invoices.forEach((inv, i) => {
|
||||
if (!inv.id) return;
|
||||
attachParts.push(
|
||||
<a key={"inv" + i} className="detail-link" href={api.files.downloadUrl(q.id, "invoice", inv.id)} download={inv.name}>
|
||||
<Paperclip className="me-1 inline h-3 w-3" />
|
||||
فاکتور اصلی: {inv.name}
|
||||
</a>,
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={setFlowEl} className="flow border-t border-line" data-status={q.status}>
|
||||
<div className="flow-cols">
|
||||
<div className="flow-col flow-items-col">
|
||||
<div className="flow-col-h">
|
||||
<Package className="h-3.5 w-3.5" /> اقلام ({fmtNum(items.length)})
|
||||
</div>
|
||||
{items.length ? items.map((li) => itemBox(li, false)) : <div className="flow-note">—</div>}
|
||||
{rejectedItems.length > 0 && (
|
||||
<>
|
||||
<div className="flow-rejected-h">
|
||||
<Ban className="h-3 w-3" /> قلمهای رد شده (از معامله خارجاند)
|
||||
</div>
|
||||
{rejectedItems.map((li) => itemBox(li, true))}
|
||||
<div className="flow-rejected-hint">
|
||||
با بازگشت وضعیت به مرحلهی پیشین (یا ویرایش)، این قلمها برمیگردند.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flow-col flow-terms-col">
|
||||
<div className="flow-col-h">
|
||||
<CreditCard className="h-3.5 w-3.5" /> شرایط پرداخت ({fmtNum(tsList.length)})
|
||||
</div>
|
||||
{tsList.length ? (
|
||||
tsList.map((t) => {
|
||||
const isAccepted = t.status === "accepted";
|
||||
const arrived = q.status === "arrived" && isAccepted;
|
||||
const badge = arrived ? (
|
||||
<span className="fb-badge arrived">رسیده</span>
|
||||
) : isAccepted ? (
|
||||
<span className="fb-badge accepted">✓ پذیرفته</span>
|
||||
) : (
|
||||
<span className="fb-badge offered">پیشنهاد</span>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
ref={(el) => {
|
||||
if (el) flowRefs.termsheets.set(t.id, el);
|
||||
else flowRefs.termsheets.delete(t.id);
|
||||
}}
|
||||
className={`flow-box flow-ts${isAccepted ? (arrived ? " ts-arrived" : " ts-accepted") : ""}`}
|
||||
>
|
||||
<div className="fb-name">
|
||||
<CreditCard className="h-3.5 w-3.5 text-muted" />
|
||||
{t.label} {badge}
|
||||
</div>
|
||||
<div className="fb-ms-list">
|
||||
{(t.milestones || []).length ? (
|
||||
t.milestones.map((m, i) => (
|
||||
<div key={i} className="fb-ms">
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
{m.when_iso ? fmtDate(m.when_iso) : "—"} ·{" "}
|
||||
{m.amount != null ? groupCommas(m.amount, true) + " تومان" : "—"}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="fb-ms">—</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flow-note">—</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FlowDiagram flowEl={flowEl} refs={flowRefs} redrawKey={redrawKey} />
|
||||
{attachParts.length > 0 && (
|
||||
<div className="flow-attach">
|
||||
{attachParts.map((p, i) => (
|
||||
<span key={i} className="detail-item">
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { AlarmClock, ChevronDown, TriangleAlert } from "lucide-react";
|
||||
import type { Quote, Store } from "../../lib/types";
|
||||
import {
|
||||
isExpired,
|
||||
itemsSummary,
|
||||
materialById,
|
||||
materialSpecs,
|
||||
projectCell,
|
||||
quoteTotal,
|
||||
supplierById,
|
||||
termsheetSummary,
|
||||
unitWarning,
|
||||
} from "../../lib/quoteUtils";
|
||||
import { STATUS_LABEL } from "../../lib/constants";
|
||||
import { esc, fmtDate, fmtNum, priceCell } from "../../lib/formatters";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface QuoteRowProps {
|
||||
store: Store;
|
||||
q: Quote;
|
||||
expanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onAccept: () => void;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
// یک ردیف جمعشدهی پیشنهاد در دفتر
|
||||
export function QuoteRow({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteRowProps) {
|
||||
const sup = supplierById(store.suppliers, q.supplier_id);
|
||||
const expired = isExpired(q);
|
||||
const total = quoteTotal(q);
|
||||
const pc = priceCell(total);
|
||||
|
||||
const ts = termsheetSummary(store, q);
|
||||
const unitWarns = unitWarning(store, q);
|
||||
|
||||
// ستون پذیرش
|
||||
let acceptedCell: React.ReactNode;
|
||||
if (q.status === "offered") {
|
||||
acceptedCell = (
|
||||
<button className="chip chip-act" onClick={onAccept}>
|
||||
پذیرش
|
||||
</button>
|
||||
);
|
||||
} else if (q.accepted_date) {
|
||||
acceptedCell = <span className="text-xs tabular-nums">{fmtDate(q.accepted_date)}</span>;
|
||||
} else {
|
||||
acceptedCell = <span className="text-xs text-muted">—</span>;
|
||||
}
|
||||
|
||||
// ستون رسید
|
||||
let arrivedCell: React.ReactNode;
|
||||
if (q.status === "accepted") {
|
||||
if (q.arrived_date) {
|
||||
arrivedCell = <span className="text-xs tabular-nums">{fmtDate(q.arrived_date)}</span>;
|
||||
} else {
|
||||
const items = (q.line_items || []).filter((li) => !li.rejected);
|
||||
const arrivedCount = items.filter((li) => li.arrived_date).length;
|
||||
// v2.6: تأیید رسید فقط در «پنل مدیریت انبار» توسط مدیر پروژه انجام میشود.
|
||||
arrivedCell = arrivedCount ? (
|
||||
<span className="text-xs tabular-nums">{fmtNum(arrivedCount)}/{fmtNum(items.length)}</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted">در انتظار PM</span>
|
||||
);
|
||||
}
|
||||
} else if (q.arrived_date) {
|
||||
arrivedCell = <span className="text-xs tabular-nums">{fmtDate(q.arrived_date)}</span>;
|
||||
} else {
|
||||
arrivedCell = <span className="text-xs text-muted">—</span>;
|
||||
}
|
||||
|
||||
const summary = itemsSummary(store, q);
|
||||
|
||||
return (
|
||||
<tr className={cn("quote-row transition-colors", expired && "stale-row", expanded && "bg-accent-bg/40")}>
|
||||
{/* تأمینکننده */}
|
||||
<td>
|
||||
<Link to={`/suppliers/${q.supplier_id}`} className="text-[13px] font-semibold text-accent no-underline hover:underline">
|
||||
{sup.name}
|
||||
</Link>
|
||||
{sup.location && <div className="text-[11px] text-muted">{sup.location}</div>}
|
||||
</td>
|
||||
|
||||
{/* اقلام */}
|
||||
<td>
|
||||
<div className="text-[13px] leading-5">
|
||||
{summary.count > 1 ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<b>{fmtNum(summary.count)} قلم</b>
|
||||
<div className="flex flex-col gap-0.5 text-[11px] text-muted">
|
||||
{(q.line_items || []).filter((li) => !li.rejected).map((li) => {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
const specs = materialSpecs(m);
|
||||
return (
|
||||
<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" />
|
||||
{m.category} {m.brand}{specs.length ? ` · ${specs.join(" · ")}` : ""}
|
||||
<span className="text-[10px] text-muted/70">
|
||||
{fmtNum(li.quantity)} {li.unit}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
summary.text
|
||||
)}
|
||||
</div>
|
||||
{unitWarns.map((w) => (
|
||||
<Link
|
||||
key={w.unit}
|
||||
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
|
||||
className="unit-warn"
|
||||
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
|
||||
>
|
||||
<TriangleAlert className="h-2.5 w-2.5" />
|
||||
واحد جدید: {w.unit}
|
||||
</Link>
|
||||
))}
|
||||
</td>
|
||||
|
||||
{/* پروژه */}
|
||||
<td className="text-[13px]">{projectCell(q)}</td>
|
||||
|
||||
{/* مجموع */}
|
||||
<td className="text-[13px] font-semibold tabular-nums whitespace-nowrap">
|
||||
{pc ? (
|
||||
<>
|
||||
{pc.num} <span className="num-words">({pc.words})</span>
|
||||
</>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* معامله */}
|
||||
<td>
|
||||
{ts.title !== "—" ? (
|
||||
<div>
|
||||
<div className="text-[12px] font-semibold leading-5" style={ts.color ? { color: ts.color } : undefined}>
|
||||
{ts.title}
|
||||
</div>
|
||||
{ts.sub && <div className="text-[11px] text-muted">{ts.sub}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* تاریخها */}
|
||||
<td className="text-xs tabular-nums whitespace-nowrap">{fmtDate(q.quote_date)}</td>
|
||||
<td className="text-xs tabular-nums whitespace-nowrap">
|
||||
{fmtDate(q.expiry_date)}
|
||||
{expired && <AlarmClock className="ms-1 inline h-3 w-3 text-amber" aria-label="منقضی" />}
|
||||
</td>
|
||||
<td>{acceptedCell}</td>
|
||||
<td>{arrivedCell}</td>
|
||||
|
||||
{/* وضعیت */}
|
||||
<td>
|
||||
<Badge variant={q.status as "offered" | "accepted" | "arrived"}>{STATUS_LABEL[q.status]}</Badge>
|
||||
</td>
|
||||
|
||||
{/* عملیات */}
|
||||
<td>
|
||||
<div className="flex items-center gap-1">
|
||||
<button className="chip" onClick={onEdit}>
|
||||
ویرایش
|
||||
</button>
|
||||
<button
|
||||
className="chip chip-expand"
|
||||
onClick={onToggleExpand}
|
||||
title={expanded ? "جمع کردن" : "نمایش جریان"}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform duration-200", expanded && "rotate-180")} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { PanelMenu } from "../../components/PanelMenu";
|
||||
import { panelMenuItems, useLogout } from "./panelMenu";
|
||||
import { apiV26 } from "../../lib/api";
|
||||
import type { PendingArrival, Warehouse } from "../../lib/types";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
|
||||
// رسید کالاهای در راه — مدیر پروژه تأیید و بین انبارها تقسیم میکند (v2.6)
|
||||
export function ArrivalsPage() {
|
||||
const location = useLocation();
|
||||
const logout = useLogout();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [items, setItems] = useState<PendingArrival[]>([]);
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||
const [selected, setSelected] = useState<Record<string, string[]>>({}); // decisionId -> warehouseIds
|
||||
const [qty, setQty] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role !== "pm") return;
|
||||
let alive = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const [arr, whs] = await Promise.all([
|
||||
apiV26.pm.onTheWay(),
|
||||
apiV26.warehouses.list(user.project_id || undefined),
|
||||
]);
|
||||
if (!alive) return;
|
||||
setItems(arr);
|
||||
setWarehouses(whs);
|
||||
} catch (err) {
|
||||
if (alive) toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه");
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [user?.role, user?.project_id]);
|
||||
|
||||
const pending = useMemo(() => items.filter((i) => i.state !== "arrived"), [items]);
|
||||
|
||||
const toggleWarehouse = (decisionId: string, whId: string) => {
|
||||
setSelected((s) => {
|
||||
const cur = s[decisionId] || [];
|
||||
return { ...s, [decisionId]: cur.includes(whId) ? cur.filter((x) => x !== whId) : [...cur, whId] };
|
||||
});
|
||||
};
|
||||
|
||||
const confirm = async (it: PendingArrival) => {
|
||||
const qtyStr = qty[it.item_decision_id]?.trim();
|
||||
const total = parseFloat(qtyStr || "");
|
||||
if (!total || total <= 0) {
|
||||
toast.error("تعداد رسیده را وارد کنید.");
|
||||
return;
|
||||
}
|
||||
const allocIds = selected[it.item_decision_id] || [];
|
||||
if (!allocIds.length) {
|
||||
toast.error("حداقل یک انبار را برای تقسیم انتخاب کنید.");
|
||||
return;
|
||||
}
|
||||
// تقسیم مساوی بین انبارهای انتخابشده (بهصورت پیشفرض؛ کاربر میتواند بعداً اصلاح کند)
|
||||
const per = Math.round((total / allocIds.length) * 10000) / 10000;
|
||||
const allocations = allocIds.map((whId, idx) => ({
|
||||
warehouse_id: whId,
|
||||
quantity: idx === allocIds.length - 1 ? +(total - per * (allocIds.length - 1)).toFixed(4) : per,
|
||||
}));
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiV26.pm.confirmReceipt({
|
||||
item_decision_id: it.item_decision_id,
|
||||
received_qty: total,
|
||||
allocations,
|
||||
});
|
||||
toast.success(`رسید ثبت شد — ${res.posted_transactions.length} تراکنش خرید ثبت شد.`);
|
||||
setItems((list) => list.filter((x) => x.item_decision_id !== it.item_decision_id));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در ثبت رسید");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="min-h-screen" />;
|
||||
if (user?.role !== "pm") {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="رسید کالاها" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
|
||||
<PageLayout>
|
||||
<EmptyState emoji="🔒" text="این بخش فقط برای مدیر پروژه است." />
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header
|
||||
title="کالاهای در راه و رسید"
|
||||
sub={user.project_name || ""}
|
||||
menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />}
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-4">
|
||||
{!pending.length ? (
|
||||
<EmptyState emoji="🚚" text="کالای در راهی برای تأیید رسید وجود ندارد." />
|
||||
) : (
|
||||
pending.map((it) => (
|
||||
<div key={it.item_decision_id} className="rounded-xl border border-line bg-panel p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[14px] font-bold">{it.material.name}</span>
|
||||
<Badge variant="outline">
|
||||
{it.state === "on_the_way" ? "در راه" : "رسید ناقص"}
|
||||
</Badge>
|
||||
<span className="text-[12px] text-muted">
|
||||
سفارش: {faNum(parseFloat(it.ordered_qty))} {it.unit?.name_fa || it.unit?.code || ""} — رسیده: {faNum(parseFloat(it.received_qty))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<div className="field">
|
||||
<label>تعداد رسیده (در واحد قلم)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
value={qty[it.item_decision_id] || ""}
|
||||
onChange={(e) => setQty((q) => ({ ...q, [it.item_decision_id]: e.target.value }))}
|
||||
placeholder={it.ordered_qty}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تقسیم بین انبارها (چندتایی)</label>
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{warehouses.map((w) => {
|
||||
const on = (selected[it.item_decision_id] || []).includes(w.id);
|
||||
return (
|
||||
<button
|
||||
key={w.id}
|
||||
type="button"
|
||||
className={`rounded-lg border px-3 py-1.5 text-[12.5px] ${on ? "border-accent bg-accent/10 text-accent" : "border-line bg-panel text-muted"}`}
|
||||
onClick={() => toggleWarehouse(it.item_decision_id, w.id)}
|
||||
>
|
||||
{w.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button size="sm" variant="green" disabled={saving} onClick={() => void confirm(it)}>
|
||||
ثبت رسید و ورود به انبار
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { PanelMenu } from "../../components/PanelMenu";
|
||||
import { panelMenuItems, useLogout } from "./panelMenu";
|
||||
import { apiV26 } from "../../lib/api";
|
||||
import { useInventoryData, unitName } from "./useInventoryData";
|
||||
import type { InventoryTransaction, TransactionType } from "../../lib/types";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { gregorianToJalali, toFa } from "../../lib/jalali";
|
||||
|
||||
type FormType = TransactionType | "TRANSFER";
|
||||
|
||||
const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [
|
||||
{ t: "PURCHASE", label: "خرید", dir: "افزایش", cls: "in", color: "#16a34a", desc: "ثبت خرید کالای جدید یا موجود — دستهبندی فقط برای کالای جدید لازم است. ثبت حداقل یک مشخصه فنی (نام و مقدار) الزامی است." },
|
||||
{ t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#1d4ed8", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدلها (برند و مشخصه متفاوت) نمایش داده میشوند. برداشتها در «کنترل روزانه» امروز دیده میشوند." },
|
||||
{ t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#16a34a", desc: "برگشت از مصالحی که امروز برداشت شدهاند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." },
|
||||
{ t: "TRANSFER", label: "انتقال", dir: "جابهجایی", cls: "mv", color: "#4f46e5", desc: "جابهجایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." },
|
||||
{ t: "DAMAGE", label: "ضایعات", dir: "کاهش", cls: "out", color: "#1d4ed8", desc: "ثبت ضایعات خرید (مثلاً لولههای سوراخ) — انبار و متریال را انتخاب و تعداد ضایعات هر مدل را وارد کنید." },
|
||||
{ t: "WASTE", label: "اتلاف", dir: "کاهش", cls: "out", color: "#1d4ed8", desc: "اتلاف بعد از خرید (خرابی در انبار یا هدررفت در کار) — انبار و متریال را انتخاب کنید." },
|
||||
{ t: "LOSS", label: "مفقودی", dir: "کاهش", cls: "out", color: "#1d4ed8", desc: "ثبت مفقودی — انبار و متریال را انتخاب و تعداد مفقودی هر مدل را وارد کنید." },
|
||||
{ t: "FOUND", label: "پیدا شده", dir: "افزایش", cls: "in", color: "#16a34a", desc: "ثبت پیدا شدن کالاهای مفقود — مفقودیهای همان انبار و متریال (یا «همه مفقودیها») نمایش داده میشود." },
|
||||
{ t: "STOCK_COUNT", label: "شمارش موجودی", dir: "اصلاح", cls: "cnt", color: "#64748b", desc: "شمارش فیزیکی — مقدار شمارششده هر رکورد را وارد کنید تا موجودی اصلاح شود و اختلاف ثبت گردد." },
|
||||
];
|
||||
|
||||
interface RowDraft { key: string; qty: string; note: string; }
|
||||
|
||||
function todayKey(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function gregKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function jalDateLabel(d: Date): string {
|
||||
const [jy, jm, jd] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||||
return `${toFa(jd)} ${["فروردین","اردیبهشت","خرداد","تیر","مرداد","شهریور","مهر","آبان","آذر","دی","بهمن","اسفند"][jm-1]} ${toFa(jy)}`;
|
||||
}
|
||||
|
||||
// تغییر موجودی — پورت کامل صفحهی ارسالی (همه انواع تراکنش)
|
||||
export function ChangeInventoryPage() {
|
||||
const location = useLocation();
|
||||
const logout = useLogout();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const data = useInventoryData();
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const [type, setType] = useState<FormType>("PURCHASE");
|
||||
const [whId, setWhId] = useState("");
|
||||
const [fromId, setFromId] = useState("");
|
||||
const [toId, setToId] = useState("");
|
||||
const [matTxt, setMatTxt] = useState("");
|
||||
const [catId, setCatId] = useState("");
|
||||
const [brandTxt, setBrandTxt] = useState("");
|
||||
const [unitTxt, setUnitTxt] = useState("");
|
||||
const [qty, setQty] = useState("");
|
||||
const [dateInput, setDateInput] = useState(() => {
|
||||
const d = new Date();
|
||||
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
|
||||
return d.toISOString().slice(0, 16);
|
||||
});
|
||||
const [specsRows, setSpecsRows] = useState<{ rid: number; label: string; value: string }[]>([
|
||||
{ rid: 1, label: "", value: "" },
|
||||
]);
|
||||
const [note, setNote] = useState("");
|
||||
const [rows, setRows] = useState<RowDraft[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// prefill از URL (پرش از کنترل روزانه)
|
||||
useEffect(() => {
|
||||
const t = params.get("type");
|
||||
const w = params.get("wh");
|
||||
const m = params.get("mat");
|
||||
if (t && FORM_TYPES.some((f) => f.t === t)) {
|
||||
setType(t as TransactionType);
|
||||
if (w) setWhId(w);
|
||||
if (m) setMatTxt(m);
|
||||
}
|
||||
}, [params]);
|
||||
|
||||
const isAdmin = user?.role === "admin";
|
||||
const isPM = user?.role === "pm";
|
||||
|
||||
const warehouses = data.warehouses;
|
||||
const whOpts = useMemo(
|
||||
() => (isAdmin ? warehouses : warehouses.filter((w) => String(w.project_id) === String(user?.project_id || ""))),
|
||||
[warehouses, isAdmin, user?.project_id],
|
||||
);
|
||||
|
||||
const formRecords = useMemo(() => {
|
||||
const txt = matTxt.trim();
|
||||
return data.inventory.filter((r) => {
|
||||
if (r.warehouse?.id !== whId) return false;
|
||||
if (txt && !r.material.name.includes(txt)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [data.inventory, whId, matTxt]);
|
||||
|
||||
const matByName = (name: string) => data.lookup.materials.find((m) => m.name === name) || null;
|
||||
const brandIdByName = (name: string) => data.lookup.brands.find((b) => b.name === name)?.id || null;
|
||||
const unitIdByName = (name: string) => data.lookup.units.find((u) => (u.name_fa || u.code) === name)?.id || null;
|
||||
const parseNum = (s: string) => { const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, "")); return isFinite(v) ? v : NaN; };
|
||||
|
||||
const specsFromRows = () => {
|
||||
const out: Record<string, string> = {};
|
||||
specsRows.forEach((r) => {
|
||||
if (r.label.trim() && r.value.trim()) out[r.label.trim()] = r.value.trim();
|
||||
});
|
||||
return out;
|
||||
};
|
||||
const specsComplete = specsRows.some((r) => r.label.trim() && r.value.trim());
|
||||
|
||||
const todayWithdrawals = useMemo(() => {
|
||||
const tk = todayKey();
|
||||
const txt = matTxt.trim();
|
||||
return data.transactions
|
||||
.filter((t) => {
|
||||
if (t.transaction_type !== "WITHDRAWAL" || t.warehouse_id !== whId) return false;
|
||||
if (gregKey(new Date(t.transaction_date)) !== tk) return false;
|
||||
if (txt && !(t.material?.name || "").includes(txt)) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
||||
}, [data.transactions, whId, matTxt]);
|
||||
|
||||
const lossTxs = useMemo(() => {
|
||||
const txt = matTxt.trim();
|
||||
return data.transactions
|
||||
.filter((t) => {
|
||||
if (t.transaction_type !== "LOSS" || t.warehouse_id !== whId) return false;
|
||||
if (txt && !(t.material?.name || "").includes(txt)) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
||||
}, [data.transactions, whId, matTxt]);
|
||||
|
||||
const postOne = async (op: Record<string, unknown>) => {
|
||||
await apiV26.inventory.createTransaction(op as never);
|
||||
};
|
||||
|
||||
const postSeq = async (ops: Record<string, unknown>[]) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
for (const op of ops) await postOne(op);
|
||||
toast.success("ثبت شد ✓");
|
||||
setRows([]);
|
||||
setMatTxt("");
|
||||
setBrandTxt("");
|
||||
setUnitTxt("");
|
||||
setQty("");
|
||||
setSpecsRows([{ rid: Date.now(), label: "", value: "" }]);
|
||||
setNote("");
|
||||
await data.refresh();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در ثبت");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- submit handlers ----------
|
||||
const submitPurchase = async () => {
|
||||
const m = matByName(matTxt);
|
||||
const bid = brandIdByName(brandTxt);
|
||||
const uid = unitIdByName(unitTxt);
|
||||
const n = parseNum(qty);
|
||||
if (!whId) return toast.error("انبار را انتخاب کنید");
|
||||
if (!m) return toast.error("متریال در فهرست نیست — نام را دقیق تایپ کنید");
|
||||
if (!m.category && !catId) return toast.error("برای کالای جدید، دستهبندی را انتخاب کنید");
|
||||
if (!bid) return toast.error("برند را انتخاب کنید");
|
||||
if (!uid) return toast.error("واحد را انتخاب کنید");
|
||||
if (!(n > 0)) return toast.error("تعداد باید بزرگتر از صفر باشد");
|
||||
if (!specsComplete) return toast.error("حداقل یک مشخصه فنی (نام و مقدار) ثبت کنید");
|
||||
const op: Record<string, unknown> = {
|
||||
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
|
||||
quantity: n, unit_id: uid, specs: specsFromRows(), note,
|
||||
transaction_date: new Date(dateInput).toISOString(),
|
||||
};
|
||||
await postSeq([op]);
|
||||
};
|
||||
|
||||
const submitList = async (t: TransactionType) => {
|
||||
const isCount = t === "STOCK_COUNT";
|
||||
const ops: Record<string, unknown>[] = rows
|
||||
.map((r, i): Record<string, unknown> | null => {
|
||||
const n = parseNum(r.qty);
|
||||
if (!(n > 0)) return null;
|
||||
const rec = formRecords[i];
|
||||
if (!rec) return null;
|
||||
if (isCount) {
|
||||
return { transaction_type: "STOCK_COUNT", warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, physical_qty: n, note: r.note, transaction_date: new Date(dateInput).toISOString() };
|
||||
}
|
||||
return { transaction_type: t, warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: new Date(dateInput).toISOString() };
|
||||
})
|
||||
.filter((x): x is Record<string, unknown> => x !== null);
|
||||
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
|
||||
await postSeq(ops);
|
||||
};
|
||||
|
||||
const submitReturn = async () => {
|
||||
const ops: Record<string, unknown>[] = rows
|
||||
.map((r, i): Record<string, unknown> | null => {
|
||||
const n = parseNum(r.qty);
|
||||
if (!(n > 0)) return null;
|
||||
const t = todayWithdrawals[i];
|
||||
if (!t) return null;
|
||||
return { transaction_type: "RETURN", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: new Date(dateInput).toISOString() };
|
||||
})
|
||||
.filter((x): x is Record<string, unknown> => x !== null);
|
||||
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
|
||||
await postSeq(ops);
|
||||
};
|
||||
|
||||
const submitTransfer = async () => {
|
||||
const ops: Record<string, unknown>[] = rows
|
||||
.map((r, i): Record<string, unknown> | null => {
|
||||
const n = parseNum(r.qty);
|
||||
if (!(n > 0)) return null;
|
||||
const rec = formRecords[i];
|
||||
if (!rec) return null;
|
||||
return { transaction_type: "TRANSFER_OUT", warehouse_id: fromId, dest_warehouse_id: toId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: new Date(dateInput).toISOString() };
|
||||
})
|
||||
.filter((x): x is Record<string, unknown> => x !== null);
|
||||
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
|
||||
await postSeq(ops);
|
||||
};
|
||||
|
||||
const submitFound = async () => {
|
||||
const ops: Record<string, unknown>[] = rows
|
||||
.map((r, i): Record<string, unknown> | null => {
|
||||
const n = parseNum(r.qty);
|
||||
if (!(n > 0)) return null;
|
||||
const t = lossTxs[i];
|
||||
if (!t) return null;
|
||||
return { transaction_type: "FOUND", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: new Date(dateInput).toISOString() };
|
||||
})
|
||||
.filter((x): x is Record<string, unknown> => x !== null);
|
||||
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
|
||||
await postSeq(ops);
|
||||
};
|
||||
|
||||
const doSubmit = () => {
|
||||
if (type === "PURCHASE") void submitPurchase();
|
||||
else if (type === "RETURN") void submitReturn();
|
||||
else if (type === "TRANSFER") void submitTransfer();
|
||||
else if (type === "FOUND") void submitFound();
|
||||
else void submitList(type);
|
||||
};
|
||||
|
||||
const canSubmit = (): boolean => {
|
||||
if (type === "PURCHASE") {
|
||||
return !!(whId && matByName(matTxt) && brandIdByName(brandTxt) && unitIdByName(unitTxt) && parseNum(qty) > 0 && specsComplete);
|
||||
}
|
||||
if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
||||
if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
||||
if (type === "FOUND") return !!(whId && rows.some((r) => parseNum(r.qty) > 0));
|
||||
return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
||||
};
|
||||
|
||||
// sync rows when the record list changes
|
||||
useEffect(() => {
|
||||
if (type === "RETURN") {
|
||||
setRows(todayWithdrawals.map((t) => ({ key: t.inventory_id, qty: "", note: "" })));
|
||||
} else if (type === "FOUND") {
|
||||
setRows(lossTxs.map((t) => ({ key: t.inventory_id, qty: "", note: "" })));
|
||||
} else if (type === "TRANSFER") {
|
||||
setRows(formRecords.map((r) => ({ key: r.id, qty: "", note: "" })));
|
||||
} else {
|
||||
setRows(formRecords.map((r) => ({ key: r.id, qty: "", note: "" })));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [type, whId, fromId, toId, matTxt, formRecords.length, todayWithdrawals.length, lossTxs.length]);
|
||||
|
||||
const meta = FORM_TYPES.find((f) => f.t === type)!;
|
||||
|
||||
if (isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="تغییر موجودی" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
|
||||
<PageLayout>
|
||||
<div className="rounded-xl border border-line bg-panel p-8 text-center">
|
||||
<div className="text-3xl">🔒</div>
|
||||
<h3 className="mt-2 text-[15px] font-bold">دسترسی فقطخواندنی</h3>
|
||||
<p className="mt-1 text-[12.5px] text-muted">نقش ادمین اجازه ثبت تغییر موجودی ندارد. این بخش برای مدیران پروژه فعال است.</p>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!isPM) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="تغییر موجودی" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
|
||||
<PageLayout>
|
||||
<div className="text-[13px] text-muted">این بخش فقط برای مدیر پروژه است.</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isCount = type === "STOCK_COUNT";
|
||||
const isTransfer = type === "TRANSFER";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header
|
||||
title="تغییر موجودی"
|
||||
sub={user?.project_name || ""}
|
||||
menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />}
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-3">
|
||||
{/* انتخاب نوع تراکنش */}
|
||||
<div className="rounded-xl border border-line bg-panel p-3">
|
||||
<h3 className="mb-2 text-[13px] font-bold">نوع تغییر موجودی</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{FORM_TYPES.map((f) => (
|
||||
<button
|
||||
key={f.t}
|
||||
type="button"
|
||||
onClick={() => { setType(f.t); setRows([]); setMatTxt(""); setWhId(""); setFromId(""); setToId(""); }}
|
||||
className={`rounded-lg border px-3 py-2 text-[12.5px] ${type === f.t ? "font-bold text-white" : "border-line bg-panel"}`}
|
||||
style={type === f.t ? { background: f.color, borderColor: f.color } : undefined}
|
||||
>
|
||||
{f.label} <span className="text-[10.5px] opacity-80">({f.dir})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-[11.5px] leading-5 text-muted">{meta.desc}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-line bg-panel p-4">
|
||||
{/* انتخاب انبارها */}
|
||||
{!isTransfer ? (
|
||||
<div className="mb-3 grid gap-3 md:grid-cols-2">
|
||||
<div className="field">
|
||||
<label>انبار *</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
value={whId}
|
||||
onChange={(e) => { setWhId(e.target.value); setMatTxt(""); setRows([]); }}
|
||||
>
|
||||
<option value="">— انتخاب کنید —</option>
|
||||
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{(type === "PURCHASE" || type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && (
|
||||
<div className="field">
|
||||
<label>{type === "FOUND" ? "متریال (خالی = همه مفقودیها)" : "متریال *"}</label>
|
||||
<input
|
||||
list="dl-mat"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder={type === "RETURN" ? "نام متریال برداشتشده امروز…" : "نام متریال — از پیشنهادها یا جدید…"}
|
||||
value={matTxt}
|
||||
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
|
||||
/>
|
||||
<datalist id="dl-mat">
|
||||
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-3 grid gap-3 md:grid-cols-2">
|
||||
<div className="field">
|
||||
<label>از (انبار مبدأ) *</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
value={fromId}
|
||||
onChange={(e) => { setFromId(e.target.value); setToId(""); setMatTxt(""); setRows([]); }}
|
||||
>
|
||||
<option value="">— انتخاب انبار مبدأ —</option>
|
||||
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>به (انبار مقصد) *</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
value={toId}
|
||||
disabled={!fromId}
|
||||
onChange={(e) => { setToId(e.target.value); setMatTxt(""); setRows([]); }}
|
||||
>
|
||||
<option value="">— انتخاب انبار مقصد —</option>
|
||||
{whOpts.filter((w) => w.id !== fromId).map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field md:col-span-2">
|
||||
<label>متریال *</label>
|
||||
<input
|
||||
list="dl-mat2"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder="نام متریال را تایپ کنید — همه مدلها نمایش داده میشوند…"
|
||||
value={matTxt}
|
||||
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
|
||||
/>
|
||||
<datalist id="dl-mat2">
|
||||
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* فرم خرید */}
|
||||
{type === "PURCHASE" && (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="field">
|
||||
<label>برند *</label>
|
||||
<input
|
||||
list="dl-brand"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder="نام برند — از پیشنهادها یا جدید…"
|
||||
value={brandTxt}
|
||||
onChange={(e) => setBrandTxt(e.target.value)}
|
||||
/>
|
||||
<datalist id="dl-brand">
|
||||
{data.lookup.brands.map((b) => <option key={b.id} value={b.name} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>واحد *</label>
|
||||
<input
|
||||
list="dl-unit"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder="واحد اندازهگیری…"
|
||||
value={unitTxt}
|
||||
onChange={(e) => setUnitTxt(e.target.value)}
|
||||
/>
|
||||
<datalist id="dl-unit">
|
||||
{data.lookup.units.map((u) => <option key={u.id} value={u.name_fa || u.code} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تعداد *</label>
|
||||
<input
|
||||
inputMode="decimal"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm ltr"
|
||||
placeholder="مثلاً ۲۰۰"
|
||||
value={qty}
|
||||
onChange={(e) => setQty(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تاریخ و زمان</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm ltr"
|
||||
value={dateInput}
|
||||
onChange={(e) => setDateInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{!matByName(matTxt) && matTxt.trim() && (
|
||||
<div className="field md:col-span-2">
|
||||
<label>دستهبندی (کالای جدید) *</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
value={catId}
|
||||
onChange={(e) => setCatId(e.target.value)}
|
||||
>
|
||||
<option value="">— انتخاب کنید —</option>
|
||||
{data.lookup.categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<div className="mt-1 text-[11px] text-muted">این متریال در فهرست نیست؛ فقط برای کالای جدید دستهبندی لازم است.</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="field md:col-span-2">
|
||||
<label>مشخصههای فنی *</label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{specsRows.map((r) => (
|
||||
<div key={r.rid} className="flex gap-2">
|
||||
<input
|
||||
className="h-8 w-1/2 rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder="نام مشخصه (مثلاً سایز)"
|
||||
value={r.label}
|
||||
onChange={(e) => setSpecsRows((rs) => rs.map((x) => (x.rid === r.rid ? { ...x, label: e.target.value } : x)))}
|
||||
/>
|
||||
<input
|
||||
className="h-8 w-1/2 rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder="مقدار (مثلاً ۱۶)"
|
||||
value={r.value}
|
||||
onChange={(e) => setSpecsRows((rs) => rs.map((x) => (x.rid === r.rid ? { ...x, value: e.target.value } : x)))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 px-2 text-muted hover:text-red"
|
||||
onClick={() => setSpecsRows((rs) => (rs.length > 1 ? rs.filter((x) => x.rid !== r.rid) : rs))}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="w-fit rounded-lg border border-dashed border-accent/50 px-3 py-1 text-[12px] text-accent"
|
||||
onClick={() => setSpecsRows((rs) => [...rs, { rid: Date.now(), label: "", value: "" }])}
|
||||
>
|
||||
+ افزودن مشخصه فنی
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field md:col-span-2">
|
||||
<label>یادداشت</label>
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm"
|
||||
placeholder="شماره فاکتور، تأمینکننده، توضیحات…"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ردیفهای عملیات لیستی */}
|
||||
{(type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT") && (
|
||||
<RowTable
|
||||
isCount={isCount}
|
||||
rows={rows}
|
||||
setRows={setRows}
|
||||
records={formRecords}
|
||||
unitNameFn={(id) => unitName(data.lookup.units, id)}
|
||||
emptyMsg={
|
||||
!whId ? "برای نمایش رکوردها، ابتدا انبار را انتخاب کنید."
|
||||
: !matTxt.trim() ? "نام متریال را تایپ کنید تا همه مدلهای آن (با برند و مشخصه متفاوت) نمایش داده شود."
|
||||
: !formRecords.length ? "برای این نام متریال در انبار انتخابی رکوردی وجود ندارد. ابتدا خرید ثبت کنید."
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* برگشت — برداشتهای امروز */}
|
||||
{type === "RETURN" && (
|
||||
<ReturnTable rows={rows} setRows={setRows} txs={todayWithdrawals} whId={whId} matTxt={matTxt} unitNameFn={(id) => unitName(data.lookup.units, id)} />
|
||||
)}
|
||||
|
||||
{/* انتقال */}
|
||||
{type === "TRANSFER" && (
|
||||
<RowTable
|
||||
isCount={false}
|
||||
rows={rows}
|
||||
setRows={setRows}
|
||||
records={formRecords}
|
||||
unitNameFn={(id) => unitName(data.lookup.units, id)}
|
||||
emptyMsg={
|
||||
!fromId || !toId ? "برای نمایش موجودیها، انبار مبدأ، مقصد و نام متریال را وارد کنید."
|
||||
: !matTxt.trim() ? "نام متریال را تایپ کنید…"
|
||||
: !formRecords.length ? "در انبار مبدأ برای این متریال موجودی نیست."
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* پیدا شده — مفقودیها */}
|
||||
{type === "FOUND" && (
|
||||
<FoundTable rows={rows} setRows={setRows} txs={lossTxs} whId={whId} unitNameFn={(id) => unitName(data.lookup.units, id)} />
|
||||
)}
|
||||
|
||||
{/* دکمه ثبت */}
|
||||
<div className="mt-4 flex items-center justify-between border-t border-line pt-3">
|
||||
<span className="text-[12px] text-muted">👤 ثبت با نام: <b>{user?.display_name}</b></span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmit() || busy}
|
||||
onClick={doSubmit}
|
||||
className="rounded-lg px-5 py-2 text-[13px] font-bold text-white disabled:opacity-40"
|
||||
style={{ background: meta.color }}
|
||||
>
|
||||
✓ {busy ? "در حال ثبت…" : meta.label === "انتقال" ? "ثبت انتقال" : meta.label === "برگشت" ? "ثبت برگشت" : meta.label === "پیدا شده" ? "ثبت پیدا شدن" : `ثبت ${meta.label}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
{isTransfer ? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد همزمان ثبت میشود." : `تاریخ انتخابی: ${jalDateLabel(new Date(dateInput))} — با «${jalDateLabel(new Date())}» برابر است.`}
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
|
||||
|
||||
isCount: boolean;
|
||||
rows: RowDraft[];
|
||||
setRows: (r: RowDraft[]) => void;
|
||||
records: { id: string; brand: { id: string; name: string } | null; unit: { id: number; code: string; name_fa: string | null } | null; quantity_number: number; specs: Record<string, string> }[];
|
||||
unitNameFn: (id: number | null | undefined) => string;
|
||||
emptyMsg: string;
|
||||
}) {
|
||||
if (emptyMsg) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-right text-muted">
|
||||
<th className="py-2 pe-3">مشخصهها</th>
|
||||
<th className="py-2 pe-3">برند</th>
|
||||
<th className="py-2 pe-3">واحد</th>
|
||||
<th className="py-2 pe-3">موجودی فعلی</th>
|
||||
<th className="py-2 pe-3">{isCount ? "شمارششده" : "تعداد"}</th>
|
||||
<th className="py-2">یادداشت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((r, i) => (
|
||||
<tr key={r.id} className="border-b border-line/50">
|
||||
<td className="py-1.5 pe-3 text-muted">{Object.entries(r.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{r.brand?.name || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{unitNameFn(r.unit?.id)}</td>
|
||||
<td className="py-1.5 pe-3 font-bold">{faNum(r.quantity_number)}</td>
|
||||
<td className="py-1.5 pe-3">
|
||||
<input
|
||||
inputMode="decimal"
|
||||
className="h-7 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||||
placeholder={isCount ? "شمارششده" : "۰"}
|
||||
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1.5">
|
||||
<input
|
||||
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||
placeholder="توضیح (اختیاری)"
|
||||
value={rows[i]?.note || ""}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-1 text-[11.5px] text-muted">ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت (در صورت نیاز) وارد کنید.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReturnTable({ rows, setRows, txs, whId, matTxt, unitNameFn }: {
|
||||
rows: RowDraft[];
|
||||
setRows: (r: RowDraft[]) => void;
|
||||
txs: InventoryTransaction[];
|
||||
whId: string;
|
||||
matTxt: string;
|
||||
unitNameFn: (id: number | null | undefined) => string;
|
||||
}) {
|
||||
if (!whId || !matTxt.trim()) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">برای مشاهده برداشتهای امروز، انبار و نام متریال را وارد کنید.</div>;
|
||||
if (!txs.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">امروز برداشتی برای این متریال در انبار انتخابی ثبت نشده است — چیزی برای برگشت وجود ندارد.</div>;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-right text-muted">
|
||||
<th className="py-2 pe-3">مشخصهها</th>
|
||||
<th className="py-2 pe-3">برند</th>
|
||||
<th className="py-2 pe-3">واحد</th>
|
||||
<th className="py-2 pe-3">برداشت امروز</th>
|
||||
<th className="py-2 pe-3">تعداد برگشتی</th>
|
||||
<th className="py-2">یادداشت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{txs.map((t, i) => (
|
||||
<tr key={t.inventory_id + i} className="border-b border-line/50">
|
||||
<td className="py-1.5 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{t.brand?.name || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{unitNameFn(t.unit?.id)}</td>
|
||||
<td className="py-1.5 pe-3 font-bold">{faNum(Math.abs(t.quantity_number || 0))}</td>
|
||||
<td className="py-1.5 pe-3">
|
||||
<input
|
||||
inputMode="decimal"
|
||||
className="h-7 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||||
placeholder="۰"
|
||||
value={rows[i]?.qty || ""}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1.5">
|
||||
<input
|
||||
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||
placeholder="توضیح برگشت"
|
||||
value={rows[i]?.note || ""}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FoundTable({ rows, setRows, txs, whId, unitNameFn }: {
|
||||
rows: RowDraft[];
|
||||
setRows: (r: RowDraft[]) => void;
|
||||
txs: InventoryTransaction[];
|
||||
whId: string;
|
||||
unitNameFn: (id: number | null | undefined) => string;
|
||||
}) {
|
||||
if (!whId) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">ابتدا انبار را انتخاب کنید.</div>;
|
||||
if (!txs.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">مفقودی ثبتنشدهای (یا قابل پیداشدن) در این انبار وجود ندارد.</div>;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-right text-muted">
|
||||
<th className="py-2 pe-3">متریال</th>
|
||||
<th className="py-2 pe-3">مشخصهها</th>
|
||||
<th className="py-2 pe-3">برند</th>
|
||||
<th className="py-2 pe-3">واحد</th>
|
||||
<th className="py-2 pe-3">مفقودی</th>
|
||||
<th className="py-2 pe-3">تعداد پیداشده</th>
|
||||
<th className="py-2">یادداشت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{txs.map((t, i) => (
|
||||
<tr key={t.inventory_id + i} className="border-b border-line/50">
|
||||
<td className="py-1.5 pe-3 font-semibold">{t.material?.name || "—"}</td>
|
||||
<td className="py-1.5 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{t.brand?.name || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{unitNameFn(t.unit?.id)}</td>
|
||||
<td className="py-1.5 pe-3 font-bold">{faNum(Math.abs(t.quantity_number || 0))}</td>
|
||||
<td className="py-1.5 pe-3">
|
||||
<input
|
||||
inputMode="decimal"
|
||||
className="h-7 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||||
placeholder="۰"
|
||||
value={rows[i]?.qty || ""}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1.5">
|
||||
<input
|
||||
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||
placeholder="توضیح"
|
||||
value={rows[i]?.note || ""}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { PanelMenu } from "../../components/PanelMenu";
|
||||
import { panelMenuItems, useLogout } from "./panelMenu";
|
||||
import { useInventoryData, unitName, whName } from "./useInventoryData";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali";
|
||||
import type { InventoryTransaction } from "../../lib/types";
|
||||
|
||||
function gregKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function todayKey(): string {
|
||||
return gregKey(new Date());
|
||||
}
|
||||
|
||||
// کنترل روزانه — پورت کامل تقویم شمسی نسخهی ارسالی
|
||||
export function DailyControlPage() {
|
||||
const location = useLocation();
|
||||
const logout = useLogout();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const data = useInventoryData();
|
||||
const navigate = useNavigate();
|
||||
const isPM = user?.role === "pm";
|
||||
|
||||
const [anchor, setAnchor] = useState<Date>(new Date());
|
||||
const [sel, setSel] = useState<string>(todayKey());
|
||||
const [actRec, setActRec] = useState<string | null>(null);
|
||||
|
||||
const txByDay = useMemo(() => {
|
||||
const map = new Map<string, InventoryTransaction[]>();
|
||||
data.transactions.forEach((t) => {
|
||||
const k = gregKey(new Date(t.transaction_date));
|
||||
const arr = map.get(k) || [];
|
||||
arr.push(t);
|
||||
map.set(k, arr);
|
||||
});
|
||||
map.forEach((arr) => arr.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()));
|
||||
return map;
|
||||
}, [data.transactions]);
|
||||
|
||||
// cells: 42 روز از شنبه قبلِ اولین روز ماه جلالی
|
||||
const cells = useMemo(() => {
|
||||
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
|
||||
let first: Date | null = null;
|
||||
for (let i = 0; i <= 33; i++) {
|
||||
const d = new Date(anchor.getTime() - i * 86400000);
|
||||
const [py, pm] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||||
if (py !== ty || pm !== tm) {
|
||||
first = new Date(anchor.getTime() - (i - 1) * 86400000);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!first) first = new Date(anchor.getTime() - 33 * 86400000);
|
||||
const offset = (first.getDay() + 1) % 7; // شنبه = 0
|
||||
const gridStart = new Date(first.getTime() - offset * 86400000);
|
||||
const out: { d: Date; inMonth: boolean; isToday: boolean; n: number }[] = [];
|
||||
for (let c = 0; c < 42; c++) {
|
||||
const day = new Date(gridStart.getTime() + c * 86400000);
|
||||
const [jy, jm] = gregorianToJalali(day.getFullYear(), day.getMonth() + 1, day.getDate());
|
||||
const k = gregKey(day);
|
||||
out.push({
|
||||
d: day,
|
||||
inMonth: jy === ty && jm === tm,
|
||||
isToday: k === todayKey(),
|
||||
n: (txByDay.get(k) || []).length,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [anchor, txByDay]);
|
||||
|
||||
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
|
||||
const monthTitle = `${toFa(ty)} ${MONTHS[tm - 1]}`;
|
||||
|
||||
const dayTx = txByDay.get(sel) || [];
|
||||
const withdrawals = dayTx.filter((t) => t.transaction_type === "WITHDRAWAL");
|
||||
const wGroups = useMemo(() => {
|
||||
const gmap = new Map<string, { recId: string; q: number; last: string; tx: InventoryTransaction }>();
|
||||
withdrawals.forEach((t) => {
|
||||
const g = gmap.get(t.inventory_id);
|
||||
if (!g) {
|
||||
gmap.set(t.inventory_id, { recId: t.inventory_id, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t });
|
||||
} else {
|
||||
g.q += Math.abs(t.quantity_number || 0);
|
||||
if (t.transaction_date > g.last) g.last = t.transaction_date;
|
||||
}
|
||||
});
|
||||
return [...gmap.values()];
|
||||
}, [withdrawals]);
|
||||
|
||||
const isToday = sel === todayKey();
|
||||
const selDate = new Date(Number(sel.split("-")[0]), Number(sel.split("-")[1]) - 1, Number(sel.split("-")[2]));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header
|
||||
title="کنترل روزانه"
|
||||
sub={user?.project_name || ""}
|
||||
menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />}
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-3">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* تقویم */}
|
||||
<div className="rounded-xl border border-line bg-panel p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-[14px] font-bold">📅 {monthTitle}</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button className="rounded-lg border border-line px-3 py-1 text-[12px]" onClick={() => setAnchor(new Date(anchor.getTime() + 33 * 86400000))}>◀ ماه بعد</button>
|
||||
<button className="rounded-lg border border-line px-3 py-1 text-[12px]" onClick={() => { setAnchor(new Date()); setSel(todayKey()); setActRec(null); }}>امروز</button>
|
||||
<button className="rounded-lg border border-line px-3 py-1 text-[12px]" onClick={() => setAnchor(new Date(anchor.getTime() - 33 * 86400000))}>▶ ماه قبل</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{["ش", "ی", "د", "س", "چ", "پ", "ج"].map((d) => (
|
||||
<div key={d} className="py-1 text-center text-[11px] font-bold text-muted">{d}</div>
|
||||
))}
|
||||
{cells.map((c, i) => {
|
||||
const [jd] = gregorianToJalali(c.d.getFullYear(), c.d.getMonth() + 1, c.d.getDate());
|
||||
const k = gregKey(c.d);
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => { setSel(k); setActRec(null); }}
|
||||
className={`relative rounded-lg py-2 text-[12px] ${!c.inMonth ? "text-muted/40" : ""} ${c.isToday ? "bg-accent/15 font-bold text-accent" : ""} ${sel === k ? "ring-2 ring-accent" : "hover:bg-accent/5"}`}
|
||||
>
|
||||
{toFa(jd)}
|
||||
{c.n > 0 && (
|
||||
<span className="absolute -top-0.5 -left-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-red text-[9px] font-bold text-white">
|
||||
{toFa(c.n)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* برداشتهای روز */}
|
||||
<div className="rounded-xl border border-line bg-panel p-4">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<h2 className="text-[14px] font-bold">🕘 کنترل روزانه — {isToday ? "امروز" : ""} {faNum(selDate.getDate())} {MONTHS[selDate.getMonth()]} {toFa(selDate.getFullYear())}</h2>
|
||||
<span className="rounded-lg bg-blue/10 px-2 py-1 text-[11.5px] font-bold text-blue">▼ برداشتها: {faNum(wGroups.length)} قلم</span>
|
||||
</div>
|
||||
<p className="mb-3 text-[11.5px] leading-5 text-muted">
|
||||
مواردی که این روز از انبار برداشت و به ساختمان برده شدهاند. روی هر مورد بزنید و برگشت / مفقودی / اتلاف را انتخاب کنید.
|
||||
</p>
|
||||
{!wGroups.length ? (
|
||||
<div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">
|
||||
{isToday ? "امروز هنوز برداشتی ثبت نشده است." : "در این روز برداشتی ثبت نشده است."} هر برداشتی که در «تغییر موجودی» ثبت کنید، همینجا نمایش داده میشود.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{wGroups.map((g) => (
|
||||
<div key={g.recId} className={`rounded-lg border p-2.5 ${actRec === g.recId ? "border-accent bg-accent/5" : "border-line bg-panel"}`}>
|
||||
<button type="button" className="flex w-full items-center gap-2 text-start" onClick={() => setActRec(actRec === g.recId ? null : g.recId)}>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-blue/10 text-blue">➖</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] font-semibold">{g.tx.material?.name || "—"}</span>
|
||||
<span className="block truncate text-[11px] text-muted">
|
||||
{[g.tx.brand?.name, Object.entries(g.tx.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · "), whName(data.warehouses, g.tx.warehouse_id)].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[13px] font-bold text-blue">−{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}</span>
|
||||
<span className="text-[10.5px] text-muted">{new Date(g.last).toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" })}</span>
|
||||
</button>
|
||||
{actRec === g.recId && isPM && (
|
||||
<div className="mt-2 flex gap-1.5 border-t border-line pt-2">
|
||||
<button
|
||||
className="rounded-lg border border-green/30 bg-green/5 px-3 py-1 text-[11.5px] text-green"
|
||||
onClick={() => navigate(`/inventory/change?type=RETURN&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
|
||||
>
|
||||
↩️ برگشت
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-blue/30 bg-blue/5 px-3 py-1 text-[11.5px] text-blue"
|
||||
onClick={() => navigate(`/inventory/change?type=LOSS&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
|
||||
>
|
||||
📦 مفقودی
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-blue/30 bg-blue/5 px-3 py-1 text-[11.5px] text-blue"
|
||||
onClick={() => navigate(`/inventory/change?type=WASTE&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
|
||||
>
|
||||
🗑️ اتلاف
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { PanelMenu } from "../../components/PanelMenu";
|
||||
import { panelMenuItems, useLogout } from "./panelMenu";
|
||||
import { useInventoryData, brandName, catName, materialName, unitName, whName } from "./useInventoryData";
|
||||
import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { isoToJalaliStr } from "../../lib/jalali";
|
||||
|
||||
const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = {
|
||||
PURCHASE: { label: "خرید", color: "#16a34a", icon: "🛒" },
|
||||
WITHDRAWAL: { label: "برداشت", color: "#1d4ed8", icon: "➖" },
|
||||
RETURN: { label: "برگشت", color: "#16a34a", icon: "↩️" },
|
||||
TRANSFER_IN: { label: "انتقال (ورودی)", color: "#16a34a", icon: "⬅️" },
|
||||
TRANSFER_OUT: { label: "انتقال (خروجی)", color: "#1d4ed8", icon: "➡️" },
|
||||
DAMAGE: { label: "ضایعات", color: "#1d4ed8", icon: "⚠️" },
|
||||
WASTE: { label: "اتلاف", color: "#1d4ed8", icon: "🗑️" },
|
||||
LOSS: { label: "مفقودی", color: "#1d4ed8", icon: "📦" },
|
||||
FOUND: { label: "پیدا شده", color: "#16a34a", icon: "🔍" },
|
||||
STOCK_COUNT: { label: "شمارش موجودی", color: "#64748b", icon: "✅" },
|
||||
};
|
||||
|
||||
interface Group {
|
||||
key: string;
|
||||
whId: string;
|
||||
matId: string;
|
||||
specs: Record<string, string>;
|
||||
unitId: number | null;
|
||||
qty: number;
|
||||
updatedAt: string;
|
||||
recs: InventoryRow[];
|
||||
brands: Record<string, number>;
|
||||
brandOrder: string[];
|
||||
}
|
||||
|
||||
function specsHash(specs: Record<string, string> | null | undefined): string {
|
||||
const s = specs || {};
|
||||
const keys = Object.keys(s).filter((k) => s[k] !== "" && s[k] != null).sort();
|
||||
if (!keys.length) return "default";
|
||||
return keys.map((k) => `${k}=${s[k]}`).join("|");
|
||||
}
|
||||
|
||||
// داشبورد انبار — پورت کامل نسخهی ارسالی (گروهبندی + خط زمانی + فیلترها)
|
||||
export function InventoryPage() {
|
||||
const location = useLocation();
|
||||
const logout = useLogout();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { warehouses, inventory, transactions, lookup, loading } = useInventoryData();
|
||||
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager"; // مدیر تدارکات = ادمین دفتر
|
||||
const isPM = user?.role === "pm";
|
||||
|
||||
const [filters, setFilters] = useState<{ category: string; warehouse: string; material: string; project: string }>({
|
||||
category: "", warehouse: "", material: "", project: "",
|
||||
});
|
||||
const [sort, setSort] = useState<"asc" | "desc" | "">("");
|
||||
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
||||
const [selTx, setSelTx] = useState<string | null>(null);
|
||||
const [expBrand, setExpBrand] = useState<string | null>(null);
|
||||
|
||||
// ادمین: انبارهای همه پروژهها یا پروژهی انتخابی
|
||||
const visibleWarehouses = useMemo(() => {
|
||||
if (isAdmin && filters.project) return warehouses.filter((w) => w.project_id === filters.project);
|
||||
if (isAdmin) return warehouses;
|
||||
return warehouses.filter((w) => String(w.project_id) === String(user?.project_id || ""));
|
||||
}, [warehouses, isAdmin, filters.project, user?.project_id]);
|
||||
|
||||
const visibleIds = useMemo(() => new Set(visibleWarehouses.map((w) => w.id)), [visibleWarehouses]);
|
||||
|
||||
const groups = useMemo<Group[]>(() => {
|
||||
const gmap = new Map<string, Group>();
|
||||
const order: string[] = [];
|
||||
inventory.forEach((r) => {
|
||||
const whId = r.warehouse?.id;
|
||||
if (!whId || !visibleIds.has(whId)) return;
|
||||
if (filters.category && r.material.category?.id !== filters.category) return;
|
||||
if (filters.warehouse && whId !== filters.warehouse) return;
|
||||
if (filters.material && !r.material.name.includes(filters.material.trim())) return;
|
||||
const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`;
|
||||
let g = gmap.get(gk);
|
||||
if (!g) {
|
||||
g = { key: gk, whId, matId: r.material.id, specs: r.specs || {}, unitId: r.unit?.id ?? null, qty: 0, updatedAt: r.updated_at, recs: [], brands: {}, brandOrder: [] };
|
||||
gmap.set(gk, g);
|
||||
order.push(gk);
|
||||
}
|
||||
g.qty += r.quantity_number || 0;
|
||||
if (r.updated_at > g.updatedAt) g.updatedAt = r.updated_at;
|
||||
g.recs.push(r);
|
||||
if (r.brand) {
|
||||
if (!(r.brand.id in g.brands)) {
|
||||
g.brands[r.brand.id] = 0;
|
||||
g.brandOrder.push(r.brand.id);
|
||||
}
|
||||
g.brands[r.brand.id] += r.quantity_number || 0;
|
||||
}
|
||||
});
|
||||
const out = order.map((k) => gmap.get(k)!);
|
||||
if (sort === "asc") out.sort((a, b) => a.qty - b.qty);
|
||||
else if (sort === "desc") out.sort((a, b) => b.qty - a.qty);
|
||||
else out.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
return out;
|
||||
}, [inventory, visibleIds, filters, sort]);
|
||||
|
||||
const groupTxs = (g: Group) =>
|
||||
transactions
|
||||
.filter((t) => g.recs.some((r) => r.id === t.inventory_id))
|
||||
.slice()
|
||||
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
||||
|
||||
const brandTxs = (g: Group, bid: string) =>
|
||||
transactions
|
||||
.filter((t) => t.brand?.id === bid && g.recs.some((r) => r.id === t.inventory_id))
|
||||
.slice()
|
||||
.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));
|
||||
|
||||
if (loading) return <div className="min-h-screen" />;
|
||||
if (!user || (!isAdmin && !isPM)) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="داشبورد انبار" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
|
||||
<PageLayout>
|
||||
<EmptyState emoji="🔒" text="دسترسی به انبار فقط برای مدیر پروژه یا ادمین است." />
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header
|
||||
title="سامانه مدیریت انبار"
|
||||
sub={isAdmin ? "فقطخواندنی" : user?.project_name ? `مدیر پروژه — ${user.project_name}` : "مدیر پروژه"}
|
||||
menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />}
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-3">
|
||||
{/* فیلترها */}
|
||||
<div className="flex flex-wrap items-end gap-2 rounded-xl border border-line bg-panel p-3">
|
||||
<div className="field">
|
||||
<label>دستهبندی</label>
|
||||
<select
|
||||
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
|
||||
value={filters.category}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value, material: "" }))}
|
||||
>
|
||||
<option value="">همه دستهبندیها</option>
|
||||
{lookup.categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="field">
|
||||
<label>پروژه</label>
|
||||
<select
|
||||
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
|
||||
value={filters.project}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, project: e.target.value, warehouse: "", material: "" }))}
|
||||
>
|
||||
<option value="">همه پروژهها</option>
|
||||
{lookup.projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="field">
|
||||
<label>انبار</label>
|
||||
<select
|
||||
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
|
||||
value={filters.warehouse}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, warehouse: e.target.value, material: "" }))}
|
||||
>
|
||||
<option value="">همه انبارها</option>
|
||||
{visibleWarehouses.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.name}{isAdmin ? ` — ${lookup.projects.find((p) => p.id === w.project_id)?.name || ""}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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 && (
|
||||
<button
|
||||
className="h-8 rounded-lg border border-red/30 bg-red/5 px-3 text-[12px] text-red"
|
||||
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })}
|
||||
>
|
||||
✕ پاک کردن فیلترها
|
||||
</button>
|
||||
)}
|
||||
<Badge variant="outline">{faNum(groups.length)} قلم کالا</Badge>
|
||||
</div>
|
||||
|
||||
{/* جدول گروهبندیشده */}
|
||||
<div className="overflow-hidden rounded-xl border border-line bg-panel">
|
||||
{!groups.length ? (
|
||||
<EmptyState emoji="📋" text="هیچ رکورد موجودیای مطابق فیلترهای انتخابی یافت نشد." />
|
||||
) : (
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b border-line bg-panel text-right text-muted">
|
||||
<th className="w-8 py-2" />
|
||||
<th className="py-2 pe-3">متریال</th>
|
||||
<th className="py-2 pe-3">دستهبندی</th>
|
||||
<th className="py-2 pe-3">انبار</th>
|
||||
<th className="py-2 pe-3">مشخصه فنی</th>
|
||||
<th className="py-2 pe-3">تعداد</th>
|
||||
<th className="py-2 pe-3">واحد</th>
|
||||
<th className="py-2 pe-3">آخرین بهروزرسانی</th>
|
||||
<th className="w-8 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.map((g) => {
|
||||
const isExp = expandedKey === g.key;
|
||||
const matCat = lookup.materials.find((m) => m.id === g.matId)?.category;
|
||||
return (
|
||||
<GroupRow
|
||||
key={g.key}
|
||||
g={g}
|
||||
isExp={isExp}
|
||||
isAdmin={isAdmin}
|
||||
matName={materialName(lookup.materials, g.matId)}
|
||||
matCatName={matCat ? catName(lookup.categories, matCat.id) : "—"}
|
||||
wh={whName(warehouses, g.whId)}
|
||||
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
|
||||
unit={unitName(lookup.units, g.unitId)}
|
||||
onToggle={() => {
|
||||
setExpandedKey(isExp ? null : g.key);
|
||||
setSelTx(null);
|
||||
setExpBrand(null);
|
||||
}}
|
||||
timeline={
|
||||
isExp ? (
|
||||
<Timeline
|
||||
g={g}
|
||||
txs={groupTxs(g)}
|
||||
allTxs={transactions}
|
||||
brandTxs={brandTxs}
|
||||
selTx={selTx}
|
||||
setSelTx={setSelTx}
|
||||
expBrand={expBrand}
|
||||
setExpBrand={setExpBrand}
|
||||
matName={materialName(lookup.materials, g.matId)}
|
||||
unit={unitName(lookup.units, g.unitId)}
|
||||
brandNameFn={(id) => brandName(lookup.brands, id)}
|
||||
whNameFn={(id) => whName(warehouses, id)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
۱۰ نوع تراکنش استاندارد • افزایش موجودی سبز، کاهش آبی • کلیک روی هر سطر: خط زمانی با «مانده» لحظهبهلحظه و تفکیک برندها
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit, onToggle, timeline }: {
|
||||
g: Group;
|
||||
isExp: boolean;
|
||||
isAdmin: boolean;
|
||||
matName: string;
|
||||
matCatName: string;
|
||||
wh: string;
|
||||
projName: string;
|
||||
unit: string;
|
||||
onToggle: () => void;
|
||||
timeline: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<tr className={`border-b border-line/60 cursor-pointer ${isExp ? "bg-accent/5" : "hover:bg-accent/5"}`} onClick={onToggle}>
|
||||
<td className="py-2 text-center text-muted">{isExp ? "▾" : "▸"}</td>
|
||||
<td className="py-2 pe-3 font-semibold">{matName}</td>
|
||||
<td className="py-2 pe-3">{matCatName}</td>
|
||||
<td className="py-2 pe-3">
|
||||
{wh}
|
||||
{isAdmin && <small className="block text-muted">{projName}</small>}
|
||||
</td>
|
||||
<td className="py-2 pe-3 text-muted">
|
||||
{Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}
|
||||
</td>
|
||||
<td className="py-2 pe-3 font-bold">{faNum(g.qty)}</td>
|
||||
<td className="py-2 pe-3">{unit}</td>
|
||||
<td className="py-2 pe-3 text-muted">{isoToJalaliStr(g.updatedAt)}</td>
|
||||
<td className="py-2 text-muted">🕘</td>
|
||||
</tr>
|
||||
{isExp && (
|
||||
<tr className="border-b border-line/40 bg-accent-bg/20">
|
||||
<td colSpan={9} className="p-3">{timeline}</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpBrand, matName, unit, brandNameFn, whNameFn }: {
|
||||
g: Group;
|
||||
txs: InventoryTransaction[];
|
||||
allTxs: InventoryTransaction[];
|
||||
brandTxs: (g: Group, bid: string) => InventoryTransaction[];
|
||||
selTx: string | null;
|
||||
setSelTx: (id: string | null) => void;
|
||||
expBrand: string | null;
|
||||
setExpBrand: (id: string | null) => void;
|
||||
matName: string;
|
||||
unit: string;
|
||||
brandNameFn: (id: string | null | undefined) => string;
|
||||
whNameFn: (id: string | null | undefined) => string;
|
||||
}) {
|
||||
const track = (list: InventoryTransaction[]) => {
|
||||
let bal = 0;
|
||||
return list.map((t) => {
|
||||
const meta = TX_META[t.transaction_type] || { label: t.transaction_type, color: "#64748b", icon: "•" };
|
||||
bal += t.quantity_number || 0;
|
||||
const sel = selTx === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
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"}`}
|
||||
style={{ borderTop: `3px solid ${meta.color}` }}
|
||||
>
|
||||
<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 }}>
|
||||
{t.quantity_number >= 0 ? "+" : "−"}{faNum(Math.abs(t.quantity_number))} {unit}
|
||||
</span>
|
||||
<span className="text-[10.5px] text-muted">مانده: <b>{faNum(bal)}</b> {unit}</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
if (!txs.length) return <div className="text-[12px] text-muted">تراکنشی ثبت نشده است.</div>;
|
||||
|
||||
const brandSec = expBrand ? (
|
||||
<div className="mt-2">
|
||||
<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>
|
||||
) : null;
|
||||
|
||||
const selDetail = selTx ? (() => {
|
||||
const t = allTxs.find((x) => x.id === selTx);
|
||||
if (!t) return null;
|
||||
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;
|
||||
return (
|
||||
<div className="mt-2 rounded-lg border p-2 text-[12px]" style={{ borderTop: `3px solid ${meta.color}` }}>
|
||||
<b style={{ color: meta.color }}>{meta.label}</b>
|
||||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-muted">
|
||||
<span>تاریخ و ساعت: <b>{isoToJalaliStr(t.transaction_date)}</b></span>
|
||||
{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>{t.transaction_type === "TRANSFER_IN" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span>
|
||||
</>
|
||||
) : (
|
||||
<span>انبار: <b>{whNameFn(t.warehouse_id)}</b></span>
|
||||
)}
|
||||
{t.brand?.name && <span>برند: <b>{t.brand.name}</b></span>}
|
||||
{t.performed_by && <span>انجامدهنده: <b>{t.performed_by}</b></span>}
|
||||
{t.note && <span>یادداشت: <b>{t.note}</b></span>}
|
||||
{t.transaction_type === "STOCK_COUNT" && t.recorded_qty != null && t.physical_qty != null && (
|
||||
<span>
|
||||
ثبتشده: {faNum(t.recorded_qty)} • شمارششده: {faNum(t.physical_qty)} • اختلاف: {faNum(t.physical_qty - t.recorded_qty)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})() : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2 text-[12px]">
|
||||
<b>خط زمانی — {matName}</b>
|
||||
{Object.entries(g.specs || {}).length > 0 && (
|
||||
<span className="rounded bg-panel px-1.5 py-0.5 text-[10.5px] text-muted">
|
||||
{Object.entries(g.specs).map(([k, v]) => `${k}: ${v}`).join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded bg-panel px-1.5 py-0.5 text-[10.5px] text-muted">{faNum(g.brandOrder.length)} برند</span>
|
||||
</div>
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{g.brandOrder.map((bid) => (
|
||||
<button
|
||||
key={bid}
|
||||
type="button"
|
||||
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"}`}
|
||||
>
|
||||
{brandNameFn(bid)}: <b>{faNum(g.brands[bid])}</b> {unit}{expBrand === bid ? " ✕" : ""}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{brandSec}
|
||||
<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>
|
||||
{selDetail}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { PanelMenu } from "../../components/PanelMenu";
|
||||
import { panelMenuItems, useLogout } from "./panelMenu";
|
||||
import { apiV26 } from "../../lib/api";
|
||||
import { useInventoryData } from "./useInventoryData";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
|
||||
// سازماندهی (فقط ادمین) — پروژهها، مدیران و انبارهای هر پروژه
|
||||
export function OrganizationPage() {
|
||||
const location = useLocation();
|
||||
const logout = useLogout();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const data = useInventoryData();
|
||||
|
||||
const [newWh, setNewWh] = useState<Record<string, string>>({});
|
||||
const [newWhDesc, setNewWhDesc] = useState<Record<string, string>>({});
|
||||
|
||||
if (user?.role !== "admin" && user?.role !== "procurement_manager") {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="سازماندهی" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
|
||||
<PageLayout>
|
||||
<div className="text-[13px] text-muted">این بخش فقط برای ادمین است.</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const addWarehouse = async (projectId: string) => {
|
||||
const name = (newWh[projectId] || "").trim();
|
||||
if (!name) return toast.error("نام انبار الزامی است");
|
||||
try {
|
||||
await apiV26.warehouses.create({ project_id: projectId, name, description: (newWhDesc[projectId] || "").trim() || null });
|
||||
setNewWh((s) => ({ ...s, [projectId]: "" }));
|
||||
setNewWhDesc((s) => ({ ...s, [projectId]: "" }));
|
||||
toast.success(`انبار «${name}» اضافه شد ✓`);
|
||||
await data.refresh();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در افزودن انبار");
|
||||
}
|
||||
};
|
||||
|
||||
const moveWarehouse = async (id: string, projectId: string) => {
|
||||
try {
|
||||
await apiV26.warehouses.update(id, { project_id: projectId });
|
||||
toast.success("انبار منتقل شد ✓");
|
||||
await data.refresh();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در انتقال انبار");
|
||||
}
|
||||
};
|
||||
|
||||
const removeWarehouse = async (id: string) => {
|
||||
if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return;
|
||||
try {
|
||||
await apiV26.warehouses.delete(id);
|
||||
toast.success("انبار حذف شد");
|
||||
await data.refresh();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در حذف انبار");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="سازماندهی" sub="ادمین" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
|
||||
<PageLayout className="flex flex-col gap-3">
|
||||
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
||||
ℹ️ پروژهها از سیستم تدارکات میآیند. اینجا مشخص میکنید هر پروژه با کدام مدیر و انبار(ها) کار میکند؛
|
||||
هر مدیر پروژه به همه انبارهای پروژه خودش دسترسی دارد. همه تغییرات فوراً در داشبورد و فرمها اعمال میشود.
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{data.lookup.projects.map((p) => {
|
||||
const whs = data.warehouses.filter((w) => w.project_id === p.id);
|
||||
return (
|
||||
<div key={p.id} className="rounded-xl border border-line bg-panel p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-[14px] font-bold">🏢 {p.name}</h3>
|
||||
<span className="rounded-lg bg-panel px-2 py-1 text-[11px] text-muted">
|
||||
{faNum(whs.length)} انبار
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<h4 className="mb-1 text-[12px] font-bold text-muted">👤 مدیر پروژه</h4>
|
||||
{p.pm_user_id ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
|
||||
<span className="font-semibold">{p.pm_name || "—"}</span>
|
||||
<span className="text-[10.5px] text-muted">(مدیر مسئول پروژه)</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-muted">
|
||||
مدیری تعیین نشده است — از بخش کاربران (Casdoor) نقش مدیر پروژه را به کاربر بدهید و در تنظیمات پروژه وصل کنید.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h4 className="mb-1 text-[12px] font-bold text-muted">🏬 انبارهای پروژه</h4>
|
||||
{!whs.length ? (
|
||||
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-muted">انباری ندارد — از فرم زیر اضافه کنید.</div>
|
||||
) : (
|
||||
<div className="mb-2 flex flex-col gap-1.5">
|
||||
{whs.map((w) => (
|
||||
<div key={w.id} className="flex items-center justify-between gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
|
||||
<div className="min-w-0">
|
||||
<b>{w.name}</b>
|
||||
<small className="block truncate text-muted">{w.description || ""}</small>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<select
|
||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[11px]"
|
||||
value={w.project_id}
|
||||
onChange={(e) => void moveWarehouse(w.id, e.target.value)}
|
||||
title="انتقال به پروژه دیگر"
|
||||
>
|
||||
{data.lookup.projects.map((p2) => (
|
||||
<option key={p2.id} value={p2.id}>{p2.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="text-muted hover:text-red" onClick={() => void removeWarehouse(w.id)} title="حذف انبار">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="h-8 w-44 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||
placeholder="نام انبار جدید"
|
||||
value={newWh[p.id] || ""}
|
||||
onChange={(e) => setNewWh((s) => ({ ...s, [p.id]: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="h-8 w-52 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||
placeholder="توضیحات (اختیاری)"
|
||||
value={newWhDesc[p.id] || ""}
|
||||
onChange={(e) => setNewWhDesc((s) => ({ ...s, [p.id]: e.target.value }))}
|
||||
/>
|
||||
<button className="rounded-lg bg-accent px-3 py-1.5 text-[12px] font-bold text-white" onClick={() => void addWarehouse(p.id)}>
|
||||
+ افزودن انبار
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { PanelMenu } from "../../components/PanelMenu";
|
||||
import { panelMenuItems, useLogout } from "./panelMenu";
|
||||
import { apiV26 } from "../../lib/api";
|
||||
import { useInventoryData, projName } from "./useInventoryData";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { isoToJalaliStr } from "../../lib/jalali";
|
||||
|
||||
// انبارها — پورت کامل نسخهی ارسالی (مدیر پروژه: افزودن/ویرایش/حذف)
|
||||
export function WarehousesPage() {
|
||||
const location = useLocation();
|
||||
const logout = useLogout();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const data = useInventoryData();
|
||||
const isAdmin = user?.role === "admin";
|
||||
const isPM = user?.role === "pm";
|
||||
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newDesc, setNewDesc] = useState("");
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDesc, setEditDesc] = useState("");
|
||||
|
||||
const addWarehouse = async () => {
|
||||
const name = newName.trim();
|
||||
if (!name) return toast.error("نام انبار الزامی است");
|
||||
const projectId = user?.project_id;
|
||||
if (!projectId) return toast.error("پروژه انبار را انتخاب کنید");
|
||||
try {
|
||||
await apiV26.warehouses.create({ project_id: projectId, name, description: newDesc.trim() || null });
|
||||
setNewName("");
|
||||
setNewDesc("");
|
||||
toast.success(`انبار «${name}» اضافه شد ✓`);
|
||||
await data.refresh();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در افزودن انبار");
|
||||
}
|
||||
};
|
||||
|
||||
const saveEdit = async (id: string) => {
|
||||
if (!editName.trim()) return toast.error("نام انبار نمیتواند خالی باشد");
|
||||
try {
|
||||
await apiV26.warehouses.update(id, { name: editName.trim(), description: editDesc.trim() || null });
|
||||
setEditId(null);
|
||||
toast.success("تغییرات ذخیره شد");
|
||||
await data.refresh();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در ذخیره");
|
||||
}
|
||||
};
|
||||
|
||||
const removeWarehouse = async (id: string) => {
|
||||
if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return;
|
||||
try {
|
||||
await apiV26.warehouses.delete(id);
|
||||
toast.success("انبار حذف شد");
|
||||
await data.refresh();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در حذف انبار");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header
|
||||
title="انبارها"
|
||||
sub={user?.project_name || ""}
|
||||
menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />}
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-3">
|
||||
{isAdmin && (
|
||||
<div className="rounded-lg border border-amber/30 bg-amber/5 px-3 py-2 text-[12px] text-amber">
|
||||
🔒 نقش ادمین فقطخواندنی است — مدیریت انبارها با مدیران پروژه است.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPM && (
|
||||
<div className="rounded-xl border border-line bg-panel p-3">
|
||||
<h3 className="mb-2 text-[13px] font-bold">+ افزودن انبار جدید</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
className="h-9 w-52 rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder="نام انبار (مثلاً انبار موقت)"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="h-9 w-64 rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder="توضیحات (اختیاری)"
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
/>
|
||||
<button className="rounded-lg bg-accent px-4 py-2 text-[12.5px] font-bold text-white" onClick={() => void addWarehouse()}>
|
||||
+ افزودن
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{data.warehouses.map((w) => {
|
||||
const cnt = data.inventory.filter((r) => r.warehouse?.id === w.id).length;
|
||||
const txc = data.transactions.filter((t) => t.warehouse_id === w.id).length;
|
||||
return (
|
||||
<div key={w.id} className="rounded-xl border border-line bg-panel p-4">
|
||||
<h3 className="text-[14px] font-bold">🏬 {w.name}</h3>
|
||||
<div className="mt-0.5 text-[11.5px] text-muted">{w.description || "—"}</div>
|
||||
<div className="mt-2 flex flex-col gap-1 text-[12px] text-muted">
|
||||
<span>پروژه: <b className="text-foreground">{projName(data.lookup.projects, w.project_id)}</b></span>
|
||||
<span>اقلام موجود: <b className="text-foreground">{faNum(cnt)}</b></span>
|
||||
<span>تراکنشها: <b className="text-foreground">{faNum(txc)}</b></span>
|
||||
<span>تاریخ ایجاد: <b className="text-foreground">{isoToJalaliStr(w.created_at)}</b></span>
|
||||
</div>
|
||||
{isPM && (
|
||||
<div className="mt-3 flex gap-1.5 border-t border-line pt-2">
|
||||
<button
|
||||
className="rounded-lg border border-line px-3 py-1 text-[11.5px]"
|
||||
onClick={() => { setEditId(w.id); setEditName(w.name); setEditDesc(w.description || ""); }}
|
||||
>
|
||||
✏️ ویرایش
|
||||
</button>
|
||||
<button className="rounded-lg border border-red/30 px-3 py-1 text-[11.5px] text-red" onClick={() => void removeWarehouse(w.id)}>
|
||||
✕ حذف
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{editId === w.id && isPM && (
|
||||
<div className="mt-2 flex flex-col gap-1.5 rounded-lg border border-dashed border-accent/40 p-2">
|
||||
<input
|
||||
className="h-8 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||
placeholder="نام انبار"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="h-8 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||
placeholder="توضیحات"
|
||||
value={editDesc}
|
||||
onChange={(e) => setEditDesc(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
<button className="rounded-lg bg-accent px-3 py-1 text-[11.5px] font-bold text-white" onClick={() => void saveEdit(w.id)}>ذخیره</button>
|
||||
<button className="rounded-lg border border-line px-3 py-1 text-[11.5px]" onClick={() => setEditId(null)}>انصراف</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { MenuItem } from "../../components/PanelMenu";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
|
||||
// خروج از حساب — پاککردن نشست و بازگشت به صفحهی ورود
|
||||
export function useLogout() {
|
||||
return () => {
|
||||
useAuthStore.getState().logout();
|
||||
window.location.href = "/login";
|
||||
};
|
||||
}
|
||||
|
||||
// آیتمهای منوی همبرگری پنل مدیریت انبار — با نشانگر پنل فعلی + «بازگشت به دفتر» + خروج
|
||||
export function panelMenuItems(pathname: string, role: string | null | undefined, onLogout?: () => void): MenuItem[] {
|
||||
const isAdmin = role === "admin" || role === "procurement_manager";
|
||||
const isPM = role === "pm";
|
||||
const items: MenuItem[] = [
|
||||
{ label: "داشبورد انبار", to: "/inventory", active: pathname === "/inventory" },
|
||||
{ label: "تغییر موجودی", to: "/inventory/change", active: pathname.startsWith("/inventory/change") },
|
||||
{ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") },
|
||||
{ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") },
|
||||
];
|
||||
if (isAdmin) {
|
||||
items.push({ label: "سازماندهی", to: "/inventory/org", active: pathname.startsWith("/inventory/org") });
|
||||
}
|
||||
if (isPM) {
|
||||
items.push({ label: "رسید کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
|
||||
}
|
||||
items.push({ divider: true });
|
||||
items.push({ label: "بازگشت به دفتر", to: "/" });
|
||||
if (onLogout) {
|
||||
items.push({ divider: true });
|
||||
items.push({ label: "خروج از حساب", onClick: onLogout });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// ============================================================
|
||||
// Inventory data hook — loads all dash data (scoped by role/project)
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { apiV26 } from "../../lib/api";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import type { InventoryRow, InventoryTransaction, Warehouse } from "../../lib/types";
|
||||
|
||||
export interface LookupData {
|
||||
categories: { id: string; slug: string; name: string }[];
|
||||
units: { id: number; code: string; name_fa: string | null }[];
|
||||
brands: { id: string; name: string }[];
|
||||
materials: { id: string; name: string; category: { id: string; name: string } | null; canonical_unit: { id: number; code: string; name_fa: string | null } | null }[];
|
||||
projects: { id: string; name: string; pm_user_id: string | null; pm_name?: string | null }[];
|
||||
}
|
||||
|
||||
export interface InventoryData {
|
||||
warehouses: Warehouse[];
|
||||
inventory: InventoryRow[];
|
||||
transactions: InventoryTransaction[];
|
||||
lookup: LookupData;
|
||||
loading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useInventoryData(): InventoryData {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isPM = user?.role === "pm";
|
||||
const projectId = isPM ? user.project_id || undefined : undefined;
|
||||
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||
const [inventory, setInventory] = useState<InventoryRow[]>([]);
|
||||
const [transactions, setTransactions] = useState<InventoryTransaction[]>([]);
|
||||
const [lookup, setLookup] = useState<LookupData>({
|
||||
categories: [], units: [], brands: [], materials: [], projects: [],
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [cats, units, brands, mats, projs, whs, inv, txs] = await Promise.all([
|
||||
apiV26.lookup.categories(),
|
||||
apiV26.lookup.units(),
|
||||
apiV26.lookup.brands(),
|
||||
apiV26.lookup.materials(),
|
||||
apiV26.lookup.projects(),
|
||||
apiV26.warehouses.list(projectId),
|
||||
apiV26.inventory.list({ project_id: projectId }),
|
||||
apiV26.inventory.transactions({ project_id: projectId }),
|
||||
]);
|
||||
setLookup({ categories: cats, units, brands, materials: mats, projects: projs });
|
||||
setWarehouses(whs);
|
||||
setInventory(inv);
|
||||
setTransactions(txs);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const scopedWarehouses = useMemo(
|
||||
() => (user?.role === "admin" ? warehouses : warehouses.filter((w) => String(w.project_id) === String(user?.project_id || ""))),
|
||||
[warehouses, user?.role, user?.project_id],
|
||||
);
|
||||
|
||||
return {
|
||||
warehouses: scopedWarehouses,
|
||||
inventory,
|
||||
transactions,
|
||||
lookup,
|
||||
loading,
|
||||
refresh: load,
|
||||
};
|
||||
}
|
||||
|
||||
export function unitName(units: LookupData["units"], id: number | null | undefined): string {
|
||||
const u = units.find((x) => x.id === id);
|
||||
return u ? u.name_fa || u.code : "—";
|
||||
}
|
||||
|
||||
export function brandName(brands: LookupData["brands"], id: string | null | undefined): string {
|
||||
const b = brands.find((x) => x.id === id);
|
||||
return b ? b.name : "—";
|
||||
}
|
||||
|
||||
export function materialName(materials: LookupData["materials"], id: string): string {
|
||||
const m = materials.find((x) => x.id === id);
|
||||
return m ? m.name : "—";
|
||||
}
|
||||
|
||||
export function catName(categories: LookupData["categories"], id: string | null | undefined): string {
|
||||
const c = categories.find((x) => x.id === id);
|
||||
return c ? c.name : "—";
|
||||
}
|
||||
|
||||
export function projName(projects: LookupData["projects"], id: string | null | undefined): string {
|
||||
const p = projects.find((x) => x.id === id);
|
||||
return p ? p.name : "—";
|
||||
}
|
||||
|
||||
export function whName(warehouses: Warehouse[], id: string | null | undefined): string {
|
||||
const w = warehouses.find((x) => x.id === id);
|
||||
return w ? w.name : "—";
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Building2, Inbox, CheckCircle2, Truck, Phone, FolderKanban } from "lucide-react";
|
||||
import type { Project, Quote, Store } from "../../lib/types";
|
||||
import { nextId } from "../../lib/api";
|
||||
import {
|
||||
useAddProject,
|
||||
useMaterials,
|
||||
useProjects,
|
||||
useQuotes,
|
||||
useStoreData,
|
||||
useSuppliers,
|
||||
useUnits,
|
||||
} from "../../hooks";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import {
|
||||
activeItems,
|
||||
lineItemTotal,
|
||||
materialById,
|
||||
materialSpecs,
|
||||
refConverted,
|
||||
supplierById,
|
||||
} from "../../lib/quoteUtils";
|
||||
import { faNum, fmtDate } from "../../lib/formatters";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
function projectQuotes(quotes: Quote[], name: string): Quote[] {
|
||||
return quotes.filter((q) => (q.projects || []).includes(name) || q.project === name);
|
||||
}
|
||||
|
||||
// اقلام مربوط به این پروژه در یک quote
|
||||
function itemsForProject(q: Quote, name: string): Quote["line_items"] {
|
||||
const items = (q.line_items || []).filter((li) => !li.rejected);
|
||||
if (items.some((li) => li.projects && li.projects.length)) {
|
||||
return items.filter((li) => (li.projects || []).some((p) => p.name === name));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function qTotal(q: Quote): number {
|
||||
return activeItems(q).reduce((sum, li) => {
|
||||
const t = lineItemTotal(li);
|
||||
return sum + (t || 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function daysSince(iso: string | null): number | null {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return Math.floor((Date.now() - d.getTime()) / 86400000);
|
||||
}
|
||||
|
||||
// صفحهی پروژهها — لیست + پنل جزئیات ۳ ستونه
|
||||
export function ProjectsPage() {
|
||||
useStoreData();
|
||||
const projects = useProjects();
|
||||
const quotes = useQuotes();
|
||||
const suppliers = useSuppliers();
|
||||
const materials = useMaterials();
|
||||
const { categoryUnits, unitConversions, standardConversions } = useUnits();
|
||||
const addProject = useAddProject();
|
||||
|
||||
const store: Store = { quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions };
|
||||
|
||||
const [openProject, setOpenProject] = useState<string | null>(null);
|
||||
const [newName, setNewName] = useState("");
|
||||
|
||||
const addProj = () => {
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
const dup = projects.some((p) => normalize(p.name) === normalize(name));
|
||||
if (dup) {
|
||||
toast.error("این پروژه قبلاً ثبت شده است.");
|
||||
return;
|
||||
}
|
||||
const p: Project = { id: nextId("p", projects), name };
|
||||
void addProject.mutateAsync(p);
|
||||
setNewName("");
|
||||
setOpenProject(name);
|
||||
toast.success("پروژه افزوده شد.");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="پروژهها" sub="روی یک پروژه کلیک کنید تا جزئیات آن باز شود" backTo="/" backLabel="بازگشت به دفتر" />
|
||||
<PageLayout className="flex flex-col gap-4">
|
||||
<div className="add-panel">
|
||||
<h4>+ افزودن پروژهی جدید</h4>
|
||||
<div className="grid max-w-md grid-cols-[1fr_auto] items-end gap-2">
|
||||
<div className="field">
|
||||
<label>نام پروژه *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="مثلاً برج آیتا - فاز ۳"
|
||||
autoComplete="off"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addProj();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={addProj}>افزودن پروژه</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!projects.length && !quotes.length ? (
|
||||
<EmptyState icon={FolderKanban} text="هنوز پروژهای ثبت نشده است." />
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{projects.map((p) => {
|
||||
const count = projectQuotes(quotes, p.name).length;
|
||||
const open = openProject === p.name;
|
||||
return (
|
||||
<div key={p.id} className="flex flex-col gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={`proj-item ${open ? "open" : ""}`}
|
||||
onClick={() => setOpenProject(open ? null : p.name)}
|
||||
>
|
||||
<span className={open ? "text-accent" : "text-muted"}>{open ? "▾" : "▸"}</span>
|
||||
<span className="font-medium">{p.name}</span>
|
||||
<span className="proj-count">{faNum(count)} پیشنهاد</span>
|
||||
</button>
|
||||
{/* جزئیات دقیقاً زیر همان پروژه باز میشود */}
|
||||
{open && <div className="ms-2">{renderProjectDetail(store, p.name)}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- پنل جزئیات یک پروژه ----------
|
||||
function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
||||
const quotes = projectQuotes(store.quotes, name);
|
||||
const sumBy = (st: Quote["status"]) =>
|
||||
quotes
|
||||
.filter((q) => q.status === st)
|
||||
.reduce((s, q) => s + qTotal(q), 0);
|
||||
|
||||
const matName = (mid: string) => {
|
||||
const m = materialById(store.materials, mid);
|
||||
if (m.category === "؟") return "؟";
|
||||
const specs = materialSpecs(m);
|
||||
return `${m.category} ${m.brand}${specs.length ? " · " + specs.join(" · ") : ""}`;
|
||||
};
|
||||
|
||||
const supName = (sid: string) => {
|
||||
const s = supplierById(store.suppliers, sid);
|
||||
if (s.name === "؟") return "؟";
|
||||
return (
|
||||
<Link to={`/suppliers/${sid}`} className="pj-sup-link">
|
||||
{s.name}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const itemLine = (li: Quote["line_items"][number]) => {
|
||||
const total = lineItemTotal(li);
|
||||
const conv = refConverted(store, li);
|
||||
return (
|
||||
<div key={li.id}>
|
||||
{matName(li.material_id)} — {faNum(li.quantity)} {li.unit} × {faNum(li.unit_price || 0)} ={" "}
|
||||
<b>{faNum(total || 0)} تومان</b>
|
||||
{conv && (
|
||||
<span className="pj-conv">
|
||||
{" "}
|
||||
— <span className="pj-conv">≈ {faNum(conv.qty)} {conv.unit}({faNum(conv.perRef ?? 0)} تومان/{conv.unit})</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const offered = quotes
|
||||
.filter((q) => q.status === "offered")
|
||||
.sort((a, b) => new Date(a.expiry_date || 0).getTime() - new Date(b.expiry_date || 0).getTime());
|
||||
const accepted = quotes.filter((q) => q.status === "accepted");
|
||||
const arrived = quotes.filter((q) => q.status === "arrived");
|
||||
|
||||
const offeredRow = (q: Quote) => {
|
||||
const days = q.expiry_date ? Math.ceil((new Date(q.expiry_date).getTime() - Date.now()) / 86400000) : null;
|
||||
return (
|
||||
<div key={q.id} className="pj-row">
|
||||
<div className="pj-main">
|
||||
<Phone className="me-1 inline h-3 w-3 text-muted" /> {supName(q.supplier_id)}
|
||||
</div>
|
||||
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
|
||||
<div className="pj-side">
|
||||
جمع: <b>{faNum(qTotal(q))} تومان</b>
|
||||
</div>
|
||||
<div className="pj-side">
|
||||
اعتبار تا {fmtDate(q.expiry_date)}<br />
|
||||
{q.expiry_date == null ? (
|
||||
<span className="pj-chip">بدون انقضا</span>
|
||||
) : days != null && days < 0 ? (
|
||||
<span className="pj-chip red">منقضی شده</span>
|
||||
) : days === 0 ? (
|
||||
<span className="pj-chip amber">امروز آخرین مهلت</span>
|
||||
) : (
|
||||
<span className="pj-chip amber">{faNum(days)} روز مانده</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const acceptedRow = (q: Quote) => {
|
||||
const d = daysSince(q.accepted_date);
|
||||
return (
|
||||
<div key={q.id} className="pj-row">
|
||||
<div className="pj-main">
|
||||
<Phone className="me-1 inline h-3 w-3 text-muted" /> {supName(q.supplier_id)}
|
||||
</div>
|
||||
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
|
||||
<div className="pj-side">
|
||||
تایید: {fmtDate(q.accepted_date)}
|
||||
{d != null && <span className="pj-chip green">{faNum(d)} روز پیش</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const arrivedRow = (q: Quote) => (
|
||||
<div key={q.id} className="pj-row">
|
||||
<div className="pj-main">
|
||||
<Phone className="me-1 inline h-3 w-3 text-muted" /> {supName(q.supplier_id)}
|
||||
</div>
|
||||
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
|
||||
<div className="pj-side">
|
||||
هزینه: <b>{faNum(qTotal(q))} تومان</b>
|
||||
</div>
|
||||
<div className="pj-side">رسید: {fmtDate(q.arrived_date)}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-line bg-panel p-4 shadow-sm">
|
||||
<h3 className="mb-2 text-[15px] font-bold">
|
||||
<Building2 className="me-1.5 inline h-4 w-4 text-accent" />
|
||||
{name}
|
||||
</h3>
|
||||
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||
<span className="pj-chip">کل پیشنهادها: {faNum(quotes.length)}</span>
|
||||
<span className="pj-chip" style={{ background: "#dbeafe", color: "#1d4ed8" }}>
|
||||
ارزش باز: {faNum(sumBy("offered"))} تومان
|
||||
</span>
|
||||
<span className="pj-chip" style={{ background: "#fef3c7", color: "#d97706" }}>
|
||||
ارزش پذیرفته: {faNum(sumBy("accepted"))} تومان
|
||||
</span>
|
||||
<span className="pj-chip" style={{ background: "#dcfce7", color: "#16a34a" }}>
|
||||
ارزش رسیده: {faNum(sumBy("arrived"))} تومان
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<div className="pj-col">
|
||||
<div className="pj-col-h">
|
||||
<Inbox className="me-1 inline h-3.5 w-3.5 text-[#1d4ed8]" /> پیشنهاد شده ({faNum(offered.length)})
|
||||
</div>
|
||||
{offered.length ? offered.map(offeredRow) : <div className="pj-empty">—</div>}
|
||||
</div>
|
||||
<div className="pj-col">
|
||||
<div className="pj-col-h">
|
||||
<CheckCircle2 className="me-1 inline h-3.5 w-3.5 text-amber" /> پذیرفته شده ({faNum(accepted.length)})
|
||||
</div>
|
||||
{accepted.length ? accepted.map(acceptedRow) : <div className="pj-empty">—</div>}
|
||||
</div>
|
||||
<div className="pj-col">
|
||||
<div className="pj-col-h">
|
||||
<Truck className="me-1 inline h-3.5 w-3.5 text-green" /> رسیده ({faNum(arrived.length)})
|
||||
</div>
|
||||
{arrived.length ? arrived.map(arrivedRow) : <div className="pj-empty">—</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Navigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Inbox, Plus } from "lucide-react";
|
||||
import type { Supplier } from "../../lib/types";
|
||||
import { nextId } from "../../lib/api";
|
||||
import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||
import { SupplierCard, suppliersOfCategory, topicCategories, topicFromParam } from "./supplierUtils";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
// صفحهی تأمینکنندههای یک متریال + فرم افزودن تأمینکننده
|
||||
export function CategoryPage() {
|
||||
useStoreData();
|
||||
const { topic: topicParam = "", category: catParam = "" } = useParams();
|
||||
|
||||
const materials = useMaterials();
|
||||
const suppliers = useSuppliers();
|
||||
const quotes = useQuotes();
|
||||
const addSupplier = useAddSupplier();
|
||||
const removeSupplier = useRemoveSupplier();
|
||||
|
||||
const topic = useMemo(() => topicFromParam(topicParam), [topicParam]);
|
||||
const matched = useMemo(
|
||||
() => (topic ? topicCategories(topic, materials, suppliers).find((c) => normalize(c) === normalize(catParam)) : undefined),
|
||||
[topic, catParam, materials, suppliers],
|
||||
);
|
||||
|
||||
const [form, setForm] = useState<{ name: string; categories: string[]; location: string; phone: string; contact: string }>({
|
||||
name: "",
|
||||
categories: [],
|
||||
location: "",
|
||||
phone: "",
|
||||
contact: "",
|
||||
});
|
||||
|
||||
// همهی تأمینکنندهها برای انتخاب سریع (task: کومبوکس کششده)
|
||||
const supplierOptions = useMemo(
|
||||
() => suppliers.map((s) => ({ id: s.id, name: s.name })),
|
||||
[suppliers],
|
||||
);
|
||||
|
||||
if (!topic || !matched) {
|
||||
return topic ? <Navigate to={`/topics/${encodeURIComponent(topic)}`} replace /> : <Navigate to="/suppliers" replace />;
|
||||
}
|
||||
|
||||
const sups = suppliersOfCategory(topic, matched, suppliers);
|
||||
|
||||
// متریالهای خریداریشده از هر تأمینکننده
|
||||
const supMats = useMemo(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
quotes.forEach((q) => {
|
||||
(q.line_items || []).forEach((li) => {
|
||||
if (li.rejected) return;
|
||||
const m = materials.find((x) => x.id === li.material_id);
|
||||
if (!m) return;
|
||||
const label = `${m.category}${m.brand ? " " + m.brand : ""}${m.specs.length ? " (" + m.specs.join("، ") + ")" : ""}`;
|
||||
if (!map.has(q.supplier_id)) map.set(q.supplier_id, []);
|
||||
const list = map.get(q.supplier_id)!;
|
||||
if (!list.includes(label)) list.push(label);
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}, [quotes, materials]);
|
||||
|
||||
const addSup = () => {
|
||||
const name = form.name.trim();
|
||||
if (!name) {
|
||||
toast.error("نام تأمینکننده را وارد کنید.");
|
||||
return;
|
||||
}
|
||||
// اگر متریالی انتخاب نشده، تأمینکننده به همین متریالِ صفحه اضافه میشود
|
||||
const cats = form.categories.length ? form.categories : [matched];
|
||||
const s: Supplier = {
|
||||
id: nextId("s", suppliers),
|
||||
topic,
|
||||
categories: cats,
|
||||
name,
|
||||
location: form.location.trim(),
|
||||
phone: form.phone.trim(),
|
||||
contact: form.contact.trim(),
|
||||
};
|
||||
void addSupplier.mutateAsync(s);
|
||||
setForm({ name: "", categories: [], location: "", phone: "", contact: "" });
|
||||
toast.success("تأمینکننده افزوده شد.");
|
||||
};
|
||||
|
||||
const removeSup = (id: string) => {
|
||||
const s = suppliers.find((x) => x.id === id);
|
||||
if (!s) return;
|
||||
if (confirm(`تأمینکننده «${s.name}» حذف شود؟ پیشنهادهای قبلی او در دفتر باقی میمانند.`)) {
|
||||
void removeSupplier.mutateAsync(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title={matched} sub={`تأمینکنندههای این متریال — ${topic}`} backTo="/suppliers" backLabel="موضوعها" />
|
||||
<PageLayout className="flex flex-col gap-4">
|
||||
{!sups.length ? (
|
||||
<EmptyState icon={Inbox} text="هنوز تأمینکنندهای برای این متریال ثبت نشده است." />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{sups.map((s) => (
|
||||
<SupplierCard key={s.id} s={s} topic={topic} deletable onDelete={removeSup} materials={supMats.get(s.id) || []} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="add-panel">
|
||||
<h4>
|
||||
<Plus className="me-1 inline h-4 w-4 text-accent" /> افزودن تأمینکنندهی جدید
|
||||
</h4>
|
||||
<div className="grid max-w-2xl grid-cols-2 gap-3">
|
||||
<div className="field">
|
||||
<label>نام تأمینکننده *</label>
|
||||
<Combobox
|
||||
value={form.name}
|
||||
onChange={(it: { id: string; label: string }) => setForm((f) => ({ ...f, name: it.label }))}
|
||||
items={supplierOptions}
|
||||
allowAdd
|
||||
addSuffix="به عنوان تأمینکنندهٔ جدید"
|
||||
placeholder="تایپ کنید یا از تأمینکنندهها انتخاب کنید"
|
||||
onInputChange={(v) => setForm((f) => ({ ...f, name: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>متریال (ها)</label>
|
||||
<MultiSelectCombobox
|
||||
values={form.categories}
|
||||
onChange={(cats) => setForm((f) => ({ ...f, categories: cats }))}
|
||||
options={topicCategories(topic, materials, suppliers)}
|
||||
placeholder="متریال را انتخاب یا تایپ کنید..."
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>موقعیت (شهر - منطقه)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="مثلاً تهران - ظفر"
|
||||
value={form.location}
|
||||
onChange={(e) => setForm((f) => ({ ...f, location: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تلفن</label>
|
||||
<input
|
||||
type="text"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="۰۲۱-..."
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>شخص رابط</label>
|
||||
<input
|
||||
type="text"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="نام فرد"
|
||||
value={form.contact}
|
||||
onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" className="mt-3" onClick={addSup}>
|
||||
ذخیره تأمینکننده
|
||||
</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Building2, Paperclip, Plus } from "lucide-react";
|
||||
import type { Attachment, Material, Quote, Supplier } from "../../lib/types";
|
||||
import { api } from "../../lib/api";
|
||||
import { activeItems, materialById, materialSpecs, quoteTotal, specsLabel } from "../../lib/quoteUtils";
|
||||
import { fmtDate, faNum } from "../../lib/formatters";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { STATUS_LABEL } from "../../lib/constants";
|
||||
|
||||
interface AttachSet {
|
||||
invoices: Attachment[];
|
||||
proformas: Attachment[];
|
||||
}
|
||||
|
||||
export interface PurchaseHistoryProps {
|
||||
supplier: Supplier;
|
||||
quotes: Quote[];
|
||||
materials: Material[];
|
||||
}
|
||||
|
||||
// سابقه خرید از یک تأمینکننده: همهی پیشنهادها (با بج وضعیت، از جدیدترین)
|
||||
// + فاکتورها و پیشفاکتورها (لینک دانلود از MinIO از طریق api.files)
|
||||
export function PurchaseHistory({ supplier, quotes, materials }: PurchaseHistoryProps) {
|
||||
const purchases = useMemo(() => {
|
||||
return quotes
|
||||
.filter((q) => q.supplier_id === supplier.id)
|
||||
.sort((a, b) => {
|
||||
const ta = a.quote_date ? new Date(a.quote_date).getTime() : 0;
|
||||
const tb = b.quote_date ? new Date(b.quote_date).getTime() : 0;
|
||||
return tb - ta;
|
||||
});
|
||||
}, [quotes, supplier.id]);
|
||||
|
||||
// پیوستها بهصورت موازی برای همهی خریدها خوانده میشوند (الگوی AcceptModal)
|
||||
const [attachments, setAttachments] = useState<Record<string, AttachSet>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!purchases.length) return;
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
void Promise.all(
|
||||
purchases.map(async (q) => {
|
||||
const [invoices, proformas] = await Promise.all([
|
||||
api.files.list(q.id, "invoice").catch(() => [] as Attachment[]),
|
||||
api.files.list(q.id, "proforma").catch(() => [] as Attachment[]),
|
||||
]);
|
||||
return [q.id, { invoices, proformas }] as const;
|
||||
}),
|
||||
)
|
||||
.then((pairs) => {
|
||||
if (!alive) return;
|
||||
const map: Record<string, AttachSet> = {};
|
||||
pairs.forEach(([id, set]) => (map[id] = set));
|
||||
setAttachments(map);
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [purchases]);
|
||||
|
||||
const wizardLink = `/wizard?topic=${encodeURIComponent(supplier.topic)}&supplier=${encodeURIComponent(supplier.id)}`;
|
||||
|
||||
return (
|
||||
<div className="ph-card">
|
||||
<div className="ph-head">
|
||||
<span className="ph-title">سابقه خرید از این تأمینکننده</span>
|
||||
{purchases.length > 0 && <span className="ph-count">{faNum(purchases.length)} پیشنهاد</span>}
|
||||
</div>
|
||||
|
||||
{purchases.length === 0 && (
|
||||
<div className="ph-empty">
|
||||
<div className="ph-empty-text">هنوز خریدی از این تأمینکننده ثبت نشده است.</div>
|
||||
<Link to={wizardLink} className="btn-line-file no-underline mt-2 inline-flex bg-accent text-white hover:bg-accent-dark">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
اولین پیشنهاد
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{purchases.map((q) => {
|
||||
const items = activeItems(q).map((li) => {
|
||||
const m = materialById(materials, li.material_id);
|
||||
const specs = materialSpecs(m);
|
||||
return `${m.category} ${m.brand}${specs.length ? " " + specsLabel(specs) : ""}`.trim();
|
||||
});
|
||||
const total = quoteTotal(q);
|
||||
const attach = attachments[q.id];
|
||||
const profs = attach?.proformas || [];
|
||||
const invs = attach?.invoices || [];
|
||||
const projects = q.projects && q.projects.length ? q.projects : q.project ? [q.project] : [];
|
||||
return (
|
||||
<div key={q.id} className={`ph-row${q.status === "expired" ? " stale" : ""}`}>
|
||||
<div className="ph-top">
|
||||
<span className="ph-date">{fmtDate(q.quote_date)}</span>
|
||||
<span className={`fb-badge ${q.status}`}>{STATUS_LABEL[q.status] || q.status}</span>
|
||||
<span className="ph-project">
|
||||
<Building2 className="me-1 inline h-3.5 w-3.5 text-muted" />
|
||||
{projects.join("، ") || "بدون پروژه"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ph-items">
|
||||
{items.length ? `${faNum(items.length)} قلم: ${items.join("، ")}` : "بدون قلم"}
|
||||
</div>
|
||||
<div className="ph-total">{groupCommas(total, true)} تومان</div>
|
||||
<div className="ph-attach">
|
||||
{loading && !attach ? (
|
||||
<span className="ph-attach-loading">در حال بارگذاری فاکتورها...</span>
|
||||
) : (
|
||||
<>
|
||||
{profs.map((pf) => (
|
||||
<a
|
||||
key={"pf-" + pf.id}
|
||||
className="detail-link"
|
||||
href={api.files.downloadUrl(q.id, "proforma", pf.id!)}
|
||||
download={pf.name}
|
||||
title="دانلود پیشفاکتور"
|
||||
>
|
||||
<Paperclip className="me-1 inline h-3 w-3" />
|
||||
پیشفاکتور: {pf.name}
|
||||
</a>
|
||||
))}
|
||||
{invs.map((inv) => (
|
||||
<a
|
||||
key={"inv-" + inv.id}
|
||||
className="detail-link"
|
||||
href={api.files.downloadUrl(q.id, "invoice", inv.id!)}
|
||||
download={inv.name}
|
||||
title="دانلود فاکتور"
|
||||
>
|
||||
<Paperclip className="me-1 inline h-3 w-3" />
|
||||
فاکتور: {inv.name}
|
||||
</a>
|
||||
))}
|
||||
{!loading && !profs.length && !invs.length && (
|
||||
<span className="ph-attach-loading">فاکتوری ثبت نشده</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { MapPin, Phone, User, Plus, Package, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks";
|
||||
import { topicByName } from "../../lib/constants";
|
||||
import { api } from "../../lib/api";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||
import { ConfirmDeleteDialog } from "../../components/ConfirmDeleteDialog";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { PurchaseHistory } from "./PurchaseHistory";
|
||||
|
||||
// صفحهی مشخصات کامل تأمینکننده + سابقه خرید + مدیریت متریالهای تأمینشده
|
||||
export function SupplierDetailPage() {
|
||||
useStoreData();
|
||||
const { id = "" } = useParams();
|
||||
const suppliers = useSuppliers();
|
||||
const quotes = useQuotes();
|
||||
const materials = useMaterials();
|
||||
|
||||
const s = useMemo(() => suppliers.find((x) => x.id === id) || null, [suppliers, id]);
|
||||
const [pendingRemove, setPendingRemove] = useState<string | null>(null);
|
||||
// متریالهای متصل واقعی (از اتصال تأمینکننده-متریال بکاند)
|
||||
const [linked, setLinked] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let alive = true;
|
||||
void api.supplierMaterials
|
||||
.list(id)
|
||||
.then((rows) => alive && setLinked(rows.map((r) => r.material_name).filter(Boolean)))
|
||||
.catch(() => alive && setLinked([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
// متریالهای تأمینشده (نام متریالها) برای پیشنهاد در کومبوکس
|
||||
const materialOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
materials.forEach((m) => m.name && set.add(m.name));
|
||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}, [materials]);
|
||||
|
||||
// متریالهای خریداریشده از این تأمینکننده (از پیشنهادها)
|
||||
const purchased = useMemo(() => {
|
||||
const out = new Set<string>();
|
||||
quotes.forEach((q) => {
|
||||
if (q.supplier_id !== id) return;
|
||||
(q.line_items || []).forEach((li) => {
|
||||
if (li.rejected) return;
|
||||
const m = materials.find((x) => x.id === li.material_id);
|
||||
if (!m || !m.category) return;
|
||||
out.add(m.category);
|
||||
});
|
||||
});
|
||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}, [quotes, materials, id]);
|
||||
|
||||
if (!s) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="اطلاعات تماس تأمینکننده" backTo="/suppliers" backLabel="تأمینکنندهها" />
|
||||
<PageLayout>
|
||||
<EmptyState emoji="😕" text="تأمینکنندهای یافت نشد." />
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const topic = topicByName(s.topic);
|
||||
const topicName = topic ? topic.name : s.topic;
|
||||
const firstCat = (s.categories || [])[0] || "";
|
||||
const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`;
|
||||
|
||||
const saveCategories = (names: string[]) => {
|
||||
// v2.6: اتصال واقعی تأمینکننده ↔ متریال (کاتالوگ) — با نام متریال
|
||||
if (!id || busy) return;
|
||||
setBusy(true);
|
||||
void (async () => {
|
||||
try {
|
||||
for (const name of names) {
|
||||
if (linked.some((x) => normalize(x) === normalize(name))) continue;
|
||||
const mat = materials.find((m) => normalize(m.name) === normalize(name));
|
||||
if (!mat) {
|
||||
toast.error(`متریال «${name}» در کاتالوگ یافت نشد — ابتدا از ویزارد بسازید.`);
|
||||
continue;
|
||||
}
|
||||
await api.supplierMaterials.create(id, mat.id);
|
||||
}
|
||||
const rows = await api.supplierMaterials.list(id);
|
||||
setLinked(rows.map((r) => r.material_name).filter(Boolean));
|
||||
toast.success("متریالها ذخیره شدند.");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "ذخیرهی متریالها ناموفق بود");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const removeCat = (name: string) => {
|
||||
if (!id || busy) return;
|
||||
setBusy(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const mat = materials.find((m) => normalize(m.name) === normalize(name));
|
||||
if (mat) await api.supplierMaterials.remove(id, mat.id);
|
||||
const rows = await api.supplierMaterials.list(id);
|
||||
setLinked(rows.map((r) => r.material_name).filter(Boolean));
|
||||
toast.success("متریال حذف شد.");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "حذف متریال ناموفق بود");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="اطلاعات تماس تأمینکننده" sub={breadcrumb} backTo="/suppliers" backLabel="تأمینکنندهها" />
|
||||
<PageLayout>
|
||||
<div className="max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-xl font-extrabold">{s.name}</div>
|
||||
<Badge variant="outline">{topicName}</Badge>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-1.5 text-[13.5px]">
|
||||
<span>
|
||||
<MapPin className="me-1.5 inline h-4 w-4 text-muted" /> <b>موقعیت:</b> {s.location || "—"}
|
||||
</span>
|
||||
<span>
|
||||
<Phone className="me-1.5 inline h-4 w-4 text-muted" /> <b>تلفن:</b> {s.phone || "—"}
|
||||
</span>
|
||||
<span>
|
||||
<User className="me-1.5 inline h-4 w-4 text-muted" /> <b>شخص رابط:</b> {s.contact || "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Link
|
||||
to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`}
|
||||
className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
پیشنهاد جدید
|
||||
</Link>
|
||||
{firstCat ? (
|
||||
<Link to={`/topics/${encodeURIComponent(topicName)}/${encodeURIComponent(firstCat)}`} className="btn-line-file no-underline">
|
||||
‹ بازگشت به دسته
|
||||
</Link>
|
||||
) : (
|
||||
<Link to={`/topics/${encodeURIComponent(topicName)}`} className="btn-line-file no-underline">
|
||||
‹ بازگشت به موضوع
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* متریالهای این تأمینکننده — افزودن/حذف + مشاهدهی همه */}
|
||||
<div className="mt-4 max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<h4 className="mb-1 text-[13.5px] font-bold">
|
||||
<Package className="me-1 inline h-4 w-4 text-accent" /> متریالهای تأمینشده
|
||||
</h4>
|
||||
<div className="mb-2 text-[11.5px] text-muted">
|
||||
متریالهایی که این تأمینکننده عرضه میکند را اینجا مدیریت کنید.
|
||||
</div>
|
||||
{(linked || []).length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{linked.map((c) => (
|
||||
<span key={c} className="spec-chip">
|
||||
{c}
|
||||
<button type="button" className="spec-chip-x" title="حذف متریال" onClick={() => setPendingRemove(c)}>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<MultiSelectCombobox
|
||||
values={[]}
|
||||
onChange={(cats) => saveCategories(cats)}
|
||||
options={materialOptions}
|
||||
placeholder="افزودن متریال..."
|
||||
/>
|
||||
{purchased.length > 0 && (
|
||||
<div className="mt-3 border-t border-line pt-2">
|
||||
<div className="mb-1 text-[11px] font-semibold text-muted">متریالهای خریداریشده (از پیشنهادها):</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{purchased.map((c) => (
|
||||
<span key={c} className="spec-chip">
|
||||
{c}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* سابقه خرید از این تأمینکننده — همهی پیشنهادها + فاکتور/پیشفاکتور */}
|
||||
<div className="mt-4 max-w-4xl">
|
||||
<PurchaseHistory supplier={s} quotes={quotes} materials={materials} />
|
||||
</div>
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
open={pendingRemove !== null}
|
||||
itemName={pendingRemove || ""}
|
||||
onConfirm={() => {
|
||||
if (pendingRemove !== null) removeCat(pendingRemove);
|
||||
setPendingRemove(null);
|
||||
}}
|
||||
onCancel={() => setPendingRemove(null)}
|
||||
/>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useMemo } from "react";
|
||||
import { useStoreData, useSuppliers } from "../../hooks";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { TOPICS } from "../../lib/constants";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { TopicGrid } from "../../components/TopicGrid";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
// صفحهی تأمینکنندهها — انتخاب موضوع
|
||||
export function SuppliersPage() {
|
||||
useStoreData();
|
||||
const suppliers = useSuppliers();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const out: Record<string, number> = {};
|
||||
TOPICS.forEach((t) => {
|
||||
out[t.name] = suppliers.filter((s) => normalize(s.topic) === normalize(t.name)).length;
|
||||
});
|
||||
return out;
|
||||
}, [suppliers]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header
|
||||
title="تأمینکنندهها"
|
||||
sub="اول موضوع (دستهبندی) را انتخاب کنید"
|
||||
backTo="/"
|
||||
backLabel="بازگشت به دفتر"
|
||||
/>
|
||||
<PageLayout>
|
||||
<TopicGrid
|
||||
onSelect={(topic) => navigate(`/topics/${encodeURIComponent(topic)}`)}
|
||||
showCounts
|
||||
counts={counts}
|
||||
/>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Navigate, useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { FolderOpen, Plus } from "lucide-react";
|
||||
import type { Material } from "../../lib/types";
|
||||
import { nextId } from "../../lib/api";
|
||||
import { useAddMaterial, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { SupplierCard, suppliersOfCategory, topicCategories, topicFromParam } from "./supplierUtils";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
// صفحهی متریالهای یک موضوع + تأمینکنندههای بدون متریال + افزودن متریال
|
||||
export function TopicPage() {
|
||||
useStoreData();
|
||||
const { topic: topicParam = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const materials = useMaterials();
|
||||
const suppliers = useSuppliers();
|
||||
const quotes = useQuotes();
|
||||
const addMaterial = useAddMaterial();
|
||||
const removeSupplier = useRemoveSupplier();
|
||||
|
||||
const topic = useMemo(() => topicFromParam(topicParam), [topicParam]);
|
||||
const [newCatName, setNewCatName] = useState("");
|
||||
|
||||
const cats = useMemo(
|
||||
() => (topic ? topicCategories(topic, materials, suppliers) : []),
|
||||
[topic, materials, suppliers],
|
||||
);
|
||||
|
||||
const uncat = useMemo(
|
||||
() =>
|
||||
topic
|
||||
? suppliers.filter((s) => normalize(s.topic) === normalize(topic) && !(s.categories || []).length)
|
||||
: [],
|
||||
[topic, suppliers],
|
||||
);
|
||||
|
||||
if (!topic) return <Navigate to="/suppliers" replace />;
|
||||
|
||||
const addCategory = () => {
|
||||
const name = newCatName.trim();
|
||||
if (!name) return;
|
||||
const dup = materials.some(
|
||||
(m) => normalize(m.topic) === normalize(topic) && normalize(m.category) === normalize(name),
|
||||
);
|
||||
if (dup) {
|
||||
toast.error("این متریال قبلاً ثبت شده است.");
|
||||
return;
|
||||
}
|
||||
const m: Material = {
|
||||
id: nextId("m", materials),
|
||||
name,
|
||||
topic,
|
||||
category: name,
|
||||
brand: "",
|
||||
specs: [],
|
||||
base_unit: "",
|
||||
};
|
||||
void addMaterial.mutateAsync(m);
|
||||
setNewCatName("");
|
||||
toast.success("متریال افزوده شد.");
|
||||
};
|
||||
|
||||
const removeSup = (id: string) => {
|
||||
const s = suppliers.find((x) => x.id === id);
|
||||
if (!s) return;
|
||||
if (confirm(`تأمینکننده «${s.name}» حذف شود؟ پیشنهادهای قبلی او در دفتر باقی میمانند.`)) {
|
||||
void removeSupplier.mutateAsync(id);
|
||||
}
|
||||
};
|
||||
|
||||
// متریالهای خریداریشده از هر تأمینکننده
|
||||
const supMats = useMemo(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
quotes.forEach((q) => {
|
||||
(q.line_items || []).forEach((li) => {
|
||||
if (li.rejected) return;
|
||||
const m = materials.find((x) => x.id === li.material_id);
|
||||
if (!m) return;
|
||||
const label = `${m.category}${m.brand ? " " + m.brand : ""}${m.specs.length ? " (" + m.specs.join("، ") + ")" : ""}`;
|
||||
if (!map.has(q.supplier_id)) map.set(q.supplier_id, []);
|
||||
const list = map.get(q.supplier_id)!;
|
||||
if (!list.includes(label)) list.push(label);
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}, [quotes, materials]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title={topic} sub="یک متریال را انتخاب کنید" backTo="/suppliers" backLabel="موضوعها" />
|
||||
<PageLayout className="flex flex-col gap-4">
|
||||
{cats.length ? (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{cats.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className="topic-card"
|
||||
onClick={() => navigate(`/topics/${encodeURIComponent(topic)}/${encodeURIComponent(c)}`)}
|
||||
>
|
||||
<div className="t-name" style={{ fontSize: 13 }}>
|
||||
{c}
|
||||
</div>
|
||||
<div className="t-count">{faNum(suppliersOfCategory(topic, c, suppliers).length)} تأمینکننده</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon={FolderOpen} text="هنوز متریالی برای این موضوع ثبت نشده — از پایین یک متریال اضافه کنید." />
|
||||
)}
|
||||
|
||||
{uncat.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-[13.5px] font-bold">تأمینکنندههای این موضوع</h3>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{uncat.map((s) => (
|
||||
<SupplierCard key={s.id} s={s} topic={topic} deletable onDelete={removeSup} materials={supMats.get(s.id) || []} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="add-panel">
|
||||
<h4>
|
||||
<Plus className="me-1 inline h-4 w-4 text-accent" /> افزودن متریال جدید
|
||||
</h4>
|
||||
<div className="grid max-w-md grid-cols-[1fr_auto] items-end gap-2">
|
||||
<div className="field">
|
||||
<label>نام متریال (مثلاً میلگرد آجدار ۱۴)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="مثلاً میلگرد آجدار ۱۴"
|
||||
autoComplete="off"
|
||||
value={newCatName}
|
||||
onChange={(e) => setNewCatName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addCategory();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={addCategory}>افزودن متریال</Button>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-muted">
|
||||
بعد از افزودن، داخل همان متریال میتوانید تأمینکننده اضافه یا حذف کنید.
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { MapPin, Phone, User, Plus } from "lucide-react";
|
||||
import type { Material, Supplier } from "../../lib/types";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { topicByName } from "../../lib/constants";
|
||||
|
||||
// دستههای یک تاپیک (اتحاد materials + categories تأمینکنندهها)
|
||||
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] {
|
||||
const out = new Set<string>();
|
||||
materials
|
||||
.filter((m) => topic && normalize(m.topic) === normalize(topic) && m.category)
|
||||
.forEach((m) => out.add(m.category));
|
||||
suppliers
|
||||
.filter((s) => topic && normalize(s.topic) === normalize(topic))
|
||||
.forEach((s) => (s.categories || []).forEach((c) => c && out.add(c)));
|
||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}
|
||||
|
||||
export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
|
||||
return suppliers.filter(
|
||||
(s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)),
|
||||
);
|
||||
}
|
||||
|
||||
export function topicFromParam(param: string | null): string | null {
|
||||
if (!param) return null;
|
||||
const t = topicByName(param);
|
||||
return t ? t.name : null;
|
||||
}
|
||||
|
||||
// کارت تأمینکننده
|
||||
export function SupplierCard({
|
||||
s,
|
||||
topic,
|
||||
deletable,
|
||||
onDelete,
|
||||
materials = [],
|
||||
}: {
|
||||
s: Supplier;
|
||||
topic: string;
|
||||
deletable?: boolean;
|
||||
onDelete?: (id: string) => void;
|
||||
// متریالهای خریداریشده از این تأمینکننده (بهجای دستهبندی)
|
||||
materials?: string[];
|
||||
}) {
|
||||
return (
|
||||
<div className="sup-card">
|
||||
<div className="sc-head">
|
||||
<Link to={`/suppliers/${s.id}`} className="sc-name-link">
|
||||
{s.name}
|
||||
</Link>
|
||||
{deletable && onDelete && (
|
||||
<button type="button" className="sup-del" title="حذف تأمینکننده" onClick={() => onDelete(s.id)}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="sc-meta">
|
||||
<span>
|
||||
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
|
||||
</span>
|
||||
<span>
|
||||
<Phone className="me-1 inline h-3 w-3" /> {s.phone || "—"}
|
||||
</span>
|
||||
<span>
|
||||
<User className="me-1 inline h-3 w-3" /> {s.contact || "—"}
|
||||
</span>
|
||||
</div>
|
||||
{materials.length > 0 && (
|
||||
<div className="mt-2 border-t border-line pt-1.5">
|
||||
<div className="mb-1 flex flex-wrap gap-1">
|
||||
{materials.map((m, i) => (
|
||||
<span key={i} className="spec-chip">
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="sc-actions">
|
||||
<Link to={`/wizard?topic=${encodeURIComponent(topic)}&supplier=${encodeURIComponent(s.id)}`} className="btn-line-file no-underline">
|
||||
<Plus className="h-3 w-3" />
|
||||
پیشنهاد جدید
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Scale, Repeat, TriangleAlert, Link2, Pencil, Trash2, Plus } from "lucide-react";
|
||||
import type { CategoryUnit, StandardConversion, UnitConversion } from "../../lib/types";
|
||||
import { api, nextId } from "../../lib/api";
|
||||
import {
|
||||
useDeleteConversion,
|
||||
useDeleteStandardConversion,
|
||||
useMaterials,
|
||||
useQuotes,
|
||||
useSaveConversion,
|
||||
useSaveRefUnit,
|
||||
useSaveStandardConversion,
|
||||
useStoreData,
|
||||
useSuppliers,
|
||||
useUnits,
|
||||
} from "../../hooks";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { TOPICS } from "../../lib/constants";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { parseNum } from "../../lib/numutil";
|
||||
import { findConversion, materialById } from "../../lib/quoteUtils";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { TopicGrid } from "../../components/TopicGrid";
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
import { NumberInput } from "../../components/NumberInput";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
interface RefUnitDraft {
|
||||
id: string;
|
||||
category: string;
|
||||
ref_unit: string;
|
||||
}
|
||||
|
||||
// داشبورد واحدها — دو مرحله: انتخاب تاپیک → تنظیم واحد مرجع و ضریب تبدیل دسته
|
||||
export function UnitsPage() {
|
||||
useStoreData();
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const suppliers = useSuppliers();
|
||||
const materials = useMaterials();
|
||||
const quotes = useQuotes();
|
||||
const { categoryUnits, unitConversions, standardConversions } = useUnits();
|
||||
const saveRefUnit = useSaveRefUnit();
|
||||
const saveConversion = useSaveConversion();
|
||||
const deleteConversion = useDeleteConversion();
|
||||
const saveStd = useSaveStandardConversion();
|
||||
const deleteStd = useDeleteStandardConversion();
|
||||
|
||||
// ---------- state ----------
|
||||
const [selectedTopic, setSelectedTopic] = useState<string | null>(null);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [refDraft, setRefDraft] = useState<RefUnitDraft | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingStdId, setEditingStdId] = useState<string | null>(null);
|
||||
|
||||
// فرم ضریب دستهای
|
||||
const [formFrom, setFormFrom] = useState("");
|
||||
const [formFactor, setFormFactor] = useState("");
|
||||
const [formBrand, setFormBrand] = useState("");
|
||||
|
||||
// فرم تبدیل ثابت
|
||||
const [stdFrom, setStdFrom] = useState("");
|
||||
const [stdFactor, setStdFactor] = useState("");
|
||||
const [stdTo, setStdTo] = useState("");
|
||||
|
||||
// ---------- مشتقات ----------
|
||||
const catTopicMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
materials.forEach((m) => {
|
||||
if (m.category && m.topic) map[m.category] = m.topic;
|
||||
});
|
||||
suppliers.forEach((s) => {
|
||||
(s.categories || []).forEach((c) => {
|
||||
if (c && !map[c]) map[c] = s.topic;
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}, [materials, suppliers]);
|
||||
|
||||
const categoriesOfTopic = useMemo(() => {
|
||||
if (!selectedTopic) return [];
|
||||
return Object.keys(catTopicMap)
|
||||
.filter((c) => normalize(catTopicMap[c]) === normalize(selectedTopic))
|
||||
.sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}, [catTopicMap, selectedTopic]);
|
||||
|
||||
const refUnitFor = (cat: string): string =>
|
||||
categoryUnits.find((c) => normalize(c.category) === normalize(cat))?.ref_unit || "";
|
||||
|
||||
const conversionsFor = (cat: string): UnitConversion[] =>
|
||||
unitConversions
|
||||
.filter((c) => normalize(c.category) === normalize(cat))
|
||||
.sort((a, b) => (b.brand ? 1 : 0) - (a.brand ? 1 : 0) || a.from_unit.localeCompare(b.from_unit, "fa"));
|
||||
|
||||
// واحدهای استفادهشده در quoteهای دسته که ضریب ندارند
|
||||
const unknownUnitsFor = (cat: string): { unit: string; count: number }[] => {
|
||||
const ref = refUnitFor(cat);
|
||||
const out = new Map<string, number>();
|
||||
quotes.forEach((q) =>
|
||||
(q.line_items || []).forEach((li) => {
|
||||
if (li.rejected || !li.unit) return;
|
||||
const m = materialById(materials, li.material_id);
|
||||
if (m.category === "؟" || normalize(m.category) !== normalize(cat)) return;
|
||||
if (ref && normalize(li.unit) === normalize(ref)) return;
|
||||
if (ref && findConversion({ unitConversions, standardConversions }, cat, m.brand, li.unit, ref)) return;
|
||||
out.set(li.unit, (out.get(li.unit) || 0) + 1);
|
||||
}),
|
||||
);
|
||||
return [...out.entries()].map(([unit, count]) => ({ unit, count })).sort((a, b) => a.unit.localeCompare(b.unit, "fa"));
|
||||
};
|
||||
|
||||
const suggestedUnitsFor = (cat: string): string[] => {
|
||||
const out = new Set<string>();
|
||||
quotes.forEach((q) =>
|
||||
(q.line_items || []).forEach((li) => {
|
||||
if (li.rejected || !li.unit) return;
|
||||
const m = materialById(materials, li.material_id);
|
||||
if (m.category !== "؟" && normalize(m.category) === normalize(cat)) out.add(li.unit);
|
||||
}),
|
||||
);
|
||||
conversionsFor(cat).forEach((c) => out.add(c.from_unit));
|
||||
standardConversions.forEach((c) => {
|
||||
out.add(c.from_unit);
|
||||
out.add(c.to_unit);
|
||||
});
|
||||
return [...out].filter(Boolean).sort((a, b) => a.localeCompare(b, "fa"));
|
||||
};
|
||||
|
||||
// برندهای پیشنهادی — همهی برندها از همهی متریالها (مثل «ثبت پیشنهاد جدید») +
|
||||
// برندهای ثبتشده در بکاند + برندهای ضرایب
|
||||
const suggestedBrandsFor = (): string[] => {
|
||||
const out = new Set<string>();
|
||||
materials
|
||||
.filter((m) => m.brand)
|
||||
.forEach((m) => out.add(m.brand));
|
||||
unitConversions.forEach((c) => c.brand && out.add(c.brand));
|
||||
allBrands.forEach((b) => out.add(b));
|
||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
};
|
||||
|
||||
const [allBrands, setAllBrands] = useState<string[]>([]);
|
||||
// همهی برندها از بکاند
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void api
|
||||
.brandsList()
|
||||
.then((list) => alive && setAllBrands(list.map((b) => b.name)))
|
||||
.catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
const topicCounts = useMemo(() => {
|
||||
const out: Record<string, number> = {};
|
||||
TOPICS.forEach((t) => {
|
||||
out[t.name] = Object.keys(catTopicMap).filter((c) => normalize(catTopicMap[c]) === normalize(t.name)).length;
|
||||
});
|
||||
return out;
|
||||
}, [catTopicMap]);
|
||||
|
||||
// ---------- actions ----------
|
||||
const saveRef = () => {
|
||||
if (!selectedCategory) return;
|
||||
const val = refDraft?.ref_unit.trim() || "";
|
||||
if (!val) return;
|
||||
const cu: CategoryUnit = refDraft?.id
|
||||
? { id: refDraft.id, category: selectedCategory, ref_unit: val }
|
||||
: { id: nextId("cu", categoryUnits), category: selectedCategory, ref_unit: val };
|
||||
void saveRefUnit
|
||||
.mutateAsync(cu)
|
||||
.catch((err) => toast.error(err instanceof Error ? err.message : "ذخیرهی واحد مرجع ناموفق بود"));
|
||||
};
|
||||
|
||||
const factorNum = (): number | null => {
|
||||
const n = parseNum(formFactor);
|
||||
return n != null && !Number.isNaN(n) && n > 0 ? n : null;
|
||||
};
|
||||
|
||||
const convLive = useMemo(() => {
|
||||
if (!selectedCategory) return null;
|
||||
const ref = refUnitFor(selectedCategory);
|
||||
const from = formFrom.trim();
|
||||
if (!from && !formFactor.trim() && !formBrand.trim())
|
||||
return 'مثلاً: واحد پیشنهادشده «کیسه»، ضریب «۵۰» و مرجع «کیلوگرم» → یعنی هر کیسه = ۵۰ کیلوگرم';
|
||||
if (!from) return "واحد پیشنهادشده را بنویسید (مثلاً کیسه).";
|
||||
if (!ref) return "⚠️ اول «واحد مرجع» این دسته را در بالا مشخص کنید.";
|
||||
const f = factorNum();
|
||||
if (f == null) return "ضریب را وارد کنید (مثلاً ۵۰) تا معنی تبدیل مشخص شود.";
|
||||
const brandPart = formBrand.trim() ? ` از برند «${formBrand.trim()}»` : "";
|
||||
return `✅ یعنی هر ${from}${brandPart} برابر ${faNum(f)} ${ref} است.`;
|
||||
}, [formFrom, formFactor, formBrand, selectedCategory, categoryUnits]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const saveConv = () => {
|
||||
if (!selectedCategory) return;
|
||||
const from = formFrom.trim();
|
||||
if (!from) return;
|
||||
const to = refUnitFor(selectedCategory);
|
||||
if (!to) {
|
||||
toast.error("اول واحد مرجع این دسته را مشخص کنید.");
|
||||
return;
|
||||
}
|
||||
const factor = factorNum();
|
||||
if (factor == null) return;
|
||||
const onError = (err: unknown) => {
|
||||
toast.error(err instanceof Error ? err.message : "ذخیرهی ضریب تبدیل ناموفق بود");
|
||||
};
|
||||
if (editingId) {
|
||||
const c: UnitConversion = {
|
||||
id: editingId,
|
||||
category: selectedCategory,
|
||||
brand: formBrand.trim(),
|
||||
from_unit: from,
|
||||
to_unit: to,
|
||||
factor,
|
||||
};
|
||||
void saveConversion.mutateAsync(c).catch(onError);
|
||||
} else {
|
||||
void saveConversion
|
||||
.mutateAsync({
|
||||
id: nextId("uc", unitConversions),
|
||||
category: selectedCategory,
|
||||
brand: formBrand.trim(),
|
||||
from_unit: from,
|
||||
to_unit: to,
|
||||
factor,
|
||||
})
|
||||
.catch(onError);
|
||||
}
|
||||
setEditingId(null);
|
||||
setFormFrom("");
|
||||
setFormFactor("");
|
||||
setFormBrand("");
|
||||
};
|
||||
|
||||
const startEdit = (id: string) => {
|
||||
const c = unitConversions.find((x) => x.id === id);
|
||||
if (!c) return;
|
||||
setEditingId(id);
|
||||
setFormFrom(c.from_unit);
|
||||
setFormFactor(String(c.factor));
|
||||
setFormBrand(c.brand || "");
|
||||
};
|
||||
|
||||
const saveStdConv = () => {
|
||||
const from = stdFrom.trim();
|
||||
const to = stdTo.trim();
|
||||
if (!from || !to) return;
|
||||
const n = parseNum(stdFactor);
|
||||
if (n == null || Number.isNaN(n) || n <= 0) return;
|
||||
if (editingStdId) {
|
||||
const c: StandardConversion = { id: editingStdId, from_unit: from, to_unit: to, factor: n };
|
||||
void saveStd.mutateAsync(c).catch((err) =>
|
||||
toast.error(err instanceof Error ? err.message : "ذخیرهی تبدیل ثابت ناموفق بود"),
|
||||
);
|
||||
} else {
|
||||
void saveStd
|
||||
.mutateAsync({ id: nextId("sc", standardConversions), from_unit: from, to_unit: to, factor: n })
|
||||
.catch((err) => toast.error(err instanceof Error ? err.message : "ذخیرهی تبدیل ثابت ناموفق بود"));
|
||||
}
|
||||
setEditingStdId(null);
|
||||
setStdFrom("");
|
||||
setStdFactor("");
|
||||
setStdTo("");
|
||||
};
|
||||
|
||||
const startStdEdit = (id: string) => {
|
||||
const c = standardConversions.find((x) => x.id === id);
|
||||
if (!c) return;
|
||||
setEditingStdId(id);
|
||||
setStdFrom(c.from_unit);
|
||||
setStdFactor(String(c.factor));
|
||||
setStdTo(c.to_unit);
|
||||
};
|
||||
|
||||
const backToTopics = () => {
|
||||
setSelectedTopic(null);
|
||||
setSelectedCategory(null);
|
||||
setEditingId(null);
|
||||
setEditingStdId(null);
|
||||
};
|
||||
|
||||
// ---------- پیشفرض از URL ----------
|
||||
useMemo(() => {
|
||||
const cat = params.get("cat");
|
||||
const topicParam = params.get("topic");
|
||||
const unitParam = params.get("unit");
|
||||
let catFound: string | null = null;
|
||||
if (cat) {
|
||||
catFound = Object.keys(catTopicMap).find((c) => normalize(c) === normalize(cat)) || null;
|
||||
if (catFound) {
|
||||
setSelectedTopic(catTopicMap[catFound]);
|
||||
setSelectedCategory(catFound);
|
||||
}
|
||||
}
|
||||
if (!catFound && topicParam) {
|
||||
const t = TOPICS.find((x) => normalize(x.name) === normalize(topicParam));
|
||||
if (t) setSelectedTopic(t.name);
|
||||
}
|
||||
if (unitParam && catFound) {
|
||||
setFormFrom(unitParam);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ---------- render ----------
|
||||
const ref = selectedCategory ? refUnitFor(selectedCategory) : "";
|
||||
const conversions = selectedCategory ? conversionsFor(selectedCategory) : [];
|
||||
const unknownUnits = selectedCategory ? unknownUnitsFor(selectedCategory) : [];
|
||||
|
||||
// گروهبندی بر اساس from_unit
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, UnitConversion[]>();
|
||||
conversions.forEach((c) => {
|
||||
const key = c.from_unit;
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(c);
|
||||
});
|
||||
return [...map.entries()];
|
||||
}, [conversions]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="ضرایب تبدیل" sub="اول موضوع، بعد دسته — واحد مرجع و ضریب تبدیل همان دسته را تنظیم کنید" backTo="/" backLabel="بازگشت به دفتر" />
|
||||
<PageLayout>
|
||||
{!selectedTopic ? (
|
||||
<div>
|
||||
<div className="units-hint mb-3 text-[12px] text-muted">
|
||||
اول موضوع را انتخاب کنید تا فقط دستههای همان موضوع بیاید
|
||||
</div>
|
||||
<TopicGrid onSelect={(t) => setSelectedTopic(t)} showCounts counts={topicCounts} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="units-layout">
|
||||
{/* درخت دستهها */}
|
||||
<aside className="units-tree">
|
||||
<div className="tree-head">
|
||||
<button type="button" className="btn-line-file" onClick={backToTopics}>
|
||||
‹ موضوعها
|
||||
</button>
|
||||
<span className="text-[12px]">
|
||||
دستههای <b>{selectedTopic}</b>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{categoriesOfTopic.length ? (
|
||||
categoriesOfTopic.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={cn("ut-cat", selectedCategory === c && "selected")}
|
||||
onClick={() => {
|
||||
setSelectedCategory(c);
|
||||
setEditingId(null);
|
||||
setRefDraft(categoryUnits.find((x) => normalize(x.category) === normalize(c)) || null);
|
||||
}}
|
||||
>
|
||||
<span>{c}</span>
|
||||
<span className="ut-ref">{refUnitFor(c) || "—"}</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="text-[12px] text-muted">دستهای در این موضوع ثبت نشده است.</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* پنل اصلی */}
|
||||
<section className="units-panel">
|
||||
{!selectedCategory ? (
|
||||
<div className="units-empty py-10 text-center text-sm text-muted">
|
||||
یک دسته را از سمت راست انتخاب کنید
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* واحد مرجع */}
|
||||
<div className="ref-unit-box">
|
||||
<div className="ref-title">
|
||||
<Scale className="me-1 inline h-4 w-4 text-accent" /> واحد مرجع این دسته
|
||||
</div>
|
||||
<div className="ref-hint">همهی پیشنهادهای این دسته برای مقایسه به این واحد تبدیل میشوند</div>
|
||||
<div className="ref-controls">
|
||||
<input
|
||||
type="text"
|
||||
className="h-9 w-48 rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="مثلاً متر"
|
||||
autoComplete="off"
|
||||
value={refDraft?.ref_unit ?? ref}
|
||||
onChange={(e) => setRefDraft((d) => ({ id: d?.id || "", category: selectedCategory, ref_unit: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
saveRef();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" onClick={saveRef}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ضرایب دسته */}
|
||||
<h4 className="sec-title">
|
||||
<Repeat className="me-1 inline h-4 w-4 text-accent" /> ضریبهای تبدیل
|
||||
</h4>
|
||||
{!grouped.length ? (
|
||||
<div className="conv-empty">هنوز ضریبی برای این دسته ثبت نشده است.</div>
|
||||
) : (
|
||||
grouped.map(([fromUnit, rows]) => (
|
||||
<div key={fromUnit}>
|
||||
<div className="cg-head">
|
||||
{fromUnit} ← {rows[0].to_unit || ref}
|
||||
</div>
|
||||
{rows.map((c) => (
|
||||
<div key={c.id} className="conv-row">
|
||||
<span className="cv-brand">{c.brand ? `برند: ${c.brand}` : "همه برندها"}</span>
|
||||
<span className="cv-factor">{faNum(c.factor)}</span>
|
||||
<span className="cv-arrow">×</span>
|
||||
<span className="cv-unit">{c.to_unit || ref}</span>
|
||||
<span className="conv-actions">
|
||||
<button type="button" title="ویرایش" onClick={() => startEdit(c.id)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="حذف"
|
||||
onClick={() => void deleteConversion.mutateAsync(c.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* فرم افزودن ضریب */}
|
||||
<div className="add-conv">
|
||||
<h4 className="mb-2 text-[13.5px] font-bold">
|
||||
{editingId ? (
|
||||
<>
|
||||
<Pencil className="me-1 inline h-3.5 w-3.5" /> ویرایش ضریب تبدیل
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="me-1 inline h-3.5 w-3.5" /> افزودن ضریب تبدیل
|
||||
</>
|
||||
)}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[1.2fr_0.6fr_1fr_0.8fr_auto] sm:items-end">
|
||||
<div className="field">
|
||||
<label>واحد پیشنهادشده</label>
|
||||
<Combobox
|
||||
value={formFrom}
|
||||
onChange={(it: { id: string; label: string }) => setFormFrom(it.label)}
|
||||
items={suggestedUnitsFor(selectedCategory)}
|
||||
allowAdd
|
||||
addSuffix="به عنوان واحد جدید"
|
||||
placeholder="مثلاً حلقه"
|
||||
onInputChange={(v) => setFormFrom(v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>ضریب</label>
|
||||
<NumberInput
|
||||
value={factorNum()}
|
||||
onChange={(n) => setFormFactor(n != null ? String(n) : "")}
|
||||
placeholder="مثلاً 0.98"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>برند (خالی = همه برندها)</label>
|
||||
<Combobox
|
||||
value={formBrand}
|
||||
onChange={(it: { id: string; label: string }) => setFormBrand(it.label)}
|
||||
items={suggestedBrandsFor()}
|
||||
allowAdd
|
||||
addSuffix="به عنوان برند جدید"
|
||||
placeholder="مثلاً سیمکو"
|
||||
onInputChange={(v) => setFormBrand(v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>به واحد (مرجع)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="h-9 w-full rounded-lg border border-line bg-slate-bg px-3 text-sm text-muted"
|
||||
value={ref || "مرجع"}
|
||||
placeholder="مرجع"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" onClick={saveConv}>
|
||||
{editingId ? "ذخیره تغییرات" : "افزودن"}
|
||||
</Button>
|
||||
{editingId && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="slate"
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setFormFrom("");
|
||||
setFormFactor("");
|
||||
setFormBrand("");
|
||||
}}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="conv-live">{convLive}</div>
|
||||
</div>
|
||||
|
||||
{/* واحدهای ناشناخته */}
|
||||
{unknownUnits.length > 0 && (
|
||||
<>
|
||||
<h4 className="sec-title">
|
||||
<TriangleAlert className="me-1 inline h-4 w-4 text-amber" /> واحدهای استفادهشده بدون ضریب
|
||||
</h4>
|
||||
{unknownUnits.map((u) => (
|
||||
<div key={u.unit} className="unknown-row">
|
||||
<span className="uk-unit">⚠️ {u.unit}</span>
|
||||
<span className="uk-count">{faNum(u.count)} پیشنهاد بدون ضریب</span>
|
||||
<span className="ms-auto">
|
||||
<Button
|
||||
variant="line"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setFormFrom(u.unit);
|
||||
setFormFactor("");
|
||||
setFormBrand("");
|
||||
}}
|
||||
>
|
||||
+ افزودن ضریب
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* تبدیلهای ثابت */}
|
||||
<h4 className="sec-title">
|
||||
<Link2 className="me-1 inline h-4 w-4 text-accent" /> تبدیلهای ثابت (همهی دستهها)
|
||||
</h4>
|
||||
{!standardConversions.length ? (
|
||||
<div className="conv-empty">تبدیل ثابتی ثبت نشده است.</div>
|
||||
) : (
|
||||
standardConversions.map((c) => (
|
||||
<div key={c.id} className="conv-row">
|
||||
<span className="cv-unit">{c.from_unit}</span>
|
||||
<span className="cv-arrow">←</span>
|
||||
<span className="cv-factor">{faNum(c.factor)}</span>
|
||||
<span className="cv-arrow">←</span>
|
||||
<span className="cv-unit">{c.to_unit}</span>
|
||||
<span className="cv-brand">ثابت</span>
|
||||
<span className="conv-actions">
|
||||
<button type="button" title="ویرایش" onClick={() => startStdEdit(c.id)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="حذف"
|
||||
onClick={() => void deleteStd.mutateAsync(c.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* فرم تبدیل ثابت */}
|
||||
<div className="add-conv">
|
||||
<h4 className="mb-2 text-[13.5px] font-bold">
|
||||
{editingStdId ? (
|
||||
<>
|
||||
<Pencil className="me-1 inline h-3.5 w-3.5" /> ویرایش تبدیل ثابت
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="me-1 inline h-3.5 w-3.5" /> افزودن تبدیل ثابت
|
||||
</>
|
||||
)}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[1fr_0.8fr_1fr_auto] sm:items-end">
|
||||
<div className="field">
|
||||
<label>از واحد</label>
|
||||
<input
|
||||
type="text"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="مثلاً تن"
|
||||
autoComplete="off"
|
||||
value={stdFrom}
|
||||
onChange={(e) => setStdFrom(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>ضریب</label>
|
||||
<NumberInput
|
||||
value={parseNum(stdFactor)}
|
||||
onChange={(n) => setStdFactor(n != null ? String(n) : "")}
|
||||
placeholder="مثلاً 1000"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>به واحد</label>
|
||||
<input
|
||||
type="text"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="مثلاً کیلوگرم"
|
||||
autoComplete="off"
|
||||
value={stdTo}
|
||||
onChange={(e) => setStdTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" onClick={saveStdConv}>
|
||||
{editingStdId ? "ذخیره تغییرات" : "افزودن"}
|
||||
</Button>
|
||||
{editingStdId && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="slate"
|
||||
onClick={() => {
|
||||
setEditingStdId(null);
|
||||
setStdFrom("");
|
||||
setStdFactor("");
|
||||
setStdTo("");
|
||||
}}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="conv-hint">
|
||||
تبدیل ثابت برای همهی دستهها اعمال میشود؛ «عدد» واحد مرجع خانوادهی عددی است. ضریب یعنی: ۱ از واحد = ضریب × به واحد
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Material, Supplier } from "../../lib/types";
|
||||
import { TOPICS } from "../../lib/constants";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { useSuppliers } from "../../hooks";
|
||||
import { TopicGrid } from "../../components/TopicGrid";
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface Step1Props {
|
||||
selectedTopic: string;
|
||||
onTopicSelect: (topic: string) => void;
|
||||
supplierName: string;
|
||||
onSupplierName: (name: string) => void;
|
||||
newSup: { location: string; phone: string; contact: string };
|
||||
onNewSup: (patch: Partial<{ location: string; phone: string; contact: string }>) => void;
|
||||
isOfficial: boolean;
|
||||
onOfficial: (v: boolean) => void;
|
||||
invalid: boolean;
|
||||
}
|
||||
|
||||
// مرحله ۱ — تاپیک + تأمینکننده (چیپهای سریع + فرم تأمینکنندهی جدید)
|
||||
export function Step1TopicSupplier({
|
||||
selectedTopic,
|
||||
onTopicSelect,
|
||||
supplierName,
|
||||
onSupplierName,
|
||||
newSup,
|
||||
onNewSup,
|
||||
isOfficial,
|
||||
onOfficial,
|
||||
invalid,
|
||||
}: Step1Props) {
|
||||
const suppliers = useSuppliers();
|
||||
const [selectedSupId, setSelectedSupId] = useState<string | null>(null);
|
||||
|
||||
const topicSups = useMemo(
|
||||
() => (selectedTopic ? suppliers.filter((s) => normalize(s.topic) === normalize(selectedTopic)) : []),
|
||||
[suppliers, selectedTopic],
|
||||
);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const out: Record<string, number> = {};
|
||||
TOPICS.forEach((t) => {
|
||||
out[t.name] = suppliers.filter((s) => normalize(s.topic) === normalize(t.name)).length;
|
||||
});
|
||||
return out;
|
||||
}, [suppliers]);
|
||||
|
||||
// آیا تأمینکنندهی فعلی در لیست هست؟
|
||||
const matched = useMemo(() => {
|
||||
const n = normalize(supplierName);
|
||||
if (!n) return null;
|
||||
return topicSups.find((s) => normalize(s.name) === n) || null;
|
||||
}, [topicSups, supplierName]);
|
||||
|
||||
const showNewSupForm = supplierName.trim().length > 0 && !matched;
|
||||
|
||||
const pickSupplier = (s: Supplier) => {
|
||||
onSupplierName(s.name);
|
||||
setSelectedSupId(s.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
موضوع (دستهبندی کالا)
|
||||
</h3>
|
||||
<div className="mb-3 text-[12px] text-muted">
|
||||
اول موضوع کالای موردنیاز را از بین تصاویر انتخاب کنید. تأمینکنندهها بر اساس موضوع فیلتر میشوند.
|
||||
</div>
|
||||
<TopicGrid
|
||||
selectedTopic={selectedTopic}
|
||||
onSelect={onTopicSelect}
|
||||
showCounts
|
||||
counts={counts}
|
||||
/>
|
||||
|
||||
{selectedTopic && (
|
||||
<div className="mt-5">
|
||||
<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>
|
||||
تأمینکننده
|
||||
</h3>
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{!topicSups.length ? (
|
||||
<div className="chip-divider">
|
||||
هنوز تأمینکنندهای در این موضوع ثبت نشده — پایین را تایپ کنید تا بهعنوان تأمینکنندهی جدید ذخیره شود.
|
||||
</div>
|
||||
) : (
|
||||
topicSups.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={cn("quick-chip", selectedSupId === s.id && "selected")}
|
||||
onClick={() => pickSupplier(s)}
|
||||
>
|
||||
{s.name} <span className="qc-sub">{s.location || ""}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("field max-w-md", invalid && "invalid")}>
|
||||
<label>یا نام تأمینکننده را تایپ و انتخاب کنید</label>
|
||||
<Combobox
|
||||
value={supplierName}
|
||||
onChange={(it: { id: string; label: string }) => {
|
||||
onSupplierName(it.label);
|
||||
setSelectedSupId(it.id === it.label ? null : it.id);
|
||||
}}
|
||||
items={topicSups.map((s) => ({ id: s.id, name: s.name }))}
|
||||
allowAdd
|
||||
addSuffix="به عنوان تأمینکنندهٔ جدید"
|
||||
placeholder="تایپ کنید... مثلاً زاگرس"
|
||||
onInputChange={(v) => {
|
||||
onSupplierName(v);
|
||||
setSelectedSupId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showNewSupForm && (
|
||||
<div className="mt-3 rounded-xl border border-dashed border-accent/40 bg-blue-bg/20 p-3">
|
||||
<div className="mb-2 text-[12px] text-muted">
|
||||
این تأمینکننده در لیست نیست. جزئیات را تکمیل کنید تا ذخیره شود:
|
||||
</div>
|
||||
<div className="grid max-w-2xl grid-cols-2 gap-3">
|
||||
<div className="field">
|
||||
<label>موقعیت (شهر - منطقه)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="مثلاً اصفهان - ذوب آهن"
|
||||
value={newSup.location}
|
||||
onChange={(e) => onNewSup({ location: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تلفن</label>
|
||||
<input
|
||||
type="text"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="۰۲۱-..."
|
||||
value={newSup.phone}
|
||||
onChange={(e) => onNewSup({ phone: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>شخص رابط</label>
|
||||
<input
|
||||
type="text"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="نام فرد"
|
||||
value={newSup.contact}
|
||||
onChange={(e) => onNewSup({ contact: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{supplierName.trim() && (
|
||||
<label className="mt-3 flex cursor-pointer items-center gap-2 text-[13px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-accent"
|
||||
checked={isOfficial}
|
||||
onChange={(e) => onOfficial(e.target.checked)}
|
||||
/>
|
||||
فاکتور رسمی
|
||||
<span className="text-[11px] text-muted">
|
||||
(تأمینکننده برای این معامله فاکتور رسمی صادر میکند — نه فقط توافق تلفنی)
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// دستههای موجود داخل یک تاپیک (از materials + تأمینکنندهها)
|
||||
export function topicCategories(topic: string, suppliers: Supplier[], materials: Material[]): string[] {
|
||||
const out = new Set<string>();
|
||||
materials
|
||||
.filter((m) => topic && normalize(m.topic) === normalize(topic) && m.category)
|
||||
.forEach((m) => out.add(m.category));
|
||||
suppliers
|
||||
.filter((s) => topic && normalize(s.topic) === normalize(topic))
|
||||
.forEach((s) => (s.categories || []).forEach((c) => c && out.add(c)));
|
||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Material } from "../../lib/types";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { useMaterials } from "../../hooks";
|
||||
import { specsKey } from "../../lib/quoteUtils";
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
import { NumberInput } from "../../components/NumberInput";
|
||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
export interface ItemDraft {
|
||||
tempId: string;
|
||||
cat: string;
|
||||
brand: string;
|
||||
specs: string[];
|
||||
qty: number | null;
|
||||
unit: string;
|
||||
price: number | null;
|
||||
total: number | null;
|
||||
}
|
||||
|
||||
export interface MaterialRec {
|
||||
topic: string;
|
||||
category: string;
|
||||
brand: string;
|
||||
specs: string[];
|
||||
}
|
||||
|
||||
export interface Step2Props {
|
||||
items: ItemDraft[];
|
||||
onItemsChange: (items: ItemDraft[]) => void;
|
||||
selectedTopic: string;
|
||||
onRemember: (rec: MaterialRec) => void;
|
||||
}
|
||||
|
||||
// برچسب انسانی یک قلم (برای termsheet، درگاندروپ و بازبینی)
|
||||
export function itemRowLabel(it: ItemDraft): string {
|
||||
const parts = [it.cat, it.brand, it.specs.join(" - ")].filter(Boolean);
|
||||
const name = parts.length ? parts.join(" - ") : "(قلم بدوننام)";
|
||||
const qtyPart = it.qty != null && it.unit ? `${it.qty} ${it.unit}` : it.qty != null ? String(it.qty) : "";
|
||||
return qtyPart ? `${name} -- ${qtyPart}` : name;
|
||||
}
|
||||
|
||||
// مقادیر مجزای category/brand/spec داخل تاپیک — با فیلتر پلهبهپله (تاپیک → دسته → برند)
|
||||
// + مقادیر تایپشده در همهی سطرهای قبلی (suggestion از همهی فیلدها)
|
||||
// نکته: برند و واحد از همهی دستهها نمایش داده میشوند (issue #7)
|
||||
export function materialOptions(
|
||||
materials: Material[],
|
||||
topic: string,
|
||||
item: ItemDraft,
|
||||
field: "cat" | "brand" | "spec",
|
||||
allItems: ItemDraft[] = [],
|
||||
apiBrands: string[] = [],
|
||||
): string[] {
|
||||
let list = materials.filter((m) => topic && normalize(m.topic) === normalize(topic));
|
||||
const cat = item.cat.trim();
|
||||
const brand = item.brand.trim();
|
||||
|
||||
// مقادیر واردشده در سطرهای دیگر — برای پیشنهاد در سطرهای جدید
|
||||
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
|
||||
const out = new Set<string>();
|
||||
allItems.forEach((it) => {
|
||||
if (it.tempId === item.tempId) return;
|
||||
const v = sel(it);
|
||||
if (Array.isArray(v)) v.forEach((x) => x && out.add(x));
|
||||
else if (v) out.add(v);
|
||||
});
|
||||
return [...out];
|
||||
};
|
||||
|
||||
if (field === "cat") {
|
||||
const base = new Set(list.map((m) => m.category).filter(Boolean));
|
||||
fromRows((it) => it.cat).forEach((c) => base.add(c));
|
||||
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}
|
||||
if (cat) list = list.filter((m) => normalize(m.category) === normalize(cat));
|
||||
if (field === "brand") {
|
||||
// همهی برندها از همهی materials + برندهای ثبتشده در بخش برندها (بکاند)
|
||||
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
|
||||
apiBrands.forEach((b) => b && allBrands.add(b));
|
||||
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
|
||||
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}
|
||||
if (brand) list = list.filter((m) => normalize(m.brand) === normalize(brand));
|
||||
const base = new Set(list.flatMap((m) => (m.specs || []).filter(Boolean)));
|
||||
fromRows((it) => it.specs).forEach((s) => base.add(s));
|
||||
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}
|
||||
|
||||
// مرحله ۲ — جدول پویای اقلام سفارش
|
||||
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
|
||||
const materials = useMaterials();
|
||||
|
||||
// واحدها از بکاند خوانده میشوند + واحدهای واردشده در سطرهای قبلی
|
||||
const [unitOptions, setUnitOptions] = useState<string[]>(() => {
|
||||
const all = new Set<string>();
|
||||
items.forEach((it) => { if (it.unit) all.add(it.unit); });
|
||||
return [...all];
|
||||
});
|
||||
// واحدهای تایپشده در سطرهای قبلی
|
||||
const allUnits = useMemo(() => {
|
||||
const out = new Set(unitOptions);
|
||||
items.forEach((it) => { if (it.unit) out.add(it.unit); });
|
||||
return [...out];
|
||||
}, [items, unitOptions]);
|
||||
|
||||
// برندهای ثبتشده در بخش برندها (بکاند) — بهعلاوهی برندهای متریالها
|
||||
const [brandOptions, setBrandOptions] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void api
|
||||
.unitsList()
|
||||
.then((list) => alive && setUnitOptions((prev) => {
|
||||
const merged = new Set(prev);
|
||||
list.forEach((u) => { if (u.name_fa || u.code) merged.add(u.name_fa || u.code); });
|
||||
return [...merged];
|
||||
}))
|
||||
.catch(() => { /* بکاند در دسترس نیست */ });
|
||||
void api
|
||||
.brandsList()
|
||||
.then((list) => alive && setBrandOptions(list.map((b) => b.name).filter(Boolean)))
|
||||
.catch(() => { /* بکاند در دسترس نیست */ });
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
const patch = (tempId: string, p: Partial<ItemDraft>) => {
|
||||
onItemsChange(items.map((it) => (it.tempId === tempId ? { ...it, ...p } : it)));
|
||||
};
|
||||
|
||||
const remove = (tempId: string) => {
|
||||
onItemsChange(items.filter((it) => it.tempId !== tempId));
|
||||
};
|
||||
|
||||
const add = () => {
|
||||
// سطر جدید — مقدارها خالی؛ پیشنهادها از همهی سطرهای قبلی میآیند
|
||||
onItemsChange([
|
||||
...items,
|
||||
{ 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) => {
|
||||
if (!selectedTopic) return;
|
||||
let rec: MaterialRec;
|
||||
if (field === "i-cat") {
|
||||
if (!it.cat.trim()) return;
|
||||
rec = { topic: selectedTopic, category: it.cat.trim(), brand: "", specs: [] };
|
||||
} else if (field === "i-brand") {
|
||||
if (!it.brand.trim()) return;
|
||||
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: [] };
|
||||
} else {
|
||||
if (!it.specs.length) return;
|
||||
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
|
||||
}
|
||||
const dup = materials.some(
|
||||
(m) =>
|
||||
normalize(m.topic) === normalize(rec.topic) &&
|
||||
normalize(m.category) === normalize(rec.category) &&
|
||||
normalize(m.brand) === normalize(rec.brand) &&
|
||||
specsKey(m.specs) === specsKey(rec.specs),
|
||||
);
|
||||
if (dup) return;
|
||||
onRemember(rec);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
اقلام سفارش
|
||||
</h3>
|
||||
<div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را میتوان از گزینهها انتخاب کرد یا تایپ کرد تا بهعنوان گزینهی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحلهی بعد مشخص میکنید.' }} />
|
||||
|
||||
<div className="item-row wizard-item-row item-row-head mb-1 pe-7">
|
||||
<span>متریال</span>
|
||||
<span>برند</span>
|
||||
<span>مشخصه</span>
|
||||
<span>تعداد</span>
|
||||
<span>واحد</span>
|
||||
<span>قیمت واحد</span>
|
||||
<span>قیمت کل</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{items.map((it) => (
|
||||
<div key={it.tempId} className="item-row wizard-item-row">
|
||||
<Combobox
|
||||
value={it.cat}
|
||||
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
||||
items={materialOptions(materials, selectedTopic, it, "cat", items)}
|
||||
allowAdd
|
||||
addSuffix="به عنوان متریال جدید"
|
||||
placeholder="متریال"
|
||||
onInputChange={(v) => patch(it.tempId, { cat: v })}
|
||||
onBlur={() => rememberField("i-cat", it)}
|
||||
inputClassName="!text-[12px] !h-9"
|
||||
className="min-w-0"
|
||||
/>
|
||||
<Combobox
|
||||
value={it.brand}
|
||||
onChange={(x) => patch(it.tempId, { brand: x.label })}
|
||||
items={materialOptions(materials, selectedTopic, it, "brand", items, brandOptions)}
|
||||
allowAdd
|
||||
addSuffix="به عنوان برند جدید"
|
||||
placeholder="برند"
|
||||
onInputChange={(v) => patch(it.tempId, { brand: v })}
|
||||
onBlur={() => rememberField("i-brand", it)}
|
||||
inputClassName="!text-[12px] !h-9"
|
||||
className="min-w-0"
|
||||
/>
|
||||
<MultiSelectCombobox
|
||||
values={it.specs}
|
||||
onChange={(specs) => patch(it.tempId, { specs })}
|
||||
options={materialOptions(materials, selectedTopic, it, "spec", items)}
|
||||
placeholder="مشخصه"
|
||||
className="min-w-0"
|
||||
/>
|
||||
<NumberInput
|
||||
value={it.qty}
|
||||
onChange={(qty) => {
|
||||
patch(it.tempId, {
|
||||
qty,
|
||||
total: qty != null && it.price != null ? Math.round(qty * it.price * 100) / 100 : it.total,
|
||||
});
|
||||
}}
|
||||
placeholder="تعداد"
|
||||
showWords
|
||||
/>
|
||||
<Combobox
|
||||
value={it.unit}
|
||||
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
||||
items={allUnits}
|
||||
allowAdd
|
||||
addSuffix="اضافه کردن واحد جدید"
|
||||
placeholder="واحد"
|
||||
onInputChange={(v) => patch(it.tempId, { unit: v })}
|
||||
inputClassName="!text-[12px] !h-9"
|
||||
className="min-w-0"
|
||||
/>
|
||||
<NumberInput
|
||||
value={it.price}
|
||||
onChange={(price) => {
|
||||
patch(it.tempId, {
|
||||
price,
|
||||
total: it.qty != null && price != null ? Math.round(it.qty * price * 100) / 100 : it.total,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت واحد"
|
||||
showWords
|
||||
maxDecimals={2}
|
||||
/>
|
||||
<NumberInput
|
||||
value={it.total}
|
||||
onChange={(total) => {
|
||||
patch(it.tempId, {
|
||||
total,
|
||||
price:
|
||||
total != null && it.qty != null && it.qty > 0 ? Math.round((total / it.qty) * 100) / 100 : it.price,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت کل"
|
||||
showWords
|
||||
/>
|
||||
<button type="button" className="cursor-pointer text-muted hover:text-red" title="حذف" onClick={() => remove(it.tempId)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="button" className="btn-line-file mt-2.5" onClick={add}>
|
||||
+ افزودن قلم
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Milestone } from "../../lib/types";
|
||||
import { TermsheetCard } from "../../components/TermsheetCard";
|
||||
import { itemRowLabel, type ItemDraft } from "./Step2LineItems";
|
||||
|
||||
export interface TsCardDraft {
|
||||
key: string;
|
||||
milestones: Milestone[];
|
||||
}
|
||||
|
||||
export interface Step3Props {
|
||||
cards: TsCardDraft[];
|
||||
onCardsChange: (cards: TsCardDraft[]) => void;
|
||||
items: ItemDraft[];
|
||||
coverage: Record<string, string[]>; // cardKey → tempIdهای تیکخورده
|
||||
onCoverageChange: (cardKey: string, ids: string[]) => void;
|
||||
}
|
||||
|
||||
// پوشش مؤثر هر کارت: تیکهای صریح، یا (کارت اول بدون تیک) همهی اقلام
|
||||
export function effectiveCovered(cards: TsCardDraft[], coverage: Record<string, string[]>, items: ItemDraft[], cardKey: string): string[] {
|
||||
const explicit = coverage[cardKey];
|
||||
if (explicit && explicit.length) return explicit;
|
||||
if (cards[0]?.key === cardKey) return items.map((i) => i.tempId);
|
||||
return [];
|
||||
}
|
||||
|
||||
// مرحله ۳ — شرایط پرداخت (termsheetها) با چکلیست اقلام
|
||||
export function Step3Termsheets({ cards, onCardsChange, items, coverage, onCoverageChange }: Step3Props) {
|
||||
const itemOptions = items.map((it) => ({ tempId: it.tempId, label: itemRowLabel(it) }));
|
||||
|
||||
const addCard = () => {
|
||||
onCardsChange([
|
||||
...cards,
|
||||
{ key: "new-ts-" + Date.now() + Math.random().toString(36).slice(2, 6), milestones: [{ when_iso: null, amount: null }] },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeCard = (key: string) => {
|
||||
onCardsChange(cards.filter((c) => c.key !== key));
|
||||
onCoverageChange(key, []);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
شرایط پرداخت — termsheet ها
|
||||
</h3>
|
||||
<div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'هر <b>termsheet</b> یک روش پرداخت است (نقد، چک، اقساط...). داخل هر termsheet مشخص کنید کدام قلمها را پوشش میدهد — یک termsheet میتواند چند قلم را بگیرد، و یک قلم هم میتواند زیر چند termsheet مختلف (مثلاً هم نقد هم چک) پیشنهاد شود.' }} />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{cards.map((card, idx) => (
|
||||
<TermsheetCard
|
||||
key={card.key}
|
||||
index={idx + 1}
|
||||
milestones={card.milestones}
|
||||
onMilestonesChange={(ms) => onCardsChange(cards.map((c) => (c.key === card.key ? { ...c, milestones: ms } : c)))}
|
||||
items={itemOptions}
|
||||
coveredIds={effectiveCovered(cards, coverage, items, card.key)}
|
||||
onCoverageChange={(ids) => onCoverageChange(card.key, ids)}
|
||||
onRemove={() => removeCard(card.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="button" className="btn-line-file mt-3" onClick={addCard}>
|
||||
+ افزودن termsheet
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useState } from "react";
|
||||
import { DndContext, PointerSensor, useDraggable, useDroppable, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { toast } from "sonner";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { itemRowLabel, type ItemDraft } from "./Step2LineItems";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface Step4Props {
|
||||
items: ItemDraft[];
|
||||
projects: string[];
|
||||
assignment: Record<string, string[]>; // tempId → project names
|
||||
onAssign: (tempId: string, proj: string) => void;
|
||||
onUnassign: (tempId: string, proj: string) => void;
|
||||
onAddProject: (name: string) => void;
|
||||
}
|
||||
|
||||
const ALL_KEY = "__all__";
|
||||
|
||||
function DraggableItem({
|
||||
item,
|
||||
label,
|
||||
assignedCount,
|
||||
assignedNames,
|
||||
picked,
|
||||
isAll,
|
||||
onPick,
|
||||
}: {
|
||||
item?: ItemDraft;
|
||||
label: string;
|
||||
assignedCount: number;
|
||||
assignedNames: string[];
|
||||
picked: boolean;
|
||||
isAll: boolean;
|
||||
onPick: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: isAll ? "item-" + ALL_KEY : "item-" + item!.tempId,
|
||||
data: { tempId: isAll ? ALL_KEY : item!.tempId, isAll },
|
||||
});
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
onClick={onPick}
|
||||
className={cn(
|
||||
"dd-item",
|
||||
!isAll && assignedCount === 0 && "dd-unassigned",
|
||||
picked && "picked",
|
||||
isDragging && "dragging",
|
||||
isAll && "border-accent/40 bg-accent-bg/30",
|
||||
)}
|
||||
style={{ transform: CSS.Translate.toString(transform) }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className="dd-label">{label}</span>
|
||||
{assignedNames.length > 0 && (
|
||||
<div className="mt-0.5 text-[10px] text-muted">لینکشده به: {assignedNames.join("، ")}</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="dd-count">{isAll ? "" : assignedCount ? faNum(assignedCount) + " پروژه" : "بدون پروژه"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DroppableProject({
|
||||
name,
|
||||
tags,
|
||||
onTagRemove,
|
||||
onPickProject,
|
||||
}: {
|
||||
name: string;
|
||||
tags: { tempId: string; label: string }[];
|
||||
onTagRemove: (tempId: string) => void;
|
||||
onPickProject: () => void;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: "proj-" + name, data: { proj: name } });
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
onClick={onPickProject}
|
||||
className={cn("dd-project", isOver && "drop-hover")}
|
||||
>
|
||||
<div className="dp-name">🏗️ {name}</div>
|
||||
<div className="dp-tags">
|
||||
{tags.length ? (
|
||||
tags.map((t) => (
|
||||
<span key={t.tempId} className="dd-tag">
|
||||
{t.label}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="dd-tag-x"
|
||||
title="حذف از این پروژه"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTagRemove(t.tempId);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<div className="dd-proj-empty">قلمی برای این پروژه رها نشده</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// مرحله ۴ — تخصیص پروژهها با درگاندروپ (یا کلیک-قلم بعد کلیک-پروژه)
|
||||
export function Step4Projects({ items, projects, assignment, onAssign, onUnassign, onAddProject }: Step4Props) {
|
||||
const [picked, setPicked] = useState<string | null>(null);
|
||||
const [newProj, setNewProj] = useState("");
|
||||
|
||||
// فاصلهی حداقلی قبل از شروع درگ — تا کلیک ساده روی قلم، «انتخاب قلم» باشد
|
||||
// نه شروع درگ (بدون این، dnd-kit رویداد click را میبلعد و کلیک-انتخاب کار نمیکند)
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
|
||||
|
||||
// افزودن پروژه بهصورت درجا — بدون رفتن به صفحهی پروژهها
|
||||
const addProjectInline = () => {
|
||||
const name = newProj.trim();
|
||||
if (!name) return;
|
||||
const dup = projects.some((p) => normalize(p) === normalize(name));
|
||||
if (dup) {
|
||||
toast.error("این پروژه قبلاً ثبت شده است.");
|
||||
return;
|
||||
}
|
||||
onAddProject(name);
|
||||
setNewProj("");
|
||||
toast.success("پروژه افزوده شد.");
|
||||
};
|
||||
|
||||
const assignAll = (proj: string) => {
|
||||
items.forEach((it) => onAssign(it.tempId, proj));
|
||||
};
|
||||
|
||||
const onDragEnd = (e: DragEndEvent) => {
|
||||
const tempId = (e.active.data.current as { tempId?: string; isAll?: boolean } | undefined)?.tempId;
|
||||
const proj = (e.over?.data.current as { proj?: string } | undefined)?.proj;
|
||||
if (!proj) return;
|
||||
if (tempId === ALL_KEY) {
|
||||
// همهی اقلام به این پروژه
|
||||
assignAll(proj);
|
||||
} else if (tempId) {
|
||||
onAssign(tempId, proj);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
پروژهها
|
||||
</h3>
|
||||
<div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'یک تماس میتواند دربارهی چند پروژه باشد. هر قلم را روی پروژهی مربوطه <b>بکشید و رها کنید</b> (یا روی قلم کلیک کنید، بعد روی پروژه کلیک کنید). هر قلم میتواند روی چند پروژه باشد. گزینهی «همه اقلام» همهی اقلام را به پروژهی موردنظر اختصاص میدهد.' }} />
|
||||
|
||||
<DndContext sensors={sensors} onDragEnd={onDragEnd}>
|
||||
<div className="dd-panels">
|
||||
<div className="dd-col dd-items-col">
|
||||
<h4 className="mb-1 text-[13px] font-bold">اقلام</h4>
|
||||
<div className="dd-hint mb-2 text-[11px] text-muted">
|
||||
قلم را بکشید و روی پروژه رها کنید — یا روی قلم کلیک کنید، بعد روی پروژه کلیک کنید
|
||||
</div>
|
||||
{/* همه اقلام — اختصاص همه به یک پروژه */}
|
||||
<DraggableItem
|
||||
label="همه اقلام"
|
||||
assignedCount={0}
|
||||
assignedNames={[]}
|
||||
picked={picked === ALL_KEY}
|
||||
isAll
|
||||
onPick={() => setPicked(picked === ALL_KEY ? null : ALL_KEY)}
|
||||
/>
|
||||
{items.map((it) => (
|
||||
<DraggableItem
|
||||
key={it.tempId}
|
||||
item={it}
|
||||
label={itemRowLabel(it)}
|
||||
assignedCount={(assignment[it.tempId] || []).length}
|
||||
assignedNames={assignment[it.tempId] || []}
|
||||
picked={picked === it.tempId}
|
||||
isAll={false}
|
||||
onPick={() => setPicked(picked === it.tempId ? null : it.tempId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="dd-col dd-projects-col">
|
||||
<h4 className="mb-1 text-[13px] font-bold">پروژهها</h4>
|
||||
{projects.map((pname) => (
|
||||
<DroppableProject
|
||||
key={pname}
|
||||
name={pname}
|
||||
tags={items
|
||||
.filter((it) => (assignment[it.tempId] || []).includes(pname))
|
||||
.map((it) => ({ tempId: it.tempId, label: itemRowLabel(it) }))}
|
||||
onTagRemove={(tempId) => onUnassign(tempId, pname)}
|
||||
onPickProject={() => {
|
||||
if (picked) {
|
||||
if (picked === ALL_KEY) assignAll(pname);
|
||||
else onAssign(picked, pname);
|
||||
setPicked(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{!projects.length && (
|
||||
<div className="dd-empty">هنوز پروژهای تعریف نشده است — از پایین یک پروژه اضافه کنید.</div>
|
||||
)}
|
||||
{/* افزودن پروژه بهصورت درجا — زیر لیست پروژهها */}
|
||||
<div className="mt-3 rounded-md border border-dashed border-line p-2">
|
||||
<div className="mb-1.5 text-[11.5px] font-semibold text-muted">افزودن پروژهی جدید:</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="نام پروژه"
|
||||
autoComplete="off"
|
||||
value={newProj}
|
||||
onChange={(e) => setNewProj(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addProjectInline();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="btn-line-file shrink-0 bg-accent text-white hover:bg-accent-dark" onClick={addProjectInline}>
|
||||
+ افزودن
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Paperclip } from "lucide-react";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||
import { FileUpload } from "../../components/FileUpload";
|
||||
|
||||
export interface Step5Props {
|
||||
quoteDate: string | null;
|
||||
onQuoteDate: (iso: string | null) => void;
|
||||
expiryDate: string | null;
|
||||
onExpiryDate: (iso: string | null) => void;
|
||||
notes: string;
|
||||
onNotes: (v: string) => void;
|
||||
proforma: File | null;
|
||||
onProforma: (f: File | null) => void;
|
||||
// خلاصهی بازبینی
|
||||
summary: {
|
||||
supplierName: string;
|
||||
topicName: string;
|
||||
projects: string[];
|
||||
itemCount: number;
|
||||
tsCount: number;
|
||||
grandTotal: number;
|
||||
isOfficial: boolean;
|
||||
orphanCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
// مرحله ۵ — تاریخ، یادداشت، پیشفاکتور و بازبینی نهایی
|
||||
export function Step5Review({
|
||||
quoteDate,
|
||||
onQuoteDate,
|
||||
expiryDate,
|
||||
onExpiryDate,
|
||||
notes,
|
||||
onNotes,
|
||||
proforma,
|
||||
onProforma,
|
||||
summary,
|
||||
}: Step5Props) {
|
||||
const rows: [string, string][] = [
|
||||
["تأمینکننده", summary.supplierName],
|
||||
["موضوع", summary.topicName],
|
||||
["پروژهها", summary.projects.join("، ") || "—"],
|
||||
["تعداد اقلام", groupCommas(summary.itemCount, true)],
|
||||
["تعداد termsheet", groupCommas(summary.tsCount, true)],
|
||||
["جمع کل تقریبی", groupCommas(summary.grandTotal, true) + " تومان"],
|
||||
["فاکتور رسمی", summary.isOfficial ? "بله" : "خیر"],
|
||||
["پیشفاکتور", proforma ? proforma.name : "—"],
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
تاریخ، یادداشت و بازبینی نهایی
|
||||
</h3>
|
||||
|
||||
<div className="grid max-w-2xl grid-cols-2 gap-3">
|
||||
<div className="field">
|
||||
<label>تاریخ پیشنهاد</label>
|
||||
<JalaliDatePicker value={quoteDate} onChange={onQuoteDate} placeholder="۱۴۰۳/۰۵/۱۲" withTime />
|
||||
<div className="hint">پیشفرض امروز</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تاریخ انقضای پیشنهاد</label>
|
||||
<JalaliDatePicker value={expiryDate} onChange={onExpiryDate} placeholder="۱۴۰۳/۰۶/۱۲" />
|
||||
<div className="hint">تا چه زمانی این قیمت معتبر است؟</div>
|
||||
</div>
|
||||
<div className="field col-span-2">
|
||||
<label>یادداشت (اختیاری)</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="توضیحات اضافی درباره تماس یا توافق..."
|
||||
value={notes}
|
||||
onChange={(e) => onNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field col-span-2">
|
||||
<label>پیشفاکتور (اختیاری)</label>
|
||||
{proforma ? (
|
||||
<div className="inv-row">
|
||||
<span className="inv-name">
|
||||
<Paperclip className="me-1 inline h-3.5 w-3.5" />
|
||||
{proforma.name}
|
||||
</span>
|
||||
<button type="button" className="inv-x" title="حذف" onClick={() => onProforma(null)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<FileUpload onUpload={onProforma} label="انتخاب فایل پیشفاکتور" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-xl border border-line bg-panel p-4 shadow-sm">
|
||||
<h4 className="mb-2 text-[13.5px] font-bold">خلاصهی پیشنهاد پیش از ذخیره</h4>
|
||||
{rows.map(([label, val]) => (
|
||||
<div key={label} className="review-row">
|
||||
<span className="r-label">{label}</span>
|
||||
<span className="r-val">{val}</span>
|
||||
</div>
|
||||
))}
|
||||
{summary.orphanCount > 0 && (
|
||||
<div className="review-row">
|
||||
<span className="r-label">هشدار</span>
|
||||
<span className="r-val review-warn">
|
||||
{faNum(summary.orphanCount)} قلم به هیچ termsheet وصل نیست -- در ذخیرهسازی بهطور خودکار به اولین termsheet وصل میشود
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Check } from "lucide-react";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface StepperProps {
|
||||
current: number;
|
||||
highestVisited: number;
|
||||
completedSteps: Set<number>;
|
||||
onGoto: (step: number) => void;
|
||||
}
|
||||
|
||||
const STEPS = ["تأمینکننده", "اقلام", "شرایط پرداخت", "پروژه", "بازبینی و ثبت"];
|
||||
|
||||
// نوار ۵ مرحلهای ویزارد — کلیک روی مراحل بازدیدشده مجاز است
|
||||
// مراحل تکمیلشده سبز، مرحلهی فعلی آبی
|
||||
export function Stepper({ current, completedSteps, onGoto }: StepperProps) {
|
||||
return (
|
||||
<div className="mb-4 flex flex-wrap gap-2 rounded-xl border border-line bg-panel p-2 shadow-sm">
|
||||
{STEPS.map((label, i) => {
|
||||
const step = i + 1;
|
||||
const isCurrent = step === current;
|
||||
const isComplete = completedSteps.has(step);
|
||||
return (
|
||||
<button
|
||||
key={step}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg px-3 py-2 text-[12.5px] transition-colors",
|
||||
isCurrent
|
||||
? "bg-[#2563eb] font-bold text-white"
|
||||
: isComplete
|
||||
? "bg-green-bg font-medium text-green"
|
||||
: "hover:bg-slate-bg",
|
||||
)}
|
||||
onClick={() => onGoto(step)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-5 w-5 items-center justify-center rounded-full text-[11px] font-bold",
|
||||
isCurrent ? "bg-white/25" : isComplete ? "bg-green-bg text-green" : "bg-slate-bg text-muted",
|
||||
)}
|
||||
>
|
||||
{isComplete ? <Check className="h-3 w-3" /> : faNum(step)}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import type { LineItem, Quote } from "../../lib/types";
|
||||
import { api, nextId } from "../../lib/api";
|
||||
import {
|
||||
useAddMaterial,
|
||||
useAddProject,
|
||||
useAddSupplier,
|
||||
useCreateQuote,
|
||||
useMaterials,
|
||||
useProjects,
|
||||
useQuotes,
|
||||
useStoreData,
|
||||
useSuppliers,
|
||||
} from "../../hooks";
|
||||
import { TOPICS } from "../../lib/constants";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { specsKey } from "../../lib/quoteUtils";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { Stepper } from "./Stepper";
|
||||
import { Step1TopicSupplier } from "./Step1TopicSupplier";
|
||||
import { Step2LineItems, type ItemDraft, type MaterialRec } from "./Step2LineItems";
|
||||
import { Step3Termsheets, effectiveCovered, type TsCardDraft } from "./Step3Termsheets";
|
||||
import { Step4Projects } from "./Step4Projects";
|
||||
import { Step5Review } from "./Step5Review";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog";
|
||||
|
||||
const TOTAL_STEPS = 5;
|
||||
|
||||
// ویزارد ۵ مرحلهای افزودن پیشنهاد جدید
|
||||
export function WizardPage() {
|
||||
useStoreData();
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const quotes = useQuotes();
|
||||
const suppliers = useSuppliers();
|
||||
const materials = useMaterials();
|
||||
const projects = useProjects();
|
||||
const createQuote = useCreateQuote();
|
||||
const addSupplier = useAddSupplier();
|
||||
const addMaterial = useAddMaterial();
|
||||
const addProject = useAddProject();
|
||||
|
||||
// ---------- state ----------
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [highestVisited, setHighestVisited] = useState(1);
|
||||
|
||||
const [selectedTopic, setSelectedTopic] = useState("");
|
||||
const [supplierName, setSupplierName] = useState("");
|
||||
const [newSup, setNewSup] = useState({ location: "", phone: "", contact: "" });
|
||||
const [isOfficial, setIsOfficial] = useState(false);
|
||||
const [supplierInvalid, setSupplierInvalid] = useState(false);
|
||||
|
||||
const [items, setItems] = useState<ItemDraft[]>(() => [
|
||||
{ tempId: "it0", cat: "", brand: "", specs: [], qty: null, unit: "", price: null, total: null },
|
||||
]);
|
||||
const [cards, setCards] = useState<TsCardDraft[]>(() => [
|
||||
{ key: "ts0", milestones: [{ when_iso: null, amount: null }] },
|
||||
]);
|
||||
const [coverage, setCoverage] = useState<Record<string, string[]>>({});
|
||||
const [assignment, setAssignment] = useState<Record<string, string[]>>({});
|
||||
|
||||
const [quoteDate, setQuoteDate] = useState<string | null>(() => {
|
||||
const d = new Date();
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString();
|
||||
});
|
||||
const [expiryDate, setExpiryDate] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [proforma, setProforma] = useState<File | null>(null);
|
||||
// دیالوگ ذخیره با فیلدهای خالی (نام فیلد + مرحلهی مربوطه)
|
||||
const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
// ---------- prefill از لینکها (?topic=&supplier=) ----------
|
||||
// وقتی suppliers هنوز لود نشده، دوباره اجرا میشود
|
||||
useEffect(() => {
|
||||
const t = params.get("topic");
|
||||
if (!t) return;
|
||||
const topic = TOPICS.find((x) => normalize(x.name) === normalize(t));
|
||||
if (!topic) return;
|
||||
setSelectedTopic(topic.name);
|
||||
const sid = params.get("supplier");
|
||||
if (sid) {
|
||||
const sup = suppliers.find((s) => s.id === sid);
|
||||
if (sup && normalize(sup.topic) === normalize(topic.name)) {
|
||||
setSupplierName(sup.name);
|
||||
setSupplierInvalid(false);
|
||||
}
|
||||
}
|
||||
}, [params, suppliers]);
|
||||
|
||||
// ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
|
||||
// مراحل تکمیلشده را بر اساس دادههای فعلی مشخص میکند
|
||||
const completedSteps = useMemo(() => {
|
||||
const s = new Set<number>();
|
||||
// مرحله ۱: تاپیک انتخاب شده + نام تأمینکننده پر شده
|
||||
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);
|
||||
// مرحله ۳: حداقل یک termsheet با مایلستون واقعی (تاریخ یا مبلغ) — نه فقط کارت پیشفرض
|
||||
if (
|
||||
cards.length > 0 &&
|
||||
cards.some((card) => (card.milestones || []).some((m) => m.when_iso || (m.amount != null && m.amount !== 0)))
|
||||
) {
|
||||
s.add(3);
|
||||
}
|
||||
// مرحله ۴: همه اقلام حداقل به یک پروژه اختصاص داده شده
|
||||
if (items.length > 0 && items.every((it) => (assignment[it.tempId] || []).length > 0)) s.add(4);
|
||||
// مرحله ۵: همیشه بازدیدشده محسوب میشود
|
||||
if (highestVisited >= 5) s.add(5);
|
||||
return s;
|
||||
}, [selectedTopic, supplierName, items, cards, coverage, assignment, highestVisited]);
|
||||
|
||||
const goToStep = (n: number) => {
|
||||
setCurrentStep(n);
|
||||
setHighestVisited((h) => Math.max(h, n));
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
goToStep(currentStep + 1);
|
||||
};
|
||||
|
||||
const rememberMaterial = (rec: MaterialRec) => {
|
||||
void addMaterial.mutateAsync({
|
||||
id: nextId("m", materials),
|
||||
name: [rec.brand, rec.category, ...rec.specs].filter(Boolean).join(" ") || "کالای جدید",
|
||||
topic: rec.topic,
|
||||
category: rec.category,
|
||||
brand: rec.brand,
|
||||
specs: rec.specs,
|
||||
base_unit: "",
|
||||
});
|
||||
};
|
||||
|
||||
// ---------- پروژهها: از مجموعه + نامهای قدیمی quoteها ----------
|
||||
const projectNames = useMemo(() => {
|
||||
const names = new Set<string>(projects.map((p) => p.name));
|
||||
quotes.forEach((q) => {
|
||||
if (q.project) names.add(q.project);
|
||||
(q.projects || []).forEach((n) => names.add(n));
|
||||
});
|
||||
return [...names];
|
||||
}, [projects, quotes]);
|
||||
|
||||
const assign = (tempId: string, proj: string) => {
|
||||
setAssignment((a) => {
|
||||
const cur = a[tempId] || [];
|
||||
if (cur.includes(proj)) return a;
|
||||
return { ...a, [tempId]: [...cur, proj] };
|
||||
});
|
||||
};
|
||||
|
||||
const unassign = (tempId: string, proj: string) => {
|
||||
setAssignment((a) => {
|
||||
const cur = (a[tempId] || []).filter((p) => p !== proj);
|
||||
const next = { ...a };
|
||||
if (cur.length) next[tempId] = cur;
|
||||
else delete next[tempId];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ---------- خلاصهی مرحله ۵ ----------
|
||||
const review = useMemo(() => {
|
||||
let grandTotal = 0;
|
||||
items.forEach((it) => {
|
||||
const t = it.total;
|
||||
if (t) grandTotal += t;
|
||||
});
|
||||
const projSet = new Set<string>();
|
||||
items.forEach((it) => (assignment[it.tempId] || []).forEach((p) => projSet.add(p)));
|
||||
const coveredTempIds = new Set<string>();
|
||||
cards.forEach((card) =>
|
||||
effectiveCovered(cards, coverage, items, card.key).forEach((tid) => coveredTempIds.add(tid)),
|
||||
);
|
||||
const orphanCount = items.filter((it) => !coveredTempIds.has(it.tempId)).length;
|
||||
return {
|
||||
supplierName: supplierName.trim() || "—",
|
||||
topicName: selectedTopic || "—",
|
||||
projects: [...projSet],
|
||||
itemCount: items.length,
|
||||
tsCount: cards.length,
|
||||
grandTotal,
|
||||
isOfficial,
|
||||
orphanCount,
|
||||
};
|
||||
}, [items, assignment, cards, coverage, supplierName, selectedTopic, isOfficial]);
|
||||
|
||||
// ---------- ذخیره ----------
|
||||
// تابع ذخیرهی اصلی — با یا بدون فیلدهای خالی (انتخاب کاربر در دیالوگ)
|
||||
const doSave = async () => {
|
||||
// خطای بخش — با دکمهی «رفع خطا» که به همان بخش میبرد
|
||||
const sectionError = (step: number, title: string, err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : "خطای ناشناخته";
|
||||
toast.error(`${title}: ${msg}`, {
|
||||
action: { label: "رفع خطا", onClick: () => goToStep(step) },
|
||||
});
|
||||
};
|
||||
// مشخص کردن بخش خطا از روی متن پیام
|
||||
const guessStep = (msg: string): number => {
|
||||
if (/تأمین|تامین|supplier/i.test(msg)) return 1;
|
||||
if (/متریال|واحد|قلم|خط آیتم|item|دسته/i.test(msg)) return 2;
|
||||
if (/ترم|termsheet|پرداخت|پوشش/i.test(msg)) return 3;
|
||||
if (/پروژه|project/i.test(msg)) return 4;
|
||||
return 5;
|
||||
};
|
||||
|
||||
// تأمینکننده
|
||||
const name = supplierName.trim();
|
||||
let supplierId = "";
|
||||
if (name) {
|
||||
const exist = suppliers.find((s) => normalize(s.name) === normalize(name));
|
||||
if (exist) {
|
||||
supplierId = exist.id;
|
||||
} else {
|
||||
try {
|
||||
const created = await addSupplier.mutateAsync({
|
||||
id: nextId("s", suppliers),
|
||||
topic: selectedTopic,
|
||||
categories: [],
|
||||
name,
|
||||
location: newSup.location.trim(),
|
||||
phone: newSup.phone.trim(),
|
||||
contact: newSup.contact.trim(),
|
||||
});
|
||||
supplierId = created.id;
|
||||
} catch (err) {
|
||||
sectionError(1, "خطا در بخش تأمینکننده", err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// اقلام
|
||||
const collected: { li: LineItem; tempId: string }[] = [];
|
||||
for (const it of items) {
|
||||
const cat = it.cat.trim();
|
||||
const brand = it.brand.trim();
|
||||
const specs = it.specs;
|
||||
const unit = it.unit.trim();
|
||||
const qty = it.qty;
|
||||
const price = it.price;
|
||||
const total = it.total;
|
||||
if (!cat && !brand && qty == null && price == null) continue;
|
||||
|
||||
// جستجوی متریال: اول با نام (مثل بکاند ساخته شده) — بعد از رفرش،
|
||||
// برند/مشخصه در حافظه نیستند ولی نام کامل در دیتابیس ذخیره شده
|
||||
const wantName = [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید";
|
||||
let material = materials.find(
|
||||
(m) => normalize(m.category) === normalize(cat) && normalize(m.name) === normalize(wantName),
|
||||
);
|
||||
if (!material) {
|
||||
// جستجوی قبلی (برند و مشخصهی ساختارمند در همین نشست)
|
||||
material = materials.find(
|
||||
(m) =>
|
||||
normalize(m.category) === normalize(cat) &&
|
||||
normalize(m.brand) === normalize(brand) &&
|
||||
specsKey(m.specs) === specsKey(specs),
|
||||
);
|
||||
}
|
||||
let materialId: string;
|
||||
if (material) {
|
||||
materialId = material.id;
|
||||
} else {
|
||||
try {
|
||||
const created = await addMaterial.mutateAsync({
|
||||
id: nextId("m", materials),
|
||||
name: wantName,
|
||||
topic: selectedTopic,
|
||||
category: cat,
|
||||
brand,
|
||||
specs,
|
||||
base_unit: unit,
|
||||
});
|
||||
materialId = created.id; // UUID واقعی از بکاند
|
||||
} catch (err) {
|
||||
sectionError(2, "خطا در بخش اقلام (متریال)", err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
collected.push({
|
||||
li: {
|
||||
id: "li-" + (collected.length + 1),
|
||||
material_id: materialId,
|
||||
quantity: qty,
|
||||
unit,
|
||||
unit_price: price,
|
||||
total_price: total,
|
||||
termsheet_ids: [],
|
||||
accepted_ts_id: null,
|
||||
rejected: false,
|
||||
arrived_date: null,
|
||||
},
|
||||
tempId: it.tempId,
|
||||
});
|
||||
}
|
||||
|
||||
const lineItems = collected.map((c) => c.li);
|
||||
|
||||
// termsheetها
|
||||
const termsheets = cards
|
||||
.map((card, i) => ({
|
||||
id: "new-ts-" + (i + 1),
|
||||
label: "گزینه " + faNum(i + 1),
|
||||
milestones: card.milestones.filter((m) => m.when_iso || m.amount != null),
|
||||
status: "offered" as const,
|
||||
}))
|
||||
.filter((t) => t.milestones.length);
|
||||
|
||||
// اتصال اقلام به termsheetها
|
||||
collected.forEach(({ li, tempId }) => {
|
||||
const ids = cards
|
||||
.map((card, i) =>
|
||||
effectiveCovered(cards, coverage, items, card.key).includes(tempId) ? "new-ts-" + (i + 1) : "",
|
||||
)
|
||||
.filter(Boolean);
|
||||
const valid = ids.filter((tid) => termsheets.some((t) => t.id === tid));
|
||||
li.termsheet_ids = valid.length ? valid : termsheets.length ? [termsheets[0].id] : [];
|
||||
});
|
||||
|
||||
// پروژهها
|
||||
const projSet = new Set<string>();
|
||||
collected.forEach(({ li, tempId }) => {
|
||||
const list = assignment[tempId] || [];
|
||||
li.projects = list.map((p) => ({ id: "", name: p }));
|
||||
list.forEach((p) => projSet.add(p));
|
||||
});
|
||||
const projectsList = [...projSet];
|
||||
|
||||
const quoteIso = quoteDate || new Date().toISOString();
|
||||
const quote: Quote = {
|
||||
id: nextId("q", quotes),
|
||||
supplier_id: supplierId,
|
||||
project: projectsList[0] || "",
|
||||
projects: projectsList,
|
||||
quote_date: quoteIso,
|
||||
expiry_date: expiryDate,
|
||||
accepted_date: null,
|
||||
arrived_date: null,
|
||||
status: "offered",
|
||||
is_official: isOfficial,
|
||||
proformas: [],
|
||||
invoices: [],
|
||||
events: [{ type: "offered", occurred_at: quoteIso }],
|
||||
notes: notes.trim(),
|
||||
line_items: lineItems,
|
||||
termsheets,
|
||||
};
|
||||
|
||||
try {
|
||||
const created = await createQuote.mutateAsync(quote);
|
||||
// آپلود پیشفاکتور به MinIO بعد از ساخت پیشنهاد
|
||||
if (proforma) {
|
||||
try {
|
||||
await api.files.upload(created.id, "proforma", proforma);
|
||||
} catch (err) {
|
||||
toast.error(`خطا در آپلود پیشفاکتور: ${err instanceof Error ? err.message : "خطای ناشناخته"}`, {
|
||||
action: { label: "رفع خطا", onClick: () => goToStep(5) },
|
||||
});
|
||||
}
|
||||
}
|
||||
toast.success("پیشنهاد ذخیره شد.");
|
||||
navigate("/");
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "خطا در ذخیره پیشنهاد";
|
||||
sectionError(guessStep(msg), "خطا در ثبت پیشنهاد", e);
|
||||
}
|
||||
};
|
||||
|
||||
// ذخیره — اگر بخشی ناقص است، دیالوگ تأیید با لینک فیلدهای خالی باز میشود
|
||||
const save = () => {
|
||||
const incomplete: { label: string; step: number }[] = [];
|
||||
if (!completedSteps.has(1)) incomplete.push({ label: "تأمینکننده", step: 1 });
|
||||
if (!completedSteps.has(2)) incomplete.push({ label: "اقلام", step: 2 });
|
||||
if (!completedSteps.has(3)) incomplete.push({ label: "شرایط پرداخت", step: 3 });
|
||||
if (!completedSteps.has(4)) incomplete.push({ label: "پروژه", step: 4 });
|
||||
if (incomplete.length > 0) {
|
||||
setEmptyFields(incomplete);
|
||||
setConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
void doSave();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header
|
||||
title="افزودن پیشنهاد جدید"
|
||||
sub="اطلاعات را از تماس با تأمینکننده وارد کنید"
|
||||
backTo="/"
|
||||
backLabel="بازگشت به دفتر"
|
||||
/>
|
||||
<PageLayout>
|
||||
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
||||
|
||||
{currentStep === 1 && (
|
||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<Step1TopicSupplier
|
||||
selectedTopic={selectedTopic}
|
||||
onTopicSelect={(t) => {
|
||||
setSelectedTopic(t);
|
||||
setSupplierName("");
|
||||
setNewSup({ location: "", phone: "", contact: "" });
|
||||
setIsOfficial(false);
|
||||
setSupplierInvalid(false);
|
||||
}}
|
||||
supplierName={supplierName}
|
||||
onSupplierName={(v) => {
|
||||
setSupplierName(v);
|
||||
setSupplierInvalid(false);
|
||||
}}
|
||||
newSup={newSup}
|
||||
onNewSup={(p) => setNewSup((s) => ({ ...s, ...p }))}
|
||||
isOfficial={isOfficial}
|
||||
onOfficial={setIsOfficial}
|
||||
invalid={supplierInvalid}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 3 && (
|
||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<Step3Termsheets
|
||||
cards={cards}
|
||||
onCardsChange={setCards}
|
||||
items={items}
|
||||
coverage={coverage}
|
||||
onCoverageChange={(key, ids) => setCoverage((c) => ({ ...c, [key]: ids }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 4 && (
|
||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<Step4Projects
|
||||
items={items}
|
||||
projects={projectNames}
|
||||
assignment={assignment}
|
||||
onAssign={assign}
|
||||
onUnassign={unassign}
|
||||
onAddProject={(name) => {
|
||||
void addProject.mutateAsync({ id: nextId("p", projects), name });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 5 && (
|
||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<Step5Review
|
||||
quoteDate={quoteDate}
|
||||
onQuoteDate={setQuoteDate}
|
||||
expiryDate={expiryDate}
|
||||
onExpiryDate={setExpiryDate}
|
||||
notes={notes}
|
||||
onNotes={setNotes}
|
||||
proforma={proforma}
|
||||
onProforma={setProforma}
|
||||
summary={review}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* پاورقی ویزارد */}
|
||||
<div className="mt-4 flex items-center gap-2 rounded-xl border border-line bg-panel p-3 shadow-sm">
|
||||
{currentStep > 1 && (
|
||||
<Button variant="slate" onClick={() => goToStep(currentStep - 1)}>
|
||||
‹ قبلی
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Link to="/" className="btn-line-file no-underline">
|
||||
انصراف
|
||||
</Link>
|
||||
{currentStep < TOTAL_STEPS && <Button onClick={next}>بعدی ›</Button>}
|
||||
{currentStep === TOTAL_STEPS && <Button onClick={() => void save()}>ذخیره پیشنهاد</Button>}
|
||||
</div>
|
||||
|
||||
{/* دیالوگ ذخیره با فیلدهای خالی */}
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>فیلدهای خالی</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-[13.5px] leading-7">
|
||||
هنوز تعدادی از فیلد ها خالی هستند. آیا مطمئنی که می خواهید تغییرات را ذخیره کنید؟
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]">
|
||||
{emptyFields.map((f) => (
|
||||
<button
|
||||
key={f.step}
|
||||
type="button"
|
||||
className="cursor-pointer text-accent underline underline-offset-2 hover:text-accent-dark"
|
||||
onClick={() => {
|
||||
setConfirmOpen(false);
|
||||
goToStep(f.step);
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter className="sm:justify-between">
|
||||
<Button
|
||||
variant="green"
|
||||
onClick={() => {
|
||||
setConfirmOpen(false);
|
||||
void doSave();
|
||||
}}
|
||||
>
|
||||
ذخیره کردن
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-red text-white hover:brightness-95"
|
||||
onClick={() => {
|
||||
setConfirmOpen(false);
|
||||
goToStep(emptyFields[0]?.step ?? 1);
|
||||
}}
|
||||
>
|
||||
تکمیل فیلد ها
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// ============================================================
|
||||
// Auth store — Casdoor OIDC + PKCE session (v2.6)
|
||||
// Dev mode: no token — identity sent via X-User-Role/X-Project-Id
|
||||
// headers (backend dev fallback) so the dash works before Casdoor
|
||||
// is fully seeded.
|
||||
// ============================================================
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
casdoor_id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
phone: string | null;
|
||||
role: "pm" | "procurement_manager" | "admin" | null;
|
||||
role_label: string | null;
|
||||
project_id: string | null;
|
||||
project_name: string | null;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
interface AuthStore {
|
||||
token: string | null;
|
||||
user: AuthUser | null;
|
||||
isDev: boolean;
|
||||
setToken: (token: string | null) => void;
|
||||
setUser: (user: AuthUser | null) => void;
|
||||
setDevUser: (role: "pm" | "admin" | "procurement_manager", projectId: string | null, projectName: string | null) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
user: null,
|
||||
isDev: false,
|
||||
setToken: (token) => set({ token, isDev: false }),
|
||||
setUser: (user) => set({ user, isDev: false }),
|
||||
setDevUser: (role, projectId, projectName) =>
|
||||
set({
|
||||
token: null,
|
||||
isDev: true,
|
||||
user: {
|
||||
id: "dev-user",
|
||||
casdoor_id: "dev",
|
||||
first_name: "کاربر",
|
||||
last_name: "آزمایشی",
|
||||
phone: null,
|
||||
role,
|
||||
role_label: role === "pm" ? "مدیر پروژه (آزمایشی)" : role === "procurement_manager" ? "مدیر تدارکات (آزمایشی)" : "ادمین (آزمایشی)",
|
||||
project_id: projectId,
|
||||
project_name: projectName,
|
||||
display_name: projectId ? `کاربر آزمایشی — ${projectName || ""}` : "کاربر آزمایشی",
|
||||
},
|
||||
}),
|
||||
logout: () => set({ token: null, user: null, isDev: false }),
|
||||
}),
|
||||
{ name: "procurement-auth-v1" },
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,133 @@
|
||||
// ============================================================
|
||||
// Zustand store — دادهها + state UI
|
||||
// دادهها از React Query میآیند (api client) و اینجا mirror میشوند؛
|
||||
// mutationها اول خوشبینانه اینجا آپدیت میکنند، بعد API را صدا
|
||||
// میزنند و آخر cache را invalidate میکنند تا با حقیقت همگرا شود.
|
||||
// ============================================================
|
||||
|
||||
import { create } from "zustand";
|
||||
import type {
|
||||
CategoryUnit,
|
||||
Material,
|
||||
Project,
|
||||
Quote,
|
||||
StandardConversion,
|
||||
Store,
|
||||
Supplier,
|
||||
UnitConversion,
|
||||
} from "../lib/types";
|
||||
|
||||
interface AppStore {
|
||||
// دادهها (همگام با API)
|
||||
suppliers: Supplier[];
|
||||
materials: Material[];
|
||||
projects: Project[];
|
||||
quotes: Quote[];
|
||||
categoryUnits: CategoryUnit[];
|
||||
unitConversions: UnitConversion[];
|
||||
standardConversions: StandardConversion[];
|
||||
|
||||
// UI state
|
||||
expandedQuoteIds: Set<string>;
|
||||
// wizard
|
||||
wizardStep: number;
|
||||
wizardHighestVisited: number;
|
||||
|
||||
// actions — data
|
||||
setData: (store: Store) => void;
|
||||
addQuote: (q: Quote) => void;
|
||||
updateQuote: (id: string, patch: Partial<Quote>) => void;
|
||||
deleteQuote: (id: string) => void;
|
||||
addSupplier: (s: Supplier) => void;
|
||||
updateSupplier: (id: string, patch: Partial<Supplier>) => void;
|
||||
deleteSupplier: (id: string) => void;
|
||||
addMaterial: (m: Material) => void;
|
||||
addProject: (p: Project) => void;
|
||||
setCategoryUnit: (cu: CategoryUnit) => void;
|
||||
setConversion: (c: UnitConversion) => void;
|
||||
deleteConversion: (id: string) => void;
|
||||
setStandardConversion: (sc: StandardConversion) => void;
|
||||
deleteStandardConversion: (id: string) => void;
|
||||
|
||||
// actions — UI
|
||||
toggleExpand: (id: string) => void;
|
||||
setWizardStep: (step: number) => void;
|
||||
setWizardHighestVisited: (n: number) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppStore>()((set) => ({
|
||||
suppliers: [],
|
||||
materials: [],
|
||||
projects: [],
|
||||
quotes: [],
|
||||
categoryUnits: [],
|
||||
unitConversions: [],
|
||||
standardConversions: [],
|
||||
|
||||
expandedQuoteIds: new Set<string>(),
|
||||
wizardStep: 1,
|
||||
wizardHighestVisited: 1,
|
||||
|
||||
setData: (store) =>
|
||||
set({
|
||||
suppliers: store.suppliers,
|
||||
materials: store.materials,
|
||||
projects: store.projects,
|
||||
quotes: store.quotes,
|
||||
categoryUnits: store.categoryUnits,
|
||||
unitConversions: store.unitConversions,
|
||||
standardConversions: store.standardConversions,
|
||||
}),
|
||||
addQuote: (q) => set((s) => ({ quotes: [...s.quotes, q] })),
|
||||
updateQuote: (id, patch) =>
|
||||
set((s) => ({
|
||||
quotes: s.quotes.map((q) => (q.id === id ? { ...q, ...patch } : q)),
|
||||
})),
|
||||
deleteQuote: (id) => set((s) => ({ quotes: s.quotes.filter((q) => q.id !== id) })),
|
||||
addSupplier: (sup) => set((s) => ({ suppliers: [...s.suppliers, sup] })),
|
||||
updateSupplier: (id, patch) =>
|
||||
set((s) => ({
|
||||
suppliers: s.suppliers.map((x) => (x.id === id ? { ...x, ...patch } : x)),
|
||||
})),
|
||||
deleteSupplier: (id) => set((s) => ({ suppliers: s.suppliers.filter((x) => x.id !== id) })),
|
||||
addMaterial: (m) => set((s) => ({ materials: [...s.materials, m] })),
|
||||
addProject: (p) => set((s) => ({ projects: [...s.projects, p] })),
|
||||
setCategoryUnit: (cu) =>
|
||||
set((s) => {
|
||||
const idx = s.categoryUnits.findIndex((x) => x.id === cu.id);
|
||||
const categoryUnits = [...s.categoryUnits];
|
||||
if (idx >= 0) categoryUnits[idx] = cu;
|
||||
else categoryUnits.push(cu);
|
||||
return { categoryUnits };
|
||||
}),
|
||||
setConversion: (c) =>
|
||||
set((s) => {
|
||||
const idx = s.unitConversions.findIndex((x) => x.id === c.id);
|
||||
const unitConversions = [...s.unitConversions];
|
||||
if (idx >= 0) unitConversions[idx] = c;
|
||||
else unitConversions.push(c);
|
||||
return { unitConversions };
|
||||
}),
|
||||
deleteConversion: (id) =>
|
||||
set((s) => ({ unitConversions: s.unitConversions.filter((x) => x.id !== id) })),
|
||||
setStandardConversion: (sc) =>
|
||||
set((s) => {
|
||||
const idx = s.standardConversions.findIndex((x) => x.id === sc.id);
|
||||
const standardConversions = [...s.standardConversions];
|
||||
if (idx >= 0) standardConversions[idx] = sc;
|
||||
else standardConversions.push(sc);
|
||||
return { standardConversions };
|
||||
}),
|
||||
deleteStandardConversion: (id) =>
|
||||
set((s) => ({ standardConversions: s.standardConversions.filter((x) => x.id !== id) })),
|
||||
|
||||
toggleExpand: (id) =>
|
||||
set((s) => {
|
||||
const next = new Set(s.expandedQuoteIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return { expandedQuoteIds: next };
|
||||
}),
|
||||
setWizardStep: (step) => set({ wizardStep: step }),
|
||||
setWizardHighestVisited: (n) => set({ wizardHighestVisited: n }),
|
||||
}));
|
||||
@@ -0,0 +1,679 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ============================================================
|
||||
طراحی بر اساس ui-ux-pro-max: Flat Design — وباپ سازمانی
|
||||
اصول: خطوط تمیز، بدون سایههای سنگین، تایپوگرافی منظم،
|
||||
ترنزیشنهای کوتاه، آیکونهای SVG (Lucide)
|
||||
============================================================ */
|
||||
|
||||
@theme {
|
||||
--color-bg: #f3f5f9;
|
||||
--color-panel: #ffffff;
|
||||
--color-ink: #0f172a;
|
||||
--color-muted: #64748b;
|
||||
--color-line: #e5e9f0;
|
||||
--color-accent: #4f46e5;
|
||||
--color-accent-dark: #4338ca;
|
||||
--color-accent-bg: #eef2ff;
|
||||
--color-green: #16a34a;
|
||||
--color-green-bg: #dcfce7;
|
||||
--color-amber: #d97706;
|
||||
--color-amber-bg: #fef3c7;
|
||||
--color-red: #dc2626;
|
||||
--color-red-bg: #fee2e2;
|
||||
--color-blue-bg: #dbeafe;
|
||||
--color-slate-bg: #f1f5f9;
|
||||
--font-sans: "Vazirmatn", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-bg text-ink antialiased;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* نشانگر دست روی همهی عناصر قابل کلیک */
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled),
|
||||
select:not(:disabled),
|
||||
a[href],
|
||||
label[for],
|
||||
input[type="checkbox"]:not(:disabled),
|
||||
input[type="radio"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* احترام به prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- تایپوگرافی ---------- */
|
||||
.num-words {
|
||||
@apply text-muted;
|
||||
font-size: 0.82em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---------- نمودار جریان (Flow) ---------- */
|
||||
.flow {
|
||||
@apply relative bg-slate-bg/70 px-4 py-3;
|
||||
}
|
||||
.flow-cols {
|
||||
@apply grid grid-cols-2 gap-10;
|
||||
}
|
||||
.flow-col {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
.flow-col-h {
|
||||
@apply mb-0.5 flex items-center gap-1 text-[11px] font-bold text-muted;
|
||||
}
|
||||
.flow-box {
|
||||
@apply relative rounded-lg border border-line bg-panel px-3 py-2 text-sm;
|
||||
}
|
||||
.flow-box.rejected {
|
||||
@apply border-dashed opacity-55;
|
||||
}
|
||||
.flow-box.deal {
|
||||
@apply border-green bg-green-bg/40;
|
||||
}
|
||||
/* کارت ترمشیت: پذیرفته = آبی، رسیده = سبز */
|
||||
.flow-ts.ts-accepted {
|
||||
@apply border-[#bfdbfe] bg-[#eff6ff];
|
||||
}
|
||||
.flow-ts.ts-arrived {
|
||||
@apply border-green/40 bg-green-bg/40;
|
||||
}
|
||||
.fb-name {
|
||||
@apply flex items-center gap-1 text-[12.5px] font-semibold leading-5;
|
||||
}
|
||||
.fb-meta {
|
||||
@apply mt-0.5 flex items-center gap-1 text-[11px] text-muted;
|
||||
}
|
||||
.fb-nums {
|
||||
@apply mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-[12px] tabular-nums;
|
||||
}
|
||||
.fb-total {
|
||||
@apply font-semibold;
|
||||
}
|
||||
.fb-conv {
|
||||
@apply mt-0.5 flex items-center gap-1 text-[10.5px] text-muted;
|
||||
}
|
||||
.fb-inv {
|
||||
@apply mt-0.5 inline-flex items-center gap-1 text-[11px] text-accent underline underline-offset-2;
|
||||
}
|
||||
.fb-badge {
|
||||
@apply ms-1 rounded-full px-1.5 py-px text-[9.5px] font-bold leading-4;
|
||||
}
|
||||
/* پیشنهاد = نارنجی، پذیرفته = آبی، رسیده = سبز، رد شده = قرمز، منقضی = خاکستری */
|
||||
.fb-badge.offered {
|
||||
@apply bg-amber-bg text-amber;
|
||||
}
|
||||
.fb-badge.accepted {
|
||||
@apply bg-blue-bg text-[#1d4ed8];
|
||||
}
|
||||
.fb-badge.arrived {
|
||||
@apply bg-green text-white;
|
||||
}
|
||||
.fb-badge.rejected {
|
||||
@apply bg-red-bg text-red;
|
||||
}
|
||||
.fb-badge.expired {
|
||||
@apply bg-slate-bg text-muted;
|
||||
}
|
||||
.fb-ms-list {
|
||||
@apply mt-1 flex flex-col gap-0.5;
|
||||
}
|
||||
.fb-ms {
|
||||
@apply flex items-center gap-1 text-[11px] text-muted;
|
||||
}
|
||||
.flow-lines {
|
||||
@apply pointer-events-none absolute inset-0 h-full w-full;
|
||||
}
|
||||
.flow-line {
|
||||
fill: none;
|
||||
stroke: #cbd5e1;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
.flow-line.accepted {
|
||||
stroke: #2563eb;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.flow-rejected-h {
|
||||
@apply mt-3 flex items-center gap-1 text-[11px] font-bold text-muted;
|
||||
}
|
||||
.flow-rejected-hint {
|
||||
@apply text-[10.5px] text-muted mt-1;
|
||||
}
|
||||
.flow-attach {
|
||||
@apply mt-3 flex flex-wrap gap-2;
|
||||
}
|
||||
.detail-item {
|
||||
@apply text-[11.5px];
|
||||
}
|
||||
.detail-link {
|
||||
@apply inline-flex items-center gap-1 text-accent underline underline-offset-2;
|
||||
}
|
||||
.detail-chip {
|
||||
@apply text-muted;
|
||||
}
|
||||
|
||||
/* هشدار «واحد جدید» در جدول دفتر */
|
||||
.unit-warn {
|
||||
@apply ms-1 inline-flex items-center gap-0.5 rounded bg-amber-bg px-1.5 py-px text-[10px] font-medium text-amber no-underline;
|
||||
}
|
||||
|
||||
/* ردیف منقضی در دفتر */
|
||||
.stale-row td {
|
||||
@apply bg-amber-bg/25;
|
||||
}
|
||||
|
||||
/* چیپ اقدامات در جدول دفتر */
|
||||
.chip {
|
||||
@apply inline-flex cursor-pointer items-center gap-0.5 rounded-full border border-line bg-panel px-2.5 py-1 text-[11px] font-medium text-ink transition-colors hover:bg-slate-bg;
|
||||
}
|
||||
.chip-act {
|
||||
@apply border-accent/30 bg-accent-bg text-accent-dark hover:bg-accent-bg/70;
|
||||
}
|
||||
.chip-arrive {
|
||||
@apply border-green/30 bg-green-bg text-green hover:bg-green-bg/70;
|
||||
}
|
||||
.chip-expand {
|
||||
@apply border-transparent bg-transparent px-1.5 text-muted hover:bg-slate-bg hover:text-ink;
|
||||
}
|
||||
|
||||
/* برگههای quick-chip ویزارد */
|
||||
.quick-chip {
|
||||
@apply inline-flex cursor-pointer items-center gap-1 rounded-full border border-line bg-panel px-3 py-1 text-xs transition-colors hover:border-accent/40 hover:bg-accent-bg/60;
|
||||
}
|
||||
.quick-chip.selected {
|
||||
@apply border-accent bg-accent-bg text-accent-dark;
|
||||
}
|
||||
.qc-sub {
|
||||
@apply text-[10px] text-muted;
|
||||
}
|
||||
.chip-divider {
|
||||
@apply text-xs text-muted;
|
||||
}
|
||||
|
||||
/* spec chips */
|
||||
.spec-chip {
|
||||
@apply inline-flex items-center gap-1 rounded-full bg-slate-bg px-2 py-0.5 text-[11px] font-medium;
|
||||
}
|
||||
.spec-chip-x {
|
||||
@apply cursor-pointer text-muted hover:text-red;
|
||||
}
|
||||
|
||||
/* کارتهای termsheet در ویزارد/مودالها */
|
||||
.variant-card {
|
||||
@apply rounded-lg border border-line bg-panel p-3;
|
||||
}
|
||||
.variant-head {
|
||||
@apply mb-2 flex items-center justify-between;
|
||||
}
|
||||
.variant-num {
|
||||
@apply text-sm font-bold;
|
||||
}
|
||||
.variant-x {
|
||||
@apply cursor-pointer rounded p-0.5 text-muted hover:text-red;
|
||||
}
|
||||
.milestone-row {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
.ms-amount-wrap {
|
||||
@apply flex-1;
|
||||
}
|
||||
.ms-x {
|
||||
@apply cursor-pointer text-muted hover:text-red;
|
||||
}
|
||||
.calc-pill {
|
||||
@apply mt-0.5 text-[10.5px] text-muted;
|
||||
}
|
||||
.calc-pill.empty {
|
||||
@apply hidden;
|
||||
}
|
||||
.ts-items-box {
|
||||
@apply mt-2 rounded-md bg-slate-bg/60 p-2;
|
||||
}
|
||||
.ts-items-title {
|
||||
@apply mb-1.5 text-[11.5px] font-bold text-muted;
|
||||
}
|
||||
.ts-items-empty {
|
||||
@apply text-[11.5px] text-muted;
|
||||
}
|
||||
.ts-item-label {
|
||||
@apply flex cursor-pointer items-center gap-1.5 py-0.5 text-[12px];
|
||||
}
|
||||
.ts-cb-label {
|
||||
@apply flex cursor-pointer items-center gap-1.5 py-0.5 text-[12px];
|
||||
}
|
||||
.e-restore {
|
||||
@apply font-medium text-green;
|
||||
}
|
||||
|
||||
/* درگاندروپ پروژهها (مرحله ۴ ویزارد) */
|
||||
.dd-panels {
|
||||
@apply grid grid-cols-2 gap-4;
|
||||
}
|
||||
.dd-col {
|
||||
@apply rounded-lg border border-line bg-panel p-3;
|
||||
}
|
||||
.dd-item {
|
||||
@apply mb-2 flex cursor-grab items-center justify-between gap-2 rounded-md border border-line bg-panel px-3 py-2 text-[12.5px] transition-colors hover:border-accent/40;
|
||||
}
|
||||
.dd-item.dd-unassigned {
|
||||
@apply border-red/40;
|
||||
}
|
||||
.dd-item.picked {
|
||||
@apply border-accent bg-accent-bg/60;
|
||||
}
|
||||
.dd-item.dragging {
|
||||
@apply opacity-50;
|
||||
}
|
||||
.dd-count {
|
||||
@apply text-[10.5px] text-muted;
|
||||
}
|
||||
.dd-project {
|
||||
@apply mb-2 rounded-md border border-dashed border-line p-2 transition-colors;
|
||||
}
|
||||
.dd-project.drop-hover {
|
||||
@apply border-accent bg-accent-bg/40;
|
||||
}
|
||||
.dp-name {
|
||||
@apply mb-1 flex items-center gap-1 text-[12.5px] font-bold;
|
||||
}
|
||||
.dp-tags {
|
||||
@apply flex flex-wrap gap-1;
|
||||
}
|
||||
.dd-tag {
|
||||
@apply inline-flex items-center gap-1 rounded-full bg-slate-bg px-2 py-0.5 text-[10.5px];
|
||||
}
|
||||
.dd-tag-x {
|
||||
@apply cursor-pointer text-muted hover:text-red;
|
||||
}
|
||||
.dd-proj-empty {
|
||||
@apply text-[11px] text-muted;
|
||||
}
|
||||
.dd-empty {
|
||||
@apply rounded-lg border border-dashed border-line p-6 text-center text-sm text-muted;
|
||||
}
|
||||
|
||||
/* لیست فاکتورها */
|
||||
.inv-empty {
|
||||
@apply mb-1 text-[12px] text-muted;
|
||||
}
|
||||
.inv-row {
|
||||
@apply mb-1 flex items-center gap-2 rounded-md border border-line bg-panel px-2 py-1.5 text-[12px];
|
||||
}
|
||||
.inv-name {
|
||||
@apply flex flex-1 items-center gap-1 truncate;
|
||||
}
|
||||
.inv-open {
|
||||
@apply text-accent no-underline;
|
||||
}
|
||||
.inv-x {
|
||||
@apply cursor-pointer text-muted hover:text-red;
|
||||
}
|
||||
|
||||
/* فایلها در ویزارد */
|
||||
.file-warn {
|
||||
@apply mt-0.5 text-[11px] text-amber;
|
||||
}
|
||||
.file-x {
|
||||
@apply cursor-pointer text-muted hover:text-red;
|
||||
}
|
||||
|
||||
/* آیتمهای ورودی عددی */
|
||||
.num-input {
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
}
|
||||
.num-pill {
|
||||
@apply mt-0.5 block text-[11.5px] font-semibold text-ink/80;
|
||||
}
|
||||
.num-pill.empty {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
/* ردیفهای اقلام در ویزارد/ویرایش */
|
||||
.item-row {
|
||||
@apply grid items-start gap-2;
|
||||
}
|
||||
.wizard-item-row {
|
||||
grid-template-columns: 1.1fr 1.1fr 0.7fr 0.8fr 0.8fr 0.9fr 0.9fr 28px;
|
||||
}
|
||||
.e-item-row {
|
||||
grid-template-columns: 1.1fr 1.1fr 0.7fr 0.8fr 0.8fr 0.9fr 0.9fr auto 28px;
|
||||
}
|
||||
.item-row-head {
|
||||
@apply text-[11px] font-bold text-muted;
|
||||
}
|
||||
.e-item-rejected td {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.spec-cell {
|
||||
@apply relative;
|
||||
}
|
||||
.spec-trigger {
|
||||
@apply flex h-9 w-full cursor-pointer items-center justify-between rounded-md border border-line bg-panel px-3 text-[12px] text-ink;
|
||||
}
|
||||
.spec-trigger-label {
|
||||
@apply text-[12px] font-medium;
|
||||
}
|
||||
.spec-trigger-label.muted {
|
||||
@apply text-muted;
|
||||
}
|
||||
.spec-drawer {
|
||||
@apply absolute right-0 left-0 z-20 hidden rounded-md border border-line bg-panel p-2 shadow-md;
|
||||
}
|
||||
.spec-drawer.open {
|
||||
@apply block;
|
||||
}
|
||||
.spec-add-row {
|
||||
@apply mt-1.5 flex items-center gap-1;
|
||||
}
|
||||
.spec-add-btn {
|
||||
@apply cursor-pointer rounded bg-accent px-2 py-1 text-[12px] font-bold text-white;
|
||||
}
|
||||
.spec-chips {
|
||||
@apply flex flex-wrap gap-1;
|
||||
}
|
||||
|
||||
/* بخش واحدها */
|
||||
.units-layout {
|
||||
@apply grid grid-cols-[220px_1fr] gap-4;
|
||||
}
|
||||
.units-tree {
|
||||
@apply rounded-lg border border-line bg-panel p-3;
|
||||
}
|
||||
.tree-head {
|
||||
@apply mb-2 flex items-center justify-between;
|
||||
}
|
||||
.ut-cat {
|
||||
@apply flex cursor-pointer items-center justify-between rounded-md px-2.5 py-2 text-[12.5px] transition-colors hover:bg-slate-bg;
|
||||
}
|
||||
.ut-cat.selected {
|
||||
@apply bg-accent-bg font-bold text-accent-dark;
|
||||
}
|
||||
.ut-ref {
|
||||
@apply rounded bg-slate-bg px-1.5 py-px text-[10.5px] text-muted;
|
||||
}
|
||||
.units-panel {
|
||||
@apply rounded-lg border border-line bg-panel p-4;
|
||||
}
|
||||
.ref-unit-box {
|
||||
@apply rounded-md bg-slate-bg/60 p-3;
|
||||
}
|
||||
.ref-title {
|
||||
@apply flex items-center gap-1 text-[13px] font-bold;
|
||||
}
|
||||
.ref-hint {
|
||||
@apply mt-0.5 text-[11px] text-muted;
|
||||
}
|
||||
.ref-controls {
|
||||
@apply mt-2 flex items-center gap-2;
|
||||
}
|
||||
.sec-title {
|
||||
@apply mb-2 mt-4 flex items-center gap-1 text-[13px] font-bold;
|
||||
}
|
||||
.conv-empty {
|
||||
@apply text-[12px] text-muted;
|
||||
}
|
||||
.cg-head {
|
||||
@apply mt-3 mb-1 text-[11.5px] font-bold text-muted;
|
||||
}
|
||||
.conv-row {
|
||||
@apply mb-1 flex items-center gap-2 rounded-md border border-line bg-panel px-2.5 py-1.5 text-[12px];
|
||||
}
|
||||
.cv-brand {
|
||||
@apply w-28 shrink-0 text-[11.5px] text-muted;
|
||||
}
|
||||
.cv-factor {
|
||||
@apply font-bold tabular-nums;
|
||||
}
|
||||
.cv-arrow {
|
||||
@apply text-muted;
|
||||
}
|
||||
.cv-unit {
|
||||
@apply font-medium;
|
||||
}
|
||||
.conv-actions {
|
||||
@apply ms-auto flex items-center gap-1.5;
|
||||
}
|
||||
.conv-actions button {
|
||||
@apply cursor-pointer text-muted transition-colors hover:text-ink;
|
||||
}
|
||||
.add-conv {
|
||||
@apply mt-4 rounded-md border border-dashed border-line p-3;
|
||||
}
|
||||
.conv-form {
|
||||
@apply grid grid-cols-[1.2fr_0.6fr_1fr_0.8fr_auto] items-end gap-2;
|
||||
}
|
||||
.conv-live {
|
||||
@apply mt-2 text-[11.5px] text-muted;
|
||||
}
|
||||
.unknown-row {
|
||||
@apply mb-1 flex items-center gap-2 rounded-md border border-amber/40 bg-amber-bg/40 px-2.5 py-1.5 text-[12px];
|
||||
}
|
||||
.uk-unit {
|
||||
@apply font-bold;
|
||||
}
|
||||
.uk-count {
|
||||
@apply text-[11px] text-muted;
|
||||
}
|
||||
.conv-hint {
|
||||
@apply mt-2 text-[11px] text-muted;
|
||||
}
|
||||
|
||||
/* کارتهای تاپیک */
|
||||
.topic-card {
|
||||
@apply flex cursor-pointer flex-col items-center gap-1.5 rounded-lg border border-line bg-panel p-3 text-center transition-colors hover:border-accent/50;
|
||||
}
|
||||
.topic-card.selected {
|
||||
@apply border-accent bg-accent-bg/50;
|
||||
}
|
||||
.topic-card img {
|
||||
@apply h-11 w-11 object-contain;
|
||||
}
|
||||
.t-name {
|
||||
@apply text-[12.5px] font-bold leading-5;
|
||||
}
|
||||
.t-count {
|
||||
@apply text-[10.5px] text-muted;
|
||||
}
|
||||
|
||||
/* کارتهای تأمینکننده */
|
||||
.sup-card {
|
||||
@apply rounded-lg border border-line bg-panel p-3 transition-colors hover:border-accent/40;
|
||||
}
|
||||
.sc-head {
|
||||
@apply flex items-center justify-between;
|
||||
}
|
||||
.sc-name-link {
|
||||
@apply text-[13.5px] font-bold text-accent no-underline hover:underline;
|
||||
}
|
||||
.sup-del {
|
||||
@apply cursor-pointer rounded px-1 text-muted transition-colors hover:text-red;
|
||||
}
|
||||
.sc-meta {
|
||||
@apply mt-2 flex flex-col gap-0.5 text-[12px] text-muted;
|
||||
}
|
||||
.sc-meta span {
|
||||
@apply flex items-center;
|
||||
}
|
||||
.sc-actions {
|
||||
@apply mt-2;
|
||||
}
|
||||
|
||||
/* پنل افزودن */
|
||||
.add-panel {
|
||||
@apply rounded-lg border border-line bg-panel p-4;
|
||||
}
|
||||
.add-panel h4 {
|
||||
@apply mb-3 flex items-center gap-1 text-[13.5px] font-bold;
|
||||
}
|
||||
|
||||
/* پروژهها */
|
||||
.proj-item {
|
||||
@apply mb-1.5 flex cursor-pointer items-center gap-2 rounded-md border border-line bg-panel px-3 py-2.5 text-[13px] transition-colors hover:border-accent/50;
|
||||
}
|
||||
.proj-item.open {
|
||||
@apply border-accent bg-accent-bg/50;
|
||||
}
|
||||
.proj-count {
|
||||
@apply ms-auto text-[11px] text-muted;
|
||||
}
|
||||
.pj-col {
|
||||
@apply rounded-lg border border-line bg-panel p-3;
|
||||
}
|
||||
.pj-col-h {
|
||||
@apply mb-2 flex items-center gap-1 text-[12.5px] font-bold;
|
||||
}
|
||||
.pj-row {
|
||||
@apply mb-2 rounded-md border border-line bg-panel px-2.5 py-2 text-[12px];
|
||||
}
|
||||
.pj-main {
|
||||
@apply flex items-center gap-1 font-bold;
|
||||
}
|
||||
.pj-meta {
|
||||
@apply mt-0.5 text-[11px] text-muted;
|
||||
}
|
||||
.pj-side {
|
||||
@apply mt-1 text-[11px];
|
||||
}
|
||||
.pj-chip {
|
||||
@apply ms-1 inline-block rounded-full px-1.5 py-px text-[10px] font-bold;
|
||||
}
|
||||
.pj-chip.red {
|
||||
@apply bg-red-bg text-red;
|
||||
}
|
||||
.pj-chip.amber {
|
||||
@apply bg-amber-bg text-amber;
|
||||
}
|
||||
.pj-chip.green {
|
||||
@apply bg-green-bg text-green;
|
||||
}
|
||||
.pj-conv {
|
||||
@apply text-[10px] text-muted;
|
||||
}
|
||||
.pj-sup-link {
|
||||
@apply text-accent no-underline hover:underline;
|
||||
}
|
||||
.pj-empty {
|
||||
@apply text-[12px] text-muted;
|
||||
}
|
||||
|
||||
/* گزینههای مودال پذیرش */
|
||||
.modal-variant {
|
||||
@apply mb-2 rounded-lg border border-line bg-panel p-3;
|
||||
}
|
||||
.modal-variant-head {
|
||||
@apply mb-2 flex items-center justify-between;
|
||||
}
|
||||
.accept-item-assign-row {
|
||||
@apply mb-2 flex items-center gap-2;
|
||||
}
|
||||
.accept-item-assign-row.rejected {
|
||||
@apply opacity-55;
|
||||
}
|
||||
.arrive-row {
|
||||
@apply flex cursor-pointer items-center gap-2 rounded-md border border-line px-3 py-2 text-[13px];
|
||||
}
|
||||
.arrive-row.arrived {
|
||||
@apply border-green bg-green-bg/40;
|
||||
}
|
||||
.arrive-meta {
|
||||
@apply ms-auto text-[11.5px] text-muted;
|
||||
}
|
||||
|
||||
/* ستون ورودیهای فرم */
|
||||
.field label {
|
||||
@apply mb-1 block text-[12.5px] font-medium text-ink;
|
||||
}
|
||||
.field .hint {
|
||||
@apply mt-1 text-[11px] text-muted;
|
||||
}
|
||||
.field.invalid input {
|
||||
@apply border-red ring-1 ring-red/50;
|
||||
}
|
||||
|
||||
/* کلاسهای عمومی */
|
||||
.empty {
|
||||
@apply py-8 text-center text-sm text-muted;
|
||||
}
|
||||
.empty .big {
|
||||
@apply mb-1 text-3xl;
|
||||
}
|
||||
.btn-line-file {
|
||||
@apply inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-line bg-panel px-3 py-1.5 text-xs font-medium text-ink transition-colors hover:bg-slate-bg;
|
||||
}
|
||||
.review-row {
|
||||
@apply flex items-center justify-between border-b border-line/60 py-1.5 text-[12.5px];
|
||||
}
|
||||
.review-row .r-label {
|
||||
@apply font-bold text-muted;
|
||||
}
|
||||
.review-warn {
|
||||
@apply text-amber;
|
||||
}
|
||||
|
||||
/* ---------- سابقه خرید تأمینکننده (صفحه اطلاعات تماس) ---------- */
|
||||
.ph-card {
|
||||
@apply rounded-xl border border-line bg-panel p-5 shadow-sm;
|
||||
}
|
||||
.ph-head {
|
||||
@apply mb-3 flex items-center gap-2;
|
||||
}
|
||||
.ph-title {
|
||||
@apply text-[14px] font-bold;
|
||||
}
|
||||
.ph-count {
|
||||
@apply rounded-full bg-accent-bg px-2 py-px text-[11px] font-bold text-accent-dark;
|
||||
}
|
||||
.ph-row {
|
||||
@apply mb-2 rounded-lg border border-line bg-panel px-3 py-2.5;
|
||||
}
|
||||
.ph-row.stale {
|
||||
@apply bg-amber-bg/25;
|
||||
}
|
||||
.ph-top {
|
||||
@apply flex flex-wrap items-center gap-x-2 gap-y-1;
|
||||
}
|
||||
.ph-date {
|
||||
@apply text-[12.5px] font-bold tabular-nums;
|
||||
}
|
||||
.ph-project {
|
||||
@apply text-[11.5px] text-muted;
|
||||
}
|
||||
.ph-items {
|
||||
@apply mt-1 text-[12px] text-ink;
|
||||
}
|
||||
.ph-total {
|
||||
@apply mt-1 text-[12px] font-bold tabular-nums;
|
||||
}
|
||||
.ph-attach {
|
||||
@apply mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-line/60 pt-1.5 text-[11.5px];
|
||||
}
|
||||
.ph-attach-loading {
|
||||
@apply text-[11px] text-muted;
|
||||
}
|
||||
.ph-empty {
|
||||
@apply py-6 text-center;
|
||||
}
|
||||
.ph-empty-text {
|
||||
@apply text-[13px] text-muted;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||