feat(frontend): add React UI — procurement office, wizard, suppliers, inventory panel (dash port), role-based login
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user