diff --git a/src/app/(protected)/crm/customers/page.tsx b/src/app/(protected)/crm/customers/page.tsx new file mode 100644 index 0000000..c786ec3 --- /dev/null +++ b/src/app/(protected)/crm/customers/page.tsx @@ -0,0 +1,1107 @@ +"use client"; + +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; +import { + User, Building2, CheckCircle2, ShieldAlert, XCircle, Circle, + BadgeCheck, ShieldCheck, ShieldX, AlertTriangle, + CreditCard, QrCode, HandCoins, Wallet, Landmark, + Search, X as XIcon, Eraser +} from "lucide-react"; + +/* ======================== Types ======================== */ +type CustomerType = "individual" | "juristic"; +type CustomerStatus = "active" | "suspended" | "blacklisted" | "inactive"; +type VatStatus = "registered" | "unregistered" | "exempt"; +type KycStatus = "not_started" | "in_progress" | "passed" | "failed" | "expired"; +type LoyaltyTier = "none" | "silver" | "gold" | "platinum"; +type Channel = "d2c" | "chat" | "shopee" | "lazada" | "tiktok"; +type Language = "th" | "en"; +type Region = "BKK" | "Central" | "North" | "Northeast" | "South" | "Ecommerce"; +type SlaTier = "standard" | "priority" | "vip"; +type PreferredPay = "card" | "bank_qr" | "cod" | "wallet" | "transfer"; + +type Customer = { + /* Identity */ + id: string; + code: string; // CUST-xxxxx + type: CustomerType; + status: CustomerStatus; + displayName: string; // ชื่อโชว์ตามชนิด (บุคคล/นิติ) + aliasName?: string | null; // ชื่อการค้า/ชื่อเล่น + + /* Individual (optional) */ + titleTH?: string | null; + firstNameTH?: string | null; + lastNameTH?: string | null; + firstNameEN?: string | null; + lastNameEN?: string | null; + gender?: "male" | "female" | "other" | null; + birthDate?: string | null; + nationalId?: string | null; // บัตรประชาชน (13 หลัก) + + /* Juristic (optional) */ + companyNameTH?: string | null; + companyNameEN?: string | null; + tradeName?: string | null; + juristicId?: string | null; // เลขนิติ 13 หลัก + branchCode?: string | null; // 5 หลัก (00000 สำนักงานใหญ่) + businessType?: string | null; + industry?: string | null; + paidUpCapitalTHB?: number | null; + + /* Tax / Compliance */ + vatStatus: VatStatus; + vatRegNo?: string | null; // ปกติ = เลขนิติ + vatEffectiveDate?: string | null; + whtDefaultRate?: number | null; // อัตราหัก ณ ที่จ่ายค่าเริ่มต้น + eTaxReady?: boolean; // พร้อม e-Tax Invoice & e-Receipt + eWithholdingReady?: boolean; // พร้อม e-Withholding + kycStatus: KycStatus; + riskScore?: number | null; // 0–100 + pepFlag?: boolean; + amlFlag?: boolean; + blacklistReason?: string | null; + docNationalIdExpiry?: string | null; + docCertIncorpExpiry?: string | null; + + /* Contacts */ + primaryEmail?: string | null; + emails?: string[]; + primaryPhone?: string | null; + phones?: string[]; + website?: string | null; + socialLineId?: string | null; + + /* Address - Billing (flat for table) */ + billingName: string; + billingTaxId?: string | null; + billingAddressLine1: string; + billingSubdistrict?: string | null; + billingDistrict: string; + billingProvince: string; + billingPostcode: string; + billingCountry: "TH"; + + /* Address - Shipping (default) */ + shippingName: string; + shippingPhone?: string | null; + shippingAddressLine1: string; + shippingSubdistrict?: string | null; + shippingDistrict: string; + shippingProvince: string; + shippingPostcode: string; + shippingCountry: "TH"; + shippingAddressesCount?: number | null; + + /* Billing / Credit */ + preferredPayment: PreferredPay; + paymentTermDays: number; // เครดิตเทอม (วัน) + creditLimitTHB: number; + creditUsedTHB: number; + arOutstandingTHB: number; // ลูกหนี้คงค้าง + invoiceDelivery: "e-tax" | "email_pdf" | "paper"; + currency: "THB"; + + /* Preferences / Account */ + language: Language; + region: Region; + channelAcquired?: Channel | null; + accountManagerName?: string | null; + slaTier: SlaTier; + tags?: string[]; + + /* Loyalty & LTV */ + tier: LoyaltyTier; + points: number; + ltvTHB: number; + avgOrderValueTHB?: number | null; + + /* Activity */ + firstOrderAt?: string | null; + lastOrderAt?: string | null; + ordersCount: number; + returnsCount?: number | null; + cancellationsCount?: number | null; + + /* Meta */ + createdByName: string; + createdAt: string; + updatedAt?: string | null; + noteInternal?: string | null; +}; + +type ColumnKey = keyof Customer; +type Column = { + key: ColumnKey; + labelTH: string; + groupTH: string; + align?: "left" | "right"; + format?: (v: Customer[ColumnKey], row: Customer) => ReactNode; +}; + +/* ======================== Meta / Renderers ======================== */ +type Tone = "ok" | "warn" | "bad" | "neutral"; +const pill = (t: Tone) => + ({ + ok: "bg-emerald-50 text-emerald-700 border border-emerald-200", + warn: "bg-amber-50 text-amber-700 border border-amber-200", + bad: "bg-red-50 text-red-700 border border-red-200", + neutral: "bg-neutral-50 text-neutral-700 border border-neutral-200", + }[t]); + +const U = (s: string) => s.toUpperCase(); +/** เส้นคอลัมน์โทนอ่อน */ +const COL_BORDER = "border-r border-neutral-200/60 last:border-r-0"; + +const IconLabel = ({ Icon, label, pillTone }: { Icon: LucideIcon; label: string; pillTone?: Tone }) => { + const base = ( + + + {label} + + ); + if (!pillTone) return base; + return ( + + + {label} + + ); +}; + +const TYPE_META: Record = { + individual: { label: U("Individual"), Icon: User }, + juristic: { label: U("Juristic"), Icon: Building2 }, +}; +const STATUS_META: Record = { + active: { label: U("Active"), Icon: CheckCircle2, tone: "ok" }, + suspended: { label: U("Suspended"), Icon: ShieldAlert, tone: "warn" }, + blacklisted: { label: U("Blacklisted"), Icon: XCircle, tone: "bad" }, + inactive: { label: U("Inactive"), Icon: Circle, tone: "neutral" }, +}; +const VAT_META: Record = { + registered: { label: U("VAT Registered"), Icon: BadgeCheck, tone: "ok" }, + unregistered: { label: U("VAT Unregistered"), Icon: AlertTriangle, tone: "warn" }, + exempt: { label: U("VAT Exempt"), Icon: ShieldCheck, tone: "neutral" }, +}; +const KYC_META: Record = { + not_started: { label: U("KYC Not Started"), Icon: Circle, tone: "neutral" }, + in_progress: { label: U("KYC In Progress"), Icon: ShieldAlert, tone: "warn" }, + passed: { label: U("KYC Passed"), Icon: CheckCircle2, tone: "ok" }, + failed: { label: U("KYC Failed"), Icon: XCircle, tone: "bad" }, + expired: { label: U("KYC Expired"), Icon: ShieldX, tone: "warn" }, +}; + +const TIER_META: Record = { + none: "NONE", + silver: "SILVER", + gold: "GOLD", + platinum: "PLATINUM", +} as const; + +const PAY_META: Record = { + card: { label: U("Card"), Icon: CreditCard }, + bank_qr: { label: U("Bank QR"), Icon: QrCode }, + cod: { label: U("COD"), Icon: HandCoins }, + wallet: { label: U("Wallet"), Icon: Wallet }, + transfer: { label: U("Transfer"), Icon: Landmark }, +}; + +const renderType = (t: CustomerType) => ; +const renderStatus = (s: CustomerStatus) => { const m = STATUS_META[s]; return ; }; +const renderVat = (v: VatStatus) => { const m = VAT_META[v]; return ; }; +const renderKyc = (k: KycStatus) => { const m = KYC_META[k]; return ; }; +const renderPay = (p: PreferredPay) => ; + +const fmtDate = (iso?: string | null) => (iso ? new Date(iso).toLocaleString("th-TH", { hour12: false }) : "-"); +const THB = (n?: number | null) => (typeof n === "number" ? `฿${n.toLocaleString()}` : "-"); +const num = (n?: number | null) => (typeof n === "number" && Number.isFinite(n) ? n.toString() : "-"); + +/* ======================== Utils ======================== */ +const LS_KEY = "customers.visibleCols.v1"; + +function getProp(obj: T, key: K): T[K] { return obj[key]; } +function escapeCsv(v: unknown): string { + if (v === null || v === undefined) return ""; + const s = typeof v === "string" ? v : Array.isArray(v) ? v.join("|") : String(v); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} +function downloadCsv(filename: string, rows: Customer[], cols: Column[]) { + if (!rows.length || !cols.length) return; + const headers = cols.map((c) => c.labelTH); + const keys = cols.map((c) => c.key); + const csv = + headers.join(",") + "\n" + + rows.map((r) => keys.map((k) => escapeCsv(getProp(r, k))).join(",")).join("\n"); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); + URL.revokeObjectURL(url); +} + +/** ค่าเริ่มต้นแบบ type-safe */ +function defaultCell(v: unknown): ReactNode { + if (v === null || v === undefined) return "-"; + if (typeof v === "string") return v.trim() === "" ? "-" : v; + if (typeof v === "number") return Number.isFinite(v) ? v.toString() : "-"; + if (typeof v === "boolean") return v ? "YES" : "NO"; + if (Array.isArray(v)) return v.length ? v.join("|") : "-"; + return String(v); +} + +/* ======================== Mock Data ======================== */ +const MOCK_CUSTOMERS: Customer[] = [ + { + id: "C-0001", + code: "CUST-0001", + type: "juristic", + status: "active", + displayName: "Amrez Beauty Co., Ltd.", + aliasName: "Amrez", + companyNameTH: "บริษัท แอมเรซ บิวตี้ จำกัด", + companyNameEN: "Amrez Beauty Co., Ltd.", + tradeName: "Amrez", + juristicId: "0105561234567", + branchCode: "00000", + businessType: "ผลิต/จำหน่ายเวชสำอาง", + industry: "Cosmetics", + paidUpCapitalTHB: 5000000, + + vatStatus: "registered", + vatRegNo: "0105561234567", + vatEffectiveDate: "2019-01-01", + whtDefaultRate: 3, + eTaxReady: true, + eWithholdingReady: true, + kycStatus: "passed", + riskScore: 12, + pepFlag: false, + amlFlag: false, + + primaryEmail: "ap@amrez.co.th", + emails: ["finance@amrez.co.th"], + primaryPhone: "021234567", + phones: ["021234567", "0801112222"], + website: "https://amrez.co.th", + socialLineId: "@amrez", + + billingName: "บริษัท แอมเรซ บิวตี้ จำกัด", + billingTaxId: "0105561234567", + billingAddressLine1: "99/1 ถนนวิภาวดีรังสิต", + billingSubdistrict: "ลาดยาว", + billingDistrict: "จตุจักร", + billingProvince: "กรุงเทพมหานคร", + billingPostcode: "10900", + billingCountry: "TH", + + shippingName: "Amrez Warehouse", + shippingPhone: "020000111", + shippingAddressLine1: "คลังสินค้า BKK-DC เลขที่ 55/9", + shippingSubdistrict: "ทุ่งสองห้อง", + shippingDistrict: "หลักสี่", + shippingProvince: "กรุงเทพมหานคร", + shippingPostcode: "10210", + shippingCountry: "TH", + shippingAddressesCount: 3, + + preferredPayment: "transfer", + paymentTermDays: 30, + creditLimitTHB: 300000, + creditUsedTHB: 120000, + arOutstandingTHB: 98000, + invoiceDelivery: "e-tax", + currency: "THB", + + language: "th", + region: "BKK", + channelAcquired: "d2c", + accountManagerName: "อ้อม ส.", + slaTier: "priority", + tags: ["key-account", "b2b"], + + tier: "gold", + points: 12450, + ltvTHB: 2480000, + avgOrderValueTHB: 42000, + + firstOrderAt: "2019-01-15T10:00:00+07:00", + lastOrderAt: "2025-10-05T14:20:00+07:00", + ordersCount: 313, + returnsCount: 12, + cancellationsCount: 4, + + createdByName: "กร พ.", + createdAt: "2018-12-20T09:00:00+07:00", + updatedAt: "2025-10-08T16:30:00+07:00", + noteInternal: "ลูกค้าองค์กรชำระตรงเวลา" + }, + { + id: "C-0002", + code: "CUST-0002", + type: "individual", + status: "active", + displayName: "Ploy Ch.", + titleTH: "น.ส.", + firstNameTH: "พลอย", + lastNameTH: "ช.", + firstNameEN: "Ploy", + lastNameEN: "Ch.", + gender: "female", + birthDate: "1996-04-11", + nationalId: "1101234567890", + + vatStatus: "unregistered", + kycStatus: "in_progress", + riskScore: 28, + pepFlag: false, + amlFlag: false, + + primaryEmail: "ploy@example.com", + emails: [], + primaryPhone: "0812345678", + phones: ["0812345678"], + socialLineId: "ploy.11", + + billingName: "Ploy Ch.", + billingTaxId: null, + billingAddressLine1: "91/518 หมู่ 1", + billingSubdistrict: "หน้าเมือง", + billingDistrict: "เมือง", + billingProvince: "นนทบุรี", + billingPostcode: "11000", + billingCountry: "TH", + + shippingName: "Ploy Ch.", + shippingPhone: "0812345678", + shippingAddressLine1: "91/518 หมู่ 1", + shippingSubdistrict: "หน้าเมือง", + shippingDistrict: "เมือง", + shippingProvince: "นนทบุรี", + shippingPostcode: "11000", + shippingCountry: "TH", + shippingAddressesCount: 1, + + preferredPayment: "bank_qr", + paymentTermDays: 0, + creditLimitTHB: 0, + creditUsedTHB: 0, + arOutstandingTHB: 0, + invoiceDelivery: "email_pdf", + currency: "THB", + + language: "th", + region: "Central", + channelAcquired: "shopee", + accountManagerName: null, + slaTier: "standard", + tags: ["retail"], + + tier: "silver", + points: 820, + ltvTHB: 14900, + avgOrderValueTHB: 745, + + firstOrderAt: "2024-03-02T11:00:00+07:00", + lastOrderAt: "2025-10-08T08:20:00+07:00", + ordersCount: 21, + returnsCount: 1, + cancellationsCount: 0, + + createdByName: "ออม ก.", + createdAt: "2024-03-02T11:05:00+07:00", + updatedAt: "2025-10-08T08:25:00+07:00", + }, + { + id: "C-0003", + code: "CUST-0003", + type: "juristic", + status: "suspended", + displayName: "Kathy Labz Co., Ltd.", + aliasName: "Kathy Labz", + companyNameTH: "บริษัท แคธี่ แลบซ์ จำกัด", + companyNameEN: "Kathy Labz Co., Ltd.", + juristicId: "0105569876543", + branchCode: "00000", + businessType: "รับจ้างผลิต/วิจัย", + industry: "Contract Manufacturing", + paidUpCapitalTHB: 10000000, + + vatStatus: "registered", + vatRegNo: "0105569876543", + vatEffectiveDate: "2021-04-01", + whtDefaultRate: 3, + eTaxReady: true, + eWithholdingReady: true, + kycStatus: "expired", + riskScore: 55, + pepFlag: false, + amlFlag: false, + + primaryEmail: "ap@kathylabz.com", + emails: ["ops@kathylabz.com"], + primaryPhone: "026789000", + phones: ["026789000"], + website: "https://kathylabz.com", + + billingName: "บริษัท แคธี่ แลบซ์ จำกัด", + billingTaxId: "0105569876543", + billingAddressLine1: "128/7 ซอยสวนหลวง", + billingSubdistrict: "สวนหลวง", + billingDistrict: "พระนคร", + billingProvince: "กรุงเทพมหานคร", + billingPostcode: "10200", + billingCountry: "TH", + + shippingName: "Kathy Labz Store", + shippingPhone: "0820001111", + shippingAddressLine1: "ตึก B ชั้น 5", + shippingSubdistrict: "สวนหลวง", + shippingDistrict: "พระนคร", + shippingProvince: "กรุงเทพมหานคร", + shippingPostcode: "10200", + shippingCountry: "TH", + shippingAddressesCount: 2, + + preferredPayment: "transfer", + paymentTermDays: 45, + creditLimitTHB: 600000, + creditUsedTHB: 480000, + arOutstandingTHB: 310000, + invoiceDelivery: "e-tax", + currency: "THB", + + language: "th", + region: "BKK", + channelAcquired: "d2c", + accountManagerName: "บี ที.", + slaTier: "priority", + tags: ["vendor", "manufacturing"], + + tier: "gold", + points: 2230, + ltvTHB: 860000, + avgOrderValueTHB: 72000, + + firstOrderAt: "2023-10-01T09:00:00+07:00", + lastOrderAt: "2025-09-24T15:30:00+07:00", + ordersCount: 47, + returnsCount: 3, + cancellationsCount: 2, + + createdByName: "กร พ.", + createdAt: "2023-09-20T10:00:00+07:00", + updatedAt: "2025-10-06T10:00:00+07:00", + noteInternal: "หมดอายุเอกสาร KYC — ขออัปเดต" + }, + { + id: "C-0004", + code: "CUST-0004", + type: "individual", + status: "blacklisted", + displayName: "Thanawat K.", + firstNameEN: "Thanawat", + lastNameEN: "K.", + nationalId: "1109876543210", + + vatStatus: "unregistered", + kycStatus: "failed", + riskScore: 92, + pepFlag: false, + amlFlag: true, + blacklistReason: "Chargeback หลายครั้ง", + + primaryEmail: "thanawat@example.com", + primaryPhone: "0820001111", + + billingName: "Thanawat K.", + billingTaxId: null, + billingAddressLine1: "128/7 ซอยสวนหลวง", + billingDistrict: "พระนคร", + billingProvince: "กรุงเทพมหานคร", + billingPostcode: "10200", + billingCountry: "TH", + + shippingName: "Thanawat K.", + shippingPhone: "0820001111", + shippingAddressLine1: "ตึก B ชั้น 5", + shippingDistrict: "พระนคร", + shippingProvince: "กรุงเทพมหานคร", + shippingPostcode: "10200", + shippingCountry: "TH", + shippingAddressesCount: 1, + + preferredPayment: "cod", + paymentTermDays: 0, + creditLimitTHB: 0, + creditUsedTHB: 0, + arOutstandingTHB: 0, + invoiceDelivery: "paper", + currency: "THB", + + language: "en", + region: "Ecommerce", + channelAcquired: "lazada", + accountManagerName: null, + slaTier: "standard", + tags: ["risk", + + ], + + tier: "none", + points: 0, + ltvTHB: 640, + avgOrderValueTHB: 640, + + firstOrderAt: "2025-08-12T12:00:00+07:00", + lastOrderAt: "2025-08-12T12:00:00+07:00", + ordersCount: 1, + returnsCount: 0, + cancellationsCount: 0, + + createdByName: "อ้อม ส.", + createdAt: "2025-08-12T12:10:00+07:00", + updatedAt: "2025-09-01T09:30:00+07:00", + noteInternal: "Blacklist — ห้ามขาย COD", + }, +]; + +/* ======================== Columns ======================== */ +const ALL_COLS: Column[] = [ + // เอกลักษณ์ & ชนิด + { key: "code", labelTH: "รหัสลูกค้า", groupTH: "เอกลักษณ์" }, + { key: "displayName", labelTH: "ชื่อแสดง", groupTH: "เอกลักษณ์" }, + { key: "type", labelTH: "ชนิด", groupTH: "เอกลักษณ์", format: (v) => renderType(v as CustomerType) }, + { key: "status", labelTH: "สถานะ", groupTH: "เอกลักษณ์", format: (v) => renderStatus(v as CustomerStatus) }, + { key: "aliasName", labelTH: "ชื่อการค้า/นามแฝง", groupTH: "เอกลักษณ์" }, + + // จดทะเบียน/ภาษี + { key: "juristicId", labelTH: "เลขนิติ 13 หลัก", groupTH: "ทะเบียน/ภาษี" }, + { key: "branchCode", labelTH: "รหัสสาขา", groupTH: "ทะเบียน/ภาษี" }, + { key: "vatStatus", labelTH: "สถานะ VAT", groupTH: "ทะเบียน/ภาษี", format: (v) => renderVat(v as VatStatus) }, + { key: "vatRegNo", labelTH: "เลข VAT", groupTH: "ทะเบียน/ภาษี" }, + { key: "vatEffectiveDate", labelTH: "เริ่ม VAT", groupTH: "ทะเบียน/ภาษี", format: (v) => fmtDate(v as string | null | undefined) }, + { key: "whtDefaultRate", labelTH: "WHT% เริ่มต้น", groupTH: "ทะเบียน/ภาษี", align: "right" }, + { key: "eTaxReady", labelTH: "พร้อม e-Tax?", groupTH: "ทะเบียน/ภาษี", format: (v) => (v ? "YES" : "NO") }, + { key: "eWithholdingReady", labelTH: "พร้อม e-WHT?", groupTH: "ทะเบียน/ภาษี", format: (v) => (v ? "YES" : "NO") }, + + // บุคคลธรรมดา (อาจว่าง) + { key: "nationalId", labelTH: "เลขบัตร ปชช.", groupTH: "บุคคล" }, + { key: "birthDate", labelTH: "วันเกิด", groupTH: "บุคคล", format: (v) => (v ? new Date(v as string).toLocaleDateString("th-TH") : "-") }, + + // ติดต่อ + { key: "primaryEmail", labelTH: "อีเมลหลัก", groupTH: "ติดต่อ" }, + { key: "primaryPhone", labelTH: "เบอร์หลัก", groupTH: "ติดต่อ" }, + { key: "emails", labelTH: "อีเมลเพิ่มเติม", groupTH: "ติดต่อ", format: (v) => (Array.isArray(v) && v.length ? v.join("|") : "-") }, + { key: "phones", labelTH: "เบอร์เพิ่มเติม", groupTH: "ติดต่อ", format: (v) => (Array.isArray(v) && v.length ? v.join("|") : "-") }, + { key: "website", labelTH: "เว็บไซต์", groupTH: "ติดต่อ" }, + { key: "socialLineId", labelTH: "LINE ID", groupTH: "ติดต่อ" }, + + // ที่อยู่บิล + { key: "billingName", labelTH: "ชื่อบิล", groupTH: "ที่อยู่บิล" }, + { key: "billingTaxId", labelTH: "เลขภาษี (บิล)", groupTH: "ที่อยู่บิล" }, + { key: "billingAddressLine1", labelTH: "ที่อยู่บิล", groupTH: "ที่อยู่บิล" }, + { key: "billingSubdistrict", labelTH: "แขวง/ตำบล (บิล)", groupTH: "ที่อยู่บิล" }, + { key: "billingDistrict", labelTH: "เขต/อำเภอ (บิล)", groupTH: "ที่อยู่บิล" }, + { key: "billingProvince", labelTH: "จังหวัด (บิล)", groupTH: "ที่อยู่บิล" }, + { key: "billingPostcode", labelTH: "รหัสไปรษณีย์ (บิล)", groupTH: "ที่อยู่บิล" }, + + // ที่อยู่จัดส่งหลัก + { key: "shippingName", labelTH: "ชื่อผู้รับ", groupTH: "ที่อยู่จัดส่ง" }, + { key: "shippingPhone", labelTH: "เบอร์ผู้รับ", groupTH: "ที่อยู่จัดส่ง" }, + { key: "shippingAddressLine1", labelTH: "ที่อยู่จัดส่ง", groupTH: "ที่อยู่จัดส่ง" }, + { key: "shippingSubdistrict", labelTH: "แขวง/ตำบล (ส่ง)", groupTH: "ที่อยู่จัดส่ง" }, + { key: "shippingDistrict", labelTH: "เขต/อำเภอ (ส่ง)", groupTH: "ที่อยู่จัดส่ง" }, + { key: "shippingProvince", labelTH: "จังหวัด (ส่ง)", groupTH: "ที่อยู่จัดส่ง" }, + { key: "shippingPostcode", labelTH: "รหัสไปรษณีย์ (ส่ง)", groupTH: "ที่อยู่จัดส่ง" }, + { key: "shippingAddressesCount", labelTH: "จำนวนที่อยู่จัดส่ง", groupTH: "ที่อยู่จัดส่ง", align: "right" }, + + // การเงิน/เครดิต + { key: "preferredPayment", labelTH: "วิธีจ่ายที่ชอบ", groupTH: "การเงิน/เครดิต", format: (v) => renderPay(v as PreferredPay) }, + { key: "paymentTermDays", labelTH: "เครดิตเทอม(วัน)", groupTH: "การเงิน/เครดิต", align: "right" }, + { key: "creditLimitTHB", labelTH: "วงเงิน", groupTH: "การเงิน/เครดิต", align: "right", format: (v) => THB(v as number) }, + { key: "creditUsedTHB", labelTH: "ใช้ไป", groupTH: "การเงิน/เครดิต", align: "right", format: (v) => THB(v as number) }, + { key: "arOutstandingTHB", labelTH: "ลูกหนี้คงค้าง", groupTH: "การเงิน/เครดิต", align: "right", format: (v) => THB(v as number) }, + { key: "invoiceDelivery", labelTH: "ส่งใบกำกับ", groupTH: "การเงิน/เครดิต" }, + { key: "currency", labelTH: "สกุล", groupTH: "การเงิน/เครดิต" }, + + // ตั้งค่า/บัญชี + { key: "language", labelTH: "ภาษา", groupTH: "ตั้งค่า" }, + { key: "region", labelTH: "ภูมิภาค", groupTH: "ตั้งค่า" }, + { key: "channelAcquired", labelTH: "ช่องทางเริ่มต้น", groupTH: "ตั้งค่า" }, + { key: "accountManagerName", labelTH: "ผู้ดูแลบัญชี", groupTH: "ตั้งค่า" }, + { key: "slaTier", labelTH: "SLA", groupTH: "ตั้งค่า" }, + + // Loyalty / LTV + { key: "tier", labelTH: "Loyalty Tier", groupTH: "Loyalty", format: (v) => (v ? TIER_META[v as LoyaltyTier] : "-") }, + { key: "points", labelTH: "แต้ม", groupTH: "Loyalty", align: "right" }, + { key: "ltvTHB", labelTH: "LTV", groupTH: "Loyalty", align: "right", format: (v) => THB(v as number) }, + { key: "avgOrderValueTHB", labelTH: "AOV", groupTH: "Loyalty", align: "right", format: (v) => THB(v as number) }, + + // กิจกรรม/ออเดอร์ + { key: "firstOrderAt", labelTH: "ออเดอร์แรก", groupTH: "กิจกรรม", format: (v) => fmtDate(v as string | null | undefined) }, + { key: "lastOrderAt", labelTH: "ออเดอร์ล่าสุด", groupTH: "กิจกรรม", format: (v) => fmtDate(v as string | null | undefined) }, + { key: "ordersCount", labelTH: "จำนวนออเดอร์", groupTH: "กิจกรรม", align: "right" }, + { key: "returnsCount", labelTH: "คืนสินค้า", groupTH: "กิจกรรม", align: "right" }, + { key: "cancellationsCount", labelTH: "ยกเลิก", groupTH: "กิจกรรม", align: "right" }, + + // ความเสี่ยง/KYC + { key: "kycStatus", labelTH: "KYC", groupTH: "ความเสี่ยง/KYC", format: (v) => renderKyc(v as KycStatus) }, + { key: "riskScore", labelTH: "คะแนนเสี่ยง", groupTH: "ความเสี่ยง/KYC", align: "right" }, + { key: "pepFlag", labelTH: "PEP?", groupTH: "ความเสี่ยง/KYC", format: (v) => (v ? "YES" : "NO") }, + { key: "amlFlag", labelTH: "AML?", groupTH: "ความเสี่ยง/KYC", format: (v) => (v ? "YES" : "NO") }, + { key: "blacklistReason", labelTH: "เหตุผล Blacklist", groupTH: "ความเสี่ยง/KYC" }, + { key: "docNationalIdExpiry", labelTH: "หมดอายุบัตร ปชช.", groupTH: "ความเสี่ยง/KYC", format: (v) => (v ? new Date(v as string).toLocaleDateString("th-TH") : "-") }, + { key: "docCertIncorpExpiry", labelTH: "หมดอายุ นจท.", groupTH: "ความเสี่ยง/KYC", format: (v) => (v ? new Date(v as string).toLocaleDateString("th-TH") : "-") }, + + // อื่น ๆ + { key: "tags", labelTH: "แท็ก", groupTH: "อื่น ๆ", format: (v) => (Array.isArray(v) && v.length ? v.join("|") : "-") }, + { key: "noteInternal", labelTH: "โน้ตภายใน", groupTH: "อื่น ๆ" }, + + // เมตา + { key: "createdByName", labelTH: "ผู้สร้าง", groupTH: "เมตา" }, + { key: "createdAt", labelTH: "สร้างเมื่อ", groupTH: "เมตา", format: (v) => fmtDate(v as string | null | undefined) }, + { key: "updatedAt", labelTH: "อัปเดตล่าสุด", groupTH: "เมตา", format: (v) => fmtDate(v as string | null | undefined) }, +]; + +const DEFAULT_VISIBLE: ColumnKey[] = [ + "code","displayName","type","status", + "vatStatus","primaryEmail","primaryPhone", + "billingProvince","shippingProvince","region", + "preferredPayment","paymentTermDays","creditUsedTHB","arOutstandingTHB", + "tier","ltvTHB","ordersCount","kycStatus","riskScore", + "createdAt","updatedAt" +]; + +const GROUPS: { titleTH: string; keys: ColumnKey[] }[] = [ + { titleTH: "เอกลักษณ์", keys: ["code","displayName","type","status","aliasName"] }, + { titleTH: "ทะเบียน/ภาษี", keys: ["juristicId","branchCode","vatStatus","vatRegNo","vatEffectiveDate","whtDefaultRate","eTaxReady","eWithholdingReady"] }, + { titleTH: "บุคคล", keys: ["nationalId","birthDate"] }, + { titleTH: "ติดต่อ", keys: ["primaryEmail","primaryPhone","emails","phones","website","socialLineId"] }, + { titleTH: "ที่อยู่บิล", keys: ["billingName","billingTaxId","billingAddressLine1","billingSubdistrict","billingDistrict","billingProvince","billingPostcode"] }, + { titleTH: "ที่อยู่จัดส่ง", keys: ["shippingName","shippingPhone","shippingAddressLine1","shippingSubdistrict","shippingDistrict","shippingProvince","shippingPostcode","shippingAddressesCount"] }, + { titleTH: "การเงิน/เครดิต", keys: ["preferredPayment","paymentTermDays","creditLimitTHB","creditUsedTHB","arOutstandingTHB","invoiceDelivery","currency"] }, + { titleTH: "ตั้งค่า", keys: ["language","region","channelAcquired","accountManagerName","slaTier"] }, + { titleTH: "Loyalty", keys: ["tier","points","ltvTHB","avgOrderValueTHB"] }, + { titleTH: "กิจกรรม", keys: ["firstOrderAt","lastOrderAt","ordersCount","returnsCount","cancellationsCount"] }, + { titleTH: "ความเสี่ยง/KYC", keys: ["kycStatus","riskScore","pepFlag","amlFlag","blacklistReason","docNationalIdExpiry","docCertIncorpExpiry"] }, + { titleTH: "อื่น ๆ", keys: ["tags","noteInternal"] }, + { titleTH: "เมตา", keys: ["createdByName","createdAt","updatedAt"] }, +]; + +/* ======================== Page ======================== */ +export default function CustomersPage() { + // ฟิลเตอร์ + const [q, setQ] = useState(""); + const [ctype, setCtype] = useState<"all" | CustomerType>("all"); + const [cstatus, setCstatus] = useState<"all" | CustomerStatus>("all"); + const [vat, setVat] = useState<"all" | VatStatus>("all"); + const [kyc, setKyc] = useState<"all" | KycStatus>("all"); + const [tier, setTier] = useState<"all" | LoyaltyTier>("all"); + const [region, setRegion] = useState<"all" | Region>("all"); + const [riskBand, setRiskBand] = useState<"all" | "low" | "mid" | "high">("all"); + + // คอลัมน์ + const [visibleKeys, setVisibleKeys] = useState(DEFAULT_VISIBLE); + const [openCustomize, setOpenCustomize] = useState(false); + const [tempKeys, setTempKeys] = useState(DEFAULT_VISIBLE); + + useEffect(() => { + try { + const raw = localStorage.getItem(LS_KEY); + if (raw) { + const parsed = JSON.parse(raw) as ColumnKey[]; + if (Array.isArray(parsed) && parsed.length) { + setVisibleKeys(parsed); + setTempKeys(parsed); + } + } + } catch { /* ignore */ } + }, []); + const persistVisible = (keys: ColumnKey[]) => { + setVisibleKeys(keys); + try { localStorage.setItem(LS_KEY, JSON.stringify(keys)); } catch { /* ignore */ } + }; + + // options + const typeOpts: CustomerType[] = ["individual","juristic"]; + const statusOpts: CustomerStatus[] = ["active","suspended","blacklisted","inactive"]; + const vatOpts: VatStatus[] = ["registered","unregistered","exempt"]; + const kycOpts: KycStatus[] = ["not_started","in_progress","passed","failed","expired"]; + const tierOpts: LoyaltyTier[] = ["none","silver","gold","platinum"]; + const regionOpts: Region[] = ["BKK","Central","North","Northeast","South","Ecommerce"]; + + // ฟิลเตอร์แถว + const rows = useMemo(() => { + let arr = [...MOCK_CUSTOMERS]; + if (ctype !== "all") arr = arr.filter(c => c.type === ctype); + if (cstatus !== "all") arr = arr.filter(c => c.status === cstatus); + if (vat !== "all") arr = arr.filter(c => c.vatStatus === vat); + if (kyc !== "all") arr = arr.filter(c => c.kycStatus === kyc); + if (tier !== "all") arr = arr.filter(c => c.tier === tier); + if (region !== "all") arr = arr.filter(c => c.region === region); + if (riskBand !== "all") { + arr = arr.filter(c => { + const r = c.riskScore ?? 0; + if (riskBand === "low") return r < 25; + if (riskBand === "mid") return r >= 25 && r < 60; + return r >= 60; + }); + } + if (q.trim()) { + const s = q.trim().toLowerCase(); + arr = arr.filter(x => + [ + x.code, x.displayName, x.aliasName, + x.primaryEmail, x.primaryPhone, + x.juristicId, x.vatRegNo, x.billingTaxId, + x.billingAddressLine1, x.billingProvince, x.billingDistrict, x.billingPostcode, + x.shippingAddressLine1, x.shippingProvince, x.shippingDistrict, x.shippingPostcode, + ...(x.emails ?? []), ...(x.phones ?? []), ...(x.tags ?? []) + ] + .filter((t): t is string => typeof t === "string") + .some(t => t.toLowerCase().includes(s)) + ); + } + // อัปเดตล่าสุดก่อน + arr.sort((a,b) => +new Date(b.updatedAt ?? b.createdAt) - +new Date(a.updatedAt ?? a.createdAt)); + return arr; + }, [ctype, cstatus, vat, kyc, tier, region, riskBand, q]); + + const visibleCols: Column[] = useMemo(() => { + const map = new Map(ALL_COLS.map((c) => [c.key, c])); + return visibleKeys + .map((k) => map.get(k)) + .filter((c): c is Column => Boolean(c)); + }, [visibleKeys]); + + const topHeaderSegments = useMemo(() => { + type Seg = { groupTH: string; colSpan: number }; + const segs: Seg[] = []; + visibleCols.forEach((c, i) => { + const g = c.groupTH; + if (i === 0 || segs[segs.length - 1].groupTH !== g) segs.push({ groupTH: g, colSpan: 1 }); + else segs[segs.length - 1].colSpan += 1; + }); + return segs; + }, [visibleCols]); + + /* ---------- Filter UI helpers ---------- */ + const Field = ({ label, children, className = "" }: { label: string; children: ReactNode; className?: string }) => ( +
+ +
{children}
+
+ ); + const inputBase = + "h-10 w-full rounded-xl border border-neutral-200/80 bg-white/80 px-3 text-sm shadow-sm outline-none placeholder:text-neutral-400 focus:border-neutral-300 focus:ring-2 focus:ring-neutral-900/10"; + const selectBase = + "appearance-none h-10 w-full rounded-xl border border-neutral-200/80 bg-white/80 px-3 text-sm shadow-sm outline-none focus:border-neutral-300 focus:ring-2 focus:ring-neutral-900/10"; + + const activeFilters = [ + ...(q.trim() ? [{ key: "q", label: "ค้นหา", value: q.trim() }] : []), + ...(ctype !== "all" ? [{ key: "t", label: "ชนิด", value: TYPE_META[ctype].label }] : []), + ...(cstatus !== "all" ? [{ key: "s", label: "สถานะ", value: STATUS_META[cstatus].label }] : []), + ...(vat !== "all" ? [{ key: "v", label: "VAT", value: VAT_META[vat].label }] : []), + ...(kyc !== "all" ? [{ key: "k", label: "KYC", value: KYC_META[kyc].label }] : []), + ...(tier !== "all" ? [{ key: "r", label: "Tier", value: TIER_META[tier] }] : []), + ...(region !== "all" ? [{ key: "g", label: "ภูมิภาค", value: region }] : []), + ...(riskBand !== "all" ? [{ key: "rb", label: "ความเสี่ยง", value: riskBand.toUpperCase() }] : []), + ] as { key: string; label: string; value: string }[]; + const clearChip = (k: string) => { + if (k === "q") setQ(""); + if (k === "t") setCtype("all"); + if (k === "s") setCstatus("all"); + if (k === "v") setVat("all"); + if (k === "k") setKyc("all"); + if (k === "r") setTier("all"); + if (k === "g") setRegion("all"); + if (k === "rb") setRiskBand("all"); + }; + const clearAll = () => { + setQ(""); setCtype("all"); setCstatus("all"); setVat("all"); setKyc("all"); + setTier("all"); setRegion("all"); setRiskBand("all"); + }; + + return ( + <> + + +
+ {/* Header */} +
+
+

ลูกค้า (Customers)

+

+ มุมมองเต็ม • แสดง {visibleCols.length} ฟิลด์ • ทั้งหมด {rows.length} ลูกค้า +

+
+
+ + + +
+
+ + {/* Filters */} +
+
+ +
+ + setQ(e.target.value)} + placeholder="รหัส/ชื่อ/นิติ/เลขภาษี/อีเมล/เบอร์/ที่อยู่/แท็ก…" + className={`pl-9 ${inputBase}`} + /> +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + {/* Filter chips */} + {activeFilters.length > 0 && ( +
+ {activeFilters.map((c) => ( + + {c.label}: + {c.value} + + + ))} + +
+ )} +
+ + {/* Table */} +
+
+
+ + + {/* Group header */} + + {topHeaderSegments.map((seg, idx) => ( + + ))} + + {/* Column labels */} + + {visibleCols.map((c) => ( + + ))} + + + + + {rows.map((r, rowIdx) => ( + + {visibleCols.map((c) => { + const raw = getProp(r, c.key); + const moneyKeys = ["creditLimitTHB","creditUsedTHB","arOutstandingTHB","ltvTHB","avgOrderValueTHB"] as ColumnKey[]; + const numKeys = ["paymentTermDays","points","ordersCount","returnsCount","cancellationsCount","whtDefaultRate","shippingAddressesCount","riskScore"] as ColumnKey[]; + const content: ReactNode = + c.format ? c.format(raw, r) + : moneyKeys.includes(c.key) ? THB(raw as number) + : numKeys.includes(c.key) ? num(raw as number) + : defaultCell(raw); + + const align = c.align === "right" ? "text-right" : ""; + return ( + + ); + })} + + ))} + +
+ {seg.groupTH} +
+ {c.labelTH} +
+ {c.key === "code" ? ( + + {content} + + ) : c.key === "tags" && Array.isArray(r.tags) ? ( +
+ {r.tags.length ? r.tags.map((t) => ( + {t} + )) : -} +
+ ) : ( + content + )} +
+
+
+
+ + {/* Customize dialog */} + {openCustomize && ( +
setOpenCustomize(false)}> +
e.stopPropagation()}> +
+

