Add Servics, Customer, Inventory, Product
This commit is contained in:
1107
src/app/(protected)/crm/customers/page.tsx
Normal file
1107
src/app/(protected)/crm/customers/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
957
src/app/(protected)/inventory/page.tsx
Normal file
957
src/app/(protected)/inventory/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ type ProductType = "simple" | "variant" | "bundle";
|
||||
type LifecycleStatus = "draft" | "active" | "archived" | "discontinued";
|
||||
type ComplianceStatus = "na" | "pending" | "approved" | "rejected";
|
||||
type QaStage = "idea" | "lab" | "pilot" | "approved" | "deprecated";
|
||||
type ChannelFilter = "all" | "d2c" | "shopee" | "lazada" | "tiktok" | "none";
|
||||
|
||||
type Product = {
|
||||
/* เอกลักษณ์ & โครงสร้าง */
|
||||
@@ -520,7 +521,7 @@ const ALL_COLS: Column[] = [
|
||||
{ key: "allergens", labelTH: "สารก่อภูมิแพ้", groupTH: "กฎหมาย/ความสอดคล้อง", format: (v) => Array.isArray(v) && v.length ? v.join("|") : "-" },
|
||||
{ key: "certificationsCount", labelTH: "จำนวนใบรับรอง", groupTH: "กฎหมาย/ความสอดคล้อง", align: "right" },
|
||||
{ key: "msdsUrl", labelTH: "MSDS", groupTH: "กฎหมาย/ความสอดคล้อง", format: (v) => v ? (
|
||||
<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" /> เปิด
|
||||
</a>
|
||||
) : "-" },
|
||||
@@ -589,7 +590,7 @@ export default function ProductsPage() {
|
||||
const [compliance, setCompliance] = useState<"all" | ComplianceStatus>("all");
|
||||
const [brand, setBrand] = useState<"all" | string>("all");
|
||||
const [category, setCategory] = useState<"all" | string>("all");
|
||||
const [channel, setChannel] = useState<"all" | "d2c" | "shopee" | "lazada" | "tiktok" | "none">("all");
|
||||
const [channel, setChannel] = useState<ChannelFilter>("all");
|
||||
|
||||
const [visibleKeys, setVisibleKeys] = useState<ColumnKey[]>(DEFAULT_VISIBLE);
|
||||
const [openCustomize, setOpenCustomize] = useState(false);
|
||||
@@ -605,17 +606,24 @@ export default function ProductsPage() {
|
||||
setTempKeys(parsed);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const persistVisible = (keys: ColumnKey[]) => {
|
||||
setVisibleKeys(keys);
|
||||
try { localStorage.setItem(LS_KEY, JSON.stringify(keys)); } catch {}
|
||||
try { localStorage.setItem(LS_KEY, JSON.stringify(keys)); } catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const brands = useMemo(() => Array.from(new Set(MOCK_PRODUCTS.map(p => p.brand))).sort(), []);
|
||||
const categories = useMemo(() => Array.from(new Set(MOCK_PRODUCTS.map(p => p.category))).sort(), []);
|
||||
|
||||
const channelFlags: Record<Exclude<ChannelFilter, "all" | "none">, keyof Product> = {
|
||||
d2c: "d2cPublished",
|
||||
shopee: "shopeePublished",
|
||||
lazada: "lazadaPublished",
|
||||
tiktok: "tiktokPublished",
|
||||
};
|
||||
|
||||
const rows = useMemo(() => {
|
||||
let arr = [...MOCK_PRODUCTS];
|
||||
|
||||
@@ -629,8 +637,8 @@ export default function ProductsPage() {
|
||||
if (channel === "none") {
|
||||
arr = arr.filter(p => !p.d2cPublished && !p.shopeePublished && !p.lazadaPublished && !p.tiktokPublished);
|
||||
} else {
|
||||
const key = (channel + "Published") as keyof Product;
|
||||
arr = arr.filter(p => Boolean(p[key]));
|
||||
const flagKey = channelFlags[channel];
|
||||
arr = arr.filter(p => Boolean(p[flagKey]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,7 +668,9 @@ export default function ProductsPage() {
|
||||
|
||||
const visibleCols: Column[] = useMemo(() => {
|
||||
const map = new Map(ALL_COLS.map((c) => [c.key, c]));
|
||||
return visibleKeys.map((k) => map.get(k)!).filter(Boolean);
|
||||
return visibleKeys
|
||||
.map((k) => map.get(k))
|
||||
.filter((c): c is Column => Boolean(c));
|
||||
}, [visibleKeys]);
|
||||
|
||||
const topHeaderSegments = useMemo(() => {
|
||||
@@ -745,6 +755,7 @@ export default function ProductsPage() {
|
||||
<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"
|
||||
aria-label="ส่งออก CSV เฉพาะคอลัมน์ที่แสดง"
|
||||
>
|
||||
ส่งออก CSV (เฉพาะคอลัมน์ที่แสดง)
|
||||
</button>
|
||||
@@ -754,7 +765,7 @@ export default function ProductsPage() {
|
||||
</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)]">
|
||||
<div className="grid gap-3 sm:grid-cols-12">
|
||||
{/* Search */}
|
||||
@@ -773,7 +784,11 @@ export default function ProductsPage() {
|
||||
|
||||
{/* Selects */}
|
||||
<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="simple">{TYPE_META.simple.label}</option>
|
||||
<option value="variant">{TYPE_META.variant.label}</option>
|
||||
@@ -782,39 +797,47 @@ export default function ProductsPage() {
|
||||
</Field>
|
||||
|
||||
<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>
|
||||
{(["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>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<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>
|
||||
{(["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>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<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>
|
||||
{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}>
|
||||
<select value={category} onChange={(e) => setCategory(e.target.value)} 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}>
|
||||
<select value={channel} onChange={(e) => setChannel(e.target.value as ChannelFilter)} className={selectBase}>
|
||||
<option value="all">ทุกช่องทาง</option>
|
||||
<option value="d2c">D2C เผยแพร่แล้ว</option>
|
||||
<option value="shopee">Shopee เผยแพร่แล้ว</option>
|
||||
@@ -875,16 +898,21 @@ export default function ProductsPage() {
|
||||
</thead>
|
||||
|
||||
<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"}>
|
||||
{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)
|
||||
: numericKeys.includes(c.key)
|
||||
? num(raw as number | null | undefined)
|
||||
: defaultCell(raw);
|
||||
|
||||
@@ -897,7 +925,7 @@ export default function ProductsPage() {
|
||||
</a>
|
||||
) : c.key === "tags" && Array.isArray(p.tags) ? (
|
||||
<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>-</span>}
|
||||
</div>
|
||||
@@ -908,7 +936,8 @@ export default function ProductsPage() {
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -946,12 +975,16 @@ export default function ProductsPage() {
|
||||
<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
|
||||
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
|
||||
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>
|
||||
|
||||
913
src/app/(protected)/services/page.tsx
Normal file
913
src/app/(protected)/services/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user