Add Servics, Customer, Inventory, Product

This commit is contained in:
Thanakarn Klangkasame
2025-10-09 16:58:56 +07:00
parent 7edf2542f6
commit ca399301bc
4 changed files with 3073 additions and 63 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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 = (
<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 core;
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>
);
};
const WAREHOUSE_META: Record<WarehouseCode, { label: string; Icon: LucideIcon }> = {
"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) => <IconLabel Icon={WAREHOUSE_META[w].Icon} label={WAREHOUSE_META[w].label} />;
const BINTYPE_META: Record<BinType, { label: string; Icon: LucideIcon }> = {
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) => <IconLabel Icon={BINTYPE_META[t].Icon} label={BINTYPE_META[t].label} />;
const QUALITY_META: Record<QualityStatus, { label: string; Icon: LucideIcon; tone: Tone }> = {
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 <IconLabel Icon={Icon} label={label} pillTone={tone} />;
};
const AVAIL_META: Record<Availability, { label: string; Icon: LucideIcon; tone: Tone }> = {
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 <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: 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<T>(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" ? <IconLabel Icon={Barcode} label="LOT" /> :
v === "serial" ? <IconLabel Icon={QrCode} label="SERIAL" /> :
<IconLabel Icon={Package} label="NONE" />
},
{ 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 <span className={`rounded-full px-2 py-0.5 text-[11px] ${pill(tone)}`}>{n}</span>;
} },
// จำนวน
{ 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 ? <span className={`rounded-full px-2 py-0.5 text-[11px] ${pill("bad")}`}>YES</span> : "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<WarehouseFilter>("all");
const [bt, setBt] = useState<BinTypeFilter>("all");
const [lt, setLt] = useState<LotTrackingFilter>("all");
const [qs, setQs] = useState<QualityFilter>("all");
const [av, setAv] = useState<AvailabilityFilter>("all");
const [expiry, setExpiry] = useState<ExpiryFilter>("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 { /* ignore */ }
}, []);
const persistVisible = (keys: ColumnKey[]) => {
setVisibleKeys(keys);
try { localStorage.setItem(LS_KEY, JSON.stringify(keys)); } catch { /* ignore */ }
};
// ค่าช่วยสร้าง select (type-safe)
const warehouses = useMemo<WarehouseCode[]>(
() => (uniq(MOCK_INV.map(r => r.warehouseCode)) as WarehouseCode[]).sort((a,b)=>a.localeCompare(b)),
[]
);
const binTypes = useMemo<BinType[]>(
() => (uniq(MOCK_INV.map(r => r.binType)) as BinType[]).sort((a,b)=>a.localeCompare(b)),
[]
);
// แปลง + คำนวณค่าเสริม
const enriched = useMemo<InventoryRow[]>(() => {
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 }) => (
<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() }] : []),
...(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 (
<>
<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"> (Inventory)</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("inventory_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" aria-label="ส่งออก CSV เฉพาะคอลัมน์ที่แสดง">
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 */}
<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">
<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="ค้นหาสต็อก"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="รหัส/ชื่อ/SKU/แบรนด์/หมวด/ช่อง/ล็อต/ซีเรียล/แท็ก/โน้ต…"
className={`${"pl-9"} ${inputBase}`}
/>
</div>
</Field>
<Field label="คลัง" className="sm:col-span-2">
<select
value={wh}
onChange={(e) => setWh(e.target.value === "all" ? "all" : (e.target.value as WarehouseCode))}
className={selectBase}
>
<option value="all"></option>
{warehouses.map((w) => <option key={w} value={w}>{WAREHOUSE_META[w].label}</option>)}
</select>
</Field>
<Field label="ประเภทช่อง" className="sm:col-span-2">
<select
value={bt}
onChange={(e) => setBt(e.target.value === "all" ? "all" : (e.target.value as BinType))}
className={selectBase}
>
<option value="all"></option>
{binTypes.map((t) => <option key={t} value={t}>{BINTYPE_META[t].label}</option>)}
</select>
</Field>
<Field label="ติดตามแบบ" className="sm:col-span-2">
<select
value={lt}
onChange={(e) => setLt(e.target.value === "all" ? "all" : (e.target.value as LotTracking))}
className={selectBase}
>
<option value="all"></option>
<option value="none">NONE</option>
<option value="lot">LOT</option>
<option value="serial">SERIAL</option>
</select>
</Field>
<Field label="คุณภาพ" className="sm:col-span-2">
<select
value={qs}
onChange={(e) => setQs(e.target.value === "all" ? "all" : (e.target.value as QualityStatus))}
className={selectBase}
>
<option value="all"></option>
{(["released","quarantine","rejected","expired","hold"] as const).map(x => (
<option key={x} value={x}>{QUALITY_META[x].label}</option>
))}
</select>
</Field>
<Field label="สถานะคลัง" className="sm:col-span-2">
<select
value={av}
onChange={(e) => setAv(e.target.value === "all" ? "all" : (e.target.value as Availability))}
className={selectBase}
>
<option value="all"></option>
{(["in_stock","oos","hold","quarantine"] as const).map(x => (
<option key={x} value={x}>{AVAIL_META[x].label}</option>
))}
</select>
</Field>
<Field label="อายุ EXP" className="sm:col-span-2">
<select
value={expiry}
onChange={(e) => setExpiry(e.target.value as ExpiryFilter)}
className={selectBase}
>
<option value="all"></option>
<option value="7"> 7 </option>
<option value="30"> 30 </option>
<option value="90"> 90 </option>
<option value="expired"></option>
<option value="noexp"> EXP</option>
</select>
</Field>
</div>
{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">
<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((r, rowIdx) => (
<tr key={r.id} className={rowIdx % 2 === 0 ? "bg-white" : "bg-neutral-50/40"}>
{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 (
<td key={`${r.id}-${String(c.key)}`} className={`whitespace-nowrap px-3 py-2.5 ${align} ${COL_BORDER}`}>
{c.key === "productCode" ? (
<a href={`/products/${r.productId}`} className="text-neutral-900 underline decoration-neutral-200 underline-offset-4 hover:decoration-neutral-400" rel="noreferrer noopener">
{content}
</a>
) : c.key === "tags" && Array.isArray(r.tags) ? (
<div className="flex gap-1">
{r.tags.length ? r.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<ColumnKey>([...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>
</>
);
}

View File

@@ -15,6 +15,7 @@ type ProductType = "simple" | "variant" | "bundle";
type LifecycleStatus = "draft" | "active" | "archived" | "discontinued"; type LifecycleStatus = "draft" | "active" | "archived" | "discontinued";
type ComplianceStatus = "na" | "pending" | "approved" | "rejected"; type ComplianceStatus = "na" | "pending" | "approved" | "rejected";
type QaStage = "idea" | "lab" | "pilot" | "approved" | "deprecated"; type QaStage = "idea" | "lab" | "pilot" | "approved" | "deprecated";
type ChannelFilter = "all" | "d2c" | "shopee" | "lazada" | "tiktok" | "none";
type Product = { type Product = {
/* เอกลักษณ์ & โครงสร้าง */ /* เอกลักษณ์ & โครงสร้าง */
@@ -520,7 +521,7 @@ const ALL_COLS: Column[] = [
{ key: "allergens", labelTH: "สารก่อภูมิแพ้", groupTH: "กฎหมาย/ความสอดคล้อง", format: (v) => Array.isArray(v) && v.length ? v.join("|") : "-" }, { key: "allergens", labelTH: "สารก่อภูมิแพ้", groupTH: "กฎหมาย/ความสอดคล้อง", format: (v) => Array.isArray(v) && v.length ? v.join("|") : "-" },
{ key: "certificationsCount", labelTH: "จำนวนใบรับรอง", groupTH: "กฎหมาย/ความสอดคล้อง", align: "right" }, { key: "certificationsCount", labelTH: "จำนวนใบรับรอง", groupTH: "กฎหมาย/ความสอดคล้อง", align: "right" },
{ key: "msdsUrl", labelTH: "MSDS", groupTH: "กฎหมาย/ความสอดคล้อง", format: (v) => v ? ( { 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"> <a href={String(v)} target="_blank" rel="noreferrer noopener" 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" /> <FileText className="h-4 w-4" />
</a> </a>
) : "-" }, ) : "-" },
@@ -589,7 +590,7 @@ export default function ProductsPage() {
const [compliance, setCompliance] = useState<"all" | ComplianceStatus>("all"); const [compliance, setCompliance] = useState<"all" | ComplianceStatus>("all");
const [brand, setBrand] = useState<"all" | string>("all"); const [brand, setBrand] = useState<"all" | string>("all");
const [category, setCategory] = 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<ChannelFilter>("all");
const [visibleKeys, setVisibleKeys] = useState<ColumnKey[]>(DEFAULT_VISIBLE); const [visibleKeys, setVisibleKeys] = useState<ColumnKey[]>(DEFAULT_VISIBLE);
const [openCustomize, setOpenCustomize] = useState(false); const [openCustomize, setOpenCustomize] = useState(false);
@@ -605,17 +606,24 @@ export default function ProductsPage() {
setTempKeys(parsed); setTempKeys(parsed);
} }
} }
} catch {} } catch { /* ignore */ }
}, []); }, []);
const persistVisible = (keys: ColumnKey[]) => { const persistVisible = (keys: ColumnKey[]) => {
setVisibleKeys(keys); 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 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 categories = useMemo(() => Array.from(new Set(MOCK_PRODUCTS.map(p => p.category))).sort(), []);
const channelFlags: Record<Exclude<ChannelFilter, "all" | "none">, keyof Product> = {
d2c: "d2cPublished",
shopee: "shopeePublished",
lazada: "lazadaPublished",
tiktok: "tiktokPublished",
};
const rows = useMemo(() => { const rows = useMemo(() => {
let arr = [...MOCK_PRODUCTS]; let arr = [...MOCK_PRODUCTS];
@@ -629,8 +637,8 @@ export default function ProductsPage() {
if (channel === "none") { if (channel === "none") {
arr = arr.filter(p => !p.d2cPublished && !p.shopeePublished && !p.lazadaPublished && !p.tiktokPublished); arr = arr.filter(p => !p.d2cPublished && !p.shopeePublished && !p.lazadaPublished && !p.tiktokPublished);
} else { } else {
const key = (channel + "Published") as keyof Product; const flagKey = channelFlags[channel];
arr = arr.filter(p => Boolean(p[key])); arr = arr.filter(p => Boolean(p[flagKey]));
} }
} }
@@ -660,7 +668,9 @@ export default function ProductsPage() {
const visibleCols: Column[] = useMemo(() => { const visibleCols: Column[] = useMemo(() => {
const map = new Map(ALL_COLS.map((c) => [c.key, c])); 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]); }, [visibleKeys]);
const topHeaderSegments = useMemo(() => { const topHeaderSegments = useMemo(() => {
@@ -745,6 +755,7 @@ export default function ProductsPage() {
<button <button
onClick={() => downloadCsv("products_visible.csv", rows, visibleCols)} 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" 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"
aria-label="ส่งออก CSV เฉพาะคอลัมน์ที่แสดง"
> >
CSV () CSV ()
</button> </button>
@@ -754,7 +765,7 @@ export default function ProductsPage() {
</div> </div>
</div> </div>
{/* Filters (ใหม่ สะอาด + chips) */} {/* Filters */}
<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)]"> <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"> <div className="grid gap-3 sm:grid-cols-12">
{/* Search */} {/* Search */}
@@ -773,7 +784,11 @@ export default function ProductsPage() {
{/* Selects */} {/* Selects */}
<Field label="ชนิดสินค้า" className="sm:col-span-2"> <Field label="ชนิดสินค้า" className="sm:col-span-2">
<select value={ptype} onChange={(e) => setPtype(e.target.value as any)} className={selectBase}> <select
value={ptype}
onChange={(e) => setPtype(e.target.value === "all" ? "all" : (e.target.value as ProductType))}
className={selectBase}
>
<option value="all"></option> <option value="all"></option>
<option value="simple">{TYPE_META.simple.label}</option> <option value="simple">{TYPE_META.simple.label}</option>
<option value="variant">{TYPE_META.variant.label}</option> <option value="variant">{TYPE_META.variant.label}</option>
@@ -782,39 +797,47 @@ export default function ProductsPage() {
</Field> </Field>
<Field label="สถานะวงจรชีวิต" className="sm:col-span-2"> <Field label="สถานะวงจรชีวิต" className="sm:col-span-2">
<select value={lifecycle} onChange={(e) => setLifecycle(e.target.value as any)} className={selectBase}> <select
value={lifecycle}
onChange={(e) => setLifecycle(e.target.value === "all" ? "all" : (e.target.value as LifecycleStatus))}
className={selectBase}
>
<option value="all"></option> <option value="all"></option>
{(["draft","active","archived","discontinued"] as LifecycleStatus[]).map(s => ( {(["draft","active","archived","discontinued"] as const).map(s => (
<option key={s} value={s}>{LIFECYCLE_META[s].label}</option> <option key={s} value={s}>{LIFECYCLE_META[s].label}</option>
))} ))}
</select> </select>
</Field> </Field>
<Field label="Compliance" className="sm:col-span-2"> <Field label="Compliance" className="sm:col-span-2">
<select value={compliance} onChange={(e) => setCompliance(e.target.value as any)} className={selectBase}> <select
value={compliance}
onChange={(e) => setCompliance(e.target.value === "all" ? "all" : (e.target.value as ComplianceStatus))}
className={selectBase}
>
<option value="all"></option> <option value="all"></option>
{(["na","pending","approved","rejected"] as ComplianceStatus[]).map(s => ( {(["na","pending","approved","rejected"] as const).map(s => (
<option key={s} value={s}>{COMPLIANCE_META[s].label}</option> <option key={s} value={s}>{COMPLIANCE_META[s].label}</option>
))} ))}
</select> </select>
</Field> </Field>
<Field label="แบรนด์" className="sm:col-span-1"> <Field label="แบรนด์" className="sm:col-span-1">
<select value={brand} onChange={(e) => setBrand(e.target.value as any)} className={selectBase}> <select value={brand} onChange={(e) => setBrand(e.target.value)} className={selectBase}>
<option value="all"></option> <option value="all"></option>
{brands.map(b => <option key={b} value={b}>{b}</option>)} {brands.map(b => <option key={b} value={b}>{b}</option>)}
</select> </select>
</Field> </Field>
<Field label="หมวดหมู่" className="sm:col-span-1"> <Field label="หมวดหมู่" className="sm:col-span-1">
<select value={category} onChange={(e) => setCategory(e.target.value as any)} className={selectBase}> <select value={category} onChange={(e) => setCategory(e.target.value)} className={selectBase}>
<option value="all"></option> <option value="all"></option>
{categories.map(c => <option key={c} value={c}>{c}</option>)} {categories.map(c => <option key={c} value={c}>{c}</option>)}
</select> </select>
</Field> </Field>
<Field label="ช่องทาง" className="sm:col-span-2"> <Field label="ช่องทาง" className="sm:col-span-2">
<select value={channel} onChange={(e) => setChannel(e.target.value as any)} className={selectBase}> <select value={channel} onChange={(e) => setChannel(e.target.value as ChannelFilter)} className={selectBase}>
<option value="all"></option> <option value="all"></option>
<option value="d2c">D2C </option> <option value="d2c">D2C </option>
<option value="shopee">Shopee </option> <option value="shopee">Shopee </option>
@@ -875,16 +898,21 @@ export default function ProductsPage() {
</thead> </thead>
<tbody className="divide-y divide-neutral-200/60"> <tbody className="divide-y divide-neutral-200/60">
{rows.map((p, rowIdx) => ( {rows.map((p, rowIdx) => {
const numericKeys: readonly ColumnKey[] = [
"weightGrams","dimLcm","dimWcm","dimHcm","netVolumeMl",
"shelfLifeMonths","ageRestrictionMin","ingredientsCount","priceListsCount",
"mfgLeadTimeDays","moq","packLevels"
];
return (
<tr key={p.id} className={rowIdx % 2 === 0 ? "bg-white" : "bg-neutral-50/40"}> <tr key={p.id} className={rowIdx % 2 === 0 ? "bg-white" : "bg-neutral-50/40"}>
{visibleCols.map((c) => { {visibleCols.map((c) => {
const raw = getProp(p, c.key); 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 const content: ReactNode = c.format
? c.format(raw, p) ? c.format(raw, p)
: c.key === "listPrice" || c.key === "compareAtPrice" : c.key === "listPrice" || c.key === "compareAtPrice"
? THB(raw as number | null | undefined) ? THB(raw as number | null | undefined)
: numericKeys.includes(c.key as string) : numericKeys.includes(c.key)
? num(raw as number | null | undefined) ? num(raw as number | null | undefined)
: defaultCell(raw); : defaultCell(raw);
@@ -897,7 +925,7 @@ export default function ProductsPage() {
</a> </a>
) : c.key === "tags" && Array.isArray(p.tags) ? ( ) : c.key === "tags" && Array.isArray(p.tags) ? (
<div className="flex gap-1"> <div className="flex gap-1">
{p.tags!.length ? p.tags!.map((t) => ( {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 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>} )) : <span>-</span>}
</div> </div>
@@ -908,7 +936,8 @@ export default function ProductsPage() {
); );
})} })}
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -946,12 +975,16 @@ export default function ProductsPage() {
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<legend className="text-sm font-semibold">{g.titleTH}</legend> <legend className="text-sm font-semibold">{g.titleTH}</legend>
<div className="flex gap-1"> <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" <button
onClick={() => setTempKeys((prev) => Array.from(new Set([...prev, ...g.keys])))}> 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<ColumnKey>([...prev, ...g.keys])))}
>
</button> </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" <button
onClick={() => setTempKeys((prev) => prev.filter((k) => !g.keys.includes(k)))}> 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> </button>
</div> </div>

View File

@@ -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 = (
<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 base;
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>
);
};
const TYPE_META: Record<ServiceType, { label: string; Icon: LucideIcon }> = {
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<ServiceStatus, { label: string; Icon: LucideIcon; tone: Tone }> = {
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<BillingType, { label: string; Icon: LucideIcon }> = {
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<LocationType, { label: string; Icon: LucideIcon }> = {
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<ComplianceLevel, { label: string; Icon: LucideIcon; tone: Tone }> = {
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) => <IconLabel Icon={TYPE_META[t].Icon} label={TYPE_META[t].label} />;
const renderStatus = (s: ServiceStatus) => {
const m = STATUS_META[s]; return <IconLabel Icon={m.Icon} label={m.label} pillTone={m.tone} />;
};
const renderBilling = (b: BillingType) => <IconLabel Icon={BILLING_META[b].Icon} label={BILLING_META[b].label} />;
const renderLoc = (l: LocationType) => <IconLabel Icon={LOC_META[l].Icon} label={LOC_META[l].label} />;
const renderComp = (c: ComplianceLevel) => {
const m = COMP_META[c]; return <IconLabel Icon={m.Icon} label={m.label} pillTone={m.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() : "-");
/* ======================== Utils ======================== */
const LS_KEY = "services.visibleCols.v1";
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: 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<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 { /* ignore */ }
}, []);
const persistVisible = (keys: ColumnKey[]) => {
setVisibleKeys(keys);
try { localStorage.setItem(LS_KEY, JSON.stringify(keys)); } catch { /* ignore */ }
};
// options (typed)
const types = useMemo<ServiceType[]>(
() => Array.from(new Set(MOCK_SERVICES.map(s => s.type))) as ServiceType[],
[]
);
const statuses = useMemo<ServiceStatus[]>(
() => Array.from(new Set(MOCK_SERVICES.map(s => s.status))) as ServiceStatus[],
[]
);
const bills = useMemo<BillingType[]>(
() => Array.from(new Set(MOCK_SERVICES.map(s => s.billingType))) as BillingType[],
[]
);
const locs = useMemo<LocationType[]>(
() => 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 }) => (
<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() }] : []),
...(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 (
<>
<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"> (Services)</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("services_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 */}
<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">
<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
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="รหัส/ชื่อ/แบรนด์/หมวด/คำอธิบาย/พื้นที่/ใบรับรอง/แท็ก…"
className={`pl-9 ${inputBase}`}
/>
</div>
</Field>
<Field label="ชนิดบริการ" className="sm:col-span-2">
<select
value={stype}
onChange={(e) => setStype(e.target.value === "all" ? "all" : (e.target.value as ServiceType))}
className={selectBase}
>
<option value="all"></option>
{types.map((t) => <option key={t} value={t}>{TYPE_META[t].label}</option>)}
</select>
</Field>
<Field label="สถานะ" className="sm:col-span-2">
<select
value={status}
onChange={(e) => setStatus(e.target.value === "all" ? "all" : (e.target.value as ServiceStatus))}
className={selectBase}
>
<option value="all"></option>
{statuses.map((s) => <option key={s} value={s}>{STATUS_META[s].label}</option>)}
</select>
</Field>
<Field label="วิธีบิล" className="sm:col-span-2">
<select
value={bill}
onChange={(e) => setBill(e.target.value === "all" ? "all" : (e.target.value as BillingType))}
className={selectBase}
>
<option value="all"></option>
{bills.map((b) => <option key={b} value={b}>{BILLING_META[b].label}</option>)}
</select>
</Field>
<Field label="รูปแบบให้บริการ" className="sm:col-span-2">
<select
value={loc}
onChange={(e) => setLoc(e.target.value === "all" ? "all" : (e.target.value as LocationType))}
className={selectBase}
>
<option value="all"></option>
{locs.map((l) => <option key={l} value={l}>{LOC_META[l].label}</option>)}
</select>
</Field>
<Field label="Compliance" className="sm:col-span-2">
<select
value={comp}
onChange={(e) => setComp(e.target.value === "all" ? "all" : (e.target.value as ComplianceLevel))}
className={selectBase}
>
<option value="all"></option>
{(["none","basic","regulated"] as ComplianceLevel[]).map(x => (
<option key={x} value={x}>{COMP_META[x].label}</option>
))}
</select>
</Field>
</div>
{/* Filter 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">
<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((s, rowIdx) => (
<tr key={s.id} className={rowIdx % 2 === 0 ? "bg-white" : "bg-neutral-50/40"}>
{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 (
<td key={`${s.id}-${String(c.key)}`} className={`whitespace-nowrap px-3 py-2.5 ${align} ${COL_BORDER}`}>
{c.key === "code" ? (
<a href={`/services/${s.id}`} className="text-neutral-900 underline decoration-neutral-200 underline-offset-4 hover:decoration-neutral-400">
{content}
</a>
) : c.key === "tags" && Array.isArray(s.tags) ? (
<div className="flex gap-1">
{s.tags!.length ? s.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<ColumnKey>([...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>
</>
);
}