ปรับแต่งตาราง

+ +
+ +
+
+ + + + เลือก {tempKeys.length} คอลัมน์ +
+ +
+ {GROUPS.map((g) => ( +
+
+ {g.titleTH} +
+ + +
+
+
+ {g.keys.map((k) => { + const col = ALL_COLS.find((c) => c.key === k)!; + const checked = tempKeys.includes(k); + return ( + + ); + })} +
+
+ ))} +
+
+ +
+
ระบบจะจำคอลัมน์ที่คุณเลือกไว้ในเบราว์เซอร์นี้
+
+ + +
+
+
+
+ )} +
+ + ); +} diff --git a/src/app/(protected)/inventory/page.tsx b/src/app/(protected)/inventory/page.tsx new file mode 100644 index 0000000..a7e4374 --- /dev/null +++ b/src/app/(protected)/inventory/page.tsx @@ -0,0 +1,957 @@ +"use client"; + +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; +import { + Package, Boxes, QrCode, Barcode, Building2, Store, Factory, + CheckCircle2, ShieldAlert, ShieldX, Ban, AlertTriangle, Hourglass, + ArrowDownCircle, ArrowUpCircle, Truck, Shuffle, + Search, X as XIcon, Eraser, CircleSlash +} from "lucide-react"; + +/* ======================== Types ======================== */ +type WarehouseCode = "BKK-DC" | "CNX-HUB" | "HKT-MINI"; +type BinType = "pick" | "putaway" | "buffer" | "overflow" | "qc" | "return" | "damaged" | "staging" | "in_transit"; +type CostMethod = "FIFO" | "MOVING_AVG" | "STANDARD"; +type LotTracking = "none" | "lot" | "serial"; +type QualityStatus = "released" | "quarantine" | "rejected" | "expired" | "hold"; +type PickRule = "FIFO" | "FEFO" | "LIFO"; +type Availability = "in_stock" | "oos" | "hold" | "quarantine"; + +type ExpiryFilter = "all" | "7" | "30" | "90" | "expired" | "noexp"; +type WarehouseFilter = "all" | WarehouseCode; +type BinTypeFilter = "all" | BinType; +type LotTrackingFilter = "all" | LotTracking; +type QualityFilter = "all" | QualityStatus; +type AvailabilityFilter = "all" | Availability; + +type InventoryRow = { + /* อ้างอิงสินค้า */ + id: string; + productId: string; + productCode: string; + productName: string; + sku: string; + brand?: string | null; + category?: string | null; + + /* ที่ตั้ง/คลัง */ + warehouseCode: WarehouseCode; + binCode: string; + binType: BinType; + + /* ล็อต/ซีเรียล */ + lotTracking: LotTracking; + lotNo?: string | null; + serialNo?: string | null; + mfgDate?: string | null; + expDate?: string | null; + qualityStatus: QualityStatus; + + /* จำนวน */ + uomLevel: "unit" | "inner" | "case"; + onHand: number; + available: number; + allocated: number; + reserved: number; + hold: number; + quarantine: number; + damaged: number; + incoming: number; + inTransit: number; + outgoing: number; + + /* ต้นทุน/มูลค่า */ + unitCostTHB: number; + costMethod: CostMethod; + + /* นโยบาย/รีพลีนิช */ + pickRule: PickRule; + reorderPoint?: number | null; + safetyStock?: number | null; + reorderQty?: number | null; + + /* วัน-เวลา */ + firstReceivedAt?: string | null; + lastMovementAt?: string | null; + + /* อื่น ๆ */ + note?: string | null; + tags?: string[]; + + /* ===== คอลัมน์คำนวณ (เติมตอนทำ rows) ===== */ + valueTHB?: number; + daysToExpiry?: number | null; + ageDays?: number | null; + availability?: Availability; + belowROP?: boolean; +}; + +type ColumnKey = keyof InventoryRow; +type Column = { + key: ColumnKey; + labelTH: string; + groupTH: string; + align?: "left" | "right"; + format?: (v: InventoryRow[ColumnKey], row: InventoryRow) => ReactNode; +}; + +/* ======================== Meta / Renderers ======================== */ +type Tone = "ok" | "warn" | "bad" | "neutral"; +const pill = (t: Tone) => + ({ + ok: "bg-emerald-50 text-emerald-700 border border-emerald-200", + warn: "bg-amber-50 text-amber-700 border border-amber-200", + bad: "bg-red-50 text-red-700 border border-red-200", + neutral: "bg-neutral-50 text-neutral-700 border border-neutral-200", + }[t]); +const U = (s: string) => s.toUpperCase(); + +/** เส้นคอลัมน์โทนอ่อน */ +const COL_BORDER = "border-r border-neutral-200/60 last:border-r-0"; + +const IconLabel = ({ Icon, label, pillTone }: { Icon: LucideIcon; label: string; pillTone?: Tone }) => { + const core = ( + + + {label} + + ); + if (!pillTone) return core; + return ( + + + {label} + + ); +}; + +const WAREHOUSE_META: Record = { + "BKK-DC": { label: U("BKK-DC"), Icon: Factory }, + "CNX-HUB": { label: U("CNX-HUB"), Icon: Building2 }, + "HKT-MINI":{ label: U("HKT-MINI"),Icon: Store }, +}; +const renderWarehouse = (w: WarehouseCode) => ; + +const BINTYPE_META: Record = { + pick: { label: U("Pick"), Icon: Boxes }, + putaway: { label: U("Putaway"), Icon: Package }, + buffer: { label: U("Buffer"), Icon: Boxes }, + overflow: { label: U("Overflow"), Icon: Shuffle }, + qc: { label: U("QC"), Icon: CheckCircle2 }, + return: { label: U("Return"), Icon: ArrowDownCircle }, + damaged: { label: U("Damaged"), Icon: AlertTriangle }, + staging: { label: U("Staging"), Icon: Truck }, + in_transit:{ label: U("In-Transit"),Icon: Truck }, +}; +const renderBinType = (t: BinType) => ; + +const QUALITY_META: Record = { + released: { label: U("Released"), Icon: CheckCircle2, tone: "ok" }, + quarantine: { label: U("Quarantine"), Icon: ShieldAlert, tone: "warn" }, + rejected: { label: U("Rejected"), Icon: ShieldX, tone: "bad" }, + expired: { label: U("Expired"), Icon: Hourglass, tone: "bad" }, + hold: { label: U("Hold"), Icon: Ban, tone: "warn" }, +}; +const renderQuality = (q: QualityStatus) => { + const { Icon, label, tone } = QUALITY_META[q]; + return ; +}; + +const AVAIL_META: Record = { + in_stock: { label: U("In Stock"), Icon: CheckCircle2, tone: "ok" }, + oos: { label: U("Out of Stock"), Icon: CircleSlash, tone: "bad" }, + hold: { label: U("Hold"), Icon: Ban, tone: "warn" }, + quarantine: { label: U("Quarantine"), Icon: ShieldAlert, tone: "warn" }, +}; +const renderAvailability = (a: Availability) => { + const { Icon, label, tone } = AVAIL_META[a]; + return ; +}; + +const fmtDate = (iso?: string | null) => (iso ? new Date(iso).toLocaleString("th-TH", { hour12: false }) : "-"); +const THB = (n?: number | null) => (typeof n === "number" ? `฿${n.toLocaleString()}` : "-"); +const num = (n?: number | null) => (typeof n === "number" && Number.isFinite(n) ? n.toString() : "-"); + +function getProp(obj: T, key: K): T[K] { return obj[key]; } +function escapeCsv(v: unknown): string { + if (v === null || v === undefined) return ""; + const s = typeof v === "string" ? v : Array.isArray(v) ? v.join("|") : String(v); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} +function downloadCsv(filename: string, rows: InventoryRow[], cols: Column[]) { + if (!rows.length || !cols.length) return; + const headers = cols.map((c) => c.labelTH); + const keys = cols.map((c) => c.key); + const csv = + headers.join(",") + "\n" + + rows.map((r) => keys.map((k) => escapeCsv(getProp(r, k))).join(",")).join("\n"); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); + URL.revokeObjectURL(url); +} +function daysDiffFromToday(iso?: string | null) { + if (!iso) return null; + const today = new Date(); today.setHours(0,0,0,0); + const d = new Date(iso); d.setHours(0,0,0,0); + const diffMs = d.getTime() - today.getTime(); + return Math.floor(diffMs / (1000 * 60 * 60 * 24)); // + = วันในอนาคต +} +function uniq(arr: T[]): T[] { return Array.from(new Set(arr)); } + +/* ======================== Mock Data ======================== */ +const MOCK_INV: InventoryRow[] = [ + { + id: "INV-0001", + productId: "P-1001", + productCode: "AMZ-COLL-BASE", + productName: "Amrez Collagen Peptides", + sku: "AMZ-COLL-BASE-250G", + brand: "Amrez", + category: "Supplements > Collagen", + + warehouseCode: "BKK-DC", + binCode: "A-01-03", + binType: "pick", + + lotTracking: "lot", + lotNo: "LOT-2409-01", + serialNo: null, + mfgDate: "2024-09-15", + expDate: "2026-09-14", + qualityStatus: "released", + + uomLevel: "unit", + onHand: 420, + available: 370, + allocated: 40, + reserved: 0, + hold: 0, + quarantine: 0, + damaged: 10, + incoming: 120, + inTransit: 0, + outgoing: 0, + + unitCostTHB: 180, + costMethod: "FIFO", + + pickRule: "FEFO", + reorderPoint: 200, + safetyStock: 150, + reorderQty: 600, + + firstReceivedAt: "2025-08-20T09:10:00+07:00", + lastMovementAt: "2025-10-08T12:30:00+07:00", + + tags: ["top_seller"], + note: null, + }, + { + id: "INV-0002", + productId: "P-1002", + productCode: "AMZ-SERUM-VC", + productName: "Vitamin C Bright Serum 30 ml", + sku: "AMZ-SERUM-VC-30ML", + brand: "Kathy Labz", + category: "Beauty > Serum", + + warehouseCode: "BKK-DC", + binCode: "QC-01", + binType: "qc", + + lotTracking: "lot", + lotNo: "LOT-2510-VC01", + serialNo: null, + mfgDate: "2025-09-20", + expDate: "2027-03-19", + qualityStatus: "quarantine", + + uomLevel: "unit", + onHand: 120, + available: 0, + allocated: 0, + reserved: 0, + hold: 0, + quarantine: 120, + damaged: 0, + incoming: 0, + inTransit: 0, + outgoing: 0, + + unitCostTHB: 210, + costMethod: "MOVING_AVG", + + pickRule: "FEFO", + reorderPoint: 80, + safetyStock: 50, + reorderQty: 300, + + firstReceivedAt: "2025-10-07T17:20:00+07:00", + lastMovementAt: "2025-10-07T17:20:00+07:00", + note: "รอผล QC", + }, + { + id: "INV-0003", + productId: "P-1003", + productCode: "AMZ-GIFT-SET-01", + productName: "Amrez Glow Gift Set", + sku: "AMZ-GIFT-SET-01", + brand: "Amrez", + category: "Sets & Bundles", + + warehouseCode: "CNX-HUB", + binCode: "B-02-07", + binType: "pick", + + lotTracking: "none", + lotNo: null, + serialNo: null, + mfgDate: null, + expDate: null, + qualityStatus: "released", + + uomLevel: "unit", + onHand: 35, + available: 12, + allocated: 23, + reserved: 0, + hold: 0, + quarantine: 0, + damaged: 0, + incoming: 0, + inTransit: 0, + outgoing: 0, + + unitCostTHB: 520, + costMethod: "MOVING_AVG", + + pickRule: "FIFO", + reorderPoint: 40, + safetyStock: 20, + reorderQty: 120, + + firstReceivedAt: "2025-09-25T09:00:00+07:00", + lastMovementAt: "2025-10-08T10:00:00+07:00", + }, + { + id: "INV-0004", + productId: "P-1001", + productCode: "AMZ-COLL-BASE", + productName: "Amrez Collagen Peptides", + sku: "AMZ-COLL-BASE-250G", + brand: "Amrez", + category: "Supplements > Collagen", + + warehouseCode: "HKT-MINI", + binCode: "RET-01", + binType: "return", + + lotTracking: "lot", + lotNo: "LOT-2401-03", + serialNo: null, + mfgDate: "2024-01-10", + expDate: "2025-01-09", + qualityStatus: "expired", + + uomLevel: "unit", + onHand: 8, + available: 0, + allocated: 0, + reserved: 0, + hold: 0, + quarantine: 0, + damaged: 8, + incoming: 0, + inTransit: 0, + outgoing: 0, + + unitCostTHB: 170, + costMethod: "FIFO", + + pickRule: "FEFO", + reorderPoint: 30, + safetyStock: 20, + reorderQty: 100, + + firstReceivedAt: "2024-03-02T10:00:00+07:00", + lastMovementAt: "2025-10-05T13:15:00+07:00", + note: "ของหมดอายุจากเคลม", + }, + { + id: "INV-0005", + productId: "HW-1001", + productCode: "HW-MASSAGER-X", + productName: "Handheld Massager X", + sku: "HW-MASSAGER-X-STD", + brand: "Amrez", + category: "Device", + + warehouseCode: "BKK-DC", + binCode: "A-05-01", + binType: "pick", + + lotTracking: "serial", + lotNo: "LOT-2510-HW1", + serialNo: "SN-00000023", + mfgDate: "2025-09-05", + expDate: null, + qualityStatus: "released", + + uomLevel: "unit", + onHand: 1, + available: 1, + allocated: 0, + reserved: 0, + hold: 0, + quarantine: 0, + damaged: 0, + incoming: 0, + inTransit: 0, + outgoing: 0, + + unitCostTHB: 1490, + costMethod: "STANDARD", + + pickRule: "FIFO", + reorderPoint: 5, + safetyStock: 3, + reorderQty: 10, + + firstReceivedAt: "2025-10-06T14:00:00+07:00", + lastMovementAt: "2025-10-06T14:00:00+07:00", + tags: ["serialized"], + }, +]; + +/* ======================== Columns ======================== */ +const ALL_COLS: Column[] = [ + // อ้างอิงสินค้า + { key: "productCode", labelTH: "รหัสสินค้า", groupTH: "อ้างอิงสินค้า" }, + { key: "productName", labelTH: "ชื่อสินค้า", groupTH: "อ้างอิงสินค้า" }, + { key: "sku", labelTH: "SKU", groupTH: "อ้างอิงสินค้า" }, + { key: "brand", labelTH: "แบรนด์", groupTH: "อ้างอิงสินค้า" }, + { key: "category", labelTH: "หมวด", groupTH: "อ้างอิงสินค้า" }, + + // ที่ตั้ง + { key: "warehouseCode", labelTH: "คลัง", groupTH: "ที่ตั้ง", format: (v) => renderWarehouse(v as WarehouseCode) }, + { key: "binCode", labelTH: "ช่อง", groupTH: "ที่ตั้ง" }, + { key: "binType", labelTH: "ประเภทช่อง", groupTH: "ที่ตั้ง", format: (v) => renderBinType(v as BinType) }, + + // ล็อต/ซีเรียล + { key: "lotTracking", labelTH: "ติดตามแบบ", groupTH: "ล็อต/ซีเรียล", format: (v) => + v === "lot" ? : + v === "serial" ? : + + }, + { key: "lotNo", labelTH: "Lot No.", groupTH: "ล็อต/ซีเรียล" }, + { key: "serialNo", labelTH: "Serial No.", groupTH: "ล็อต/ซีเรียล" }, + { key: "mfgDate", labelTH: "MFG", groupTH: "ล็อต/ซีเรียล", format: (v) => fmtDate(v as string | null | undefined) }, + { key: "expDate", labelTH: "EXP", groupTH: "ล็อต/ซีเรียล", format: (v) => fmtDate(v as string | null | undefined) }, + { key: "daysToExpiry", labelTH: "เหลือ (วัน)", groupTH: "ล็อต/ซีเรียล", align: "right", + format: (v) => { + if (v === null || v === undefined) return "-"; + const n = Number(v); + const tone: Tone = n < 0 ? "bad" : n <= 30 ? "warn" : "ok"; + return {n}; + } }, + + // จำนวน + { key: "uomLevel", labelTH: "UOM", groupTH: "จำนวน" }, + { key: "onHand", labelTH: "คงคลัง", groupTH: "จำนวน", align: "right" }, + { key: "available", labelTH: "พร้อมขาย", groupTH: "จำนวน", align: "right" }, + { key: "allocated", labelTH: "จัดสรร", groupTH: "จำนวน", align: "right" }, + { key: "reserved", labelTH: "จอง", groupTH: "จำนวน", align: "right" }, + { key: "hold", labelTH: "พัก", groupTH: "จำนวน", align: "right" }, + { key: "quarantine", labelTH: "กักกัน", groupTH: "จำนวน", align: "right" }, + { key: "damaged", labelTH: "เสียหาย", groupTH: "จำนวน", align: "right" }, + { key: "incoming", labelTH: "กำลังเข้า", groupTH: "จำนวน", align: "right" }, + { key: "inTransit", labelTH: "ระหว่างทาง", groupTH: "จำนวน", align: "right" }, + { key: "outgoing", labelTH: "กำลังออก", groupTH: "จำนวน", align: "right" }, + + // ต้นทุน/มูลค่า + { key: "unitCostTHB", labelTH: "ต้นทุน/หน่วย", groupTH: "ต้นทุน", align: "right", format: (v) => THB(v as number) }, + { key: "valueTHB", labelTH: "มูลค่าคงคลัง", groupTH: "ต้นทุน", align: "right", format: (v) => THB(v as number) }, + { key: "costMethod", labelTH: "วิธีคิดต้นทุน", groupTH: "ต้นทุน" }, + + // นโยบาย + { key: "pickRule", labelTH: "Pick Rule", groupTH: "นโยบาย" }, + { key: "reorderPoint", labelTH: "จุดสั่งซื้อ", groupTH: "นโยบาย", align: "right" }, + { key: "safetyStock", labelTH: "Safety", groupTH: "นโยบาย", align: "right" }, + { key: "reorderQty", labelTH: "สั่งครั้งละ", groupTH: "นโยบาย", align: "right" }, + { key: "belowROP", labelTH: "ต่ำกว่า ROP?", groupTH: "นโยบาย", format: (v) => + typeof v === "boolean" ? (v ? YES : "NO") : "-" }, + + // สถานะ/คุณภาพ + { key: "qualityStatus", labelTH: "คุณภาพ", groupTH: "สถานะ/คุณภาพ", format: (v) => renderQuality(v as QualityStatus) }, + { key: "availability", labelTH: "สถานะคลัง", groupTH: "สถานะ/คุณภาพ", format: (v) => renderAvailability(v as Availability) }, + + // วัน-เวลา + { key: "firstReceivedAt", labelTH: "รับเข้าครั้งแรก", groupTH: "วัน–เวลา", format: (v) => fmtDate(v as string | null | undefined) }, + { key: "lastMovementAt", labelTH: "เคลื่อนไหวล่าสุด", groupTH: "วัน–เวลา", format: (v) => fmtDate(v as string | null | undefined) }, + + // อื่น ๆ + { key: "tags", labelTH: "แท็ก", groupTH: "อื่น ๆ", format: (v) => (Array.isArray(v) && v.length ? v.join("|") : "-") }, + { key: "note", labelTH: "โน้ต", groupTH: "อื่น ๆ" }, +]; + +const DEFAULT_VISIBLE: ColumnKey[] = [ + "productCode","productName","sku", + "warehouseCode","binCode","binType", + "lotTracking","lotNo","expDate","daysToExpiry", + "onHand","available","allocated", + "unitCostTHB","valueTHB", + "qualityStatus","availability", +]; + +/* กลุ่มคอลัมน์ (สำหรับ dialog ปรับแต่ง) */ +const GROUPS: { titleTH: string; keys: ColumnKey[] }[] = [ + { titleTH: "อ้างอิงสินค้า", keys: ["productCode","productName","sku","brand","category"] }, + { titleTH: "ที่ตั้ง", keys: ["warehouseCode","binCode","binType"] }, + { titleTH: "ล็อต/ซีเรียล", keys: ["lotTracking","lotNo","serialNo","mfgDate","expDate","daysToExpiry"] }, + { titleTH: "จำนวน", keys: ["uomLevel","onHand","available","allocated","reserved","hold","quarantine","damaged","incoming","inTransit","outgoing"] }, + { titleTH: "ต้นทุน", keys: ["unitCostTHB","valueTHB","costMethod"] }, + { titleTH: "นโยบาย", keys: ["pickRule","reorderPoint","safetyStock","reorderQty","belowROP"] }, + { titleTH: "สถานะ/คุณภาพ", keys: ["qualityStatus","availability"] }, + { titleTH: "วัน–เวลา", keys: ["firstReceivedAt","lastMovementAt"] }, + { titleTH: "อื่น ๆ", keys: ["tags","note"] }, +]; + +/* ======================== Page ======================== */ +const LS_KEY = "inventory.visibleCols.v1"; + +export default function InventoryPage() { + // ฟิลเตอร์ + const [q, setQ] = useState(""); + const [wh, setWh] = useState("all"); + const [bt, setBt] = useState("all"); + const [lt, setLt] = useState("all"); + const [qs, setQs] = useState("all"); + const [av, setAv] = useState("all"); + const [expiry, setExpiry] = useState("all"); + + // คอลัมน์ + const [visibleKeys, setVisibleKeys] = useState(DEFAULT_VISIBLE); + const [openCustomize, setOpenCustomize] = useState(false); + const [tempKeys, setTempKeys] = useState(DEFAULT_VISIBLE); + + useEffect(() => { + try { + const raw = localStorage.getItem(LS_KEY); + if (raw) { + const parsed = JSON.parse(raw) as ColumnKey[]; + if (Array.isArray(parsed) && parsed.length) { + setVisibleKeys(parsed); + setTempKeys(parsed); + } + } + } catch { /* ignore */ } + }, []); + const persistVisible = (keys: ColumnKey[]) => { + setVisibleKeys(keys); + try { localStorage.setItem(LS_KEY, JSON.stringify(keys)); } catch { /* ignore */ } + }; + + // ค่าช่วยสร้าง select (type-safe) + const warehouses = useMemo( + () => (uniq(MOCK_INV.map(r => r.warehouseCode)) as WarehouseCode[]).sort((a,b)=>a.localeCompare(b)), + [] + ); + const binTypes = useMemo( + () => (uniq(MOCK_INV.map(r => r.binType)) as BinType[]).sort((a,b)=>a.localeCompare(b)), + [] + ); + + // แปลง + คำนวณค่าเสริม + const enriched = useMemo(() => { + return MOCK_INV.map((r) => { + const daysTo = daysDiffFromToday(r.expDate); + const ageDays = r.firstReceivedAt ? Math.max(0, Math.floor((Date.now() - new Date(r.firstReceivedAt).getTime())/(1000*60*60*24))) : null; + const availability: Availability = + r.quarantine > 0 ? "quarantine" : + r.hold > 0 ? "hold" : + r.available > 0 ? "in_stock" : "oos"; + const belowROP = typeof r.reorderPoint === "number" ? r.available < r.reorderPoint : false; + return { + ...r, + daysToExpiry: daysTo, + ageDays, + availability, + valueTHB: r.onHand * r.unitCostTHB, + belowROP, + }; + }); + }, []); + + // Apply filters + const rows = useMemo(() => { + let arr = [...enriched]; + if (wh !== "all") arr = arr.filter(r => r.warehouseCode === wh); + if (bt !== "all") arr = arr.filter(r => r.binType === bt); + if (lt !== "all") arr = arr.filter(r => r.lotTracking === lt); + if (qs !== "all") arr = arr.filter(r => r.qualityStatus === qs); + if (av !== "all") arr = arr.filter(r => r.availability === av); + if (expiry !== "all") { + arr = arr.filter(r => { + const d = r.daysToExpiry; + if (expiry === "noexp") return d === null; + if (expiry === "expired") return typeof d === "number" && d < 0; + if (typeof d !== "number") return false; + const lim = Number(expiry); + return d >= 0 && d <= lim; + }); + } + if (q.trim()) { + const s = q.trim().toLowerCase(); + arr = arr.filter((r) => + [ + r.productCode, r.productName, r.sku, r.brand, r.category, + r.binCode, r.lotNo, r.serialNo, r.note, ...(r.tags ?? []) + ].filter((x): x is string => typeof x === "string") + .some((x) => x.toLowerCase().includes(s)) + ); + } + // เรียงตามเคลื่อนไหวล่าสุด + arr.sort((a,b) => +new Date(b.lastMovementAt ?? b.firstReceivedAt ?? 0) - +new Date(a.lastMovementAt ?? a.firstReceivedAt ?? 0)); + return arr; + }, [enriched, wh, bt, lt, qs, av, expiry, q]); + + const visibleCols: Column[] = useMemo(() => { + const map = new Map(ALL_COLS.map((c) => [c.key, c])); + return visibleKeys + .map((k) => map.get(k)) + .filter((c): c is Column => Boolean(c)); + }, [visibleKeys]); + + const topHeaderSegments = useMemo(() => { + type Seg = { groupTH: string; colSpan: number }; + const segs: Seg[] = []; + visibleCols.forEach((c, i) => { + const g = c.groupTH; + if (i === 0 || segs[segs.length - 1].groupTH !== g) segs.push({ groupTH: g, colSpan: 1 }); + else segs[segs.length - 1].colSpan += 1; + }); + return segs; + }, [visibleCols]); + + /* ---------- Filter UI helpers ---------- */ + const Field = ({ label, children, className = "" }: { label: string; children: ReactNode; className?: string }) => ( +
+ +
{children}
+
+ ); + const inputBase = + "h-10 w-full rounded-xl border border-neutral-200/80 bg-white/80 px-3 text-sm shadow-sm outline-none placeholder:text-neutral-400 focus:border-neutral-300 focus:ring-2 focus:ring-neutral-900/10"; + const selectBase = + "appearance-none h-10 w-full rounded-xl border border-neutral-200/80 bg-white/80 px-3 text-sm shadow-sm outline-none focus:border-neutral-300 focus:ring-2 focus:ring-neutral-900/10"; + + const activeFilters = [ + ...(q.trim() ? [{ key: "q", label: "ค้นหา", value: q.trim() }] : []), + ...(wh !== "all" ? [{ key: "wh", label: "คลัง", value: WAREHOUSE_META[wh].label }] : []), + ...(bt !== "all" ? [{ key: "bt", label: "ประเภทช่อง", value: BINTYPE_META[bt].label }] : []), + ...(lt !== "all" ? [{ key: "lt", label: "ติดตามแบบ", value: U(lt) }] : []), + ...(qs !== "all" ? [{ key: "qs", label: "คุณภาพ", value: QUALITY_META[qs].label }] : []), + ...(av !== "all" ? [{ key: "av", label: "สถานะคลัง", value: AVAIL_META[av].label }] : []), + ...(expiry !== "all" ? [{ + key: "exp", label: "EXP", + value: expiry === "expired" ? "หมดอายุ" : expiry === "noexp" ? "ไม่มี EXP" : `ภายใน ${expiry} วัน` + }] : []), + ] as { key: string; label: string; value: string }[]; + + const clearChip = (key: string) => { + switch (key) { + case "q": setQ(""); break; + case "wh": setWh("all"); break; + case "bt": setBt("all"); break; + case "lt": setLt("all"); break; + case "qs": setQs("all"); break; + case "av": setAv("all"); break; + case "exp": setExpiry("all"); break; + } + }; + const clearAllFilters = () => { setQ(""); setWh("all"); setBt("all"); setLt("all"); setQs("all"); setAv("all"); setExpiry("all"); }; + + return ( + <> + + +
+ {/* Header */} +
+
+

สต็อกคงคลัง (Inventory)

+

+ มุมมองเต็ม • แสดง {visibleCols.length} ฟิลด์ • ทั้งหมด {rows.length} แถว +

+
+
+ + + +
+
+ + {/* Filters */} +
+
+ +
+ + setQ(e.target.value)} + placeholder="รหัส/ชื่อ/SKU/แบรนด์/หมวด/ช่อง/ล็อต/ซีเรียล/แท็ก/โน้ต…" + className={`${"pl-9"} ${inputBase}`} + /> +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + {activeFilters.length > 0 && ( +
+ {activeFilters.map((c) => ( + + {c.label}: + {c.value} + + + ))} + +
+ )} +
+ + {/* Table */} +
+
+
+ + + {/* Group header */} + + {topHeaderSegments.map((seg, idx) => ( + + ))} + + {/* Column labels */} + + {visibleCols.map((c) => ( + + ))} + + + + + {rows.map((r, rowIdx) => ( + + {visibleCols.map((c) => { + const raw = getProp(r, c.key); + const numeric: readonly ColumnKey[] = ["onHand","available","allocated","reserved","hold","quarantine","damaged","incoming","inTransit","outgoing","reorderPoint","reorderQty","safetyStock"]; + const content: ReactNode = c.format + ? c.format(raw, r) + : c.key === "unitCostTHB" || c.key === "valueTHB" + ? THB(raw as number) + : numeric.includes(c.key) + ? num(raw as number) + : (raw ?? "-"); + + const align = c.align === "right" ? "text-right" : ""; + return ( + + ); + })} + + ))} + +
+ {seg.groupTH} +
+ {c.labelTH} +
+ {c.key === "productCode" ? ( + + {content} + + ) : c.key === "tags" && Array.isArray(r.tags) ? ( +
+ {r.tags.length ? r.tags.map((t) => ( + {t} + )) : -} +
+ ) : ( + content + )} +
+
+
+
+ + {/* Customize dialog */} + {openCustomize && ( +
setOpenCustomize(false)}> +
e.stopPropagation()}> +
+

