diff --git a/src/app/(protected)/products/page.tsx b/src/app/(protected)/products/page.tsx new file mode 100644 index 0000000..1280931 --- /dev/null +++ b/src/app/(protected)/products/page.tsx @@ -0,0 +1,1004 @@ +"use client"; + +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; +import { + Package, Tag, + CheckCircle2, Hourglass, RotateCcw, Ban, + ShieldCheck, ShieldAlert, ShieldX, + FileText, Image as ImageIcon, Link as LinkIcon, + Search, Eraser, X as XIcon +} from "lucide-react"; + +/* ======================== Types ======================== */ +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 Product = { + /* เอกลักษณ์ & โครงสร้าง */ + id: string; + productCode: string; // รหัสสินค้าแม่ + parentProductId?: string | null; // ถ้าเป็น variant อ้างอิงแม่ + sku: string; // SKU ระดับ variant + gtin?: string | null; + type: ProductType; + attributesSummary?: string | null; + + brandId?: string | null; + brand: string; + categoryId?: string | null; + category: string; + categoryPath?: string | null; + + /* คอนเทนต์ */ + productName: string; + variantName?: string | null; + descriptionShort?: string | null; + slug?: string | null; + + /* หน่วย & ขนาด */ + uom: string; + weightGrams?: number | null; + dimLcm?: number | null; + dimWcm?: number | null; + dimHcm?: number | null; + netVolumeMl?: number | null; + + /* ราคา/การตลาด (สรุป) */ + currency?: "THB" | "USD" | "JPY" | "EUR"; + listPrice?: number | null; + compareAtPrice?: number | null; + priceListsCount?: number | null; + + /* สื่อ & เอกสาร/SEO */ + imageCount?: number | null; + docsCount?: number | null; + seoTitle?: string | null; + + /* ช่องทาง/ลิสติ้ง */ + d2cPublished?: boolean; + shopeePublished?: boolean; + lazadaPublished?: boolean; + tiktokPublished?: boolean; + channelsPublished?: string | null; + d2cUrl?: string | null; + shopeeListingId?: string | null; + lazadaListingId?: string | null; + tiktokListingId?: string | null; + + /* กฎหมาย/ความสอดคล้อง */ + complianceStatus: ComplianceStatus; + thaiFdaNo?: string | null; + hazardClass?: string | null; + storageInstruction?: string | null; + shelfLifeMonths?: number | null; + ageRestrictionMin?: number | null; + allergens?: string[] | null; + certificationsCount?: number | null; + msdsUrl?: string | null; + + /* R&D / สูตร */ + qaStage?: QaStage; + formulationVersion?: string | null; + ingredientsCount?: number | null; + rdOwner?: string | null; + + /* บรรจุภัณฑ์ */ + packLevels?: number | null; // 1=Unit, 2=Unit+Case, 3=Unit+Inner+Case + packPrimaryBarcode?: string | null; // GTIN Unit + packCaseBarcode?: string | null; // GTIN Case + packNotes?: string | null; + + /* ซัพพลาย/การผลิต */ + manufacturer?: string | null; + mfgCountry?: string | null; + mfgLeadTimeDays?: number | null; + moq?: number | null; + + /* วงจรชีวิต */ + status: LifecycleStatus; + publishedAt?: string | null; + createdAt: string; + updatedAt?: string | null; + archivedAt?: string | null; + + /* อื่น ๆ */ + tags?: string[]; + note?: string | null; +}; + +type ColumnKey = keyof Product; +type Column = { + key: ColumnKey; + labelTH: string; + groupTH: string; + align?: "left" | "right"; + format?: (v: Product[ColumnKey], row: Product) => ReactNode; +}; + +/* ======================== Meta & helpers ======================== */ +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(); + +// เส้นคอลัมน์โทนอ่อน (ใช้กับทั้ง th/td) +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 content = ( + + + {label} + + ); + if (!pillTone) return content; + return ( + + + {label} + + ); +}; + +/* Renderers */ +const TYPE_META: Record = { + simple: { label: U("Simple"), Icon: Package }, + variant: { label: U("Variant"), Icon: Tag }, + bundle: { label: U("Bundle"), Icon: Package }, +}; +const renderType = (t: ProductType) => { + const { Icon, label } = TYPE_META[t]; + return ; +}; + +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" }, +}; +const renderLifecycle = (s: LifecycleStatus) => { + const { Icon, label, tone } = LIFECYCLE_META[s]; + return ; +}; + +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" }, +}; +const renderCompliance = (s: ComplianceStatus) => { + const { Icon, label, tone } = COMPLIANCE_META[s]; + 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: Product[], 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 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_PRODUCTS: Product[] = [ + { + id: "P-1001", + productCode: "AMZ-COLL-BASE", + parentProductId: null, + sku: "AMZ-COLL-BASE-250G", + gtin: "08850001112223", + type: "variant", + attributesSummary: "Weight=250g|Flavor=Unflavored", + + brandId: "BRD-0001", + brand: "Amrez", + categoryId: "CAT-0002", + category: "Supplements > Collagen", + categoryPath: "Supplements>Collagen", + + productName: "Amrez Collagen Peptides", + variantName: "250g Pouch", + descriptionShort: "คอลลาเจนเพปไทด์ ไฮโดรไลซ์", + slug: "amrez-collagen-250g", + + uom: "ชิ้น", + weightGrams: 280, + dimLcm: 20, dimWcm: 14, dimHcm: 6, + netVolumeMl: null, + + currency: "THB", + listPrice: 790, + compareAtPrice: 890, + priceListsCount: 3, + + imageCount: 5, + docsCount: 3, + seoTitle: "คอลลาเจน Amrez 250 กรัม", + + d2cPublished: true, + shopeePublished: true, + lazadaPublished: true, + tiktokPublished: false, + channelsPublished: "D2C|Shopee|Lazada", + d2cUrl: "/p/amrez-collagen-250g", + shopeeListingId: "SP-556677", + lazadaListingId: "LZ-889900", + tiktokListingId: null, + + complianceStatus: "approved", + thaiFdaNo: "11-1-12345-5-0001", + hazardClass: "Non-hazard", + storageInstruction: "เก็บให้พ้นแสงแดด", + shelfLifeMonths: 24, + ageRestrictionMin: 12, + allergens: [], + certificationsCount: 2, + msdsUrl: null, + + qaStage: "approved", + formulationVersion: "v1.3", + ingredientsCount: 8, + rdOwner: "Korn P.", + + packLevels: 2, + packPrimaryBarcode: "08850001112223", + packCaseBarcode: "18850001112220", + packNotes: "12 units/case", + + manufacturer: "Amrez Foods Co., Ltd.", + mfgCountry: "TH", + mfgLeadTimeDays: 21, + moq: 500, + + status: "active", + publishedAt: "2025-08-15T09:00:00+07:00", + createdAt: "2025-07-10T15:21:00+07:00", + updatedAt: "2025-10-07T10:10:00+07:00", + archivedAt: null, + + tags: ["top_seller", "online_only"], + note: null, + }, + { + id: "P-1002", + productCode: "AMZ-SERUM-VC", + parentProductId: null, + sku: "AMZ-SERUM-VC-30ML", + gtin: "08850001113334", + type: "simple", + attributesSummary: null, + + brandId: "BRD-0002", + brand: "Kathy Labz", + categoryId: "CAT-0101", + category: "Beauty > Serum", + categoryPath: "Beauty>Serum", + + productName: "Vitamin C Bright Serum 30 ml", + variantName: null, + descriptionShort: "วิตซี + HA", + slug: "vitamin-c-bright-serum-30ml", + + uom: "ขวด", + weightGrams: 120, + dimLcm: 14, dimWcm: 5, dimHcm: 5, + netVolumeMl: 30, + + currency: "THB", + listPrice: 590, + compareAtPrice: null, + priceListsCount: 2, + + imageCount: 4, + docsCount: 2, + seoTitle: "เซรั่มวิตซี 30ml", + + d2cPublished: false, + shopeePublished: false, + lazadaPublished: false, + tiktokPublished: false, + channelsPublished: "", + d2cUrl: null, shopeeListingId: null, lazadaListingId: null, tiktokListingId: null, + + complianceStatus: "pending", + thaiFdaNo: null, + hazardClass: "Non-hazard", + storageInstruction: "ปิดฝาให้สนิท", + shelfLifeMonths: 18, + ageRestrictionMin: 12, + allergens: ["Fragrance"], + certificationsCount: 1, + msdsUrl: "https://example.com/msds/serum-vc.pdf", + + qaStage: "lab", + formulationVersion: "v0.9", + ingredientsCount: 15, + rdOwner: "Bee T.", + + packLevels: 2, + packPrimaryBarcode: "08850001113334", + packCaseBarcode: "18850001113331", + packNotes: "24 units/case", + + manufacturer: "Kathy Labz Lab", + mfgCountry: "TH", + mfgLeadTimeDays: 35, + moq: 1000, + + status: "draft", + publishedAt: null, + createdAt: "2025-09-01T11:00:00+07:00", + updatedAt: "2025-10-08T09:30:00+07:00", + archivedAt: null, + + tags: ["r&d", "new"], + note: "รอเลข อย.", + }, + { + id: "P-1003", + productCode: "AMZ-GIFT-SET-01", + parentProductId: null, + sku: "AMZ-GIFT-SET-01", + gtin: null, + type: "bundle", + attributesSummary: "Bundle of: COLL-250G + VC-30ML", + + brandId: "BRD-0001", + brand: "Amrez", + categoryId: "CAT-0901", + category: "Sets & Bundles", + categoryPath: "Sets&Bundles", + + productName: "Amrez Glow Gift Set", + variantName: null, + descriptionShort: "ชุดของขวัญผิวโกลว์", + slug: "amrez-glow-gift-set", + + uom: "ชุด", + weightGrams: 420, + dimLcm: 24, dimWcm: 18, dimHcm: 8, + netVolumeMl: null, + + currency: "THB", + listPrice: 1290, + compareAtPrice: 1480, + priceListsCount: 2, + + imageCount: 6, + docsCount: 1, + seoTitle: "ชุดของขวัญผิวโกลว์", + + d2cPublished: true, + shopeePublished: true, + lazadaPublished: false, + tiktokPublished: true, + channelsPublished: "D2C|Shopee|TikTok", + d2cUrl: "/p/amrez-glow-gift-set", + shopeeListingId: "SP-777888", + lazadaListingId: null, + tiktokListingId: "TT-445566", + + complianceStatus: "na", + thaiFdaNo: null, + hazardClass: "Non-hazard", + storageInstruction: "—", + shelfLifeMonths: 24, + ageRestrictionMin: null, + allergens: null, + certificationsCount: 0, + msdsUrl: null, + + qaStage: "approved", + formulationVersion: null, + ingredientsCount: null, + rdOwner: "Aom S.", + + packLevels: 2, + packPrimaryBarcode: null, + packCaseBarcode: null, + packNotes: "ชุดบันเดิล", + + manufacturer: "Amrez Assembly", + mfgCountry: "TH", + mfgLeadTimeDays: 10, + moq: 200, + + status: "active", + publishedAt: "2025-10-01T08:00:00+07:00", + createdAt: "2025-09-20T10:10:00+07:00", + updatedAt: "2025-10-08T12:00:00+07:00", + archivedAt: null, + + tags: ["bundle", "gift"], + note: null, + }, +]; + +/* ======================== Columns (ขยายชุดใหญ่) ======================== */ +const ALL_COLS: Column[] = [ + // เอกลักษณ์ & โครงสร้าง + { key: "id", labelTH: "ไอดี", groupTH: "เอกลักษณ์ & โครงสร้าง" }, + { key: "productCode", labelTH: "รหัสสินค้าแม่", groupTH: "เอกลักษณ์ & โครงสร้าง" }, + { key: "parentProductId", labelTH: "รหัสแม่ (ถ้ามี)", groupTH: "เอกลักษณ์ & โครงสร้าง" }, + { key: "type", labelTH: "ชนิดสินค้า", groupTH: "เอกลักษณ์ & โครงสร้าง", format: (v) => renderType(v as ProductType) }, + { key: "sku", labelTH: "SKU (Variant)", groupTH: "เอกลักษณ์ & โครงสร้าง" }, + { key: "gtin", labelTH: "GTIN", groupTH: "เอกลักษณ์ & โครงสร้าง" }, + { key: "attributesSummary", labelTH: "แอตทริบิวต์", groupTH: "เอกลักษณ์ & โครงสร้าง" }, + + // แบรนด์/หมวด + { key: "brand", labelTH: "แบรนด์", groupTH: "แบรนด์/หมวด" }, + { key: "brandId", labelTH: "รหัสแบรนด์", groupTH: "แบรนด์/หมวด" }, + { key: "category", labelTH: "หมวดหมู่", groupTH: "แบรนด์/หมวด" }, + { key: "categoryPath", labelTH: "Taxonomy Path", groupTH: "แบรนด์/หมวด" }, + { key: "categoryId", labelTH: "รหัสหมวด", groupTH: "แบรนด์/หมวด" }, + + // คอนเทนต์ + { key: "productName", labelTH: "ชื่อสินค้า", groupTH: "คอนเทนต์" }, + { key: "variantName", labelTH: "ชื่อรุ่นย่อย", groupTH: "คอนเทนต์" }, + { key: "descriptionShort", labelTH: "คำอธิบายสั้น", groupTH: "คอนเทนต์" }, + { key: "slug", labelTH: "Slug", groupTH: "คอนเทนต์" }, + + // หน่วย & ขนาด + { key: "uom", labelTH: "หน่วยนับ", groupTH: "หน่วย & ขนาด" }, + { key: "weightGrams", labelTH: "น้ำหนัก(g)", groupTH: "หน่วย & ขนาด", align: "right" }, + { key: "dimLcm", labelTH: "ยาว(cm)", groupTH: "หน่วย & ขนาด", align: "right" }, + { key: "dimWcm", labelTH: "กว้าง(cm)", groupTH: "หน่วย & ขนาด", align: "right" }, + { key: "dimHcm", labelTH: "สูง(cm)", groupTH: "หน่วย & ขนาด", align: "right" }, + { key: "netVolumeMl", labelTH: "ปริมาตร(ml)", groupTH: "หน่วย & ขนาด", align: "right" }, + + // ราคา/การตลาด + { key: "currency", labelTH: "สกุล", groupTH: "ราคา/การตลาด" }, + { key: "listPrice", labelTH: "ราคา", groupTH: "ราคา/การตลาด", align: "right", format: (v) => THB(v as number | null | undefined) }, + { key: "compareAtPrice", labelTH: "ราคาเทียบ", groupTH: "ราคา/การตลาด", align: "right", format: (v) => THB(v as number | null | undefined) }, + { key: "priceListsCount", labelTH: "จำนวนราคาลิสต์", groupTH: "ราคา/การตลาด", align: "right" }, + + // สื่อ & เอกสาร/SEO + { key: "imageCount", labelTH: "จำนวนสื่อ", groupTH: "สื่อ/เอกสาร/SEO", align: "right", format: (v) => ( + {num(v as number)} + ) }, + { key: "docsCount", labelTH: "จำนวนเอกสาร", groupTH: "สื่อ/เอกสาร/SEO", align: "right", format: (v) => ( + {num(v as number)} + ) }, + { key: "seoTitle", labelTH: "SEO Title", groupTH: "สื่อ/เอกสาร/SEO" }, + + // ช่องทาง/ลิสติ้ง + { key: "channelsPublished", labelTH: "เผยแพร่ที่", groupTH: "ช่องทาง/ลิสติ้ง" }, + { key: "d2cPublished", labelTH: "D2C", groupTH: "ช่องทาง/ลิสติ้ง" }, + { key: "shopeePublished", labelTH: "Shopee", groupTH: "ช่องทาง/ลิสติ้ง" }, + { key: "lazadaPublished", labelTH: "Lazada", groupTH: "ช่องทาง/ลิสติ้ง" }, + { key: "tiktokPublished", labelTH: "TikTok", groupTH: "ช่องทาง/ลิสติ้ง" }, + { key: "d2cUrl", labelTH: "D2C URL", groupTH: "ช่องทาง/ลิสติ้ง", format: (v) => v ? ( + + เปิด + + ) : "-" }, + { key: "shopeeListingId", labelTH: "Shopee ID", groupTH: "ช่องทาง/ลิสติ้ง" }, + { key: "lazadaListingId", labelTH: "Lazada ID", groupTH: "ช่องทาง/ลิสติ้ง" }, + { key: "tiktokListingId", labelTH: "TikTok ID", groupTH: "ช่องทาง/ลิสติ้ง" }, + + // กฎหมาย/ความสอดคล้อง + { key: "complianceStatus", labelTH: "Compliance", groupTH: "กฎหมาย/ความสอดคล้อง", format: (v) => renderCompliance(v as ComplianceStatus) }, + { key: "thaiFdaNo", labelTH: "เลข อย.", groupTH: "กฎหมาย/ความสอดคล้อง" }, + { key: "hazardClass", labelTH: "Hazard", groupTH: "กฎหมาย/ความสอดคล้อง" }, + { key: "storageInstruction", labelTH: "คำแนะนำการเก็บ", groupTH: "กฎหมาย/ความสอดคล้อง" }, + { key: "shelfLifeMonths", labelTH: "อายุสินค้า(ด.)", groupTH: "กฎหมาย/ความสอดคล้อง", align: "right" }, + { key: "ageRestrictionMin", labelTH: "อายุขั้นต่ำ", groupTH: "กฎหมาย/ความสอดคล้อง", align: "right" }, + { 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 ? ( + + เปิด + + ) : "-" }, + + // R&D / สูตร + { key: "qaStage", labelTH: "QA Stage", groupTH: "R&D/สูตร" }, + { key: "formulationVersion", labelTH: "สูตรเวอร์ชัน", groupTH: "R&D/สูตร" }, + { key: "ingredientsCount", labelTH: "จำนวนส่วนผสม", groupTH: "R&D/สูตร", align: "right" }, + { key: "rdOwner", labelTH: "ผู้รับผิดชอบ R&D", groupTH: "R&D/สูตร" }, + + // บรรจุภัณฑ์ + { key: "packLevels", labelTH: "ระดับแพ็ก", groupTH: "บรรจุภัณฑ์", align: "right" }, + { key: "packPrimaryBarcode", labelTH: "บาร์โค้ดหน่วย", groupTH: "บรรจุภัณฑ์" }, + { key: "packCaseBarcode", labelTH: "บาร์โค้ดลัง", groupTH: "บรรจุภัณฑ์" }, + { key: "packNotes", labelTH: "บันทึกแพ็ก", groupTH: "บรรจุภัณฑ์" }, + + // ซัพพลาย/การผลิต + { key: "manufacturer", labelTH: "ผู้ผลิต", groupTH: "ซัพพลาย/การผลิต" }, + { key: "mfgCountry", labelTH: "ประเทศผลิต", groupTH: "ซัพพลาย/การผลิต" }, + { key: "mfgLeadTimeDays", labelTH: "Lead Time(วัน)", groupTH: "ซัพพลาย/การผลิต", align: "right" }, + { key: "moq", labelTH: "MOQ", groupTH: "ซัพพลาย/การผลิต", align: "right" }, + + // วงจรชีวิต + { key: "status", labelTH: "สถานะ", groupTH: "วงจรชีวิต", format: (v) => renderLifecycle(v as LifecycleStatus) }, + { key: "publishedAt", labelTH: "เผยแพร่เมื่อ", groupTH: "วงจรชีวิต", format: (v) => fmtDate(v as string | null | undefined) }, + { key: "createdAt", labelTH: "สร้างเมื่อ", groupTH: "วงจรชีวิต", format: (v) => fmtDate(v as string | null | undefined) }, + { key: "updatedAt", labelTH: "แก้ไขล่าสุด", groupTH: "วงจรชีวิต", format: (v) => fmtDate(v as string | null | undefined) }, + { key: "archivedAt", 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: "อื่น ๆ" }, +]; + +/* ======================== Visible/Groups ======================== */ +const DEFAULT_VISIBLE: ColumnKey[] = [ + "productCode","productName","variantName","sku","type", + "brand","category","status", + "complianceStatus","thaiFdaNo","listPrice", + "channelsPublished","imageCount","docsCount", +]; + +const GROUPS: { titleTH: string; keys: ColumnKey[] }[] = [ + { titleTH: "เอกลักษณ์ & โครงสร้าง", keys: ["id","productCode","parentProductId","type","sku","gtin","attributesSummary"] }, + { titleTH: "แบรนด์/หมวด", keys: ["brand","brandId","category","categoryPath","categoryId"] }, + { titleTH: "คอนเทนต์", keys: ["productName","variantName","descriptionShort","slug"] }, + { titleTH: "หน่วย & ขนาด", keys: ["uom","weightGrams","dimLcm","dimWcm","dimHcm","netVolumeMl"] }, + { titleTH: "ราคา/การตลาด", keys: ["currency","listPrice","compareAtPrice","priceListsCount"] }, + { titleTH: "สื่อ/เอกสาร/SEO", keys: ["imageCount","docsCount","seoTitle"] }, + { titleTH: "ช่องทาง/ลิสติ้ง", keys: ["channelsPublished","d2cPublished","shopeePublished","lazadaPublished","tiktokPublished","d2cUrl","shopeeListingId","lazadaListingId","tiktokListingId"] }, + { titleTH: "กฎหมาย/ความสอดคล้อง", keys: ["complianceStatus","thaiFdaNo","hazardClass","storageInstruction","shelfLifeMonths","ageRestrictionMin","allergens","certificationsCount","msdsUrl"] }, + { titleTH: "R&D/สูตร", keys: ["qaStage","formulationVersion","ingredientsCount","rdOwner"] }, + { titleTH: "บรรจุภัณฑ์", keys: ["packLevels","packPrimaryBarcode","packCaseBarcode","packNotes"] }, + { titleTH: "ซัพพลาย/การผลิต", keys: ["manufacturer","mfgCountry","mfgLeadTimeDays","moq"] }, + { titleTH: "วงจรชีวิต", keys: ["status","publishedAt","createdAt","updatedAt","archivedAt"] }, + { titleTH: "อื่น ๆ", keys: ["tags","note"] }, +]; + +/* ======================== Page ======================== */ +const LS_KEY = "products.visibleCols.v2"; + +export default function ProductsPage() { + const [q, setQ] = useState(""); + const [ptype, setPtype] = useState<"all" | ProductType>("all"); + const [lifecycle, setLifecycle] = useState<"all" | LifecycleStatus>("all"); + 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 [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 {} + }, []); + + const persistVisible = (keys: ColumnKey[]) => { + setVisibleKeys(keys); + try { localStorage.setItem(LS_KEY, JSON.stringify(keys)); } catch {} + }; + + 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 rows = useMemo(() => { + let arr = [...MOCK_PRODUCTS]; + + if (ptype !== "all") arr = arr.filter(p => p.type === ptype); + if (lifecycle !== "all") arr = arr.filter(p => p.status === lifecycle); + if (compliance !== "all") arr = arr.filter(p => p.complianceStatus === compliance); + if (brand !== "all") arr = arr.filter(p => p.brand === brand); + if (category !== "all") arr = arr.filter(p => p.category === category); + + if (channel !== "all") { + 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])); + } + } + + if (q.trim()) { + const s = q.trim().toLowerCase(); + arr = arr.filter((p) => + [ + p.productCode, p.productName, p.variantName, p.sku, p.gtin, + p.brand, p.category, p.categoryPath, p.slug, + p.thaiFdaNo, p.hazardClass, p.storageInstruction, p.channelsPublished, + p.shopeeListingId, p.lazadaListingId, p.tiktokListingId, p.d2cUrl, + p.rdOwner, p.manufacturer, p.note, p.attributesSummary, ...(p.tags ?? []) + ] + .filter((x): x is string => typeof x === "string") + .some((x) => x.toLowerCase().includes(s)), + ); + } + + arr.sort((a, b) => { + const tb = +new Date(b.updatedAt ?? b.createdAt); + const ta = +new Date(a.updatedAt ?? a.createdAt); + return tb - ta; + }); + + return arr; + }, [q, ptype, lifecycle, compliance, brand, category, channel]); + + const visibleCols: Column[] = useMemo(() => { + const map = new Map(ALL_COLS.map((c) => [c.key, c])); + return visibleKeys.map((k) => map.get(k)!).filter(Boolean); + }, [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 helpers / styles ---------- */ + 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() }] : []), + ...(ptype !== "all" ? [{ key: "ptype", label: "ชนิด", value: TYPE_META[ptype].label }] : []), + ...(lifecycle !== "all" ? [{ key: "lifecycle", label: "สถานะ", value: LIFECYCLE_META[lifecycle].label }] : []), + ...(compliance !== "all" ? [{ key: "compliance", label: "Compliance", value: COMPLIANCE_META[compliance].label }] : []), + ...(brand !== "all" ? [{ key: "brand", label: "แบรนด์", value: brand }] : []), + ...(category !== "all" ? [{ key: "category", label: "หมวด", value: category }] : []), + ...(channel !== "all" ? [{ key: "channel", label: "ช่องทาง", value: channel === "none" ? "ยังไม่เผยแพร่" : channel.toUpperCase() }] : []), + ] as { key: string; label: string; value: string }[]; + + const clearChip = (key: string) => { + switch (key) { + case "q": setQ(""); break; + case "ptype": setPtype("all"); break; + case "lifecycle": setLifecycle("all"); break; + case "compliance": setCompliance("all"); break; + case "brand": setBrand("all"); break; + case "category": setCategory("all"); break; + case "channel": setChannel("all"); break; + } + }; + + const clearAllFilters = () => { + setQ(""); + setPtype("all"); + setLifecycle("all"); + setCompliance("all"); + setBrand("all"); + setCategory("all"); + setChannel("all"); + }; + + return ( + <> + {/* กว้างหน้า “นิ่ง” */} + + +
+ {/* Header */} +
+
+

สินค้า (Product Master)

+

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

+
+
+ + + +
+
+ + {/* Filters (ใหม่ สะอาด + chips) */} +
+
+ {/* Search */} + +
+ + setQ(e.target.value)} + className={`${inputBase} pl-9`} + /> +
+
+ + {/* Selects */} + + + + + + + + + + + + + + + + + + + + + + + +
+ + {/* 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((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); + + const align = c.align === "right" ? "text-right" : ""; + return ( + + ); + })} + + ))} + +
+ {seg.groupTH} +
+ {c.labelTH} +
+ {c.key === "productCode" ? ( + + {content} + + ) : c.key === "tags" && Array.isArray(p.tags) ? ( +
+ {p.tags!.length ? p.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 ( + + ); + })} +
+
+ ))} +
+
+ +
+
ระบบจะจำคอลัมน์ที่คุณเลือกไว้ในเบราว์เซอร์นี้
+
+ + +
+
+
+
+ )} +
+ + ); +}