Files
amrez-nova-eop-services-app/src/app/(protected)/orders/page.tsx
Thanakarn Klangkasame 1c840420b3 Fix Layout
2025-10-08 13:28:53 +07:00

1195 lines
53 KiB
TypeScript

// File: src/app/(protected)/orders/page.tsx
"use client";
/**
* EOP Orders — มุมมองเต็ม (Full) + ตัวกรองแบบ Select + Modal ปรับแต่งคอลัมน์
* - เฮดเดอร์ตารางแบบ 2 ชั้น (กลุ่มหัวข้อ ➜ หัวคอลัมน์ย่อย) ภาษาไทยทั้งหมด
* - เลือกคอลัมน์ที่ต้องการแสดงได้จาก "ปรับแต่งตาราง" (จำค่าบน localStorage)
* - แก้ Modal ให้เนื้อหาเลื่อน (scroll) ได้ และปุ่ม Apply ชิดด้านล่างเสมอ
* - เพิ่มฟิลด์ "ผู้เปิดออเดอร์" (createdBy*)
* - ตารางไม่ fix size โดยให้คอนเทนต์กำหนดความกว้างเอง และให้ทุกเซลล์แสดงบรรทัดเดียว (nowrap)
*
* หมายเหตุ: ใช้ mock แค่ไม่กี่เรคคอร์ดเพื่อโชว์ UX — ต่อ API จริงได้โดยแทนที่ MOCK_ORDERS
*/
import { useEffect, useMemo, useState, type ReactNode } from "react";
/* ===========================
Types
=========================== */
type Channel = "shopee" | "lazada" | "tiktok" | "d2c" | "chat";
type PaymentStatus = "paid" | "pending" | "failed" | "cod" | "refunded";
type FulfillmentStatus =
| "unfulfilled"
| "processing"
| "shipped"
| "delivered"
| "returned"
| "cancelled";
type Order = {
// เอกลักษณ์ & ช่องทาง
id: string;
orderNo: string;
orderRef: string;
channel: Channel;
channelOrderId: string;
marketplaceShopId: string;
marketplaceShopName: string;
// ผู้เปิดออเดอร์
createdById: string;
createdByName: string;
createdByEmail?: string | null;
// วัน–เวลา (ISO)
dateCreated: string;
datePaid?: string | null;
datePacked?: string | null;
dateShipped?: string | null;
dateDelivered?: string | null;
dateCancelled?: string | null;
// ลูกค้า
customerId: string;
customerName: string;
customerEmail?: string | null;
customerPhone?: string | null;
// ที่อยู่จัดส่ง
shippingName: string;
shippingPhone?: string | null;
shippingAddressLine1: string;
shippingAddressLine2?: string | null;
shippingSubdistrict?: string | null;
shippingDistrict: string;
shippingProvince: string;
shippingPostcode: string;
shippingCountry: string;
// ออกใบเสร็จ/ภาษี
billingName: string;
billingTaxId?: string | null;
billingAddressLine1: string;
billingSubdistrict?: string | null;
billingDistrict: string;
billingProvince: string;
billingPostcode: string;
billingCountry: string;
// การชำระเงิน
paymentMethod: "card" | "bank_qr" | "cod" | "wallet" | "transfer";
paymentStatus: PaymentStatus;
paymentProvider?: string | null;
paymentTxId?: string | null;
paymentFeeTHB?: number | null;
// ราคา/ต้นทุนย่อย
currency: "THB";
exchangeRate: number; // 1 สำหรับ THB
subtotalTHB: number;
discountTHB: number;
shippingFeeTHB: number;
codFeeTHB: number;
packagingFeeTHB: number;
taxTHB: number;
totalTHB: number;
// สินค้า/โลจิสติกส์
itemsCount: number;
totalWeightGrams: number;
packageCount: number;
dimensionsCmL?: number | null;
dimensionsCmW?: number | null;
dimensionsCmH?: number | null;
// สถานะจัดส่ง
fulfillStatus: FulfillmentStatus;
shipBy?: string | null;
carrier?: string | null;
serviceLevel?: string | null;
trackingNo?: string | null;
// โกดัง/ปฏิบัติการ
warehouseCode: "BKK-DC" | "CNX-HUB" | "HKT-MINI";
pickListId?: string | null;
waveId?: string | null;
binFrom?: string | null;
binTo?: string | null;
// ธง/โน้ต/ความเสี่ยง
tags?: string[];
rmaFlag?: boolean;
rmaReason?: string | null;
noteSeller?: string | null;
noteBuyer?: string | null;
slaShipOnTime?: boolean;
giftWrap?: boolean;
fragile?: boolean;
priorityLevel?: 0 | 1 | 2 | 3;
riskScore?: number | null;
holdReason?: string | null;
fraudCheckStatus?: "clear" | "review" | "hold" | null;
};
type ColumnKey = keyof Order;
type Column = {
key: ColumnKey;
labelTH: string; // ป้ายหัวคอลัมน์ (ไทย)
groupTH: string; // กลุ่มหัวข้อชั้นบน (ไทย)
align?: "left" | "right";
format?: (v: Order[ColumnKey], row: Order) => ReactNode;
};
/* ===========================
Mock (ไม่กี่เรคคอร์ด)
=========================== */
const MOCK_ORDERS: Order[] = [
{
id: "1",
orderNo: "SO-2025-000781",
orderRef: "OREF-7A1C",
channel: "shopee",
channelOrderId: "SP-9988776655",
marketplaceShopId: "sp_shop_10293",
marketplaceShopName: "Amrez Beauty Official",
createdById: "U-001",
createdByName: "อ้อม ส.",
createdByEmail: "aom@amrez.co.th",
dateCreated: "2025-10-08T08:15:00+07:00",
datePaid: "2025-10-08T08:16:10+07:00",
datePacked: null,
dateShipped: null,
dateDelivered: null,
dateCancelled: null,
customerId: "CUST-000142",
customerName: "Ploy Ch.",
customerEmail: "ploy@example.com",
customerPhone: "0812345678",
shippingName: "Ploy Ch.",
shippingPhone: "0812345678",
shippingAddressLine1: "91/518 หมู่ 1",
shippingAddressLine2: null,
shippingSubdistrict: "หน้าเมือง",
shippingDistrict: "เมือง",
shippingProvince: "นนทบุรี",
shippingPostcode: "11000",
shippingCountry: "TH",
billingName: "Ploy Ch.",
billingTaxId: null,
billingAddressLine1: "91/518 หมู่ 1",
billingSubdistrict: "หน้าเมือง",
billingDistrict: "เมือง",
billingProvince: "นนทบุรี",
billingPostcode: "11000",
billingCountry: "TH",
paymentMethod: "bank_qr",
paymentStatus: "paid",
paymentProvider: "2C2P",
paymentTxId: "2C2P_2a8e7b0b",
paymentFeeTHB: 9.5,
currency: "THB",
exchangeRate: 1,
subtotalTHB: 1550,
discountTHB: 60,
shippingFeeTHB: 0,
codFeeTHB: 0,
packagingFeeTHB: 0,
taxTHB: 0,
totalTHB: 1490,
itemsCount: 2,
totalWeightGrams: 420,
packageCount: 1,
dimensionsCmL: 22,
dimensionsCmW: 16,
dimensionsCmH: 10,
fulfillStatus: "processing",
shipBy: "2025-10-09T18:00:00+07:00",
carrier: null,
serviceLevel: "Standard",
trackingNo: null,
warehouseCode: "BKK-DC",
pickListId: null,
waveId: null,
binFrom: "A-01-03",
binTo: null,
tags: ["priority", "giftwrap"],
rmaFlag: false,
rmaReason: null,
noteSeller: "ห่อของขวัญ",
noteBuyer: "การ์ด HBD ด้วย",
slaShipOnTime: true,
giftWrap: true,
fragile: false,
priorityLevel: 2,
riskScore: 12,
holdReason: null,
fraudCheckStatus: "clear",
},
{
id: "2",
orderNo: "SO-2025-000782",
orderRef: "OREF-9F4D",
channel: "lazada",
channelOrderId: "LZ-5566778899",
marketplaceShopId: "lz_shop_8811",
marketplaceShopName: "Kathy Labz Store",
createdById: "U-002",
createdByName: "บี ที.",
createdByEmail: "bee@amrez.co.th",
dateCreated: "2025-10-08T08:22:00+07:00",
datePaid: null,
datePacked: null,
dateShipped: null,
dateDelivered: null,
dateCancelled: null,
customerId: "CUST-000320",
customerName: "Thanawat K.",
customerEmail: "thanawat@example.com",
customerPhone: "0820001111",
shippingName: "Thanawat K.",
shippingPhone: "0820001111",
shippingAddressLine1: "128/7 ซอยสวนหลวง",
shippingAddressLine2: "ตึก B ชั้น 5",
shippingSubdistrict: "สวนหลวง",
shippingDistrict: "พระนคร",
shippingProvince: "กรุงเทพมหานคร",
shippingPostcode: "10200",
shippingCountry: "TH",
billingName: "Thanawat K.",
billingTaxId: "0105561234567",
billingAddressLine1: "128/7 ซอยสวนหลวง",
billingSubdistrict: "สวนหลวง",
billingDistrict: "พระนคร",
billingProvince: "กรุงเทพมหานคร",
billingPostcode: "10200",
billingCountry: "TH",
paymentMethod: "cod",
paymentStatus: "cod",
paymentProvider: null,
paymentTxId: null,
paymentFeeTHB: 0,
currency: "THB",
exchangeRate: 1,
subtotalTHB: 590,
discountTHB: 0,
shippingFeeTHB: 30,
codFeeTHB: 20,
packagingFeeTHB: 0,
taxTHB: 0,
totalTHB: 640,
itemsCount: 1,
totalWeightGrams: 220,
packageCount: 1,
dimensionsCmL: 18,
dimensionsCmW: 12,
dimensionsCmH: 8,
fulfillStatus: "unfulfilled",
shipBy: "2025-10-09T18:00:00+07:00",
carrier: "Flash",
serviceLevel: "COD-Standard",
trackingNo: null,
warehouseCode: "BKK-DC",
pickListId: null,
waveId: "WAVE-101",
binFrom: "C-02-11",
binTo: null,
tags: ["fragile"],
rmaFlag: false,
rmaReason: null,
noteSeller: null,
noteBuyer: null,
slaShipOnTime: true,
giftWrap: false,
fragile: true,
priorityLevel: 1,
riskScore: 35,
holdReason: null,
fraudCheckStatus: "review",
},
{
id: "3",
orderNo: "SO-2025-000783",
orderRef: "OREF-3B92",
channel: "tiktok",
channelOrderId: "TT-4433221100",
marketplaceShopId: "tt_shop_5501",
marketplaceShopName: "Amrez TikTok",
createdById: "U-003",
createdByName: "กร พ.",
createdByEmail: "korn@amrez.co.th",
dateCreated: "2025-10-08T08:40:00+07:00",
datePaid: "2025-10-08T08:41:05+07:00",
datePacked: "2025-10-08T09:05:30+07:00",
dateShipped: "2025-10-08T12:10:00+07:00",
dateDelivered: null,
dateCancelled: null,
customerId: "CUST-000511",
customerName: "Mint W.",
customerEmail: "mint@example.com",
customerPhone: "0869997777",
shippingName: "Mint W.",
shippingPhone: "0869997777",
shippingAddressLine1: "55/9 ถนนสายน้ำ",
shippingAddressLine2: null,
shippingSubdistrict: "ช้างเผือก",
shippingDistrict: "เมือง",
shippingProvince: "เชียงใหม่",
shippingPostcode: "50300",
shippingCountry: "TH",
billingName: "Mint W.",
billingTaxId: null,
billingAddressLine1: "55/9 ถนนสายน้ำ",
billingSubdistrict: "ช้างเผือก",
billingDistrict: "เมือง",
billingProvince: "เชียงใหม่",
billingPostcode: "50300",
billingCountry: "TH",
paymentMethod: "wallet",
paymentStatus: "paid",
paymentProvider: "TikTokPay",
paymentTxId: "TTPAY_c0ffeecafe",
paymentFeeTHB: 14.9,
currency: "THB",
exchangeRate: 1,
subtotalTHB: 2250,
discountTHB: 60,
shippingFeeTHB: 0,
codFeeTHB: 0,
packagingFeeTHB: 10,
taxTHB: 0,
totalTHB: 2200,
itemsCount: 4,
totalWeightGrams: 650,
packageCount: 1,
dimensionsCmL: 24,
dimensionsCmW: 18,
dimensionsCmH: 10,
fulfillStatus: "shipped",
shipBy: "2025-10-10T12:00:00+07:00",
carrier: "J&T",
serviceLevel: "Express",
trackingNo: "JTTH123456789",
warehouseCode: "CNX-HUB",
pickListId: "PICK-5509",
waveId: "WAVE-102",
binFrom: "Z-03-02",
binTo: null,
tags: [],
rmaFlag: false,
rmaReason: null,
noteSeller: "ออเดอร์ไลฟ์สด เร่งจัดส่ง",
noteBuyer: null,
slaShipOnTime: true,
giftWrap: false,
fragile: false,
priorityLevel: 2,
riskScore: 9,
holdReason: null,
fraudCheckStatus: "clear",
},
];
/* ===========================
Helpers
=========================== */
const LS_KEY = "orders.visibleCols.v2";
const THB = (n: number) => `฿${n.toLocaleString()}`;
const fmtDate = (iso?: string | null) =>
iso ? new Date(iso).toLocaleString("th-TH", { hour12: false }) : "-";
const pill = (variant: "ok" | "warn" | "bad" | "neutral") =>
({
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",
}[variant]);
function mapPaymentColor(p: PaymentStatus) {
switch (p) {
case "paid":
return pill("ok");
case "pending":
case "cod":
return pill("warn");
case "failed":
return pill("bad");
case "refunded":
return pill("neutral");
}
}
function mapFulfillmentColor(f: FulfillmentStatus) {
switch (f) {
case "delivered":
return pill("ok");
case "processing":
case "shipped":
case "unfulfilled":
return pill("warn");
case "returned":
case "cancelled":
return pill("bad");
}
}
// typed getter (เลี่ยง as any)
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: Order[], cols: Column[]) {
if (!rows.length || !cols.length) return;
const headers = cols.map((c) => c.labelTH);
const keys = cols.map((c) => c.key);
const csv =
headers.join(",") +
"\n" +
rows
.map((r) => keys.map((k) => escapeCsv(getProp(r, k))).join(","))
.join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function defaultCell(v: unknown): ReactNode {
if (v === null || v === undefined) return "-";
if (typeof v === "string") return v.trim() === "" ? "-" : v;
if (typeof v === "number") return Number.isFinite(v) ? v.toString() : "-";
if (typeof v === "boolean") return v ? "YES" : "NO";
if (Array.isArray(v)) return v.length ? v.join("|") : "-";
return String(v);
}
/* ===========================
คอนฟิกคอลัมน์ (ไทย + กลุ่ม)
=========================== */
const ALL_COLS: Column[] = [
// เอกลักษณ์ & ช่องทาง
{ key: "id", labelTH: "ไอดี", groupTH: "เอกลักษณ์ & ช่องทาง" },
{ key: "orderNo", labelTH: "เลขออเดอร์", groupTH: "เอกลักษณ์ & ช่องทาง" },
{ key: "orderRef", labelTH: "อ้างอิงภายใน", groupTH: "เอกลักษณ์ & ช่องทาง" },
{ key: "channel", labelTH: "ช่องทาง", groupTH: "เอกลักษณ์ & ช่องทาง" },
{
key: "channelOrderId",
labelTH: "เลขออเดอร์ช่องทาง",
groupTH: "เอกลักษณ์ & ช่องทาง",
},
{
key: "marketplaceShopId",
labelTH: "รหัสร้าน",
groupTH: "เอกลักษณ์ & ช่องทาง",
},
{
key: "marketplaceShopName",
labelTH: "ชื่อร้าน",
groupTH: "เอกลักษณ์ & ช่องทาง",
},
// ผู้เปิดออเดอร์
{ key: "createdByName", labelTH: "ผู้เปิดออเดอร์", groupTH: "ผู้เปิดออเดอร์" },
{ key: "createdByEmail", labelTH: "อีเมลผู้เปิด", groupTH: "ผู้เปิดออเดอร์" },
{ key: "createdById", labelTH: "รหัสผู้เปิด", groupTH: "ผู้เปิดออเดอร์" },
// วัน–เวลา
{ key: "dateCreated", labelTH: "สร้างเมื่อ", groupTH: "วัน–เวลา", format: (v) => fmtDate(v as string | null | undefined) },
{ key: "datePaid", labelTH: "ชำระเงิน", groupTH: "วัน–เวลา", format: (v) => fmtDate(v as string | null | undefined) },
{ key: "datePacked", labelTH: "แพ็คของ", groupTH: "วัน–เวลา", format: (v) => fmtDate(v as string | null | undefined) },
{ key: "dateShipped", labelTH: "ส่งของ", groupTH: "วัน–เวลา", format: (v) => fmtDate(v as string | null | undefined) },
{ key: "dateDelivered", labelTH: "ถึงปลายทาง", groupTH: "วัน–เวลา", format: (v) => fmtDate(v as string | null | undefined) },
{ key: "dateCancelled", labelTH: "ยกเลิก", groupTH: "วัน–เวลา", format: (v) => fmtDate(v as string | null | undefined) },
// ลูกค้า
{ key: "customerId", labelTH: "รหัสลูกค้า", groupTH: "ลูกค้า" },
{ key: "customerName", labelTH: "ชื่อลูกค้า", groupTH: "ลูกค้า" },
{ key: "customerEmail", labelTH: "อีเมลลูกค้า", groupTH: "ลูกค้า" },
{ key: "customerPhone", labelTH: "เบอร์ลูกค้า", groupTH: "ลูกค้า" },
// ที่อยู่จัดส่ง
{ key: "shippingName", labelTH: "ชื่อผู้รับ", groupTH: "ที่อยู่จัดส่ง" },
{ key: "shippingPhone", labelTH: "เบอร์ผู้รับ", groupTH: "ที่อยู่จัดส่ง" },
{
key: "shippingAddressLine1",
labelTH: "ที่อยู่ (บรรทัด 1)",
groupTH: "ที่อยู่จัดส่ง",
},
{
key: "shippingAddressLine2",
labelTH: "ที่อยู่ (บรรทัด 2)",
groupTH: "ที่อยู่จัดส่ง",
},
{ key: "shippingSubdistrict", labelTH: "แขวง/ตำบล", groupTH: "ที่อยู่จัดส่ง" },
{ key: "shippingDistrict", labelTH: "เขต/อำเภอ", groupTH: "ที่อยู่จัดส่ง" },
{ key: "shippingProvince", labelTH: "จังหวัด", groupTH: "ที่อยู่จัดส่ง" },
{ key: "shippingPostcode", labelTH: "รหัสไปรษณีย์", groupTH: "ที่อยู่จัดส่ง" },
{ key: "shippingCountry", labelTH: "ประเทศ", groupTH: "ที่อยู่จัดส่ง" },
// ออกใบเสร็จ/ภาษี
{ key: "billingName", labelTH: "ชื่อสำหรับบิล", groupTH: "ออกใบเสร็จ/ภาษี" },
{ key: "billingTaxId", labelTH: "เลขภาษี", groupTH: "ออกใบเสร็จ/ภาษี" },
{
key: "billingAddressLine1",
labelTH: "ที่อยู่บิล (บรรทัด 1)",
groupTH: "ออกใบเสร็จ/ภาษี",
},
{ key: "billingSubdistrict", labelTH: "แขวง/ตำบล (บิล)", groupTH: "ออกใบเสร็จ/ภาษี" },
{ key: "billingDistrict", labelTH: "เขต/อำเภอ (บิล)", groupTH: "ออกใบเสร็จ/ภาษี" },
{ key: "billingProvince", labelTH: "จังหวัด (บิล)", groupTH: "ออกใบเสร็จ/ภาษี" },
{
key: "billingPostcode",
labelTH: "รหัสไปรษณีย์ (บิล)",
groupTH: "ออกใบเสร็จ/ภาษี",
},
{ key: "billingCountry", labelTH: "ประเทศ (บิล)", groupTH: "ออกใบเสร็จ/ภาษี" },
// การชำระเงิน
{ key: "paymentMethod", labelTH: "วิธีชำระ", groupTH: "การชำระเงิน" },
{
key: "paymentStatus",
labelTH: "สถานะชำระ",
groupTH: "การชำระเงิน",
format: (v) => (
<span className={`rounded-full px-2 py-0.5 text-[11px] ${mapPaymentColor(v as PaymentStatus)}`}>{v as string}</span>
),
},
{ key: "paymentProvider", labelTH: "ผู้ให้บริการชำระ", groupTH: "การชำระเงิน" },
{ key: "paymentTxId", labelTH: "รหัสรายการชำระ", groupTH: "การชำระเงิน" },
{
key: "paymentFeeTHB",
labelTH: "ค่าธรรมเนียม",
groupTH: "การชำระเงิน",
align: "right",
format: (v) => (typeof v === "number" ? THB(v) : "-"),
},
// ราคา/ต้นทุนย่อย
{ key: "currency", labelTH: "สกุล", groupTH: "ราคา/ต้นทุนย่อย" },
{ key: "exchangeRate", labelTH: "เรท", groupTH: "ราคา/ต้นทุนย่อย", align: "right" },
{
key: "subtotalTHB",
labelTH: "ยอดก่อนลด",
groupTH: "ราคา/ต้นทุนย่อย",
align: "right",
format: (v) => THB(Number(v)),
},
{
key: "discountTHB",
labelTH: "ส่วนลด",
groupTH: "ราคา/ต้นทุนย่อย",
align: "right",
format: (v) => THB(Number(v)),
},
{
key: "shippingFeeTHB",
labelTH: "ค่าส่ง",
groupTH: "ราคา/ต้นทุนย่อย",
align: "right",
format: (v) => THB(Number(v)),
},
{
key: "codFeeTHB",
labelTH: "ค่า COD",
groupTH: "ราคา/ต้นทุนย่อย",
align: "right",
format: (v) => THB(Number(v)),
},
{
key: "packagingFeeTHB",
labelTH: "ค่าบรรจุภัณฑ์",
groupTH: "ราคา/ต้นทุนย่อย",
align: "right",
format: (v) => THB(Number(v)),
},
{ key: "taxTHB", labelTH: "ภาษี", groupTH: "ราคา/ต้นทุนย่อย", align: "right", format: (v) => THB(Number(v)) },
{ key: "totalTHB", labelTH: "ยอดรวม", groupTH: "ราคา/ต้นทุนย่อย", align: "right", format: (v) => THB(Number(v)) },
// สินค้า/โลจิสติกส์
{ key: "itemsCount", labelTH: "จำนวนชิ้น", groupTH: "สินค้า/โลจิสติกส์", align: "right" },
{ key: "totalWeightGrams", labelTH: "น้ำหนัก(g)", groupTH: "สินค้า/โลจิสติกส์", align: "right" },
{ key: "packageCount", labelTH: "จำนวนพัสดุ", groupTH: "สินค้า/โลจิสติกส์", align: "right" },
{ key: "dimensionsCmL", labelTH: "ยาว(cm)", groupTH: "สินค้า/โลจิสติกส์", align: "right" },
{ key: "dimensionsCmW", labelTH: "กว้าง(cm)", groupTH: "สินค้า/โลจิสติกส์", align: "right" },
{ key: "dimensionsCmH", labelTH: "สูง(cm)", groupTH: "สินค้า/โลจิสติกส์", align: "right" },
// สถานะจัดส่ง
{
key: "fulfillStatus",
labelTH: "สถานะจัดส่ง",
groupTH: "สถานะจัดส่ง",
format: (v) => (
<span className={`rounded-full px-2 py-0.5 text-[11px] ${mapFulfillmentColor(v as FulfillmentStatus)}`}>{v as string}</span>
),
},
{ key: "shipBy", labelTH: "กำหนดส่งภายใน", groupTH: "สถานะจัดส่ง", format: (v) => fmtDate(v as string | null | undefined) },
{ key: "carrier", labelTH: "ขนส่ง", groupTH: "สถานะจัดส่ง" },
{ key: "serviceLevel", labelTH: "บริการ", groupTH: "สถานะจัดส่ง" },
{ key: "trackingNo", labelTH: "เลขติดตาม", groupTH: "สถานะจัดส่ง" },
// โกดัง/ปฏิบัติการ
{ key: "warehouseCode", labelTH: "คลังสินค้า", groupTH: "โกดัง/ปฏิบัติการ" },
{ key: "pickListId", labelTH: "ใบหยิบ", groupTH: "โกดัง/ปฏิบัติการ" },
{ key: "waveId", labelTH: "รหัสเวฟ", groupTH: "โกดัง/ปฏิบัติการ" },
{ key: "binFrom", labelTH: "หยิบจากช่อง", groupTH: "โกดัง/ปฏิบัติการ" },
{ key: "binTo", labelTH: "ย้ายไปช่อง", groupTH: "โกดัง/ปฏิบัติการ" },
// ธง/โน้ต/ความเสี่ยง
{
key: "tags",
labelTH: "แท็ก",
groupTH: "ธง/โน้ต/ความเสี่ยง",
format: (v) => (Array.isArray(v) && v.length ? v.join("|") : "-"),
},
{
key: "rmaFlag",
labelTH: "มีเคลม?",
groupTH: "ธง/โน้ต/ความเสี่ยง",
format: (v) => (typeof v === "boolean" ? (v ? "YES" : "NO") : "-"),
},
{ key: "rmaReason", labelTH: "สาเหตุเคลม", groupTH: "ธง/โน้ต/ความเสี่ยง" },
{ key: "noteSeller", labelTH: "โน้ตผู้ขาย", groupTH: "ธง/โน้ต/ความเสี่ยง" },
{ key: "noteBuyer", labelTH: "โน้ตผู้ซื้อ", groupTH: "ธง/โน้ต/ความเสี่ยง" },
{
key: "slaShipOnTime",
labelTH: "ส่งทันเวลา?",
groupTH: "ธง/โน้ต/ความเสี่ยง",
format: (v) => (typeof v === "boolean" ? (v ? "OK" : "N/A") : "N/A"),
},
{
key: "giftWrap",
labelTH: "ห่อของขวัญ",
groupTH: "ธง/โน้ต/ความเสี่ยง",
format: (v) => (typeof v === "boolean" ? (v ? "YES" : "NO") : "NO"),
},
{
key: "fragile",
labelTH: "แตกหักง่าย",
groupTH: "ธง/โน้ต/ความเสี่ยง",
format: (v) => (typeof v === "boolean" ? (v ? "YES" : "NO") : "NO"),
},
{ key: "priorityLevel", labelTH: "ลำดับความสำคัญ", groupTH: "ธง/โน้ต/ความเสี่ยง", align: "right" },
{ key: "riskScore", labelTH: "คะแนนเสี่ยง", groupTH: "ธง/โน้ต/ความเสี่ยง", align: "right" },
{ key: "holdReason", labelTH: "เหตุผลพักออเดอร์", groupTH: "ธง/โน้ต/ความเสี่ยง" },
{ key: "fraudCheckStatus", labelTH: "สถานะ Fraud", groupTH: "ธง/โน้ต/ความเสี่ยง" },
];
// ค่าตั้งต้นคอลัมน์ที่แสดง
const DEFAULT_VISIBLE: ColumnKey[] = [
"orderNo",
"dateCreated",
"channel",
"marketplaceShopName",
"createdByName",
"customerName",
"itemsCount",
"totalTHB",
"paymentStatus",
"fulfillStatus",
"shipBy",
"trackingNo",
"warehouseCode",
];
/* ===========================
จัดกลุ่มสำหรับ Modal (ไทย)
=========================== */
const GROUPS: { titleTH: string; keys: ColumnKey[] }[] = [
{
titleTH: "เอกลักษณ์ & ช่องทาง",
keys: ["id", "orderNo", "orderRef", "channel", "channelOrderId", "marketplaceShopId", "marketplaceShopName"],
},
{
titleTH: "ผู้เปิดออเดอร์",
keys: ["createdByName", "createdByEmail", "createdById"],
},
{
titleTH: "วัน–เวลา",
keys: ["dateCreated", "datePaid", "datePacked", "dateShipped", "dateDelivered", "dateCancelled"],
},
{
titleTH: "ลูกค้า",
keys: ["customerId", "customerName", "customerEmail", "customerPhone"],
},
{
titleTH: "ที่อยู่จัดส่ง",
keys: [
"shippingName",
"shippingPhone",
"shippingAddressLine1",
"shippingAddressLine2",
"shippingSubdistrict",
"shippingDistrict",
"shippingProvince",
"shippingPostcode",
"shippingCountry",
],
},
{
titleTH: "ออกใบเสร็จ/ภาษี",
keys: [
"billingName",
"billingTaxId",
"billingAddressLine1",
"billingSubdistrict",
"billingDistrict",
"billingProvince",
"billingPostcode",
"billingCountry",
],
},
{
titleTH: "การชำระเงิน",
keys: ["paymentMethod", "paymentStatus", "paymentProvider", "paymentTxId", "paymentFeeTHB"],
},
{
titleTH: "ราคา/ต้นทุนย่อย",
keys: ["currency", "exchangeRate", "subtotalTHB", "discountTHB", "shippingFeeTHB", "codFeeTHB", "packagingFeeTHB", "taxTHB", "totalTHB"],
},
{
titleTH: "สินค้า/โลจิสติกส์",
keys: ["itemsCount", "totalWeightGrams", "packageCount", "dimensionsCmL", "dimensionsCmW", "dimensionsCmH"],
},
{
titleTH: "สถานะจัดส่ง",
keys: ["fulfillStatus", "shipBy", "carrier", "serviceLevel", "trackingNo"],
},
{
titleTH: "โกดัง/ปฏิบัติการ",
keys: ["warehouseCode", "pickListId", "waveId", "binFrom", "binTo"],
},
{
titleTH: "ธง/โน้ต/ความเสี่ยง",
keys: [
"tags",
"rmaFlag",
"rmaReason",
"noteSeller",
"noteBuyer",
"slaShipOnTime",
"giftWrap",
"fragile",
"priorityLevel",
"riskScore",
"holdReason",
"fraudCheckStatus",
],
},
];
/* ===========================
Page
=========================== */
export default function OrdersPage() {
const [q, setQ] = useState("");
const [channel, setChannel] = useState<"all" | Channel>("all");
const [payment, setPayment] = useState<"all" | PaymentStatus>("all");
const [fulfillment, setFulfillment] = useState<"all" | FulfillmentStatus>("all");
const [visibleKeys, setVisibleKeys] = useState<ColumnKey[]>(DEFAULT_VISIBLE);
const [openCustomize, setOpenCustomize] = useState(false);
const [tempKeys, setTempKeys] = useState<ColumnKey[]>(DEFAULT_VISIBLE);
// โหลด/จำคอลัมน์ที่เลือก
useEffect(() => {
try {
const raw = localStorage.getItem(LS_KEY);
if (raw) {
const parsed = JSON.parse(raw) as ColumnKey[];
if (Array.isArray(parsed) && parsed.length) {
setVisibleKeys(parsed);
setTempKeys(parsed);
}
}
} catch {}
}, []);
const persistVisible = (keys: ColumnKey[]) => {
setVisibleKeys(keys);
try {
localStorage.setItem(LS_KEY, JSON.stringify(keys));
} catch {}
};
// กรองรายการ
const rows = useMemo(() => {
let arr = [...MOCK_ORDERS];
if (channel !== "all") arr = arr.filter((o) => o.channel === channel);
if (payment !== "all") arr = arr.filter((o) => o.paymentStatus === payment);
if (fulfillment !== "all") arr = arr.filter((o) => o.fulfillStatus === fulfillment);
if (q.trim()) {
const s = q.trim().toLowerCase();
arr = arr.filter((o) =>
[
o.orderNo,
o.orderRef,
o.customerName,
o.customerEmail,
o.customerPhone,
o.channelOrderId,
o.trackingNo,
o.billingTaxId,
o.createdByName,
o.createdByEmail,
]
.filter((x): x is string => typeof x === "string")
.some((x) => x.toLowerCase().includes(s)),
);
}
// ใหม่สุดก่อน
arr.sort((a, b) => +new Date(b.dateCreated) - +new Date(a.dateCreated));
return arr;
}, [q, channel, payment, fulfillment]);
// สร้างคอลัมน์ที่แสดงจริง
const visibleCols: Column[] = useMemo(() => {
const map = new Map(ALL_COLS.map((c) => [c.key, c]));
return visibleKeys.map((k) => map.get(k)!).filter(Boolean);
}, [visibleKeys]);
// สร้างหัวตาราง 2 ชั้น: ชั้นบน = กลุ่ม, ชั้นล่าง = หัวคอลัมน์ย่อย
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]);
return (
<div className="space-y-6 overflow-x-clip">
{/* ส่วนหัว */}
<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"></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("orders_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>
{/* ตัวกรอง */}
<section className="rounded-3xl border border-neutral-200/70 bg-white/80 p-4 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]">
<div className="grid gap-3 sm:grid-cols-12">
<div className="sm:col-span-6">
<input
placeholder="ค้นหา: เลขออเดอร์ / อ้างอิง / ลูกค้า / เบอร์ / อีเมล / ผู้เปิด / เลขภาษี / Tracking…"
value={q}
onChange={(e) => setQ(e.target.value)}
className="w-full rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-2 text-sm shadow-sm outline-none placeholder:text-neutral-400 focus:border-neutral-300"
/>
</div>
{/* ช่องทาง (Select) */}
<div className="sm:col-span-2">
<select
value={channel}
onChange={(e) => setChannel(e.target.value as "all" | Channel)}
className="w-full rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-2 text-sm shadow-sm outline-none"
>
<option value="all"></option>
<option value="shopee">Shopee</option>
<option value="lazada">Lazada</option>
<option value="tiktok">TikTok</option>
<option value="d2c"> (D2C)</option>
<option value="chat"></option>
</select>
</div>
{/* การชำระเงิน */}
<div className="sm:col-span-2">
<select
value={payment}
onChange={(e) => setPayment(e.target.value as "all" | PaymentStatus)}
className="w-full rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-2 text-sm shadow-sm outline-none"
>
<option value="all">การชำระเงิน: ทั้งหมด</option>
<option value="paid"> (paid)</option>
<option value="pending"> (pending)</option>
<option value="failed"> (failed)</option>
<option value="cod">COD</option>
<option value="refunded"> (refunded)</option>
</select>
</div>
{/* สถานะจัดส่ง */}
<div className="sm:col-span-2">
<select
value={fulfillment}
onChange={(e) => setFulfillment(e.target.value as "all" | FulfillmentStatus)}
className="w-full rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-2 text-sm shadow-sm outline-none"
>
<option value="all">จัดส่ง: ทั้งหมด</option>
<option value="unfulfilled"> (unfulfilled)</option>
<option value="processing"> (processing)</option>
<option value="shipped"> (shipped)</option>
<option value="delivered"> (delivered)</option>
<option value="returned"> (returned)</option>
<option value="cancelled"> (cancelled)</option>
</select>
</div>
</div>
</section>
{/* ตาราง (2 ชั้น) — ไม่ fix size: ใช้ table-auto, ให้คอนเทนต์กำหนดความกว้าง, บรรทัดเดียวทุกเซลล์ */}
<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="w-full max-w-full overflow-x-auto">
<table className="table-auto text-sm">
<thead>
{/* ชั้นบน: กลุ่มหัวข้อ (ไทย) */}
<tr className="text-neutral-600">
{topHeaderSegments.map((seg, idx) => (
<th
key={`${seg.groupTH}-${idx}`}
colSpan={seg.colSpan}
className="pb-1 pr-4 text-center text-[12px] font-semibold whitespace-nowrap"
>
{seg.groupTH}
</th>
))}
</tr>
{/* ชั้นล่าง: หัวคอลัมน์ย่อย (ไทย) */}
<tr className="text-left text-neutral-500">
{visibleCols.map((c) => (
<th
key={String(c.key)}
className={`pb-2 pr-4 whitespace-nowrap ${c.align === "right" ? "text-right" : ""}`}
>
{c.labelTH}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-neutral-200/70">
{rows.map((o) => (
<tr key={o.id} className="align-middle">
{visibleCols.map((c) => {
const raw = getProp(o, c.key);
const content: ReactNode = c.format ? c.format(raw, o) : defaultCell(raw);
const align = c.align === "right" ? "text-right" : "";
return (
<td key={`${o.id}-${String(c.key)}`} className={`py-2 pr-4 whitespace-nowrap ${align}`}>
{c.key === "orderNo" ? (
<a
href={`/orders/${o.id}`}
className="text-neutral-900 underline decoration-neutral-200 underline-offset-4 hover:decoration-neutral-400"
>
{content}
</a>
) : c.key === "tags" && Array.isArray(o.tags) ? (
// ไม่ให้ขึ้นหลายบรรทัด: ตัด flex-wrap ออก
<div className="flex gap-1">
{o.tags!.length ? (
o.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>
</section>
{/* Modal: ปรับแต่งตาราง (ทำให้ scroll ได้ + footer ติดล่าง) */}
{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()}
>
{/* Header (ติดบน) */}
<div className="shrink-0 flex 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>
{/* Body (เลื่อนลงได้) */}
<div className="grow overflow-y-auto p-5">
<div className="mb-4 flex flex-wrap items-center gap-2">
<button
onClick={() => setTempKeys(ALL_COLS.map((c) => c.key))}
className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-xs shadow-sm hover:bg-neutral-100/80"
>
</button>
<button
onClick={() => setTempKeys([])}
className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-xs shadow-sm hover:bg-neutral-100/80"
>
</button>
<button
onClick={() => setTempKeys(DEFAULT_VISIBLE)}
className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-xs shadow-sm hover:bg-neutral-100/80"
>
</button>
<span className="text-xs text-neutral-500"> {tempKeys.length} </span>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{GROUPS.map((g) => (
<fieldset key={g.titleTH} className="rounded-2xl border border-neutral-200/70 bg-white/60 p-4">
<div className="mb-2 flex items-center justify-between">
<legend className="text-sm font-semibold">{g.titleTH}</legend>
<div className="flex gap-1">
<button
className="rounded-lg border border-neutral-200/70 bg-white/70 px-2 py-1 text-[11px] shadow-sm hover:bg-neutral-100/80"
onClick={() => setTempKeys((prev) => Array.from(new Set([...prev, ...g.keys])))}
>
</button>
<button
className="rounded-lg border border-neutral-200/70 bg-white/70 px-2 py-1 text-[11px] shadow-sm hover:bg-neutral-100/80"
onClick={() => setTempKeys((prev) => prev.filter((k) => !g.keys.includes(k)))}
>
</button>
</div>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{g.keys.map((k) => {
const col = ALL_COLS.find((c) => c.key === k)!;
const checked = tempKeys.includes(k);
return (
<label key={k} className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
className="h-4 w-4 rounded border-neutral-300 text-neutral-900 focus:ring-neutral-900"
checked={checked}
onChange={(e) =>
setTempKeys((prev) =>
e.target.checked ? [...prev, k] : prev.filter((x) => x !== k),
)
}
/>
<span>{col.labelTH}</span>
</label>
);
})}
</div>
</fieldset>
))}
</div>
</div>
{/* Footer (ติดล่าง) */}
<div className="shrink-0 flex 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>
);
}