ปรับแต่งตาราง

+ +
+ +
+
+ + + + เลือก {tempKeys.length} คอลัมน์ +
+ +
+ {GROUPS.map((g) => ( +
+
+ {g.titleTH} +
+ + +
+
+
+ {g.keys.map((k) => { + const col = ALL_COLS.find((c) => c.key === k)!; + const checked = tempKeys.includes(k); + return ( + + ); + })} +
+
+ ))} +
+
+ +
+
ระบบจะจำคอลัมน์ที่คุณเลือกไว้ในเบราว์เซอร์นี้
+
+ + +
+
+
+
+ )} +
+ + ); +} diff --git a/src/app/(protected)/products/page.tsx b/src/app/(protected)/products/page.tsx index 1280931..580412e 100644 --- a/src/app/(protected)/products/page.tsx +++ b/src/app/(protected)/products/page.tsx @@ -15,6 +15,7 @@ type ProductType = "simple" | "variant" | "bundle"; type LifecycleStatus = "draft" | "active" | "archived" | "discontinued"; type ComplianceStatus = "na" | "pending" | "approved" | "rejected"; type QaStage = "idea" | "lab" | "pilot" | "approved" | "deprecated"; +type ChannelFilter = "all" | "d2c" | "shopee" | "lazada" | "tiktok" | "none"; type Product = { /* เอกลักษณ์ & โครงสร้าง */ @@ -151,9 +152,9 @@ const IconLabel = ({ Icon, label, pillTone }: { Icon: LucideIcon; label: string; /* Renderers */ const TYPE_META: Record = { - simple: { label: U("Simple"), Icon: Package }, + simple: { label: U("Simple"), Icon: Package }, variant: { label: U("Variant"), Icon: Tag }, - bundle: { label: U("Bundle"), Icon: Package }, + bundle: { label: U("Bundle"), Icon: Package }, }; const renderType = (t: ProductType) => { const { Icon, label } = TYPE_META[t]; @@ -161,10 +162,10 @@ const renderType = (t: ProductType) => { }; const LIFECYCLE_META: Record = { - draft: { label: U("Draft"), Icon: Hourglass, tone: "warn" }, - active: { label: U("Active"), Icon: CheckCircle2, tone: "ok" }, - archived: { label: U("Archived"), Icon: RotateCcw, tone: "neutral" }, - discontinued: { label: U("Discontinued"), Icon: Ban, tone: "bad" }, + draft: { label: U("Draft"), Icon: Hourglass, tone: "warn" }, + active: { label: U("Active"), Icon: CheckCircle2, tone: "ok" }, + archived: { label: U("Archived"), Icon: RotateCcw, tone: "neutral" }, + discontinued: { label: U("Discontinued"), Icon: Ban, tone: "bad" }, }; const renderLifecycle = (s: LifecycleStatus) => { const { Icon, label, tone } = LIFECYCLE_META[s]; @@ -172,10 +173,10 @@ const renderLifecycle = (s: LifecycleStatus) => { }; const COMPLIANCE_META: Record = { - na: { label: U("N/A"), Icon: ShieldAlert, tone: "neutral" }, - pending: { label: U("Pending"), Icon: ShieldAlert, tone: "warn" }, - approved: { label: U("Approved"), Icon: ShieldCheck, tone: "ok" }, - rejected: { label: U("Rejected"), Icon: ShieldX, tone: "bad" }, + na: { label: U("N/A"), Icon: ShieldAlert, tone: "neutral" }, + pending: { label: U("Pending"), Icon: ShieldAlert, tone: "warn" }, + approved: { label: U("Approved"),Icon: ShieldCheck, tone: "ok" }, + rejected: { label: U("Rejected"),Icon: ShieldX, tone: "bad" }, }; const renderCompliance = (s: ComplianceStatus) => { const { Icon, label, tone } = COMPLIANCE_META[s]; @@ -520,7 +521,7 @@ const ALL_COLS: Column[] = [ { key: "allergens", labelTH: "สารก่อภูมิแพ้", groupTH: "กฎหมาย/ความสอดคล้อง", format: (v) => Array.isArray(v) && v.length ? v.join("|") : "-" }, { key: "certificationsCount", labelTH: "จำนวนใบรับรอง", groupTH: "กฎหมาย/ความสอดคล้อง", align: "right" }, { key: "msdsUrl", labelTH: "MSDS", groupTH: "กฎหมาย/ความสอดคล้อง", format: (v) => v ? ( - + เปิด ) : "-" }, @@ -589,7 +590,7 @@ export default function ProductsPage() { const [compliance, setCompliance] = useState<"all" | ComplianceStatus>("all"); const [brand, setBrand] = useState<"all" | string>("all"); const [category, setCategory] = useState<"all" | string>("all"); - const [channel, setChannel] = useState<"all" | "d2c" | "shopee" | "lazada" | "tiktok" | "none">("all"); + const [channel, setChannel] = useState("all"); const [visibleKeys, setVisibleKeys] = useState(DEFAULT_VISIBLE); const [openCustomize, setOpenCustomize] = useState(false); @@ -605,17 +606,24 @@ export default function ProductsPage() { setTempKeys(parsed); } } - } catch {} + } catch { /* ignore */ } }, []); const persistVisible = (keys: ColumnKey[]) => { setVisibleKeys(keys); - try { localStorage.setItem(LS_KEY, JSON.stringify(keys)); } catch {} + try { localStorage.setItem(LS_KEY, JSON.stringify(keys)); } catch { /* ignore */ } }; const brands = useMemo(() => Array.from(new Set(MOCK_PRODUCTS.map(p => p.brand))).sort(), []); const categories = useMemo(() => Array.from(new Set(MOCK_PRODUCTS.map(p => p.category))).sort(), []); + const channelFlags: Record, keyof Product> = { + d2c: "d2cPublished", + shopee: "shopeePublished", + lazada: "lazadaPublished", + tiktok: "tiktokPublished", + }; + const rows = useMemo(() => { let arr = [...MOCK_PRODUCTS]; @@ -629,8 +637,8 @@ export default function ProductsPage() { if (channel === "none") { arr = arr.filter(p => !p.d2cPublished && !p.shopeePublished && !p.lazadaPublished && !p.tiktokPublished); } else { - const key = (channel + "Published") as keyof Product; - arr = arr.filter(p => Boolean(p[key])); + const flagKey = channelFlags[channel]; + arr = arr.filter(p => Boolean(p[flagKey])); } } @@ -660,7 +668,9 @@ export default function ProductsPage() { const visibleCols: Column[] = useMemo(() => { const map = new Map(ALL_COLS.map((c) => [c.key, c])); - return visibleKeys.map((k) => map.get(k)!).filter(Boolean); + return visibleKeys + .map((k) => map.get(k)) + .filter((c): c is Column => Boolean(c)); }, [visibleKeys]); const topHeaderSegments = useMemo(() => { @@ -745,6 +755,7 @@ export default function ProductsPage() { @@ -754,7 +765,7 @@ export default function ProductsPage() { - {/* Filters (ใหม่ สะอาด + chips) */} + {/* Filters */}
{/* Search */} @@ -773,7 +784,11 @@ export default function ProductsPage() { {/* Selects */} - setPtype(e.target.value === "all" ? "all" : (e.target.value as ProductType))} + className={selectBase} + > @@ -782,39 +797,47 @@ export default function ProductsPage() { - setLifecycle(e.target.value === "all" ? "all" : (e.target.value as LifecycleStatus))} + className={selectBase} + > - {(["draft","active","archived","discontinued"] as LifecycleStatus[]).map(s => ( + {(["draft","active","archived","discontinued"] as const).map(s => ( ))} - setCompliance(e.target.value === "all" ? "all" : (e.target.value as ComplianceStatus))} + className={selectBase} + > - {(["na","pending","approved","rejected"] as ComplianceStatus[]).map(s => ( + {(["na","pending","approved","rejected"] as const).map(s => ( ))} - setBrand(e.target.value)} className={selectBase}> {brands.map(b => )} - setCategory(e.target.value)} className={selectBase}> {categories.map(c => )} - setChannel(e.target.value as ChannelFilter)} className={selectBase}> @@ -875,40 +898,46 @@ export default function ProductsPage() { - {rows.map((p, rowIdx) => ( - - {visibleCols.map((c) => { - const raw = getProp(p, c.key); - const numericKeys = ["weightGrams","dimLcm","dimWcm","dimHcm","netVolumeMl","shelfLifeMonths","ageRestrictionMin","ingredientsCount","priceListsCount","mfgLeadTimeDays","moq","packLevels"]; - const content: ReactNode = c.format - ? c.format(raw, p) - : c.key === "listPrice" || c.key === "compareAtPrice" - ? THB(raw as number | null | undefined) - : numericKeys.includes(c.key as string) - ? num(raw as number | null | undefined) - : defaultCell(raw); + {rows.map((p, rowIdx) => { + const numericKeys: readonly ColumnKey[] = [ + "weightGrams","dimLcm","dimWcm","dimHcm","netVolumeMl", + "shelfLifeMonths","ageRestrictionMin","ingredientsCount","priceListsCount", + "mfgLeadTimeDays","moq","packLevels" + ]; + return ( + + {visibleCols.map((c) => { + const raw = getProp(p, c.key); + const content: ReactNode = c.format + ? c.format(raw, p) + : c.key === "listPrice" || c.key === "compareAtPrice" + ? THB(raw as number | null | undefined) + : numericKeys.includes(c.key) + ? num(raw as number | null | undefined) + : defaultCell(raw); - const align = c.align === "right" ? "text-right" : ""; - return ( - - {c.key === "productCode" ? ( - - {content} - - ) : c.key === "tags" && Array.isArray(p.tags) ? ( -
- {p.tags!.length ? p.tags!.map((t) => ( - {t} - )) : -} -
- ) : ( - content - )} - - ); - })} - - ))} + const align = c.align === "right" ? "text-right" : ""; + return ( + + {c.key === "productCode" ? ( + + {content} + + ) : c.key === "tags" && Array.isArray(p.tags) ? ( +
+ {p.tags.length ? p.tags.map((t) => ( + {t} + )) : -} +
+ ) : ( + content + )} + + ); + })} + + ); + })}
@@ -946,12 +975,16 @@ export default function ProductsPage() {
{g.titleTH}
- -
diff --git a/src/app/(protected)/services/page.tsx b/src/app/(protected)/services/page.tsx new file mode 100644 index 0000000..674b285 --- /dev/null +++ b/src/app/(protected)/services/page.tsx @@ -0,0 +1,913 @@ +"use client"; + +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; +import { + Wrench, Settings, GraduationCap, ClipboardList, + Clock, ShieldCheck, ShieldAlert, CheckCircle2, XCircle, + Building2, Home, Globe, Hammer, Layers, ToolCase, + DollarSign, BadgeCheck, Truck, Search, X as XIcon, Eraser +} from "lucide-react"; + +/* ======================== Types ======================== */ +type Channel = "d2c" | "chat" | "shopee" | "lazada" | "tiktok"; +type ServiceType = "installation" | "repair" | "maintenance" | "consulting" | "training" | "customization" | "subscription"; +type BillingType = "one_time" | "hourly" | "subscription" | "milestone"; +type ServiceStatus = "active" | "draft" | "paused" | "retired"; +type LocationType = "on_site" | "remote" | "pickup" | "hybrid"; +type SkillLevel = "L1" | "L2" | "L3"; +type ComplianceLevel = "none" | "basic" | "regulated"; +type Period = "week" | "month" | "quarter" | "year"; + +type Service = { + id: string; + code: string; + name: string; + version: string; + type: ServiceType; + status: ServiceStatus; + + channelList?: Channel[]; + brand?: string | null; + category?: string | null; + shortDesc?: string | null; + + billingType: BillingType; + listPriceTHB?: number | null; + hourlyRateTHB?: number | null; + subscriptionFeeTHB?: number | null; + subscriptionPeriod?: Period | null; + setupFeeTHB?: number | null; + minHours?: number | null; + minQty?: number | null; + taxCategory?: "vat7" | "nonvat" | "withholding" | null; + + locationType: LocationType; + durationMins?: number | null; + requiresAppointment?: boolean; + region?: string | null; + travelFeeTHB?: number | null; + leadTimeDays?: number | null; + capacityPerDay?: number | null; + + slaResponseHrs?: number | null; + slaResolveHrs?: number | null; + warrantyDays?: number | null; + + skillLevel?: SkillLevel | null; + requiredCerts?: string[]; + complianceLevel: ComplianceLevel; + backgroundCheckRequired?: boolean; + + requiresParts?: boolean; + partsCount?: number | null; + relatedSkuCodes?: string[]; + + isBundle?: boolean; + bundleItemsCount?: number | null; + dependsOn?: string[]; + upsellTo?: string[]; + + ratingAvg?: number | null; + ratingCount?: number | null; + + sopUrl?: string | null; + checklistUrl?: string | null; + + createdByName: string; + dateCreated: string; + updatedAt?: string | null; + + tags?: string[]; + note?: string | null; +}; + +type ColumnKey = keyof Service; +type Column = { + key: ColumnKey; + labelTH: string; + groupTH: string; + align?: "left" | "right"; + format?: (v: Service[ColumnKey], row: Service) => ReactNode; +}; + +/* ======================== Meta / Renderers ======================== */ +type Tone = "ok" | "warn" | "bad" | "neutral"; +const pill = (t: Tone) => + ({ + ok: "bg-emerald-50 text-emerald-700 border border-emerald-200", + warn: "bg-amber-50 text-amber-700 border border-amber-200", + bad: "bg-red-50 text-red-700 border border-red-200", + neutral: "bg-neutral-50 text-neutral-700 border border-neutral-200", + }[t]); + +const U = (s: string) => s.toUpperCase(); +/** เส้นคอลัมน์โทนอ่อน */ +const COL_BORDER = "border-r border-neutral-200/60 last:border-r-0"; + +const IconLabel = ({ Icon, label, pillTone }: { Icon: LucideIcon; label: string; pillTone?: Tone }) => { + const base = ( + + + {label} + + ); + if (!pillTone) return base; + return ( + + + {label} + + ); +}; + +const TYPE_META: Record = { + installation: { label: U("Installation"), Icon: ToolCase }, + repair: { label: U("Repair"), Icon: Wrench }, + maintenance: { label: U("Maintenance"), Icon: Hammer }, + consulting: { label: U("Consulting"), Icon: Settings }, + training: { label: U("Training"), Icon: GraduationCap }, + customization: { label: U("Customization"), Icon: Layers }, + subscription: { label: U("Subscription"), Icon: DollarSign }, +}; +const STATUS_META: Record = { + active: { label: U("Active"), Icon: CheckCircle2, tone: "ok" }, + draft: { label: U("Draft"), Icon: ClipboardList, tone: "neutral" }, + paused: { label: U("Paused"), Icon: ShieldAlert, tone: "warn" }, + retired: { label: U("Retired"), Icon: XCircle, tone: "bad" }, +}; +const BILLING_META: Record = { + one_time: { label: U("One-time"), Icon: DollarSign }, + hourly: { label: U("Hourly"), Icon: Clock }, + subscription: { label: U("Subscription"), Icon: DollarSign }, + milestone: { label: U("Milestone"), Icon: ClipboardList }, +}; +const LOC_META: Record = { + on_site: { label: U("On-site"), Icon: Building2 }, + remote: { label: U("Remote"), Icon: Globe }, + pickup: { label: U("Pickup"), Icon: Truck }, + hybrid: { label: U("Hybrid"), Icon: Home }, +}; +const COMP_META: Record = { + none: { label: U("None"), Icon: CheckCircle2, tone: "neutral" }, + basic: { label: U("Basic"), Icon: BadgeCheck, tone: "ok" }, + regulated: { label: U("Regulated"), Icon: ShieldCheck, tone: "warn" }, +}; + +const renderType = (t: ServiceType) => ; +const renderStatus = (s: ServiceStatus) => { + const m = STATUS_META[s]; return ; +}; +const renderBilling = (b: BillingType) => ; +const renderLoc = (l: LocationType) => ; +const renderComp = (c: ComplianceLevel) => { + const m = COMP_META[c]; return ; +}; + +const fmtDate = (iso?: string | null) => (iso ? new Date(iso).toLocaleString("th-TH", { hour12: false }) : "-"); +const THB = (n?: number | null) => (typeof n === "number" ? `฿${n.toLocaleString()}` : "-"); +const num = (n?: number | null) => (typeof n === "number" && Number.isFinite(n) ? n.toString() : "-"); + +/* ======================== Utils ======================== */ +const LS_KEY = "services.visibleCols.v1"; + +function getProp(obj: T, key: K): T[K] { return obj[key]; } +function escapeCsv(v: unknown): string { + if (v === null || v === undefined) return ""; + const s = typeof v === "string" ? v : Array.isArray(v) ? v.join("|") : String(v); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} +function downloadCsv(filename: string, rows: Service[], cols: Column[]) { + if (!rows.length || !cols.length) return; + const headers = cols.map((c) => c.labelTH); + const keys = cols.map((c) => c.key); + const csv = + headers.join(",") + "\n" + + rows.map((r) => keys.map((k) => escapeCsv(getProp(r, k))).join(",")).join("\n"); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); + URL.revokeObjectURL(url); +} + +/** แสดงค่าดีฟอลต์แบบ type-safe */ +function defaultCell(v: unknown): ReactNode { + if (v === null || v === undefined) return "-"; + if (typeof v === "string") return v.trim() === "" ? "-" : v; + if (typeof v === "number") return Number.isFinite(v) ? v.toString() : "-"; + if (typeof v === "boolean") return v ? "YES" : "NO"; + if (Array.isArray(v)) return v.length ? v.join("|") : "-"; + return String(v); +} + +/* ======================== Mock Data ======================== */ +const MOCK_SERVICES: Service[] = [ + { + id: "SVC-0001", + code: "INST-HW-MASSAGER", + name: "ติดตั้ง & สาธิต Handheld Massager X", + version: "1.0", + type: "installation", + status: "active", + channelList: ["d2c", "chat"], + brand: "Amrez", + category: "Device Services", + shortDesc: "ติดตั้ง อธิบายวิธีใช้งาน และทดสอบระบบเบื้องต้นที่บ้านลูกค้า", + + billingType: "one_time", + listPriceTHB: 790, + setupFeeTHB: 0, + minQty: 1, + taxCategory: "vat7", + + locationType: "on_site", + durationMins: 60, + requiresAppointment: true, + region: "กทม./ปริมณฑล", + travelFeeTHB: 120, + leadTimeDays: 1, + capacityPerDay: 30, + + slaResponseHrs: 24, + slaResolveHrs: 48, + warrantyDays: 30, + + skillLevel: "L1", + requiredCerts: ["อบรมรุ่น X"], + complianceLevel: "basic", + backgroundCheckRequired: true, + + requiresParts: false, + partsCount: 0, + relatedSkuCodes: ["HW-MASSAGER-X-STD"], + + isBundle: false, + bundleItemsCount: 0, + + ratingAvg: 4.82, + ratingCount: 231, + + sopUrl: "/docs/sop-install-massager.pdf", + checklistUrl: "/docs/checklist-install-massager.pdf", + + createdByName: "ออม ก.", + dateCreated: "2025-08-20T09:00:00+07:00", + updatedAt: "2025-10-08T11:10:00+07:00", + tags: ["popular", "onsite"], + note: null, + }, + { + id: "SVC-0002", + code: "CONS-NEWBRAND", + name: "Consulting: วางแผนเปิดตัวแบรนด์ใหม่ (3 ชม.)", + version: "2.1", + type: "consulting", + status: "active", + channelList: ["d2c", "chat"], + brand: "Kathy Labz", + category: "Business Consulting", + shortDesc: "กลยุทธ์สินค้า/ราคา/ช่องทาง พร้อม Action Plan คร่าว ๆ", + + billingType: "hourly", + hourlyRateTHB: 2500, + minHours: 3, + taxCategory: "withholding", + + locationType: "remote", + durationMins: 180, + requiresAppointment: true, + region: "ทั่วประเทศ (ออนไลน์)", + leadTimeDays: 2, + capacityPerDay: 4, + + slaResponseHrs: 48, + slaResolveHrs: null, + warrantyDays: null, + + skillLevel: "L2", + requiredCerts: ["PMP หรือเทียบเท่า"], + complianceLevel: "none", + + requiresParts: false, + partsCount: 0, + + isBundle: false, + bundleItemsCount: 0, + + ratingAvg: 4.6, + ratingCount: 88, + + createdByName: "บี ที.", + dateCreated: "2025-07-05T10:00:00+07:00", + updatedAt: "2025-10-07T16:00:00+07:00", + tags: ["remote", "hourly"], + }, + { + id: "SVC-0003", + code: "TRAIN-TEAM-VC", + name: "Workshop: เทคนิคใช้ Vitamin C Serum อย่างปลอดภัย (10 คน)", + version: "1.2", + type: "training", + status: "paused", + channelList: ["d2c"], + brand: "Amrez", + category: "Training", + shortDesc: "กลุ่มย่อยสูงสุด 10 คน + เอกสารคู่มือ", + + billingType: "milestone", + listPriceTHB: 15000, + setupFeeTHB: 0, + minQty: 1, + taxCategory: "vat7", + + locationType: "hybrid", + durationMins: 180, + requiresAppointment: true, + region: "กทม./เชียงใหม่ (หรือออนไลน์)", + leadTimeDays: 5, + capacityPerDay: 1, + + slaResponseHrs: 72, + warrantyDays: null, + + skillLevel: "L2", + requiredCerts: ["ครูฝึกผลิตภัณฑ์"], + complianceLevel: "basic", + + requiresParts: true, + partsCount: 3, + relatedSkuCodes: ["KIT-TRAIN-VC", "AMZ-SERUM-VC-30ML"], + + isBundle: true, + bundleItemsCount: 2, + dependsOn: ["CONS-NEWBRAND"], + + ratingAvg: 4.9, + ratingCount: 40, + + sopUrl: "/docs/sop-train-vc.pdf", + checklistUrl: "/docs/check-train-vc.pdf", + + createdByName: "กร พ.", + dateCreated: "2025-06-12T11:00:00+07:00", + updatedAt: "2025-09-30T09:00:00+07:00", + tags: ["bundle", "education"], + }, + { + id: "SVC-0004", + code: "SUBS-MA-SILVER", + name: "Maintenance Plan – SILVER", + version: "3.0", + type: "subscription", + status: "active", + + billingType: "subscription", + subscriptionFeeTHB: 1290, + subscriptionPeriod: "month", + setupFeeTHB: 300, + taxCategory: "vat7", + + locationType: "remote", + durationMins: 30, + requiresAppointment: false, + region: "ทั่วประเทศ (ออนไลน์)", + capacityPerDay: 100, + + slaResponseHrs: 12, + slaResolveHrs: 72, + warrantyDays: null, + + skillLevel: "L1", + complianceLevel: "basic", + + requiresParts: false, + partsCount: 0, + + isBundle: false, + bundleItemsCount: 0, + + createdByName: "อ้อม ส.", + dateCreated: "2025-08-01T09:00:00+07:00", + updatedAt: "2025-10-05T15:00:00+07:00", + tags: ["subscription"], + note: "รวมช่องทางแชท/อีเมล + 1 ครั้งรีโมต/เดือน", + }, + { + id: "SVC-0005", + code: "REPAIR-MASSAGER", + name: "ซ่อม Handheld Massager X (นอกประกัน)", + version: "1.0", + type: "repair", + status: "active", + + billingType: "milestone", + listPriceTHB: 450, + setupFeeTHB: 0, + taxCategory: "vat7", + + locationType: "pickup", + durationMins: 120, + requiresAppointment: true, + region: "รับ-ส่งทั่วประเทศ", + travelFeeTHB: 80, + leadTimeDays: 1, + + slaResponseHrs: 24, + slaResolveHrs: 120, + warrantyDays: 90, + + skillLevel: "L1", + complianceLevel: "regulated", + requiredCerts: ["ช่างไฟฟ้า 1 ระดับ"], + + requiresParts: true, + partsCount: 5, + relatedSkuCodes: ["PART-MOTOR-X", "PART-BATT-X"], + + isBundle: false, + bundleItemsCount: 0, + + createdByName: "ออม ก.", + dateCreated: "2025-09-01T09:00:00+07:00", + updatedAt: "2025-10-08T10:20:00+07:00", + tags: ["repair", "pickup"], + }, +]; + +/* ======================== Columns ======================== */ +const ALL_COLS: Column[] = [ + // เอกลักษณ์ + { key: "code", labelTH: "รหัสบริการ", groupTH: "เอกลักษณ์" }, + { key: "name", labelTH: "ชื่อบริการ", groupTH: "เอกลักษณ์" }, + { key: "version", labelTH: "เวอร์ชัน", groupTH: "เอกลักษณ์" }, + { key: "type", labelTH: "ชนิด", groupTH: "เอกลักษณ์", format: (v) => renderType(v as ServiceType) }, + { key: "status", labelTH: "สถานะ", groupTH: "เอกลักษณ์", format: (v) => renderStatus(v as ServiceStatus) }, + { key: "brand", labelTH: "แบรนด์", groupTH: "เอกลักษณ์" }, + { key: "category", labelTH: "หมวด", groupTH: "เอกลักษณ์" }, + + // การขาย/ราคา + { key: "billingType", labelTH: "วิธีบิล", groupTH: "การขาย/ราคา", format: (v) => renderBilling(v as BillingType) }, + { key: "listPriceTHB", labelTH: "ราคาเหมาจ่าย", groupTH: "การขาย/ราคา", align: "right", format: (v) => THB(v as number) }, + { key: "hourlyRateTHB", labelTH: "ราคา/ชั่วโมง", groupTH: "การขาย/ราคา", align: "right", format: (v) => THB(v as number) }, + { key: "subscriptionFeeTHB", labelTH: "ค่าสมาชิกรายงวด", groupTH: "การขาย/ราคา", align: "right", format: (v) => THB(v as number) }, + { key: "subscriptionPeriod", labelTH: "รอบบิล", groupTH: "การขาย/ราคา" }, + { key: "setupFeeTHB", labelTH: "ค่าเซ็ตอัพ", groupTH: "การขาย/ราคา", align: "right", format: (v) => THB(v as number) }, + { key: "minHours", labelTH: "ขั้นต่ำ (ชม.)", groupTH: "การขาย/ราคา", align: "right" }, + { key: "minQty", labelTH: "ขั้นต่ำ (ครั้ง/ชุด)", groupTH: "การขาย/ราคา", align: "right" }, + { key: "taxCategory", labelTH: "ภาษี", groupTH: "การขาย/ราคา" }, + + // ขอบเขต/นัดหมาย + { key: "locationType", labelTH: "รูปแบบให้บริการ", groupTH: "ขอบเขต/นัดหมาย", format: (v) => renderLoc(v as LocationType) }, + { key: "durationMins", labelTH: "ระยะเวลา (นาที)", groupTH: "ขอบเขต/นัดหมาย", align: "right" }, + { key: "requiresAppointment", labelTH: "ต้องนัดหมาย?", groupTH: "ขอบเขต/นัดหมาย", format: (v) => (v ? "YES" : "NO") }, + { key: "region", labelTH: "พื้นที่บริการ", groupTH: "ขอบเขต/นัดหมาย" }, + { key: "travelFeeTHB", labelTH: "ค่าเดินทาง", groupTH: "ขอบเขต/นัดหมาย", align: "right", format: (v) => THB(v as number) }, + { key: "leadTimeDays", labelTH: "เตรียมตัว (วัน)", groupTH: "ขอบเขต/นัดหมาย", align: "right" }, + { key: "capacityPerDay", labelTH: "คิว/วัน", groupTH: "ขอบเขต/นัดหมาย", align: "right" }, + + // SLA/ประกัน + { key: "slaResponseHrs", labelTH: "ตอบกลับ (ชม.)", groupTH: "SLA/ประกัน", align: "right" }, + { key: "slaResolveHrs", labelTH: "แก้ไขเสร็จ (ชม.)", groupTH: "SLA/ประกัน", align: "right" }, + { key: "warrantyDays", labelTH: "ประกัน (วัน)", groupTH: "SLA/ประกัน", align: "right" }, + + // ทีม/ทักษะ/กำกับดูแล + { key: "skillLevel", labelTH: "ระดับทักษะ", groupTH: "ทีม/Compliance" }, + { key: "requiredCerts", labelTH: "ใบรับรองที่ต้องมี", groupTH: "ทีม/Compliance", format: (v) => (Array.isArray(v) && v.length ? v.join("|") : "-") }, + { key: "complianceLevel", labelTH: "Compliance", groupTH: "ทีม/Compliance", format: (v) => renderComp(v as ComplianceLevel) }, + { key: "backgroundCheckRequired", labelTH: "เช็คประวัติ?", groupTH: "ทีม/Compliance", format: (v) => (v ? "YES" : "NO") }, + + // อะไหล่/ผลิตภัณฑ์เกี่ยวข้อง + { key: "requiresParts", labelTH: "ใช้อะไหล่?", groupTH: "อะไหล่/สินค้า", format: (v) => (v ? "YES" : "NO") }, + { key: "partsCount", labelTH: "ชนิดอะไหล่", groupTH: "อะไหล่/สินค้า", align: "right" }, + { key: "relatedSkuCodes", labelTH: "SKU ที่เกี่ยวข้อง", groupTH: "อะไหล่/สินค้า", format: (v) => (Array.isArray(v) && v.length ? v.join("|") : "-") }, + + // บันเดิล/ความสัมพันธ์ + { key: "isBundle", labelTH: "เป็นบันเดิล?", groupTH: "บันเดิล", format: (v) => (v ? "YES" : "NO") }, + { key: "bundleItemsCount", labelTH: "จำนวนองค์ประกอบ", groupTH: "บันเดิล", align: "right" }, + { key: "dependsOn", labelTH: "ต้องทำก่อน", groupTH: "บันเดิล", format: (v) => (Array.isArray(v) && v.length ? v.join("|") : "-") }, + { key: "upsellTo", labelTH: "Upsell ไปยัง", groupTH: "บันเดิล", format: (v) => (Array.isArray(v) && v.length ? v.join("|") : "-") }, + + // เรตติ้ง + { key: "ratingAvg", labelTH: "เรตติ้ง", groupTH: "เรตติ้ง", align: "right" }, + { key: "ratingCount", labelTH: "จำนวนรีวิว", groupTH: "เรตติ้ง", align: "right" }, + + // เมตา/ไฟล์ + { key: "sopUrl", labelTH: "SOP", groupTH: "ไฟล์/เมตา" }, + { key: "checklistUrl", labelTH: "Checklist", groupTH: "ไฟล์/เมตา" }, + { key: "createdByName", labelTH: "ผู้สร้าง", groupTH: "ไฟล์/เมตา" }, + { key: "dateCreated", labelTH: "สร้างเมื่อ", groupTH: "ไฟล์/เมตา", format: (v) => fmtDate(v as string | null | undefined) }, + { key: "updatedAt", labelTH: "อัปเดตล่าสุด", groupTH: "ไฟล์/เมตา", format: (v) => fmtDate(v as string | null | undefined) }, + + // อื่น ๆ + { key: "tags", labelTH: "แท็ก", groupTH: "อื่น ๆ", format: (v) => (Array.isArray(v) && v.length ? v.join("|") : "-") }, + { key: "note", labelTH: "โน้ต", groupTH: "อื่น ๆ" }, +]; + +const DEFAULT_VISIBLE: ColumnKey[] = [ + "code","name","type","status", + "billingType","listPriceTHB","hourlyRateTHB","subscriptionFeeTHB","subscriptionPeriod", + "locationType","durationMins","requiresAppointment","region", + "slaResponseHrs","warrantyDays", + "skillLevel","complianceLevel", +]; + +/* กลุ่มคอลัมน์ (สำหรับ dialog ปรับแต่ง) */ +const GROUPS: { titleTH: string; keys: ColumnKey[] }[] = [ + { titleTH: "เอกลักษณ์", keys: ["code","name","version","type","status","brand","category"] }, + { titleTH: "การขาย/ราคา", keys: ["billingType","listPriceTHB","hourlyRateTHB","subscriptionFeeTHB","subscriptionPeriod","setupFeeTHB","minHours","minQty","taxCategory"] }, + { titleTH: "ขอบเขต/นัดหมาย", keys: ["locationType","durationMins","requiresAppointment","region","travelFeeTHB","leadTimeDays","capacityPerDay"] }, + { titleTH: "SLA/ประกัน", keys: ["slaResponseHrs","slaResolveHrs","warrantyDays"] }, + { titleTH: "ทีม/Compliance", keys: ["skillLevel","requiredCerts","complianceLevel","backgroundCheckRequired"] }, + { titleTH: "อะไหล่/สินค้า", keys: ["requiresParts","partsCount","relatedSkuCodes"] }, + { titleTH: "บันเดิล", keys: ["isBundle","bundleItemsCount","dependsOn","upsellTo"] }, + { titleTH: "เรตติ้ง", keys: ["ratingAvg","ratingCount"] }, + { titleTH: "ไฟล์/เมตา", keys: ["sopUrl","checklistUrl","createdByName","dateCreated","updatedAt"] }, + { titleTH: "อื่น ๆ", keys: ["tags","note"] }, +]; + +/* ======================== Page ======================== */ +export default function ServicesPage() { + // ฟิลเตอร์ + const [q, setQ] = useState(""); + const [stype, setStype] = useState<"all" | ServiceType>("all"); + const [status, setStatus] = useState<"all" | ServiceStatus>("all"); + const [bill, setBill] = useState<"all" | BillingType>("all"); + const [loc, setLoc] = useState<"all" | LocationType>("all"); + const [comp, setComp] = useState<"all" | ComplianceLevel>("all"); + + // คอลัมน์ + const [visibleKeys, setVisibleKeys] = useState(DEFAULT_VISIBLE); + const [openCustomize, setOpenCustomize] = useState(false); + const [tempKeys, setTempKeys] = useState(DEFAULT_VISIBLE); + + useEffect(() => { + try { + const raw = localStorage.getItem(LS_KEY); + if (raw) { + const parsed = JSON.parse(raw) as ColumnKey[]; + if (Array.isArray(parsed) && parsed.length) { + setVisibleKeys(parsed); + setTempKeys(parsed); + } + } + } catch { /* ignore */ } + }, []); + const persistVisible = (keys: ColumnKey[]) => { + setVisibleKeys(keys); + try { localStorage.setItem(LS_KEY, JSON.stringify(keys)); } catch { /* ignore */ } + }; + + // options (typed) + const types = useMemo( + () => Array.from(new Set(MOCK_SERVICES.map(s => s.type))) as ServiceType[], + [] + ); + const statuses = useMemo( + () => Array.from(new Set(MOCK_SERVICES.map(s => s.status))) as ServiceStatus[], + [] + ); + const bills = useMemo( + () => Array.from(new Set(MOCK_SERVICES.map(s => s.billingType))) as BillingType[], + [] + ); + const locs = useMemo( + () => Array.from(new Set(MOCK_SERVICES.map(s => s.locationType))) as LocationType[], + [] + ); + + // ฟิลเตอร์แถว + const rows = useMemo(() => { + let arr = [...MOCK_SERVICES]; + if (stype !== "all") arr = arr.filter(s => s.type === stype); + if (status !== "all") arr = arr.filter(s => s.status === status); + if (bill !== "all") arr = arr.filter(s => s.billingType === bill); + if (loc !== "all") arr = arr.filter(s => s.locationType === loc); + if (comp !== "all") arr = arr.filter(s => s.complianceLevel === comp); + if (q.trim()) { + const s = q.trim().toLowerCase(); + arr = arr.filter(x => + [ + x.code, x.name, x.brand, x.category, x.shortDesc, x.region, + ...(x.channelList ?? []), ...(x.requiredCerts ?? []), ...(x.relatedSkuCodes ?? []), ...(x.tags ?? []) + ] + .filter((t): t is string => typeof t === "string") + .some(t => t.toLowerCase().includes(s)) + ); + } + // เรียงล่าสุดอัปเดต + arr.sort((a, b) => +new Date(b.updatedAt ?? b.dateCreated) - +new Date(a.updatedAt ?? a.dateCreated)); + return arr; + }, [stype, status, bill, loc, comp, q]); + + const visibleCols: Column[] = useMemo(() => { + const map = new Map(ALL_COLS.map((c) => [c.key, c])); + return visibleKeys + .map((k) => map.get(k)) + .filter((c): c is Column => Boolean(c)); + }, [visibleKeys]); + + const topHeaderSegments = useMemo(() => { + type Seg = { groupTH: string; colSpan: number }; + const segs: Seg[] = []; + visibleCols.forEach((c, i) => { + const g = c.groupTH; + if (i === 0 || segs[segs.length - 1].groupTH !== g) segs.push({ groupTH: g, colSpan: 1 }); + else segs[segs.length - 1].colSpan += 1; + }); + return segs; + }, [visibleCols]); + + /* ---------- Filter UI helpers ---------- */ + const Field = ({ label, children, className = "" }: { label: string; children: ReactNode; className?: string }) => ( +
+ +
{children}
+
+ ); + const inputBase = + "h-10 w-full rounded-xl border border-neutral-200/80 bg-white/80 px-3 text-sm shadow-sm outline-none placeholder:text-neutral-400 focus:border-neutral-300 focus:ring-2 focus:ring-neutral-900/10"; + const selectBase = + "appearance-none h-10 w-full rounded-xl border border-neutral-200/80 bg-white/80 px-3 text-sm shadow-sm outline-none focus:border-neutral-300 focus:ring-2 focus:ring-neutral-900/10"; + + const activeFilters = [ + ...(q.trim() ? [{ key: "q", label: "ค้นหา", value: q.trim() }] : []), + ...(stype !== "all" ? [{ key: "t", label: "ชนิด", value: TYPE_META[stype].label }] : []), + ...(status !== "all" ? [{ key: "s", label: "สถานะ", value: STATUS_META[status].label }] : []), + ...(bill !== "all" ? [{ key: "b", label: "บิล", value: BILLING_META[bill].label }] : []), + ...(loc !== "all" ? [{ key: "l", label: "รูปแบบ", value: LOC_META[loc].label }] : []), + ...(comp !== "all" ? [{ key: "c", label: "Compliance", value: COMP_META[comp].label }] : []), + ] as { key: string; label: string; value: string }[]; + const clearChip = (k: string) => { + if (k === "q") setQ(""); + if (k === "t") setStype("all"); + if (k === "s") setStatus("all"); + if (k === "b") setBill("all"); + if (k === "l") setLoc("all"); + if (k === "c") setComp("all"); + }; + const clearAllFilters = () => { setQ(""); setStype("all"); setStatus("all"); setBill("all"); setLoc("all"); setComp("all"); }; + + return ( + <> + + +
+ {/* Header */} +
+
+

บริการ (Services)

+

มุมมองเต็ม • แสดง {visibleCols.length} ฟิลด์ • ทั้งหมด {rows.length} บริการ

+
+
+ + + +
+
+ + {/* Filters */} +
+
+ +
+ + setQ(e.target.value)} + placeholder="รหัส/ชื่อ/แบรนด์/หมวด/คำอธิบาย/พื้นที่/ใบรับรอง/แท็ก…" + className={`pl-9 ${inputBase}`} + /> +
+
+ + + + + + + + + + + + + + + + + + + + +
+ + {/* Filter chips */} + {activeFilters.length > 0 && ( +
+ {activeFilters.map((c) => ( + + {c.label}: + {c.value} + + + ))} + +
+ )} +
+ + {/* Table */} +
+
+
+ + + {/* Group header */} + + {topHeaderSegments.map((seg, idx) => ( + + ))} + + {/* Column labels */} + + {visibleCols.map((c) => ( + + ))} + + + + + {rows.map((s, rowIdx) => ( + + {visibleCols.map((c) => { + const raw = getProp(s, c.key); + const moneyKeys = ["listPriceTHB","hourlyRateTHB","subscriptionFeeTHB","setupFeeTHB","travelFeeTHB"] as ColumnKey[]; + const numKeys = ["minHours","minQty","durationMins","leadTimeDays","capacityPerDay","slaResponseHrs","slaResolveHrs","warrantyDays","partsCount","ratingAvg","ratingCount"] as ColumnKey[]; + const content: ReactNode = c.format + ? c.format(raw, s) + : moneyKeys.includes(c.key) ? THB(raw as number) + : numKeys.includes(c.key) ? num(raw as number) + : defaultCell(raw); + + const align = c.align === "right" ? "text-right" : ""; + return ( + + ); + })} + + ))} + +
+ {seg.groupTH} +
+ {c.labelTH} +
+ {c.key === "code" ? ( + + {content} + + ) : c.key === "tags" && Array.isArray(s.tags) ? ( +
+ {s.tags!.length ? s.tags!.map((t) => ( + {t} + )) : -} +
+ ) : ( + content + )} +
+
+
+
+ + {/* Customize dialog */} + {openCustomize && ( +
setOpenCustomize(false)}> +
e.stopPropagation()}> +
+

ปรับแต่งตาราง

+ +
+ +
+
+ + + + เลือก {tempKeys.length} คอลัมน์ +
+ +
+ {GROUPS.map((g) => ( +
+
+ {g.titleTH} +
+ + +
+
+
+ {g.keys.map((k) => { + const col = ALL_COLS.find((c) => c.key === k)!; + const checked = tempKeys.includes(k); + return ( + + ); + })} +
+
+ ))} +
+
+ +
+
ระบบจะจำคอลัมน์ที่คุณเลือกไว้ในเบราว์เซอร์นี้
+
+ + +
+
+
+
+ )} +
+ + ); +}