1005 lines
53 KiB
TypeScript
1005 lines
53 KiB
TypeScript
"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 = (
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<Icon className="h-4 w-4" aria-hidden />
|
|
<span className="tracking-wide">{label}</span>
|
|
</span>
|
|
);
|
|
if (!pillTone) return content;
|
|
return (
|
|
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] ${pill(pillTone)}`}>
|
|
<Icon className="h-3.5 w-3.5" aria-hidden />
|
|
<span className="font-medium">{label}</span>
|
|
</span>
|
|
);
|
|
};
|
|
|
|
/* Renderers */
|
|
const TYPE_META: Record<ProductType, { label: string; Icon: LucideIcon }> = {
|
|
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 <IconLabel Icon={Icon} label={label} />;
|
|
};
|
|
|
|
const LIFECYCLE_META: Record<LifecycleStatus, { label: string; Icon: LucideIcon; tone: Tone }> = {
|
|
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 <IconLabel Icon={Icon} label={label} pillTone={tone} />;
|
|
};
|
|
|
|
const COMPLIANCE_META: Record<ComplianceStatus, { label: string; Icon: LucideIcon; tone: Tone }> = {
|
|
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 <IconLabel Icon={Icon} label={label} pillTone={tone} />;
|
|
};
|
|
|
|
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<T, K extends keyof T>(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) => (
|
|
<span className="inline-flex items-center gap-1.5"><ImageIcon className="h-4 w-4" />{num(v as number)}</span>
|
|
) },
|
|
{ key: "docsCount", labelTH: "จำนวนเอกสาร", groupTH: "สื่อ/เอกสาร/SEO", align: "right", format: (v) => (
|
|
<span className="inline-flex items-center gap-1.5"><FileText className="h-4 w-4" />{num(v as number)}</span>
|
|
) },
|
|
{ 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 ? (
|
|
<a href={String(v)} className="inline-flex items-center gap-1 text-neutral-900 underline decoration-neutral-200 underline-offset-4 hover:decoration-neutral-400">
|
|
<LinkIcon className="h-4 w-4" /> เปิด
|
|
</a>
|
|
) : "-" },
|
|
{ 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 ? (
|
|
<a href={String(v)} target="_blank" className="inline-flex items-center gap-1 text-neutral-900 underline decoration-neutral-200 underline-offset-4 hover:decoration-neutral-400">
|
|
<FileText className="h-4 w-4" /> เปิด
|
|
</a>
|
|
) : "-" },
|
|
|
|
// 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<ColumnKey[]>(DEFAULT_VISIBLE);
|
|
const [openCustomize, setOpenCustomize] = useState(false);
|
|
const [tempKeys, setTempKeys] = useState<ColumnKey[]>(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 }) => (
|
|
<div className={className}>
|
|
<label className="block text-xs font-medium text-neutral-600">{label}</label>
|
|
<div className="mt-1">{children}</div>
|
|
</div>
|
|
);
|
|
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 (
|
|
<>
|
|
{/* กว้างหน้า “นิ่ง” */}
|
|
<style jsx global>{`
|
|
html { scrollbar-gutter: stable both-edges; }
|
|
@supports not (scrollbar-gutter: stable both-edges) { html { overflow-y: scroll; } }
|
|
`}</style>
|
|
|
|
<div className="mx-auto w-full max-w-7xl px-6 space-y-6">
|
|
{/* Header */}
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-extrabold tracking-tight">สินค้า (Product Master)</h1>
|
|
<p className="mt-1 text-sm text-neutral-500">
|
|
มุมมองเต็ม • แสดง {visibleCols.length} ฟิลด์ • ทั้งหมด {rows.length} รายการ
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setOpenCustomize(true)}
|
|
className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80"
|
|
>
|
|
ปรับแต่งตาราง
|
|
</button>
|
|
<button
|
|
onClick={() => downloadCsv("products_visible.csv", rows, visibleCols)}
|
|
className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80"
|
|
>
|
|
ส่งออก CSV (เฉพาะคอลัมน์ที่แสดง)
|
|
</button>
|
|
<button className="rounded-xl bg-neutral-900 px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-neutral-800">
|
|
สร้างสินค้า
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters (ใหม่ สะอาด + chips) */}
|
|
<section className="rounded-3xl border border-neutral-200/70 bg-white/80 p-4 sm:p-5 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]">
|
|
<div className="grid gap-3 sm:grid-cols-12">
|
|
{/* Search */}
|
|
<Field label="ค้นหา" className="sm:col-span-4">
|
|
<div className="relative">
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-400" />
|
|
<input
|
|
aria-label="ค้นหาสินค้า"
|
|
placeholder="รหัส/ชื่อ/SKU/GTIN/แบรนด์/หมวด/เลข อย./แอตทริบิวต์/แท็ก…"
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
className={`${inputBase} pl-9`}
|
|
/>
|
|
</div>
|
|
</Field>
|
|
|
|
{/* Selects */}
|
|
<Field label="ชนิดสินค้า" className="sm:col-span-2">
|
|
<select value={ptype} onChange={(e) => setPtype(e.target.value as any)} className={selectBase}>
|
|
<option value="all">ทุกชนิด</option>
|
|
<option value="simple">{TYPE_META.simple.label}</option>
|
|
<option value="variant">{TYPE_META.variant.label}</option>
|
|
<option value="bundle">{TYPE_META.bundle.label}</option>
|
|
</select>
|
|
</Field>
|
|
|
|
<Field label="สถานะวงจรชีวิต" className="sm:col-span-2">
|
|
<select value={lifecycle} onChange={(e) => setLifecycle(e.target.value as any)} className={selectBase}>
|
|
<option value="all">สถานะทั้งหมด</option>
|
|
{(["draft","active","archived","discontinued"] as LifecycleStatus[]).map(s => (
|
|
<option key={s} value={s}>{LIFECYCLE_META[s].label}</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
|
|
<Field label="Compliance" className="sm:col-span-2">
|
|
<select value={compliance} onChange={(e) => setCompliance(e.target.value as any)} className={selectBase}>
|
|
<option value="all">ทั้งหมด</option>
|
|
{(["na","pending","approved","rejected"] as ComplianceStatus[]).map(s => (
|
|
<option key={s} value={s}>{COMPLIANCE_META[s].label}</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
|
|
<Field label="แบรนด์" className="sm:col-span-1">
|
|
<select value={brand} onChange={(e) => setBrand(e.target.value as any)} className={selectBase}>
|
|
<option value="all">ทุกแบรนด์</option>
|
|
{brands.map(b => <option key={b} value={b}>{b}</option>)}
|
|
</select>
|
|
</Field>
|
|
|
|
<Field label="หมวดหมู่" className="sm:col-span-1">
|
|
<select value={category} onChange={(e) => setCategory(e.target.value as any)} className={selectBase}>
|
|
<option value="all">ทุกหมวด</option>
|
|
{categories.map(c => <option key={c} value={c}>{c}</option>)}
|
|
</select>
|
|
</Field>
|
|
|
|
<Field label="ช่องทาง" className="sm:col-span-2">
|
|
<select value={channel} onChange={(e) => setChannel(e.target.value as any)} className={selectBase}>
|
|
<option value="all">ทุกช่องทาง</option>
|
|
<option value="d2c">D2C เผยแพร่แล้ว</option>
|
|
<option value="shopee">Shopee เผยแพร่แล้ว</option>
|
|
<option value="lazada">Lazada เผยแพร่แล้ว</option>
|
|
<option value="tiktok">TikTok เผยแพร่แล้ว</option>
|
|
<option value="none">ยังไม่เผยแพร่</option>
|
|
</select>
|
|
</Field>
|
|
</div>
|
|
|
|
{/* Chips ของตัวกรองที่ใช้งาน */}
|
|
{activeFilters.length > 0 && (
|
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
{activeFilters.map((c) => (
|
|
<span key={c.key} className="inline-flex items-center gap-1 rounded-full border border-neutral-200 bg-neutral-50 px-2.5 py-1 text-[11.5px] text-neutral-700">
|
|
<span className="font-medium">{c.label}:</span>
|
|
<span>{c.value}</span>
|
|
<button onClick={() => clearChip(c.key)} className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full hover:bg-neutral-200/70" aria-label={`ล้าง ${c.label}`}>
|
|
<XIcon className="h-3.5 w-3.5" />
|
|
</button>
|
|
</span>
|
|
))}
|
|
<button
|
|
onClick={clearAllFilters}
|
|
className="inline-flex items-center gap-1 rounded-full border border-neutral-200 bg-white px-2.5 py-1 text-[11.5px] text-neutral-700 hover:bg-neutral-50"
|
|
aria-label="ล้างตัวกรองทั้งหมด"
|
|
>
|
|
<Eraser className="h-4 w-4" /> ล้างทั้งหมด
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Table */}
|
|
<section className="rounded-3xl border border-neutral-200/70 bg-white/80 p-5 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]">
|
|
<div className="-mx-5 overflow-x-auto">
|
|
<div className="px-5">
|
|
<table className="min-w-full table-auto border-separate border-spacing-0 text-sm">
|
|
<thead>
|
|
{/* Group header (เส้น/พื้นที่ชัด) */}
|
|
<tr className="bg-neutral-50/80 text-neutral-700">
|
|
{topHeaderSegments.map((seg, idx) => (
|
|
<th key={`${seg.groupTH}-${idx}`} colSpan={seg.colSpan}
|
|
className={`whitespace-nowrap px-3 py-2.5 text-center text-[13px] font-semibold border-b border-neutral-200/70 ${COL_BORDER}`}>
|
|
{seg.groupTH}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
{/* Column labels */}
|
|
<tr className="bg-neutral-50/80 text-neutral-600">
|
|
{visibleCols.map((c) => (
|
|
<th key={String(c.key)}
|
|
className={`whitespace-nowrap px-3 py-2.5 text-[12.5px] font-semibold border-b border-neutral-200/70 ${c.align === "right" ? "text-right" : "text-left"} ${COL_BORDER}`}>
|
|
{c.labelTH}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
|
|
<tbody className="divide-y divide-neutral-200/60">
|
|
{rows.map((p, rowIdx) => (
|
|
<tr key={p.id} className={rowIdx % 2 === 0 ? "bg-white" : "bg-neutral-50/40"}>
|
|
{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 (
|
|
<td key={`${p.id}-${String(c.key)}`} className={`whitespace-nowrap px-3 py-2.5 ${align} ${COL_BORDER}`}>
|
|
{c.key === "productCode" ? (
|
|
<a href={`/products/${p.id}`} className="text-neutral-900 underline decoration-neutral-200 underline-offset-4 hover:decoration-neutral-400">
|
|
{content}
|
|
</a>
|
|
) : c.key === "tags" && Array.isArray(p.tags) ? (
|
|
<div className="flex gap-1">
|
|
{p.tags!.length ? p.tags!.map((t) => (
|
|
<span key={t} className="rounded-full border border-neutral-200 bg-neutral-50 px-2 py-0.5 text-[11px] text-neutral-700">{t}</span>
|
|
)) : <span>-</span>}
|
|
</div>
|
|
) : (
|
|
content
|
|
)}
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Customize dialog */}
|
|
{openCustomize && (
|
|
<div className="fixed inset-0 z-40 flex items-end justify-center bg-black/40 p-3 sm:items-center" onClick={() => setOpenCustomize(false)}>
|
|
<div className="flex h-[90vh] max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-3xl border border-neutral-200/70 bg-white shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
|
<div className="flex shrink-0 items-center justify-between border-b border-neutral-200/70 px-5 py-4">
|
|
<h3 className="text-lg font-semibold tracking-tight">ปรับแต่งตาราง</h3>
|
|
<button onClick={() => setOpenCustomize(false)} className="rounded-lg border border-neutral-200/70 bg-white/70 px-2.5 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
|
|
ปิด
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grow overflow-y-auto p-5">
|
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
|
<button onClick={() => setTempKeys(ALL_COLS.map((c) => c.key))} className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-xs shadow-sm hover:bg-neutral-100/80">
|
|
เลือกทั้งหมด
|
|
</button>
|
|
<button onClick={() => setTempKeys([])} className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-xs shadow-sm hover:bg-neutral-100/80">
|
|
ล้างการเลือก
|
|
</button>
|
|
<button onClick={() => setTempKeys(DEFAULT_VISIBLE)} className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-xs shadow-sm hover:bg-neutral-100/80">
|
|
คืนค่าเริ่มต้น
|
|
</button>
|
|
<span className="text-xs text-neutral-500">เลือก {tempKeys.length} คอลัมน์</span>
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
{GROUPS.map((g) => (
|
|
<fieldset key={g.titleTH} className="rounded-2xl border border-neutral-200/70 bg-white/60 p-4">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<legend className="text-sm font-semibold">{g.titleTH}</legend>
|
|
<div className="flex gap-1">
|
|
<button className="rounded-lg border border-neutral-200/70 bg-white/70 px-2 py-1 text-[11px] shadow-sm hover:bg-neutral-100/80"
|
|
onClick={() => setTempKeys((prev) => Array.from(new Set([...prev, ...g.keys])))}>
|
|
เลือกกลุ่มนี้
|
|
</button>
|
|
<button className="rounded-lg border border-neutral-200/70 bg-white/70 px-2 py-1 text-[11px] shadow-sm hover:bg-neutral-100/80"
|
|
onClick={() => setTempKeys((prev) => prev.filter((k) => !g.keys.includes(k)))}>
|
|
เอาออกกลุ่มนี้
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
{g.keys.map((k) => {
|
|
const col = ALL_COLS.find((c) => c.key === k)!;
|
|
const checked = tempKeys.includes(k);
|
|
return (
|
|
<label key={k} className="flex cursor-pointer items-center gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded border-neutral-300 text-neutral-900 focus:ring-neutral-900"
|
|
checked={checked}
|
|
onChange={(e) =>
|
|
setTempKeys((prev) => e.target.checked ? [...prev, k] : prev.filter((x) => x !== k))
|
|
}
|
|
/>
|
|
<span>{col.labelTH}</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
</fieldset>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center justify-between border-t border-neutral-200/70 px-5 py-4">
|
|
<div className="text-xs text-neutral-500">ระบบจะจำคอลัมน์ที่คุณเลือกไว้ในเบราว์เซอร์นี้</div>
|
|
<div className="flex items-center gap-2">
|
|
<button onClick={() => setOpenCustomize(false)} className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
|
|
ยกเลิก
|
|
</button>
|
|
<button
|
|
disabled={tempKeys.length === 0}
|
|
onClick={() => { persistVisible(tempKeys); setOpenCustomize(false); }}
|
|
className={`rounded-xl px-3 py-1.5 text-sm font-semibold shadow-sm ${tempKeys.length === 0 ? "cursor-not-allowed bg-neutral-200 text-neutral-500" : "bg-neutral-900 text-white hover:bg-neutral-800"}`}
|
|
>
|
|
ใช้ค่าที่เลือก
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|