From a1f60fd12ebbec41c5854743d2a378e9f722ac07 Mon Sep 17 00:00:00 2001 From: Thanakarn Klangkasame <77600906+Simulationable@users.noreply.github.com> Date: Wed, 8 Oct 2025 11:27:28 +0700 Subject: [PATCH] Order Page --- src/app/(protected)/layout.tsx | 2 +- src/app/(protected)/orders/page.tsx | 1194 +++++++++++++++++++++++++ src/app/component/layout/AppShell.tsx | 34 +- src/lib/nav.ts | 430 ++++++++- 4 files changed, 1634 insertions(+), 26 deletions(-) create mode 100644 src/app/(protected)/orders/page.tsx diff --git a/src/app/(protected)/layout.tsx b/src/app/(protected)/layout.tsx index c731e2c..8413b69 100644 --- a/src/app/(protected)/layout.tsx +++ b/src/app/(protected)/layout.tsx @@ -39,7 +39,7 @@ export default async function ProtectedLayout({ children }: { children: ReactNod redirect(`/login?returnUrl=${encodeURIComponent("/dashboard")}`); } - const nav = buildNavForRoles(merged.roles); + const nav = buildNavForRoles(merged.roles, [], { showAll: true }); return ( diff --git a/src/app/(protected)/orders/page.tsx b/src/app/(protected)/orders/page.tsx new file mode 100644 index 0000000..af8e1ce --- /dev/null +++ b/src/app/(protected)/orders/page.tsx @@ -0,0 +1,1194 @@ +// 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(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) => ( + {v as string} + ), + }, + { 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) => ( + {v as string} + ), + }, + { 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(DEFAULT_VISIBLE); + const [openCustomize, setOpenCustomize] = useState(false); + const [tempKeys, setTempKeys] = useState(DEFAULT_VISIBLE); + + // โหลด/จำคอลัมน์ที่เลือก + useEffect(() => { + try { + const raw = localStorage.getItem(LS_KEY); + if (raw) { + const parsed = JSON.parse(raw) as ColumnKey[]; + if (Array.isArray(parsed) && parsed.length) { + setVisibleKeys(parsed); + setTempKeys(parsed); + } + } + } catch {} + }, []); + const persistVisible = (keys: ColumnKey[]) => { + setVisibleKeys(keys); + try { + localStorage.setItem(LS_KEY, JSON.stringify(keys)); + } catch {} + }; + + // กรองรายการ + const 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 ( +
+ {/* ส่วนหัว */} +
+
+

ออเดอร์

+

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

+
+
+ + + +
+
+ + {/* ตัวกรอง */} +
+
+
+ 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" + /> +
+ + {/* ช่องทาง (Select) */} +
+ +
+ + {/* การชำระเงิน */} +
+ +
+ + {/* สถานะจัดส่ง */} +
+ +
+
+
+ + {/* ตาราง (2 ชั้น) — ไม่ fix size: ใช้ table-auto, ให้คอนเทนต์กำหนดความกว้าง, บรรทัดเดียวทุกเซลล์ */} +
+
+ + + {/* ชั้นบน: กลุ่มหัวข้อ (ไทย) */} + + {topHeaderSegments.map((seg, idx) => ( + + ))} + + {/* ชั้นล่าง: หัวคอลัมน์ย่อย (ไทย) */} + + {visibleCols.map((c) => ( + + ))} + + + + + {rows.map((o) => ( + + {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 ( + + ); + })} + + ))} + +
+ {seg.groupTH} +
+ {c.labelTH} +
+ {c.key === "orderNo" ? ( + + {content} + + ) : c.key === "tags" && Array.isArray(o.tags) ? ( + // ไม่ให้ขึ้นหลายบรรทัด: ตัด flex-wrap ออก +
+ {o.tags!.length ? ( + o.tags!.map((t) => ( + + {t} + + )) + ) : ( + - + )} +
+ ) : ( + content + )} +
+
+
+ + {/* Modal: ปรับแต่งตาราง (ทำให้ scroll ได้ + footer ติดล่าง) */} + {openCustomize && ( +
setOpenCustomize(false)} + > +
e.stopPropagation()} + > + {/* Header (ติดบน) */} +
+

ปรับแต่งตาราง

+ +
+ + {/* Body (เลื่อนลงได้) */} +
+
+ + + + เลือก {tempKeys.length} คอลัมน์ +
+ +
+ {GROUPS.map((g) => ( +
+
+ {g.titleTH} +
+ + +
+
+
+ {g.keys.map((k) => { + const col = ALL_COLS.find((c) => c.key === k)!; + const checked = tempKeys.includes(k); + return ( + + ); + })} +
+
+ ))} +
+
+ + {/* Footer (ติดล่าง) */} +
+
เคล็ดลับ: ระบบจะจำคอลัมน์ที่คุณเลือกไว้ในเบราว์เซอร์นี้
+
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/src/app/component/layout/AppShell.tsx b/src/app/component/layout/AppShell.tsx index 6b9637f..b2d9906 100644 --- a/src/app/component/layout/AppShell.tsx +++ b/src/app/component/layout/AppShell.tsx @@ -1,15 +1,15 @@ // File: src/app/component/layout/AppShell.tsx "use client"; -import { ReactNode, useMemo, useState } from "react"; +import {ReactNode, useMemo, useState} from "react"; import Link from "next/link"; -import { usePathname } from "next/navigation"; -import type { UserSession } from "@/types/auth"; +import {usePathname} from "next/navigation"; +import type {UserSession} from "@/types/auth"; type NavItem = { label: string; href: string; icon?: React.ReactNode; match?: RegExp }; type Props = { user: UserSession; nav: NavItem[]; children: ReactNode }; -export default function AppShell({ user, nav, children }: Props) { +export default function AppShell({user, nav, children}: Props) { const [sidebarOpen, setSidebarOpen] = useState(true); const pathname = usePathname(); @@ -28,7 +28,7 @@ export default function AppShell({ user, nav, children }: Props) { {/* Sidebar (desktop) */}