Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fe7c5b9c1 | ||
|
|
b8bb1d4617 | ||
|
|
c78a898af7 | ||
|
|
fdbbaea021 | ||
|
|
f49776f9cb | ||
|
|
4fcdd8a816 | ||
|
|
b030c08f68 | ||
|
|
a5584d0d46 | ||
|
|
a0abfbf306 | ||
|
|
d8f6d8139f | ||
|
|
a647c9682d | ||
|
|
b13f37cf7e | ||
|
|
1ce711d960 | ||
|
|
809f420fe4 | ||
|
|
cbd816592b | ||
|
|
408f07e27a | ||
|
|
468b690ac3 | ||
|
|
18c12a6a14 | ||
|
|
ce252dc061 | ||
|
|
8a8438096c | ||
|
|
765cdd1fa9 | ||
|
|
dd79519bf7 | ||
|
|
0b6e5310c0 | ||
|
|
1be4657f3f | ||
|
|
647428961a | ||
|
|
731619af9d | ||
|
|
5ba4afb2de | ||
|
|
2364aa32b0 | ||
|
|
ca79692b3e | ||
|
|
0bf544720b | ||
|
|
64f7ecf729 |
No files matched your search
@@ -1,5 +1,7 @@
|
|||||||
# Original User Request
|
# Original User Request
|
||||||
|
|
||||||
|
> **Historical context:** This is an unedited record of an earlier development request for the Split-Screen Editorial Carousel feature. The current platform identity is DBK Platform.
|
||||||
|
|
||||||
## Initial Request — 2026-06-28T10:50:33Z
|
## Initial Request — 2026-06-28T10:50:33Z
|
||||||
|
|
||||||
Implement a Split-Screen Editorial Carousel by extending the existing `EDITORIAL_CAROUSEL` schema and updating the backend, admin, spa, and website apps.
|
Implement a Split-Screen Editorial Carousel by extending the existing `EDITORIAL_CAROUSEL` schema and updating the backend, admin, spa, and website apps.
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Agent System & Workspaces Directory
|
||||||
|
|
||||||
|
> **Agent Infrastructure & Historical Provenance:** This directory houses both active multi-agent skills and historical subagent run workspaces. The platform identity is **DBK Platform**, with **HomAroma** as the reference tenant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Active Skills (`.agents/skills/`)
|
||||||
|
The `.agents/skills/` directory contains active domain skill definitions loaded on demand by Antigravity and orchestrator agents. Each skill contains a `SKILL.md` specifying role responsibilities, routing logic, and verification rules:
|
||||||
|
|
||||||
|
- **`orchestrator`**: Central project planning, task routing, delegation, and verification.
|
||||||
|
- **`frontend-lead`**: React 19 SPA & Tailwind CSS development.
|
||||||
|
- **`frontend-code-auditor`**: Review gate for bug risks, contracts, and regressions.
|
||||||
|
- **`nextjs-seo-architect`**: Next.js 15 SSR, metadata, canonical URLs, and performance.
|
||||||
|
- **`backend-mcp-lead`**: Elysia API endpoints, server architecture, and MCP tools.
|
||||||
|
- **`database-architect`**: Prisma schema, PostgreSQL migrations, and data safety.
|
||||||
|
- **`cms-architect`**: Dynamic page builder payloads and headless CMS schemas.
|
||||||
|
- **`cms-content-manager`**: Content management and MCP blackbox testing.
|
||||||
|
- **`product-manager`**: Product scope, roadmap, and business impact analysis.
|
||||||
|
- **`ux-ui-designer`**: Design system, interaction models, and responsive layouts.
|
||||||
|
- **`qa-product-auditor`**: User flows, regression checks, and release readiness.
|
||||||
|
- **`brand-strategist`**: SEO copywriting, brand voice, and positioning.
|
||||||
|
- **`theme-compliance-auditor`**: CMS dynamic theme token compliance (avoid hardcoded colors).
|
||||||
|
|
||||||
|
*Note: Changes to `.agents/skills/` directly affect agent behavior and should follow the operational rules defined in [`AGENTS.md`](../AGENTS.md).*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Historical Subagent Workspaces
|
||||||
|
Directories matching `auditor_*`, `worker_*`, `explorer_*`, `reviewer_*`, `challenger_*`, `teamwork_preview_*`, `sentinel`, and `sub_orch_*` are historical workspaces and artifact directories generated during previous autonomous multi-agent sprint sessions.
|
||||||
|
|
||||||
|
- **Status:** Historical records preserved for provenance, auditability, and debugging.
|
||||||
|
- **Usage:** Do not edit active platform rules within historical folders. Refer to [`AGENTS.md`](../AGENTS.md) and [`docs/PLATFORM_OVERVIEW.md`](../docs/PLATFORM_OVERVIEW.md) for current standards.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Handle line endings automatically for files detected as text
|
||||||
|
* text=auto
|
||||||
|
|
||||||
|
# Lockfile must use LF across all platforms for Bun consistency
|
||||||
|
bun.lock text eol=lf
|
||||||
|
|
||||||
|
# Shell scripts must use LF
|
||||||
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
# Windows batch scripts must use CRLF
|
||||||
|
*.bat text eol=crlf
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# DBK Platform Admin Section Guide
|
# DBK Platform Admin Section Guide
|
||||||
|
|
||||||
> **Platform scope:** This guide describes shared CMS administration capabilities. Product and content examples are tenant-specific; HomAroma is the reference tenant.
|
> **Platform scope:** This guide describes shared CMS administration capabilities. Product and content examples are tenant-specific; HomAroma is the reference tenant. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||||
|
|
||||||
This guide explains the dynamic page blocks available in the Page Builder and what each block contains.
|
This guide explains the dynamic page blocks available in the Page Builder and what each block contains.
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ This guide explains the dynamic page blocks available in the Page Builder and wh
|
|||||||
|
|
||||||
### `SCENT_NOTES_BREAKDOWN`
|
### `SCENT_NOTES_BREAKDOWN`
|
||||||
|
|
||||||
- Purpose: Fragrance layer breakdown
|
- Purpose: Fragrance layer breakdown (reference tenant vertical block; pattern for multi-tier attribute breakdowns)
|
||||||
- Contains:
|
- Contains:
|
||||||
- `title` localized string optional
|
- `title` localized string optional
|
||||||
- `description` localized string optional
|
- `description` localized string optional
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Architecture Decisions — DBK Platform
|
# Architecture Decisions — DBK Platform
|
||||||
|
|
||||||
> **Platform scope:** HomAroma references in this ADR are reference-tenant context unless a decision explicitly names an operational resource.
|
> **Platform scope:** DBK Platform architecture decisions. HomAroma references in this ADR are reference-tenant context unless a decision explicitly names an operational resource. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||||
|
|
||||||
> บันทึกการตัดสินใจเชิงสถาปัตยกรรม (ADR) จากการประชุมทีม
|
> บันทึกการตัดสินใจเชิงสถาปัตยกรรม (ADR) จากการประชุมทีม
|
||||||
> อัปเดตล่าสุด: 2026-07-12
|
> อัปเดตล่าสุด: 2026-07-12
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# รายงานการกำหนดสูตร BOM สำหรับกลุ่มสินค้า Aroma Oil (Production)
|
||||||
|
|
||||||
|
* **วันที่กำหนดสูตร:** 2026-10-18T10:46:07.236Z
|
||||||
|
* **ระบบอ้างอิง:** Production Database (`https://api.homaroma.shop/api`)
|
||||||
|
* **สัดส่วนสูตร:** **หัวน้ำหอม 20% + DPG 80% + บรรจุภัณฑ์ขวดพร้อมฝา (฿4.00)**
|
||||||
|
* **จำนวนสินค้าทั้งหมด:** **45** กลิ่น (**180** Variants)
|
||||||
|
* **สถานะความสำเร็จ:** **100% (45/45 สินค้า)**
|
||||||
|
|
||||||
|
## 📋 ตารางรายละเอียดต้นทุน BOM และอัตรากำไร (Margin) ทั้ง 45 กลิ่น
|
||||||
|
|
||||||
|
| # | ID | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุน 30ml (กำไร%) | ต้นทุน 60ml (กำไร%) | ต้นทุน 85ml (กำไร%) | ต้นทุน 120ml (กำไร%) |
|
||||||
|
| :-: | :-: | :--- | :--- | :-: | :-: | :-: | :-: |
|
||||||
|
| 1 | 2 | **ตะไคร้ Aroma Oil** | หัวน้ำหอม กลิ่นตะไคร้ (฿4.50) | ฿34.60 (61.6%) | ฿65.20 (56.5%) | ฿90.70 (45%) | ฿126.40 (43.8%) |
|
||||||
|
| 2 | 7 | **ลาเวนเดอร์ Aroma Oil** | หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 3 | 12 | **วานิลลา Aroma Oil** | หัวน้ำหอม กลิ่นวานิลลา (฿1.30) | ฿15.40 (82.9%) | ฿26.80 (82.1%) | ฿36.30 (78%) | ฿49.60 (78%) |
|
||||||
|
| 4 | 17 | **ซากุระ Aroma Oil** | หัวน้ำหอม กลิ่นซากุระ (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 5 | 22 | **มะลิ Aroma Oil** | หัวน้ำหอม กลิ่นมะลิ (฿1.00) | ฿13.60 (84.9%) | ฿23.20 (84.5%) | ฿31.20 (81.1%) | ฿42.40 (81.2%) |
|
||||||
|
| 6 | 27 | **ยูคาลิปตัส Aroma Oil** | หัวน้ำหอม กลิ่นยูคาลิปตัส (฿2.10) | ฿20.20 (77.6%) | ฿36.40 (75.7%) | ฿49.90 (69.8%) | ฿68.80 (69.4%) |
|
||||||
|
| 7 | 32 | **เฟรชลาเวนเดอร์ Aroma Oil** | หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 8 | 37 | **เปเปอร์มิ้น Aroma Oil** | หัวน้ำหอม กลิ่นเปเปอร์มิ้น (฿1.40) | ฿16.00 (82.2%) | ฿28.00 (81.3%) | ฿38.00 (77%) | ฿52.00 (76.9%) |
|
||||||
|
| 9 | 42 | **โอเชี่ยน Aroma Oil** | หัวน้ำหอม กลิ่นโอเชี่ยน (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 10 | 47 | **เซเว่นซี Aroma Oil** | หัวน้ำหอม กลิ่นเซเว่นซี (฿1.70) | ฿17.80 (80.2%) | ฿31.60 (78.9%) | ฿43.10 (73.9%) | ฿59.20 (73.7%) |
|
||||||
|
| 11 | 52 | **ดอกแก้ว Aroma Oil** | หัวน้ำหอม กลิ่นดอกแก้ว (฿1.40) | ฿16.00 (82.2%) | ฿28.00 (81.3%) | ฿38.00 (77%) | ฿52.00 (76.9%) |
|
||||||
|
| 12 | 57 | **ดอกโมก Aroma Oil** | หัวน้ำหอม กลิ่นดอกโมก (฿1.50) | ฿16.60 (81.6%) | ฿29.20 (80.5%) | ฿39.70 (75.9%) | ฿54.40 (75.8%) |
|
||||||
|
| 13 | 62 | **ลีลาวดี Aroma Oil** | หัวน้ำหอม กลิ่นลีลาวดี (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 14 | 67 | **ส้ม Aroma Oil** | หัวน้ำหอม กลิ่นส้ม (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 15 | 72 | **มะนาว Aroma Oil** | หัวน้ำหอม กลิ่นมะนาว (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 16 | 77 | **แคนตาลูป Aroma Oil** | หัวน้ำหอม กลิ่นแคนตาลูป (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 17 | 82 | **แอปเปิ้ลแดง Aroma Oil** | หัวน้ำหอม กลิ่นแอปเปิ้ลแดง (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 18 | 87 | **แอปเปิ้ลเขียว Aroma Oil** | หัวน้ำหอม กลิ่นแอปเปิ้ลเขียว (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 19 | 92 | **ดอกลิลลี่ Aroma Oil** | หัวน้ำหอม กลิ่นดอกลิลลี่ (฿1.50) | ฿16.60 (81.6%) | ฿29.20 (80.5%) | ฿39.70 (75.9%) | ฿54.40 (75.8%) |
|
||||||
|
| 20 | 97 | **โรสเมรี่ Aroma Oil** | หัวน้ำหอม กลิ่นโรสเมรี่ (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 21 | 102 | **อัญชัน Aroma Oil** | หัวน้ำหอม กลิ่นอัญชัน (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 22 | 107 | **กุหลาบแดง Aroma Oil** | หัวน้ำหอม กลิ่นกุหลาบแดง (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 23 | 112 | **หมากฝรั่ง Aroma Oil** | หัวน้ำหอม กลิ่นหมากฝรั่ง (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 24 | 117 | **ผลไม้รวม Aroma Oil** | หัวน้ำหอม กลิ่นผลไม้รวม (฿1.40) | ฿16.00 (82.2%) | ฿28.00 (81.3%) | ฿38.00 (77%) | ฿52.00 (76.9%) |
|
||||||
|
| 25 | 122 | **มะกรูด Aroma Oil** | หัวน้ำหอม กลิ่นมะกรูด (฿1.50) | ฿16.60 (81.6%) | ฿29.20 (80.5%) | ฿39.70 (75.9%) | ฿54.40 (75.8%) |
|
||||||
|
| 26 | 127 | **แซนด้าวูส Aroma Oil** | หัวน้ำหอม กลิ่นแซนด้าวูส (฿1.00) | ฿13.60 (84.9%) | ฿23.20 (84.5%) | ฿31.20 (81.1%) | ฿42.40 (81.2%) |
|
||||||
|
| 27 | 132 | **ดอกปีบ Aroma Oil** | หัวน้ำหอม กลิ่นดอกปีบ (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 28 | 137 | **ดอกบัว Aroma Oil** | หัวน้ำหอม กลิ่นดอกบัว (฿0.70) | ฿11.80 (86.9%) | ฿19.60 (86.9%) | ฿26.10 (84.2%) | ฿35.20 (84.4%) |
|
||||||
|
| 29 | 142 | **โอโซน Aroma Oil** | หัวน้ำหอม กลิ่นโอโซน (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 30 | 147 | **ไทยสปา Aroma Oil** | หัวน้ำหอม กลิ่นไทยสปา (฿1.50) | ฿16.60 (81.6%) | ฿29.20 (80.5%) | ฿39.70 (75.9%) | ฿54.40 (75.8%) |
|
||||||
|
| 31 | 152 | **กล้วย Aroma Oil** | หัวน้ำหอม กลิ่นกล้วย (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 32 | 157 | **กาแฟ Aroma Oil** | หัวน้ำหอม กลิ่นกาแฟ (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 33 | 162 | **ดอกชมนาด Aroma Oil** | หัวน้ำหอม กลิ่นดอกชมนาด (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 34 | 167 | **กลิ่นน้ำนม Aroma Oil** | หัวน้ำหอม กลิ่นน้ำนม (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 35 | 172 | **ดอกการะเวก Aroma Oil** | หัวน้ำหอม กลิ่นดอกการะเวก (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 36 | 177 | **ดอกจำปา Aroma Oil** | หัวน้ำหอม กลิ่นดอกจำปา (฿1.50) | ฿16.60 (81.6%) | ฿29.20 (80.5%) | ฿39.70 (75.9%) | ฿54.40 (75.8%) |
|
||||||
|
| 37 | 182 | **ดอกพิกุล Aroma Oil** | หัวน้ำหอม กลิ่นดอกพิกุล (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 38 | 187 | **ดอกพุด Aroma Oil** | หัวน้ำหอม กลิ่นดอกพุด (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 39 | 192 | **ดอกราตรี Aroma Oil** | หัวน้ำหอม กลิ่นดอกราตรี (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 40 | 197 | **ดอกลำดวน Aroma Oil** | หัวน้ำหอม กลิ่นดอกลำดวน (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 41 | 202 | **ทับทิม Aroma Oil** | หัวน้ำหอม กลิ่นทับทิม (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 42 | 207 | **มะละกอสุก Aroma Oil** | หัวน้ำหอม กลิ่นมะละกอสุก (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 43 | 212 | **ดอกราชาวดี Aroma Oil** | หัวน้ำหอม กลิ่นดอกราชาวดี (฿1.30) | ฿15.40 (82.9%) | ฿26.80 (82.1%) | ฿36.30 (78%) | ฿49.60 (78%) |
|
||||||
|
| 44 | 217 | **กลิ่นลูกพีช Aroma Oil** | หัวน้ำหอม กลิ่นลูกพีช (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
|
| 45 | 222 | **กลิ่นองุ่น Aroma Oil** | หัวน้ำหอม กลิ่นองุ่น (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) |
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# แผนการพัฒนาและคู่มือสร้างแผนที่ Malaysia & Vietnam Map
|
# แผนการพัฒนาและคู่มือสร้างแผนที่ Malaysia & Vietnam Map
|
||||||
|
|
||||||
> **Platform capability plan:** Geographic coverage, data availability, and analytics configuration must be enabled and validated per tenant.
|
> **Platform capability plan:** Geographic coverage, data availability, and analytics configuration are platform capabilities enabled per tenant. HomAroma is the reference tenant. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||||
สำหรับเมนู Business Analytics บนระบบ HomAroma Admin
|
สำหรับเมนู Business Analytics บนระบบ DBK Platform Admin (Multi-tenant & Multi-region)
|
||||||
|
|
||||||
เอกสารนี้รวบรวมรายละเอียด แหล่งข้อมูล Open-source Repositories สำหรับดาวน์โหลดไฟล์ GeoJSON สถาปัตยกรรมทางเทคนิค และแผนขั้นตอนการนำไปพัฒนาต่อยอดให้เหมือนกับ **Omnichannel Sales Map (แผนที่ประเทศไทย)** เดิม
|
เอกสารนี้รวบรวมรายละเอียด แหล่งข้อมูล Open-source Repositories สำหรับดาวน์โหลดไฟล์ GeoJSON สถาปัตยกรรมทางเทคนิค และแผนขั้นตอนการนำไปพัฒนาต่อยอดให้เหมือนกับ **Omnichannel Sales Map (แผนที่ประเทศไทย)** เดิม
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# รายงานการกำหนดสูตร BOM สำหรับกลุ่มสินค้า Car Diffuser (Production)
|
||||||
|
|
||||||
|
* **วันที่กำหนดสูตร:** 2026-10-18T10:55:32.384Z
|
||||||
|
* **ระบบอ้างอิง:** Production Database (`https://api.homaroma.shop/api`)
|
||||||
|
* **สเปคขวด:** ป้ายระบุ 25ml / ปริมาตรบรรจุจริง 12ml
|
||||||
|
* **สัดส่วนสูตร (12ml):** **หัวน้ำหอม 8% (0.96 ml) + DPG 92% (11.04 ml) + ขวดพร้อมฝาไม้ (฿18.00)**
|
||||||
|
* **จำนวนสินค้าทั้งหมด:** **45** กลิ่น (**45** Variants)
|
||||||
|
* **สถานะความสำเร็จ:** **100% (45/45 สินค้า)**
|
||||||
|
|
||||||
|
## 📋 ตารางรายละเอียดต้นทุน BOM และอัตรากำไร (Margin) ทั้ง 45 กลิ่น
|
||||||
|
|
||||||
|
| # | ID | Master SKU | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุนน้ำยา (12ml) | ต้นทุนขวด | ต้นทุนรวม BOM | ราคาขาย Master | กำไรขั้นต้น (GP) | GP Margin % |
|
||||||
|
| :-: | :-: | :--- | :--- | :--- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
|
| 1 | 5 | `1-5-25ML` | **ตะไคร้ Car Diffuser** | หัวน้ำหอม กลิ่นตะไคร้ (฿4.50) | ฿5.98 | ฿18.00 | **฿23.98** | ฿60.00 | +฿36.02 | **60%** |
|
||||||
|
| 2 | 10 | `2-5-25ML` | **ลาเวนเดอร์ Car Diffuser** | หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 3 | 15 | `3-5-25ML` | **วานิลลา Car Diffuser** | หัวน้ำหอม กลิ่นวานิลลา (฿1.30) | ฿2.91 | ฿18.00 | **฿20.90** | ฿60.00 | +฿39.10 | **65.2%** |
|
||||||
|
| 4 | 20 | `4-5-25ML` | **ซากุระ Car Diffuser** | หัวน้ำหอม กลิ่นซากุระ (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 5 | 25 | `5-5-25ML` | **มะลิ Car Diffuser** | หัวน้ำหอม กลิ่นมะลิ (฿1.00) | ฿2.62 | ฿18.00 | **฿20.62** | ฿60.00 | +฿39.38 | **65.6%** |
|
||||||
|
| 6 | 30 | `6-5-25ML` | **ยูคาลิปตัส Car Diffuser** | หัวน้ำหอม กลิ่นยูคาลิปตัส (฿2.10) | ฿3.68 | ฿18.00 | **฿21.67** | ฿60.00 | +฿38.33 | **63.9%** |
|
||||||
|
| 7 | 35 | `7-5-25ML` | **เฟรชลาเวนเดอร์ Car Diffuser** | หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 8 | 40 | `8-5-25ML` | **เปเปอร์มิ้น Car Diffuser** | หัวน้ำหอม กลิ่นเปเปอร์มิ้น (฿1.40) | ฿3.00 | ฿18.00 | **฿21.00** | ฿60.00 | +฿39.00 | **65%** |
|
||||||
|
| 9 | 45 | `9-5-25ML` | **โอเชี่ยน Car Diffuser** | หัวน้ำหอม กลิ่นโอเชี่ยน (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 10 | 50 | `10-5-25ML` | **เซเว่นซี Car Diffuser** | หัวน้ำหอม กลิ่นเซเว่นซี (฿1.70) | ฿3.29 | ฿18.00 | **฿21.29** | ฿60.00 | +฿38.71 | **64.5%** |
|
||||||
|
| 11 | 55 | `11-5-25ML` | **ดอกแก้ว Car Diffuser** | หัวน้ำหอม กลิ่นดอกแก้ว (฿1.40) | ฿3.00 | ฿18.00 | **฿21.00** | ฿60.00 | +฿39.00 | **65%** |
|
||||||
|
| 12 | 60 | `12-5-25ML` | **ดอกโมก Car Diffuser** | หัวน้ำหอม กลิ่นดอกโมก (฿1.50) | ฿3.10 | ฿18.00 | **฿21.10** | ฿60.00 | +฿38.90 | **64.8%** |
|
||||||
|
| 13 | 65 | `13-5-25ML` | **ลีลาวดี Car Diffuser** | หัวน้ำหอม กลิ่นลีลาวดี (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 14 | 70 | `14-5-25ML` | **ส้ม Car Diffuser** | หัวน้ำหอม กลิ่นส้ม (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 15 | 75 | `15-5-25ML` | **มะนาว Car Diffuser** | หัวน้ำหอม กลิ่นมะนาว (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 16 | 80 | `16-5-25ML` | **แคนตาลูป Car Diffuser** | หัวน้ำหอม กลิ่นแคนตาลูป (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 17 | 85 | `17-5-25ML` | **แอปเปิ้ลแดง Car Diffuser** | หัวน้ำหอม กลิ่นแอปเปิ้ลแดง (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 18 | 90 | `18-5-25ML` | **แอปเปิ้ลเขียว Car Diffuser** | หัวน้ำหอม กลิ่นแอปเปิ้ลเขียว (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 19 | 95 | `19-5-25ML` | **ดอกลิลลี่ Car Diffuser** | หัวน้ำหอม กลิ่นดอกลิลลี่ (฿1.50) | ฿3.10 | ฿18.00 | **฿21.10** | ฿60.00 | +฿38.90 | **64.8%** |
|
||||||
|
| 20 | 100 | `20-5-25ML` | **โรสเมรี่ Car Diffuser** | หัวน้ำหอม กลิ่นโรสเมรี่ (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 21 | 105 | `21-5-25ML` | **อัญชัน Car Diffuser** | หัวน้ำหอม กลิ่นอัญชัน (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 22 | 110 | `22-5-25ML` | **กุหลาบแดง Car Diffuser** | หัวน้ำหอม กลิ่นกุหลาบแดง (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 23 | 115 | `23-5-25ML` | **หมากฝรั่ง Car Diffuser** | หัวน้ำหอม กลิ่นหมากฝรั่ง (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 24 | 120 | `24-5-25ML` | **ผลไม้รวม Car Diffuser** | หัวน้ำหอม กลิ่นผลไม้รวม (฿1.40) | ฿3.00 | ฿18.00 | **฿21.00** | ฿60.00 | +฿39.00 | **65%** |
|
||||||
|
| 25 | 125 | `25-5-25ML` | **มะกรูด Car Diffuser** | หัวน้ำหอม กลิ่นมะกรูด (฿1.50) | ฿3.10 | ฿18.00 | **฿21.10** | ฿60.00 | +฿38.90 | **64.8%** |
|
||||||
|
| 26 | 130 | `26-5-25ML` | **แซนด้าวูส Car Diffuser** | หัวน้ำหอม กลิ่นแซนด้าวูส (฿1.00) | ฿2.62 | ฿18.00 | **฿20.62** | ฿60.00 | +฿39.38 | **65.6%** |
|
||||||
|
| 27 | 135 | `27-5-25ML` | **ดอกปีบ Car Diffuser** | หัวน้ำหอม กลิ่นดอกปีบ (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 28 | 140 | `28-5-25ML` | **ดอกบัว Car Diffuser** | หัวน้ำหอม กลิ่นดอกบัว (฿0.70) | ฿2.33 | ฿18.00 | **฿20.33** | ฿60.00 | +฿39.67 | **66.1%** |
|
||||||
|
| 29 | 145 | `29-5-25ML` | **โอโซน Car Diffuser** | หัวน้ำหอม กลิ่นโอโซน (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 30 | 150 | `30-5-25ML` | **ไทยสปา Car Diffuser** | หัวน้ำหอม กลิ่นไทยสปา (฿1.50) | ฿3.10 | ฿18.00 | **฿21.10** | ฿60.00 | +฿38.90 | **64.8%** |
|
||||||
|
| 31 | 155 | `31-5-25ML` | **กล้วย Car Diffuser** | หัวน้ำหอม กลิ่นกล้วย (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 32 | 160 | `32-5-25ML` | **กาแฟ Car Diffuser** | หัวน้ำหอม กลิ่นกาแฟ (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 33 | 165 | `33-5-25ML` | **ดอกชมนาด Car Diffuser** | หัวน้ำหอม กลิ่นดอกชมนาด (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 34 | 170 | `34-5-25ML` | **กลิ่นน้ำนม Car Diffuser** | หัวน้ำหอม กลิ่นน้ำนม (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 35 | 175 | `35-5-25ML` | **ดอกการะเวก Car Diffuser** | หัวน้ำหอม กลิ่นดอกการะเวก (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 36 | 180 | `36-5-25ML` | **ดอกจำปา Car Diffuser** | หัวน้ำหอม กลิ่นดอกจำปา (฿1.50) | ฿3.10 | ฿18.00 | **฿21.10** | ฿60.00 | +฿38.90 | **64.8%** |
|
||||||
|
| 37 | 185 | `37-5-25ML` | **ดอกพิกุล Car Diffuser** | หัวน้ำหอม กลิ่นดอกพิกุล (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 38 | 190 | `38-5-25ML` | **ดอกพุด Car Diffuser** | หัวน้ำหอม กลิ่นดอกพุด (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 39 | 195 | `39-5-25ML` | **ดอกราตรี Car Diffuser** | หัวน้ำหอม กลิ่นดอกราตรี (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 40 | 200 | `40-5-25ML` | **ดอกลำดวน Car Diffuser** | หัวน้ำหอม กลิ่นดอกลำดวน (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 41 | 205 | `41-5-25ML` | **ทับทิม Car Diffuser** | หัวน้ำหอม กลิ่นทับทิม (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 42 | 210 | `42-5-25ML` | **มะละกอสุก Car Diffuser** | หัวน้ำหอม กลิ่นมะละกอสุก (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 43 | 215 | `43-5-25ML` | **ดอกราชาวดี Car Diffuser** | หัวน้ำหอม กลิ่นดอกราชาวดี (฿1.30) | ฿2.91 | ฿18.00 | **฿20.90** | ฿60.00 | +฿39.10 | **65.2%** |
|
||||||
|
| 44 | 220 | `44-5-25ML` | **กลิ่นลูกพีช Car Diffuser** | หัวน้ำหอม กลิ่นลูกพีช (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
|
| 45 | 225 | `45-5-25ML` | **กลิ่นองุ่น Car Diffuser** | หัวน้ำหอม กลิ่นองุ่น (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** |
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# Custom Block Flow Guide
|
# Custom Block Flow Guide — DBK Platform
|
||||||
|
|
||||||
> **Platform scope:** This guide describes shared CMS block workflows. Content examples belong to their named tenant.
|
> **Platform scope:** This guide describes shared CMS block workflows for DBK Platform. Content examples (e.g. `BakeryCategories`) belong to their named tenant. HomAroma is the reference tenant. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||||
|
|
||||||
เอกสารนี้อธิบาย Flow การสร้าง Custom Block สำหรับ HomAroma Page Builder โดยใช้ `BakeryCategories` เป็นตัวอย่างจริง เพื่อให้เพิ่ม block ถัดไปได้โดยไม่ต้อง hardcode section type ใหม่ทุกครั้ง
|
เอกสารนี้อธิบาย Flow การสร้าง Custom Block สำหรับ DBK Platform Page Builder โดยใช้ `BakeryCategories` เป็นตัวอย่าง tenant จริง เพื่อให้เพิ่ม block ถัดไปได้โดยไม่ต้อง hardcode section type ใหม่ทุกครั้ง
|
||||||
|
|
||||||
## แนวคิดหลัก
|
## แนวคิดหลัก
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# DDC Deployment Runbook
|
# DDC Deployment Runbook
|
||||||
|
|
||||||
> **Tenant operational runbook:** This document records DDC deployment configuration. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding.
|
> **Tenant operational runbook:** This document records DDC deployment configuration for DBK Platform. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||||
|
|
||||||
เอกสารนี้เป็น runbook เฉพาะลูกค้า `ddc` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/pkh-*`
|
เอกสารนี้เป็น runbook เฉพาะลูกค้า `ddc` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/pkh-*`
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# คู่มือการติดตั้งโปรเจกต์ใหม่ (New Project Deployment Guide)
|
# คู่มือการติดตั้งโปรเจกต์ใหม่ (New Project Deployment Guide)
|
||||||
|
|
||||||
> **DBK Platform operational guide:** Substitute tenant-specific domains, paths, credentials, and process names. Any `homaroma` identifier shown elsewhere in the repository is a legacy or reference-tenant identifier unless this guide explicitly selects it.
|
> **DBK Platform operational guide:** Substitute tenant-specific domains, paths, credentials, and process names. Any `homaroma` identifier shown elsewhere in the repository is a legacy or reference-tenant identifier unless this guide explicitly selects it. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||||
|
|
||||||
เอกสารนี้รวบรวมขั้นตอนการสร้างสภาพแวดล้อม (Environment) และการตั้งค่าระบบสำหรับลูกค้ารายใหม่ (เช่น โปรเจกต์ `pkh`) โดยอ้างอิงจากโครงสร้างระบบปัจจุบันบนเซิร์ฟเวอร์ `kitizone.com`
|
เอกสารนี้รวบรวมขั้นตอนการสร้างสภาพแวดล้อม (Environment) และการตั้งค่าระบบสำหรับลูกค้ารายใหม่ (เช่น โปรเจกต์ `pkh`) โดยอ้างอิงจากโครงสร้างระบบปัจจุบันบนเซิร์ฟเวอร์ `kitizone.com`
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# HuaHin Deployment Runbook
|
# HuaHin Deployment Runbook
|
||||||
|
|
||||||
> **Tenant operational runbook:** This document records HuaHin deployment configuration. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding.
|
> **Tenant operational runbook:** This document records HuaHin deployment configuration for DBK Platform. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||||
|
|
||||||
เอกสารนี้เป็น runbook เฉพาะลูกค้า `huahin` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/ddc-*`
|
เอกสารนี้เป็น runbook เฉพาะลูกค้า `huahin` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/ddc-*`
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Kittisak Deployment Runbook
|
# Kittisak Deployment Runbook
|
||||||
|
|
||||||
> **Tenant operational runbook:** This document records Kittisak deployment configuration. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding.
|
> **Tenant operational runbook:** This document records Kittisak deployment configuration for DBK Platform. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||||
|
|
||||||
เอกสารนี้เป็น runbook เฉพาะลูกค้า `kittisak` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/ddc-*` / `serverinfo/huahin-*`
|
เอกสารนี้เป็น runbook เฉพาะลูกค้า `kittisak` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/ddc-*` / `serverinfo/huahin-*`
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Lazada Marketplace Product Matching & Mapping Ledger
|
||||||
|
|
||||||
|
* **Generated At:** 2026-10-18T07:36:06.117Z
|
||||||
|
* **Target Server:** `https://api.homaroma.shop/api` (Production)
|
||||||
|
* **Total Lazada Products:** 1651
|
||||||
|
* **Successfully Mapped:** 1594 (96.5%)
|
||||||
|
* **Held / Requires Decision:** 57 (3.5%)
|
||||||
|
|
||||||
|
### สรุปตามหมวดหมู่สินค้าที่เชื่อมโยงสำเร็จ:
|
||||||
|
- **ก้านไม้หอมปรับอากาศ:** 131 รายการ
|
||||||
|
- **น้ำมันหอมระเหยโอโซน:** 1402 รายการ
|
||||||
|
- **สเปรย์ปรับอากาศ:** 25 รายการ
|
||||||
|
- **น้ำหอมปรับอากาศในรถยนต์:** 36 รายการ
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Held / Unmapped Items (ต้องตัดสินใจเพิ่มเติม - 57 รายการ)
|
||||||
|
|
||||||
|
| ID | ชื่อสินค้า Lazada | Variation | SKU | ราคาเฉลี่ย | สาเหตุที่ Hold |
|
||||||
|
|:---|:---|:---|:---|:---|:---|
|
||||||
|
| 1463 | TheDecember Aroma oil Lemongrass For Ultrasonic Humidifier | สี:ตะไคร้บ้าน | `OZO317` | ฿177.69 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1118 | ก้านไม้กระจายกลิ่น ก้านไม้หวาย สำหรับนำกลิ่น ฟอกสีขาว ไม้กระจายกลิ่นน้ำมันหอม แบ่งขาย สูง 8 นิ้ว ทางร้านมีขนาดเดียว | ก้านไม้หวาย:ขนาด 8 นิ้ว 15 ก้าน | `reed15` | ฿36.71 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 2265 | น้ำหอมเครื่องพ่นไอน้ำ น้ำมันหอมระเหย กลิ่นดอกปีบ สำหรับเตาอโรม่า เครื่องพ่นไอน้ำ | กลิ่น:ดอกปีบ | `3769652085-1652073968741-0` | ฿83.67 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1342 | ก้านไม้กระจายกลิ่น ก้านไม้หวาย สำหรับนำกลิ่น ฟอกสีขาว ไม้กระจายกลิ่นน้ำมันหอม แบ่งขาย สูง 8 นิ้ว ทางร้านมีขนาดเดียว | Variation3:ขนาด 8 นิ้ว 15 ก้าน | `reed15` | ฿50.78 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1546 | ราคาโปร 99 บาท iRelaxShop Aroma Room Spray สเปรย์น้ำหอมปรับอากาศ น้ำหอมปรับอากาศ กลิ่น ตะไคร้บ้าน Lemongrass | Variation3:ดอกแก้ว | `3889863236-1654920750614-10` | ฿100.80 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 1142 | TheDecember น้ำมันหอมระเหย กลิ่นโอเชี่ยน สำหรับเครื่องพ่น น้ำหอมสำหรับเครื่องฟอกอากาศ เครื่องพ่นอโรม่า ออยโอโซน | โทนสี:โอเชี่ยน | `101054522-1618206132775-1` | ฿211.55 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1466 | TheDecember Aroma oil Butterfly Pea For Ultrasonic Humidifier | สี:สีขาว | `OZO309` | ฿179.28 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 2309 | กลิ่นวนิลา ออยโอโซน น้ำมันอโรม่า เหมาะกับทุกโอกาส เพื่มความหอมในบ้าน มีให้เลือกหลายขนาด | กลิ่น:วนิลา | `3769545730-1652073961460-0` | ฿70.72 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1089 | TheDecember Aroma oil Lemongrass For Ultrasonic Humidifier | Color:ตะไคร้บ้าน | `OZO317` | ฿308.40 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 2124 | น้ำหอมเครื่องพ่นไอน้ำ น้ำมันหอมระเหย กลิ่นแคนตาลูป สำหรับเตาอโรม่า เครื่องพ่นไอน้ำ | กลิ่น:แคนตาลูป | `3769564587-1652073965359-0` | ฿71.84 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1191 | TheDecember น้ำมันหอมระเหย สำหรับเครื่องพ่น กลิ่นตะไคร้บ้าน น้ำหอมสำหรับเครื่องฟอกอากาศ เครื่องพ่นอโรม่า ออยโอโซน | โทนสี:ตะไคร้บ้าน | `OZO317` | ฿280.00 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1454 | TheDecember Aroma oil Cantaloupe For Ultrasonic Humidifier Oil Ozone | สี:แคนตาลูป | `101054485-1618207184796-1` | ฿269.00 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1231 | ก้านไม้กระจายกลิ่น ก้านไม้หวาย สำหรับนำกลิ่น ฟอกสีขาว ไม้กระจายกลิ่นน้ำมันหอม แบ่งขาย สูง 8 นิ้ว ทางร้านมีขนาดเดียว | Variation3:ขนาด 8 นิ้ว 10 ก้าน | `reed10` | ฿29.59 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1508 | iRelaxShop น้ำมันหอมระเหย ชาเขียว Green Tea Aromatherapy Oil ขนาด 30 ML อโรม่า ออย Aroma Oil | Color Family:Clear | `ATPO-Greentea-30` | ฿79.99 | ไม่พบชื่อกลิ่นที่ตรงกับ Master Catalog (เช่น ชาเขียว หรือไม่มีระบุกลิ่น) |
|
||||||
|
| 1144 | ก้านไม้กระจายกลิ่น ก้านไม้หวาย สำหรับนำกลิ่น ฟอกสีขาว ไม้กระจายกลิ่นน้ำมันหอม แบ่งขาย สูง 8 นิ้ว ทางร้านมีขนาดเดียว | ก้านไม้หวาย:ขนาด 8 นิ้ว 10 ก้าน | `reed10` | ฿23.84 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1330 | TheDecember Aroma oil Cantaloupe For Ultrasonic Humidifier Oil Ozone | Color Family:แคนตาลูป | `101054485-1618207184796-1` | ฿219.10 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1514 | TheDecember Aroma oil Lemongrass For Ultrasonic Humidifier | Color Family:ตะไคร้บ้าน | `OZO317` | ฿198.00 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 2204 | น้ำหอมเครื่องพ่นไอน้ำ น้ำมันหอมระเหย กลิ่นดอกบัว สำหรับเตาอโรม่า เครื่องพ่นไอน้ำ | กลิ่น:ดอกบัว | `3769533755-1652073969038-0` | ฿86.85 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1743 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์มิ้น Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | กลิ่น:เปเปอร์มิ้น, ขนาด:10 ม.ล | `802108757-1653090692783-0` | ฿33.77 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 1275 | TheDecember Aroma oil Rose For Ultrasonic Humidifier Oil Ozone | สี:กุหลาบชมพู | `101054621-1618205446197-1` | ฿155.43 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 2180 | น้ำหอมเครื่องพ่นไอน้ำ น้ำมันหอมระเหย กลิ่นแคนตาลูป สำหรับเตาอโรม่า เครื่องพ่นไอน้ำ | Variation3:แคนตาลูป | `3769564587-1652073965359-0` | ฿75.59 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1755 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์มิ้น Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | กลิ่น:โรสเมรี่, ขนาด:10 ม.ล | `802108757-1653090692783-55` | ฿31.50 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 1439 | ราคาโปร 99 บาท iRelaxShop Aroma Room Spray สเปรย์น้ำหอมปรับอากาศ น้ำหอมปรับอากาศ กลิ่น ตะไคร้บ้าน Lemongrass | Variation3:แซนด้าวูส | `3889863236-1654920750614-25` | ฿111.60 | พบหมวด 'สเปรย์ปรับอากาศ', กลิ่น 'แซนด้าวูส', ขนาด '25ml' แต่ไม่มี Master Variant ขนาดนี้ในแคตตาล็อก |
|
||||||
|
| 1749 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์มิ้น Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | กลิ่น:ไทยสปา, ขนาด:10 ม.ล | `802108757-1653090692783-15` | ฿32.70 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 2301 | น้ำหอมเครื่องพ่นไอน้ำ น้ำมันหอมระเหย กลิ่นดอกแก้ว สำหรับเตาอโรม่า เครื่องพ่นไอน้ำ | กลิ่น:ดอกแก้ว | `3769539783-1652073963920-0` | ฿97.10 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1784 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์มิ้น Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | กลิ่น:ส้ม, ขนาด:10 ม.ล | `802108757-1653090692783-65` | ฿32.33 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 1753 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์มิ้น Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | กลิ่น:ตะไคร้, ขนาด:10 ม.ล | `802108757-1653090692783-10` | ฿31.75 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 2431 | กลิ่นดอกบัว ออยโอโซน น้ำมันอโรม่า น้ำหอมปรับอากาศ สบายอารมณ์ อย่าลืมใช้คูปอง | กลิ่น:ดอกบัว | `3769533755-1652073969038-0` | ฿95.00 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 948 | Aroma Essential Oil for Humidifier Diffuser and Home Fragrance | ขนาด (Size):(Small) เล็ก 35 ml, กลิ่น (Scent):มะลิ Jasmine | `248993355-1739954808457-9` | ฿92.20 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 2289 | น้ำหอมเครื่องพ่นไอน้ำ น้ำมันหอมระเหย กลิ่นหมากฝรั่ง สำหรับเตาอโรม่า เครื่องพ่นไอน้ำ | กลิ่น:หมากฝรั่ง | `3769520871-1652073967598-0` | ฿92.10 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 2385 | กลิ่นไทยสปา ออยโอโซน น้ำมันอโรม่า เพิ่มความหอม ในรถ ขายดีที่สุด ไม่มีแอลกอฮอล์ | fragrance_family:ไทยสปา | `3769552716-1652073969601-0` | ฿89.12 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1487 | ราคาโปร 99 บาท iRelaxShop Aroma Room Spray สเปรย์น้ำหอมปรับอากาศ น้ำหอมปรับอากาศ กลิ่น ตะไคร้บ้าน Lemongrass | เลือกกลิ่น:ดอกแก้ว | `3889863236-1654920750614-10` | ฿88.08 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 1883 | น้ำมันหอมระเหย ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย สำหรับ เครื่องพ่นไอน้ำ Oil Ozone | โทนสี:โอเชี่ยน | `802288500-1598587105589-14` | ฿79.00 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1882 | น้ำมันหอมระเหย ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย สำหรับ เครื่องพ่นไอน้ำ Oil Ozone | โทนสี:ไม้หอม | `802288500-1598587105589-19` | ฿79.00 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1881 | น้ำมันหอมระเหย ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย สำหรับ เครื่องพ่นไอน้ำ Oil Ozone | โทนสี:ส้ม | `802288500-1598587105589-20` | ฿79.00 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1904 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น ยูคาลิปตัส eucalyptus สำหรับ เครื่องพ่นไอน้ำ Oil Ozone | สี:มะกรูด | `802288500-1598587105589-29` | ฿77.96 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 2276 | น้ำหอมเครื่องพ่นไอน้ำ น้ำมันหอมระเหย กลิ่นดอกปีบ สำหรับเตาอโรม่า เครื่องพ่นไอน้ำ | Variation3:ดอกปีบ | `3769652085-1652073968741-0` | ฿74.60 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1886 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น ยูคาลิปตัส eucalyptus สำหรับ เครื่องพ่นไอน้ำ Oil Ozone | Color Family:แอปเปิ้ลเขียว | `802288500-1598587105589-7` | ฿73.47 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1885 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น ยูคาลิปตัส eucalyptus สำหรับ เครื่องพ่นไอน้ำ Oil Ozone | Color Family:วานิลลา | `802288500-1598587105589-16` | ฿73.47 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1874 | น้ำมันหอมระเหย ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย สำหรับ เครื่องพ่นไอน้ำ Oil Ozone | โทนสี:มะกรูด | `802288500-1598587105589-29` | ฿73.47 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1884 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น ยูคาลิปตัส eucalyptus สำหรับ เครื่องพ่นไอน้ำ Oil Ozone | Color Family:ดอกมะลิ | `802288500-1598587105589-8` | ฿73.47 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 2298 | น้ำหอมเครื่องพ่นไอน้ำ น้ำมันหอมระเหย กลิ่นไทยสปา สำหรับเตาอโรม่า เครื่องพ่นไอน้ำ | กลิ่น:ไทยสปา | `3769552716-1652073969601-0` | ฿72.54 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 2260 | น้ำหอมเครื่องพ่นไอน้ำ น้ำมันหอมระเหย กลิ่นดอกบัว สำหรับเตาอโรม่า เครื่องพ่นไอน้ำ | Variation3:ดอกบัว | `3769533755-1652073969038-0` | ฿71.52 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1703 | iRelax Fragrant Oil Ozone Essential Aromatherapy Oil Aroma Scent Lavender For Aroma Stoves Benefits of Aromatherapy Create Home Atmosphere | กลิ่น:แคนตาลูป | `2874895750-1675057400797-11` | ฿70.22 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1702 | iRelax Fragrant Oil Ozone Essential Aromatherapy Oil Aroma Scent Lavender For Aroma Stoves Benefits of Aromatherapy Create Home Atmosphere | กลิ่น:ซากุระ | `2874895750-1675057400797-10` | ฿70.22 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 1751 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์มิ้น Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | กลิ่น:ลาเวนเดอร์, ขนาด:10 ม.ล | `802108757-1653090692783-45` | ฿31.50 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 1765 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์มิ้น Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | กลิ่น:ยูคาลิปตัส, ขนาด:10 ม.ล | `802108757-1653090692783-5` | ฿30.21 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 1781 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์มิ้น Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | Variation3:เปเปอร์มิ้น, Variation1:10 ม.ล | `802108757-1653090692783-0` | ฿28.81 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 1782 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์มิ้น Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | Variation3:ยูคาลิปตัส, Variation1:10 ม.ล | `802108757-1653090692783-5` | ฿28.81 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 1752 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์มิ้น Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | กลิ่น:ดอกโมก, ขนาด:10 ม.ล | `802108757-1653090692783-30` | ฿28.80 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 2432 | กลิ่นดอกแก้ว ออยโอโซน น้ำมันอโรม่า ลดกลิ่นอับ ในรถ ในบ้าน กดติดตามร้าน ได้ส่วนลด น้ำหอมปรับอากาศ | fragrance_family:ดอกแก้ว | `3769539783-1652073963920-0` | ฿55.96 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU |
|
||||||
|
| 1801 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์มิ้น Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | Variation3:ตะไคร้, Variation1:10 ม.ล | `802108757-1653090692783-10` | ฿36.55 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 1802 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์มิ้น Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | Variation3:ไทยสปา, Variation1:10 ม.ล | `802108757-1653090692783-15` | ฿36.55 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 1803 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์มิ้น Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | Variation3:โอเชี่ยน, Variation1:10 ม.ล | `802108757-1653090692783-20` | ฿36.55 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 1750 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์มิ้น Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | กลิ่น:ดอกซากุระ, ขนาด:10 ม.ล | `802108757-1653090692783-50` | ฿31.50 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 1764 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์มิ้น Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | กลิ่น:โอเชี่ยน, ขนาด:10 ม.ล | `802108757-1653090692783-20` | ฿26.08 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 1817 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์มิ้น Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | Variation3:ส้ม, Variation1:10 ม.ล | `802108757-1653090692783-65` | ฿24.75 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
|
||||||
|
## 2. Sample of Successfully Mapped Items (ตัวอย่างที่เชื่อมโยงสำเร็จ)
|
||||||
|
|
||||||
|
| ID | Lazada SKU | หมวดหมู่ | กลิ่น | ขนาด | Master SKU | Master Variant | BOM Status |
|
||||||
|
|:---|:---|:---|:---|:---|:---|:---|:---|
|
||||||
|
| 1155 | `LMG120ml` | ก้านไม้หอมปรับอากาศ | ตะไคร้ | 120ml | `1-3-120ML` | ตะไคร้ Reed Diffuser (120ml) [1-3-120ML] | รอสร้าง BOM |
|
||||||
|
| 1060 | `101054933-1618197462982-1` | น้ำมันหอมระเหยโอโซน | ตะไคร้ | 120ml | `1-1-120ML` | ตะไคร้ Oil Ozone (120ml) [1-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1205 | `LMG120ml` | ก้านไม้หอมปรับอากาศ | ตะไคร้ | 120ml | `1-3-120ML` | ตะไคร้ Reed Diffuser (120ml) [1-3-120ML] | รอสร้าง BOM |
|
||||||
|
| 1149 | `lemongrass120ml` | น้ำมันหอมระเหยโอโซน | ตะไคร้ | 120ml | `1-1-120ML` | ตะไคร้ Oil Ozone (120ml) [1-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1383 | `101055114-1653027088508-43` | น้ำมันหอมระเหยโอโซน | ลาเวนเดอร์ | 120ml | `2-1-120ML` | ลาเวนเดอร์ Oil Ozone (120ml) [2-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1996 | `120-วานิลลา vanilla-120 ม.ล` | น้ำมันหอมระเหยโอโซน | วานิลลา | 120ml | `3-1-120ML` | วานิลลา Oil Ozone (120ml) [3-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1360 | `101055114-1653027088508-35` | น้ำมันหอมระเหยโอโซน | ตะไคร้ | 120ml | `1-1-120ML` | ตะไคร้ Oil Ozone (120ml) [1-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1354 | `101055114-1653027088508-31` | น้ำมันหอมระเหยโอโซน | มะลิ | 120ml | `5-1-120ML` | มะลิ Oil Ozone (120ml) [5-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1225 | `LVN120` | ก้านไม้หอมปรับอากาศ | ลาเวนเดอร์ | 120ml | `2-3-120ML` | ลาเวนเดอร์ Reed Diffuser (120ml) [2-3-120ML] | รอสร้าง BOM |
|
||||||
|
| 1194 | `มะกรูดก้านไม้-120` | ก้านไม้หอมปรับอากาศ | มะกรูด | 120ml | `25-3-120ML` | มะกรูด Reed Diffuser (120ml) [25-3-120ML] | รอสร้าง BOM |
|
||||||
|
| 1035 | `LMG120ml` | ก้านไม้หอมปรับอากาศ | ตะไคร้ | 120ml | `1-3-120ML` | ตะไคร้ Reed Diffuser (120ml) [1-3-120ML] | รอสร้าง BOM |
|
||||||
|
| 1218 | `lemongrass120ml` | น้ำมันหอมระเหยโอโซน | ตะไคร้ | 120ml | `1-1-120ML` | ตะไคร้ Oil Ozone (120ml) [1-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1221 | `LMG60` | ก้านไม้หอมปรับอากาศ | ตะไคร้ | 60ml | `1-3-60ML` | ตะไคร้ Reed Diffuser (60ml) [1-3-60ML] | รอสร้าง BOM |
|
||||||
|
| 1039 | `120-ซากุระ-120 ml` | น้ำมันหอมระเหยโอโซน | ซากุระ | 120ml | `4-1-120ML` | ซากุระ Oil Ozone (120ml) [4-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1040 | `120-ตะไคร้บ้าน-120 ml` | น้ำมันหอมระเหยโอโซน | ตะไคร้ | 120ml | `1-1-120ML` | ตะไคร้ Oil Ozone (120ml) [1-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1163 | `Sandalwood` | ก้านไม้หอมปรับอากาศ | แซนด้าวูส | 120ml | `26-3-120ML` | แซนด้าวูส Reed Diffuser (120ml) [26-3-120ML] | รอสร้าง BOM |
|
||||||
|
| 1397 | `101055114-1653027088508-23` | น้ำมันหอมระเหยโอโซน | เปเปอร์มิ้น | 120ml | `8-1-120ML` | เปเปอร์มิ้น Oil Ozone (120ml) [8-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1936 | `30-ลาเวนเดอร์ Lavender-30 ม.ล` | น้ำมันหอมระเหยโอโซน | ลาเวนเดอร์ | 30ml | `2-1-30ML` | ลาเวนเดอร์ Oil Ozone (30ml) [2-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1173 | `LVN120` | ก้านไม้หอมปรับอากาศ | ลาเวนเดอร์ | 120ml | `2-3-120ML` | ลาเวนเดอร์ Reed Diffuser (120ml) [2-3-120ML] | รอสร้าง BOM |
|
||||||
|
| 1049 | `LMG120ml` | ก้านไม้หอมปรับอากาศ | ตะไคร้ | 120ml | `1-3-120ML` | ตะไคร้ Reed Diffuser (120ml) [1-3-120ML] | รอสร้าง BOM |
|
||||||
|
| 1877 | `802308238-1618112605393-1` | น้ำมันหอมระเหยโอโซน | ตะไคร้ | 85ml | `1-1-85ML` | ตะไคร้ Oil Ozone (85ml) [1-1-85ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1043 | `101054933-1618197462982-1` | น้ำมันหอมระเหยโอโซน | ตะไคร้ | 120ml | `1-1-120ML` | ตะไคร้ Oil Ozone (120ml) [1-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1941 | `120-ลาเวนเดอร์ Lavender-120 ม.ล` | น้ำมันหอมระเหยโอโซน | ลาเวนเดอร์ | 120ml | `2-1-120ML` | ลาเวนเดอร์ Oil Ozone (120ml) [2-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1361 | `101055114-1653027088508-63` | น้ำมันหอมระเหยโอโซน | ลาเวนเดอร์ | 120ml | `2-1-120ML` | ลาเวนเดอร์ Oil Ozone (120ml) [2-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1222 | `EUC120` | ก้านไม้หอมปรับอากาศ | ยูคาลิปตัส | 120ml | `6-3-120ML` | ยูคาลิปตัส Reed Diffuser (120ml) [6-3-120ML] | รอสร้าง BOM |
|
||||||
|
| 1935 | `30-มะลิ Jasmine-30 ม.ล` | น้ำมันหอมระเหยโอโซน | มะลิ | 30ml | `5-1-30ML` | มะลิ Oil Ozone (30ml) [5-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1948 | `30-ตะไคร้ Lemongrass-30 ม.ล` | น้ำมันหอมระเหยโอโซน | ตะไคร้ | 30ml | `1-1-30ML` | ตะไคร้ Oil Ozone (30ml) [1-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1746 | `SKU003` | น้ำมันหอมระเหยโอโซน | มะลิ | 85ml | `5-1-85ML` | มะลิ Oil Ozone (85ml) [5-1-85ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1386 | `101055114-1653027088508-40` | น้ำมันหอมระเหยโอโซน | ลาเวนเดอร์ | 30ml | `2-1-30ML` | ลาเวนเดอร์ Oil Ozone (30ml) [2-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1334 | `101055114-1653027088508-35` | น้ำมันหอมระเหยโอโซน | ตะไคร้ | 120ml | `1-1-120ML` | ตะไคร้ Oil Ozone (120ml) [1-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1988 | `30-ตะไคร้ Lemongrass-30 ม.ล` | น้ำมันหอมระเหยโอโซน | ตะไคร้ | 30ml | `1-1-30ML` | ตะไคร้ Oil Ozone (30ml) [1-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1303 | `VAN120` | ก้านไม้หอมปรับอากาศ | วานิลลา | 120ml | `3-3-120ML` | วานิลลา Reed Diffuser (120ml) [3-3-120ML] | รอสร้าง BOM |
|
||||||
|
| 1154 | `LMG60` | ก้านไม้หอมปรับอากาศ | ตะไคร้ | 60ml | `1-3-60ML` | ตะไคร้ Reed Diffuser (60ml) [1-3-60ML] | รอสร้าง BOM |
|
||||||
|
| 1376 | `101055114-1653027088508-19` | น้ำมันหอมระเหยโอโซน | ซากุระ | 120ml | `4-1-120ML` | ซากุระ Oil Ozone (120ml) [4-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1162 | `PEP120` | ก้านไม้หอมปรับอากาศ | เปเปอร์มิ้น | 120ml | `8-3-120ML` | เปเปอร์มิ้น Reed Diffuser (120ml) [8-3-120ML] | รอสร้าง BOM |
|
||||||
|
| 1979 | `120-ซากุระ Sakura-120 ม.ล` | น้ำมันหอมระเหยโอโซน | ซากุระ | 120ml | `4-1-120ML` | ซากุระ Oil Ozone (120ml) [4-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1973 | `120-ไทยสปา-120 ม.ล` | น้ำมันหอมระเหยโอโซน | ไทยสปา | 120ml | `30-1-120ML` | ไทยสปา Oil Ozone (120ml) [30-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1390 | `101055114-1653027088508-55` | น้ำมันหอมระเหยโอโซน | ส้ม | 120ml | `14-1-120ML` | ส้ม Oil Ozone (120ml) [14-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1965 | `120-ตะไคร้ Lemongrass-120 ม.ล` | น้ำมันหอมระเหยโอโซน | ตะไคร้ | 120ml | `1-1-120ML` | ตะไคร้ Oil Ozone (120ml) [1-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 1719 | `802108757-1653090692783-4` | น้ำมันหอมระเหยโอโซน | เปเปอร์มิ้น | 120ml | `8-1-120ML` | เปเปอร์มิ้น Oil Ozone (120ml) [8-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| ... | ... | ... | ... | ... | ... | *(แสดง 40 จากทั้งหมด 1594 รายการ)* | ... |
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# รายการสินค้ากลุ่ม Oil Ozone สำหรับกำหนดสูตร BOM (Production)
|
||||||
|
|
||||||
|
> **เป้าหมาย:** กำหนดสูตร BOM อัตราส่วน หัวน้ำหอม 8% + DPG 92% + ขวดและฝา 4 บาท (ทุกขนาด: 30ml, 60ml, 85ml, 120ml)
|
||||||
|
> **อัปเดตล่าสุด:** 18/10/2569 13:05:32
|
||||||
|
> **จำนวนสินค้าทั้งหมด:** 45 รายการ
|
||||||
|
|
||||||
|
## 📦 สรุปสถานะภาพรวม
|
||||||
|
- **ตั้งค่าเสร็จแล้ว (CONFIGURED):** 45 / 45 รายการ (100%)
|
||||||
|
- **ยังไม่ได้ตั้งค่า (MISSING):** 0 รายการ
|
||||||
|
- **ตั้งค่าบางส่วน (PARTIAL):** 0 รายการ
|
||||||
|
- **พบวัตถุดิบหัวน้ำหอมตรงกลิ่น:** 45 / 45 รายการ
|
||||||
|
|
||||||
|
## 📋 ตารางรายละเอียดต้นทุน BOM และอัตรากำไร (Margin)
|
||||||
|
|
||||||
|
| # | ID | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุน 30ml (กำไร%) | ต้นทุน 60ml (กำไร%) | ต้นทุน 85ml (กำไร%) | ต้นทุน 120ml (กำไร%) | สถานะ |
|
||||||
|
| :-: | :-: | :--- | :--- | :-: | :-: | :-: | :-: | :-: |
|
||||||
|
| 1 | **#1** | [ตะไคร้ Oil Ozone](https://admin.homaroma.shop/products/1/bom) | [ID: 41] หัวน้ำหอม กลิ่นตะไคร้ (฿4.5) | ฿18.94 (72.6%) | ฿33.88 (66.1%) | ฿46.33 (61.4%) | ฿63.76 (57.5%) | ✅ พร้อมขาย |
|
||||||
|
| 2 | **#6** | [ลาเวนเดอร์ Oil Ozone](https://admin.homaroma.shop/products/6/bom) | [ID: 15] หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
|
||||||
|
| 3 | **#11** | [วานิลลา Oil Ozone](https://admin.homaroma.shop/products/11/bom) | [ID: 53] หัวน้ำหอม กลิ่นวานิลลา (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 4 | **#16** | [ซากุระ Oil Ozone](https://admin.homaroma.shop/products/16/bom) | [ID: 27] หัวน้ำหอม กลิ่นซากุระ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
|
||||||
|
| 5 | **#21** | [มะลิ Oil Ozone](https://admin.homaroma.shop/products/21/bom) | [ID: 48] หัวน้ำหอม กลิ่นมะลิ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
|
||||||
|
| 6 | **#26** | [ยูคาลิปตัส Oil Ozone](https://admin.homaroma.shop/products/26/bom) | [ID: 49] หัวน้ำหอม กลิ่นยูคาลิปตัส (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 7 | **#31** | [เฟรชลาเวนเดอร์ Oil Ozone](https://admin.homaroma.shop/products/31/bom) | [ID: 15] หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
|
||||||
|
| 8 | **#36** | [เปเปอร์มิ้น Oil Ozone](https://admin.homaroma.shop/products/36/bom) | [ID: 14] หัวน้ำหอม กลิ่นเปเปอร์มิ้น (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 9 | **#41** | [โอเชี่ยน Oil Ozone](https://admin.homaroma.shop/products/41/bom) | [ID: 21] หัวน้ำหอม กลิ่นโอเชี่ยน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 10 | **#46** | [เซเว่นซี Oil Ozone](https://admin.homaroma.shop/products/46/bom) | [ID: 13] หัวน้ำหอม กลิ่นเซเว่นซี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 11 | **#51** | [ดอกแก้ว Oil Ozone](https://admin.homaroma.shop/products/51/bom) | [ID: 28] หัวน้ำหอม กลิ่นดอกแก้ว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 12 | **#56** | [ดอกโมก Oil Ozone](https://admin.homaroma.shop/products/56/bom) | [ID: 29] หัวน้ำหอม กลิ่นดอกโมก (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 13 | **#61** | [ลีลาวดี Oil Ozone](https://admin.homaroma.shop/products/61/bom) | [ID: 51] หัวน้ำหอม กลิ่นลีลาวดี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 14 | **#66** | [ส้ม Oil Ozone](https://admin.homaroma.shop/products/66/bom) | [ID: 54] หัวน้ำหอม กลิ่นส้ม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 15 | **#71** | [มะนาว Oil Ozone](https://admin.homaroma.shop/products/71/bom) | [ID: 46] หัวน้ำหอม กลิ่นมะนาว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 16 | **#76** | [แคนตาลูป Oil Ozone](https://admin.homaroma.shop/products/76/bom) | [ID: 16] หัวน้ำหอม กลิ่นแคนตาลูป (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 17 | **#81** | [แอปเปิ้ลแดง Oil Ozone](https://admin.homaroma.shop/products/81/bom) | [ID: 19] หัวน้ำหอม กลิ่นแอปเปิ้ลแดง (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 18 | **#86** | [แอปเปิ้ลเขียว Oil Ozone](https://admin.homaroma.shop/products/86/bom) | [ID: 18] หัวน้ำหอม กลิ่นแอปเปิ้ลเขียว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 19 | **#91** | [ดอกลิลลี่ Oil Ozone](https://admin.homaroma.shop/products/91/bom) | [ID: 40] หัวน้ำหอม กลิ่นดอกลิลลี่ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 20 | **#96** | [โรสเมรี่ Oil Ozone](https://admin.homaroma.shop/products/96/bom) | [ID: 20] หัวน้ำหอม กลิ่นโรสเมรี่ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 21 | **#101** | [อัญชัน Oil Ozone](https://admin.homaroma.shop/products/101/bom) | [ID: 57] หัวน้ำหอม กลิ่นอัญชัน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 22 | **#106** | [กุหลาบแดง Oil Ozone](https://admin.homaroma.shop/products/106/bom) | [ID: 26] หัวน้ำหอม กลิ่นกุหลาบแดง (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 23 | **#111** | [หมากฝรั่ง Oil Ozone](https://admin.homaroma.shop/products/111/bom) | [ID: 55] หัวน้ำหอม กลิ่นหมากฝรั่ง (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 24 | **#116** | [ผลไม้รวม Oil Ozone](https://admin.homaroma.shop/products/116/bom) | [ID: 44] หัวน้ำหอม กลิ่นผลไม้รวม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 25 | **#121** | [มะกรูด Oil Ozone](https://admin.homaroma.shop/products/121/bom) | [ID: 45] หัวน้ำหอม กลิ่นมะกรูด (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 26 | **#126** | [แซนด้าวูส Oil Ozone](https://admin.homaroma.shop/products/126/bom) | [ID: 17] หัวน้ำหอม กลิ่นแซนด้าวูส (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 27 | **#131** | [ดอกปีบ Oil Ozone](https://admin.homaroma.shop/products/131/bom) | [ID: 34] หัวน้ำหอม กลิ่นดอกปีบ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 28 | **#136** | [ดอกบัว Oil Ozone](https://admin.homaroma.shop/products/136/bom) | [ID: 33] หัวน้ำหอม กลิ่นดอกบัว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 29 | **#141** | [โอโซน Oil Ozone](https://admin.homaroma.shop/products/141/bom) | [ID: 22] หัวน้ำหอม กลิ่นโอโซน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 30 | **#146** | [ไทยสปา Oil Ozone](https://admin.homaroma.shop/products/146/bom) | [ID: 23] หัวน้ำหอม กลิ่นไทยสปา (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 31 | **#151** | [กล้วย Oil Ozone](https://admin.homaroma.shop/products/151/bom) | [ID: 24] หัวน้ำหอม กลิ่นกล้วย (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 32 | **#156** | [กาแฟ Oil Ozone](https://admin.homaroma.shop/products/156/bom) | [ID: 25] หัวน้ำหอม กลิ่นกาแฟ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 33 | **#161** | [ดอกชมนาด Oil Ozone](https://admin.homaroma.shop/products/161/bom) | [ID: 32] หัวน้ำหอม กลิ่นดอกชมนาด (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 34 | **#166** | [กลิ่นน้ำนม Oil Ozone](https://admin.homaroma.shop/products/166/bom) | [ID: 43] หัวน้ำหอม กลิ่นน้ำนม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 35 | **#171** | [ดอกการะเวก Oil Ozone](https://admin.homaroma.shop/products/171/bom) | [ID: 30] หัวน้ำหอม กลิ่นดอกการะเวก (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 36 | **#176** | [ดอกจำปา Oil Ozone](https://admin.homaroma.shop/products/176/bom) | [ID: 31] หัวน้ำหอม กลิ่นดอกจำปา (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 37 | **#181** | [ดอกพิกุล Oil Ozone](https://admin.homaroma.shop/products/181/bom) | [ID: 35] หัวน้ำหอม กลิ่นดอกพิกุล (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 38 | **#186** | [ดอกพุด Oil Ozone](https://admin.homaroma.shop/products/186/bom) | [ID: 36] หัวน้ำหอม กลิ่นดอกพุด (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 39 | **#191** | [ดอกราตรี Oil Ozone](https://admin.homaroma.shop/products/191/bom) | [ID: 38] หัวน้ำหอม กลิ่นดอกราตรี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 40 | **#196** | [ดอกลำดวน Oil Ozone](https://admin.homaroma.shop/products/196/bom) | [ID: 39] หัวน้ำหอม กลิ่นดอกลำดวน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 41 | **#201** | [ทับทิม Oil Ozone](https://admin.homaroma.shop/products/201/bom) | [ID: 42] หัวน้ำหอม กลิ่นทับทิม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 42 | **#206** | [มะละกอสุก Oil Ozone](https://admin.homaroma.shop/products/206/bom) | [ID: 47] หัวน้ำหอม กลิ่นมะละกอสุก (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 43 | **#211** | [ดอกราชาวดี Oil Ozone](https://admin.homaroma.shop/products/211/bom) | [ID: 37] หัวน้ำหอม กลิ่นดอกราชาวดี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 44 | **#216** | [กลิ่นลูกพีช Oil Ozone](https://admin.homaroma.shop/products/216/bom) | [ID: 52] หัวน้ำหอม กลิ่นลูกพีช (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 45 | **#221** | [กลิ่นองุ่น Oil Ozone](https://admin.homaroma.shop/products/221/bom) | [ID: 56] หัวน้ำหอม กลิ่นองุ่น (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
# Prisma 7 — คู่มือการเชื่อมต่อและทำ Seed (ไม่งงอีกต่อไป)
|
# Prisma 7 — คู่มือการเชื่อมต่อและทำ Seed — DBK Platform
|
||||||
|
|
||||||
|
> **Platform engineering guide:** Any HomAroma seed data in this guide is reference-tenant data. Validate the target tenant database and seed strategy before use. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||||
|
|
||||||
> **Platform engineering guide:** Any HomAroma seed data in this guide is reference-tenant data. Validate the target tenant database and seed strategy before use.
|
|
||||||
|
|
||||||
xx
|
|
||||||
> **สำคัญมาก:** Prisma 7 เปลี่ยนวิธีการเชื่อมต่อฐานข้อมูลอย่างพื้นฐาน
|
> **สำคัญมาก:** Prisma 7 เปลี่ยนวิธีการเชื่อมต่อฐานข้อมูลอย่างพื้นฐาน
|
||||||
> ถ้าเคยใช้ Prisma 4, 5, หรือ 6 มาก่อน — ของเก่าพวกนั้น **ใช้ไม่ได้อีกแล้ว**
|
> ถ้าเคยใช้ Prisma 4, 5, หรือ 6 มาก่อน — ของเก่าพวกนั้น **ใช้ไม่ได้อีกแล้ว**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. สิ่งที่เปลี่ยนไปจาก Prisma 6fdss → Prisma 7
|
## 1. สิ่งที่เปลี่ยนไปจาก Prisma 5/6 → Prisma 7
|
||||||
|
|
||||||
| เรื่อง | Prisma 5/6 (เก่า) | Prisma 7 (ใหม่) |
|
| เรื่อง | Prisma 5/6 (เก่า) | Prisma 7 (ใหม่) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **DBK Platform tenant example:** Schema fields are shared platform contracts. The HomAroma brand, aroma products, categories, and copy below are reference-tenant sample data only.
|
> **DBK Platform tenant example:** Schema fields are shared platform contracts. The HomAroma brand, aroma products, categories, and copy below are reference-tenant sample data only.
|
||||||
|
|
||||||
ระบบนำเข้าสินค้าของ HomAroma รองรับการ Import ผ่านไฟล์ `.json` หรือวาง Payload JSON Array ได้โดยตรงผ่าน:
|
ระบบนำเข้าสินค้าของ DBK Platform (ตัวอย่างข้อมูลแคตตาล็อกสินค้าอ้างอิงจาก Reference Tenant: HomAroma) รองรับการ Import ผ่านไฟล์ `.json` หรือวาง Payload JSON Array ได้โดยตรงผ่าน:
|
||||||
1. **หน้าจอ Admin:** เมนู `/products` -> กดปุ่ม **"Import JSON"**
|
1. **หน้าจอ Admin:** เมนู `/products` -> กดปุ่ม **"Import JSON"**
|
||||||
2. **API Endpoint:** `POST /api/products/bulk` หรือ `POST /api/products`
|
2. **API Endpoint:** `POST /api/products/bulk` หรือ `POST /api/products`
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# สรุปรายการสินค้า Product Master ที่ยังไม่มีสูตร BOM (Bill of Materials)
|
||||||
|
|
||||||
|
* **วันที่จัดทำรายงาน:** 2026-10-18T11:26:21.423Z
|
||||||
|
* **ระบบอ้างอิง:** Production Database (`https://api.homaroma.shop/api`)
|
||||||
|
* **จำนวน Product Master Variant ทั้งหมด:** **585** รายการ
|
||||||
|
* **มีสูตร BOM แล้ว:** **585** รายการ (100.0%)
|
||||||
|
* **ยังไม่มีสูตร BOM:** **0** รายการ (0.0%)
|
||||||
|
|
||||||
|
> **ข้อสังเกต:** สินค้าในหมวด **"น้ำมันหอมระเหยโอโซน (Oil Ozone)"** ทั้งหมด 180 รายการ (45 กลิ่น x 4 ขนาด) มีสูตร BOM ครบถ้วน 100% แล้ว ส่วนสินค้าที่ยังไม่มีสูตร BOM มี 4 หมวดหมู่อยู่ด้านล่างนี้
|
||||||
|
|
||||||
|
## 1. ภาพรวมสถานะ BOM แยกตามหมวดหมู่ (Category Overview)
|
||||||
|
|
||||||
|
| หมวดหมู่ (Category) | จำนวนทั้งหมด | มี BOM แล้ว | ยังไม่มี BOM | สถานะความพร้อม |
|
||||||
|
|:---|:---:|:---:|:---:|:---:|
|
||||||
|
| **น้ำมันหอมระเหยอโรม่า** | 180 | 180 | **0** | ✅ ครบ 100% |
|
||||||
|
| **น้ำหอมปรับอากาศในรถยนต์** | 45 | 45 | **0** | ✅ ครบ 100% |
|
||||||
|
| **น้ำมันหอมระเหยโอโซน** | 180 | 180 | **0** | ✅ ครบ 100% |
|
||||||
|
| **ก้านไม้หอมปรับอากาศ** | 135 | 135 | **0** | ✅ ครบ 100% |
|
||||||
|
| **สเปรย์ปรับอากาศ** | 45 | 45 | **0** | ✅ ครบ 100% |
|
||||||
|
| **รวมทั้งหมด** | **585** | **585** | **0** | **100.0%** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. รายละเอียดสินค้าที่ยังไม่มีสูตร BOM แยกตามหมวดหมู่
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. คำแนะนำในการสร้าง BOM ลำดับถัดไป (Next Steps & Recommendations)
|
||||||
|
|
||||||
|
1. **ก้านไม้หอมปรับอากาศ (Reed Diffuser - 135 รายการ):** แนะนำทำเป็นลำดับแรก เนื่องจากมียอดขายและการผูกบน Marketplace (Shopee/Lazada) มากที่สุด วัตถุดิบหลักประกอบด้วย: น้ำหอม Base Reed Diffuser, ก้านไม้หวาย (Diffuser Sticks), ขวดแก้ว, ฝาจุก
|
||||||
|
2. **น้ำหอมปรับอากาศในรถยนต์ (Car Diffuser - 45 รายการ):** มีขนาดเดียวคือ 25ml ประกอบด้วย: หัวเชื้อน้ำหอม, ขวดแขวนฝาไม้, เชือกแขวน, จุกพลาสติกกันรั่ว
|
||||||
|
3. **สเปรย์ปรับอากาศ (Room Spray - 45 รายการ):** มีขนาดเดียวคือ 85ml ประกอบด้วย: เบสสเปรย์ (Alcohol/Water Solution), หัวเชื้อน้ำหอม, ขวดพลาสติก 85ml, หัวฉีดสเปรย์
|
||||||
|
4. **น้ำมันหอมระเหยอโรม่า (Aroma Oil - 179 รายการ):** สูตรจะใกล้เคียงกับ Oil Ozone แต่ใช้ตัวทำละลายต่างกัน (Aroma Oil ใช้สำหรับเตาอโรม่า/Burner ไม่ต้องใช้น้ำมันละลายน้ำ)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# รายงานการกำหนดสูตร BOM สำหรับกลุ่มสินค้าก้านไม้หอมปรับอากาศ (Reed Diffuser)
|
||||||
|
|
||||||
|
* **วันที่กำหนดสูตร:** 2026-10-18T11:26:11.539Z
|
||||||
|
* **ระบบอ้างอิง:** Production Database (`https://api.homaroma.shop/api`)
|
||||||
|
* **วัตถุดิบและสัดส่วนสูตร:**
|
||||||
|
- **สูตรน้ำยา:** หัวน้ำหอม 8% + DPM 92% (ราคา DPM: ฿0.10/ml)
|
||||||
|
- **ขวดพร้อมฝา Oilozone:** 60ml (ID 11: ฿4.00), 85ml (ID 12: ฿4.00), 120ml (ID 9: ฿4.00)
|
||||||
|
- **ก้านไม้กระจายกลิ่น (1 set):** (ID 61: ฿20.00/set)
|
||||||
|
* **จำนวนสินค้าทั้งหมด:** **45** กลิ่น (**135** Variants)
|
||||||
|
* **สถานะความสำเร็จ:** **100% (45/45 สินค้า)**
|
||||||
|
|
||||||
|
## 📋 สรุปต้นทุนมาตรฐานเทียบกลิ่นยอดนิยม
|
||||||
|
|
||||||
|
| ขนาดบรรจุ | สัดส่วนน้ำยา | Fixed Base Cost (DPM + ขวด + ก้านไม้) | ต้นทุน BOM (กลิ่นมาตรฐาน ฿1.20) | ราคาขาย Master | Gross Margin % |
|
||||||
|
| :-: | :--- | :-: | :-: | :-: | :-: |
|
||||||
|
| **60 ml** | หัวน้ำหอม 4.8ml + DPM 55.2ml (฿5.52) | ฿29.52 | **฿35.28** | ฿120.00 | **70.6%** |
|
||||||
|
| **85 ml** | หัวน้ำหอม 6.8ml + DPM 78.2ml (฿7.82) | ฿31.82 | **฿40.00** | ฿160.00 | **75.0%** |
|
||||||
|
| **120 ml** | หัวน้ำหอม 9.6ml + DPM 110.4ml (฿11.04) | ฿35.04 | **฿46.56** | ฿190.00 | **75.5%** |
|
||||||
|
|
||||||
|
## 📋 ตารางรายละเอียดต้นทุน BOM ครบทั้ง 45 กลิ่น
|
||||||
|
|
||||||
|
| # | ID | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุน 60ml (GP%) | ต้นทุน 85ml (GP%) | ต้นทุน 120ml (GP%) |
|
||||||
|
| :-: | :-: | :--- | :--- | :---: | :---: | :---: |
|
||||||
|
| 1 | 3 | **ตะไคร้ Reed Diffuser** | หัวน้ำหอม กลิ่นตะไคร้ (฿4.50) | ฿51.12 (57.4%) | ฿62.42 (61%) | ฿78.24 (58.8%) |
|
||||||
|
| 2 | 8 | **ลาเวนเดอร์ Reed Diffuser** | หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 3 | 13 | **วานิลลา Reed Diffuser** | หัวน้ำหอม กลิ่นวานิลลา (฿1.30) | ฿35.76 (70.2%) | ฿40.66 (74.6%) | ฿47.52 (75%) |
|
||||||
|
| 4 | 18 | **ซากุระ Reed Diffuser** | หัวน้ำหอม กลิ่นซากุระ (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 5 | 23 | **มะลิ Reed Diffuser** | หัวน้ำหอม กลิ่นมะลิ (฿1.00) | ฿34.32 (71.4%) | ฿38.62 (75.9%) | ฿44.64 (76.5%) |
|
||||||
|
| 6 | 28 | **ยูคาลิปตัส Reed Diffuser** | หัวน้ำหอม กลิ่นยูคาลิปตัส (฿2.10) | ฿39.60 (67%) | ฿46.10 (71.2%) | ฿55.20 (70.9%) |
|
||||||
|
| 7 | 33 | **เฟรชลาเวนเดอร์ Reed Diffuser** | หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 8 | 38 | **เปเปอร์มิ้น Reed Diffuser** | หัวน้ำหอม กลิ่นเปเปอร์มิ้น (฿1.40) | ฿36.24 (69.8%) | ฿41.34 (74.2%) | ฿48.48 (74.5%) |
|
||||||
|
| 9 | 43 | **โอเชี่ยน Reed Diffuser** | หัวน้ำหอม กลิ่นโอเชี่ยน (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 10 | 48 | **เซเว่นซี Reed Diffuser** | หัวน้ำหอม กลิ่นเซเว่นซี (฿1.70) | ฿37.68 (68.6%) | ฿43.38 (72.9%) | ฿51.36 (73%) |
|
||||||
|
| 11 | 53 | **ดอกแก้ว Reed Diffuser** | หัวน้ำหอม กลิ่นดอกแก้ว (฿1.40) | ฿36.24 (69.8%) | ฿41.34 (74.2%) | ฿48.48 (74.5%) |
|
||||||
|
| 12 | 58 | **ดอกโมก Reed Diffuser** | หัวน้ำหอม กลิ่นดอกโมก (฿1.50) | ฿36.72 (69.4%) | ฿42.02 (73.7%) | ฿49.44 (74%) |
|
||||||
|
| 13 | 63 | **ลีลาวดี Reed Diffuser** | หัวน้ำหอม กลิ่นลีลาวดี (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 14 | 68 | **ส้ม Reed Diffuser** | หัวน้ำหอม กลิ่นส้ม (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 15 | 73 | **มะนาว Reed Diffuser** | หัวน้ำหอม กลิ่นมะนาว (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 16 | 78 | **แคนตาลูป Reed Diffuser** | หัวน้ำหอม กลิ่นแคนตาลูป (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 17 | 83 | **แอปเปิ้ลแดง Reed Diffuser** | หัวน้ำหอม กลิ่นแอปเปิ้ลแดง (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 18 | 88 | **แอปเปิ้ลเขียว Reed Diffuser** | หัวน้ำหอม กลิ่นแอปเปิ้ลเขียว (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 19 | 93 | **ดอกลิลลี่ Reed Diffuser** | หัวน้ำหอม กลิ่นดอกลิลลี่ (฿1.50) | ฿36.72 (69.4%) | ฿42.02 (73.7%) | ฿49.44 (74%) |
|
||||||
|
| 20 | 98 | **โรสเมรี่ Reed Diffuser** | หัวน้ำหอม กลิ่นโรสเมรี่ (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 21 | 103 | **อัญชัน Reed Diffuser** | หัวน้ำหอม กลิ่นอัญชัน (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 22 | 108 | **กุหลาบแดง Reed Diffuser** | หัวน้ำหอม กลิ่นกุหลาบแดง (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 23 | 113 | **หมากฝรั่ง Reed Diffuser** | หัวน้ำหอม กลิ่นหมากฝรั่ง (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 24 | 118 | **ผลไม้รวม Reed Diffuser** | หัวน้ำหอม กลิ่นผลไม้รวม (฿1.40) | ฿36.24 (69.8%) | ฿41.34 (74.2%) | ฿48.48 (74.5%) |
|
||||||
|
| 25 | 123 | **มะกรูด Reed Diffuser** | หัวน้ำหอม กลิ่นมะกรูด (฿1.50) | ฿36.72 (69.4%) | ฿42.02 (73.7%) | ฿49.44 (74%) |
|
||||||
|
| 26 | 128 | **แซนด้าวูส Reed Diffuser** | หัวน้ำหอม กลิ่นแซนด้าวูส (฿1.00) | ฿34.32 (71.4%) | ฿38.62 (75.9%) | ฿44.64 (76.5%) |
|
||||||
|
| 27 | 133 | **ดอกปีบ Reed Diffuser** | หัวน้ำหอม กลิ่นดอกปีบ (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 28 | 138 | **ดอกบัว Reed Diffuser** | หัวน้ำหอม กลิ่นดอกบัว (฿0.70) | ฿32.88 (72.6%) | ฿36.58 (77.1%) | ฿41.76 (78%) |
|
||||||
|
| 29 | 143 | **โอโซน Reed Diffuser** | หัวน้ำหอม กลิ่นโอโซน (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 30 | 148 | **ไทยสปา Reed Diffuser** | หัวน้ำหอม กลิ่นไทยสปา (฿1.50) | ฿36.72 (69.4%) | ฿42.02 (73.7%) | ฿49.44 (74%) |
|
||||||
|
| 31 | 153 | **กล้วย Reed Diffuser** | หัวน้ำหอม กลิ่นกล้วย (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 32 | 158 | **กาแฟ Reed Diffuser** | หัวน้ำหอม กลิ่นกาแฟ (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 33 | 163 | **ดอกชมนาด Reed Diffuser** | หัวน้ำหอม กลิ่นดอกชมนาด (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 34 | 168 | **กลิ่นน้ำนม Reed Diffuser** | หัวน้ำหอม กลิ่นน้ำนม (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 35 | 173 | **ดอกการะเวก Reed Diffuser** | หัวน้ำหอม กลิ่นดอกการะเวก (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 36 | 178 | **ดอกจำปา Reed Diffuser** | หัวน้ำหอม กลิ่นดอกจำปา (฿1.50) | ฿36.72 (69.4%) | ฿42.02 (73.7%) | ฿49.44 (74%) |
|
||||||
|
| 37 | 183 | **ดอกพิกุล Reed Diffuser** | หัวน้ำหอม กลิ่นดอกพิกุล (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 38 | 188 | **ดอกพุด Reed Diffuser** | หัวน้ำหอม กลิ่นดอกพุด (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 39 | 193 | **ดอกราตรี Reed Diffuser** | หัวน้ำหอม กลิ่นดอกราตรี (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 40 | 198 | **ดอกลำดวน Reed Diffuser** | หัวน้ำหอม กลิ่นดอกลำดวน (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 41 | 203 | **ทับทิม Reed Diffuser** | หัวน้ำหอม กลิ่นทับทิม (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 42 | 208 | **มะละกอสุก Reed Diffuser** | หัวน้ำหอม กลิ่นมะละกอสุก (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 43 | 213 | **ดอกราชาวดี Reed Diffuser** | หัวน้ำหอม กลิ่นดอกราชาวดี (฿1.30) | ฿35.76 (70.2%) | ฿40.66 (74.6%) | ฿47.52 (75%) |
|
||||||
|
| 44 | 218 | **กลิ่นลูกพีช Reed Diffuser** | หัวน้ำหอม กลิ่นลูกพีช (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
|
| 45 | 223 | **กลิ่นองุ่น Reed Diffuser** | หัวน้ำหอม กลิ่นองุ่น (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) |
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# รายงานการกำหนดสูตร BOM สำหรับกลุ่มสินค้า Room Spray (Production)
|
||||||
|
|
||||||
|
* **วันที่กำหนดสูตร:** 2026-10-18T11:02:00.809Z
|
||||||
|
* **ระบบอ้างอิง:** Production Database (`https://api.homaroma.shop/api`)
|
||||||
|
* **ขนาดบรรจุ:** 85 ml (ขวดแก้วหัว Spray)
|
||||||
|
* **สัดส่วนสูตร:** **หัวน้ำหอม 8% (6.8 ml) + DPM 92% (78.2 ml) + ขวดแก้วหัว Spray ขนาด 85 ml (฿25.00)**
|
||||||
|
* **จำนวนสินค้าทั้งหมด:** **45** กลิ่น (**45** Variants)
|
||||||
|
* **สถานะความสำเร็จ:** **100% (45/45 สินค้า)**
|
||||||
|
|
||||||
|
## 📋 ตารางรายละเอียดต้นทุน BOM และอัตรากำไร (Margin) ทั้ง 45 กลิ่น
|
||||||
|
|
||||||
|
| # | ID | Master SKU | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุนน้ำยา (85ml) | ต้นทุนขวดแก้ว | ต้นทุนรวม BOM | ราคาขาย Master | กำไรขั้นต้น (GP) | GP Margin % |
|
||||||
|
| :-: | :-: | :--- | :--- | :--- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
|
| 1 | 4 | `1-4-85ML` | **ตะไคร้ Room Spray** | หัวน้ำหอม กลิ่นตะไคร้ (฿4.50) | ฿38.42 | ฿25.00 | **฿63.42** | ฿150.00 | +฿86.58 | **57.7%** |
|
||||||
|
| 2 | 9 | `2-4-85ML` | **ลาเวนเดอร์ Room Spray** | หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 3 | 14 | `3-4-85ML` | **วานิลลา Room Spray** | หัวน้ำหอม กลิ่นวานิลลา (฿1.30) | ฿16.66 | ฿25.00 | **฿41.66** | ฿150.00 | +฿108.34 | **72.2%** |
|
||||||
|
| 4 | 19 | `4-4-85ML` | **ซากุระ Room Spray** | หัวน้ำหอม กลิ่นซากุระ (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 5 | 24 | `5-4-85ML` | **มะลิ Room Spray** | หัวน้ำหอม กลิ่นมะลิ (฿1.00) | ฿14.62 | ฿25.00 | **฿39.62** | ฿150.00 | +฿110.38 | **73.6%** |
|
||||||
|
| 6 | 29 | `6-4-85ML` | **ยูคาลิปตัส Room Spray** | หัวน้ำหอม กลิ่นยูคาลิปตัส (฿2.10) | ฿22.10 | ฿25.00 | **฿47.10** | ฿150.00 | +฿102.90 | **68.6%** |
|
||||||
|
| 7 | 34 | `7-4-85ML` | **เฟรชลาเวนเดอร์ Room Spray** | หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 8 | 39 | `8-4-85ML` | **เปเปอร์มิ้น Room Spray** | หัวน้ำหอม กลิ่นเปเปอร์มิ้น (฿1.40) | ฿17.34 | ฿25.00 | **฿42.34** | ฿150.00 | +฿107.66 | **71.8%** |
|
||||||
|
| 9 | 44 | `9-4-85ML` | **โอเชี่ยน Room Spray** | หัวน้ำหอม กลิ่นโอเชี่ยน (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 10 | 49 | `10-4-85ML` | **เซเว่นซี Room Spray** | หัวน้ำหอม กลิ่นเซเว่นซี (฿1.70) | ฿19.38 | ฿25.00 | **฿44.38** | ฿150.00 | +฿105.62 | **70.4%** |
|
||||||
|
| 11 | 54 | `11-4-85ML` | **ดอกแก้ว Room Spray** | หัวน้ำหอม กลิ่นดอกแก้ว (฿1.40) | ฿17.34 | ฿25.00 | **฿42.34** | ฿150.00 | +฿107.66 | **71.8%** |
|
||||||
|
| 12 | 59 | `12-4-85ML` | **ดอกโมก Room Spray** | หัวน้ำหอม กลิ่นดอกโมก (฿1.50) | ฿18.02 | ฿25.00 | **฿43.02** | ฿150.00 | +฿106.98 | **71.3%** |
|
||||||
|
| 13 | 64 | `13-4-85ML` | **ลีลาวดี Room Spray** | หัวน้ำหอม กลิ่นลีลาวดี (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 14 | 69 | `14-4-85ML` | **ส้ม Room Spray** | หัวน้ำหอม กลิ่นส้ม (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 15 | 74 | `15-4-85ML` | **มะนาว Room Spray** | หัวน้ำหอม กลิ่นมะนาว (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 16 | 79 | `16-4-85ML` | **แคนตาลูป Room Spray** | หัวน้ำหอม กลิ่นแคนตาลูป (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 17 | 84 | `17-4-85ML` | **แอปเปิ้ลแดง Room Spray** | หัวน้ำหอม กลิ่นแอปเปิ้ลแดง (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 18 | 89 | `18-4-85ML` | **แอปเปิ้ลเขียว Room Spray** | หัวน้ำหอม กลิ่นแอปเปิ้ลเขียว (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 19 | 94 | `19-4-85ML` | **ดอกลิลลี่ Room Spray** | หัวน้ำหอม กลิ่นดอกลิลลี่ (฿1.50) | ฿18.02 | ฿25.00 | **฿43.02** | ฿150.00 | +฿106.98 | **71.3%** |
|
||||||
|
| 20 | 99 | `20-4-85ML` | **โรสเมรี่ Room Spray** | หัวน้ำหอม กลิ่นโรสเมรี่ (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 21 | 104 | `21-4-85ML` | **อัญชัน Room Spray** | หัวน้ำหอม กลิ่นอัญชัน (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 22 | 109 | `22-4-85ML` | **กุหลาบแดง Room Spray** | หัวน้ำหอม กลิ่นกุหลาบแดง (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 23 | 114 | `23-4-85ML` | **หมากฝรั่ง Room Spray** | หัวน้ำหอม กลิ่นหมากฝรั่ง (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 24 | 119 | `24-4-85ML` | **ผลไม้รวม Room Spray** | หัวน้ำหอม กลิ่นผลไม้รวม (฿1.40) | ฿17.34 | ฿25.00 | **฿42.34** | ฿150.00 | +฿107.66 | **71.8%** |
|
||||||
|
| 25 | 124 | `25-4-85ML` | **มะกรูด Room Spray** | หัวน้ำหอม กลิ่นมะกรูด (฿1.50) | ฿18.02 | ฿25.00 | **฿43.02** | ฿150.00 | +฿106.98 | **71.3%** |
|
||||||
|
| 26 | 129 | `26-4-85ML` | **แซนด้าวูส Room Spray** | หัวน้ำหอม กลิ่นแซนด้าวูส (฿1.00) | ฿14.62 | ฿25.00 | **฿39.62** | ฿150.00 | +฿110.38 | **73.6%** |
|
||||||
|
| 27 | 134 | `27-4-85ML` | **ดอกปีบ Room Spray** | หัวน้ำหอม กลิ่นดอกปีบ (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 28 | 139 | `28-4-85ML` | **ดอกบัว Room Spray** | หัวน้ำหอม กลิ่นดอกบัว (฿0.70) | ฿12.58 | ฿25.00 | **฿37.58** | ฿150.00 | +฿112.42 | **74.9%** |
|
||||||
|
| 29 | 144 | `29-4-85ML` | **โอโซน Room Spray** | หัวน้ำหอม กลิ่นโอโซน (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 30 | 149 | `30-4-85ML` | **ไทยสปา Room Spray** | หัวน้ำหอม กลิ่นไทยสปา (฿1.50) | ฿18.02 | ฿25.00 | **฿43.02** | ฿150.00 | +฿106.98 | **71.3%** |
|
||||||
|
| 31 | 154 | `31-4-85ML` | **กล้วย Room Spray** | หัวน้ำหอม กลิ่นกล้วย (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 32 | 159 | `32-4-85ML` | **กาแฟ Room Spray** | หัวน้ำหอม กลิ่นกาแฟ (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 33 | 164 | `33-4-85ML` | **ดอกชมนาด Room Spray** | หัวน้ำหอม กลิ่นดอกชมนาด (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 34 | 169 | `34-4-85ML` | **กลิ่นน้ำนม Room Spray** | หัวน้ำหอม กลิ่นน้ำนม (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 35 | 174 | `35-4-85ML` | **ดอกการะเวก Room Spray** | หัวน้ำหอม กลิ่นดอกการะเวก (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 36 | 179 | `36-4-85ML` | **ดอกจำปา Room Spray** | หัวน้ำหอม กลิ่นดอกจำปา (฿1.50) | ฿18.02 | ฿25.00 | **฿43.02** | ฿150.00 | +฿106.98 | **71.3%** |
|
||||||
|
| 37 | 184 | `37-4-85ML` | **ดอกพิกุล Room Spray** | หัวน้ำหอม กลิ่นดอกพิกุล (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 38 | 189 | `38-4-85ML` | **ดอกพุด Room Spray** | หัวน้ำหอม กลิ่นดอกพุด (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 39 | 194 | `39-4-85ML` | **ดอกราตรี Room Spray** | หัวน้ำหอม กลิ่นดอกราตรี (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 40 | 199 | `40-4-85ML` | **ดอกลำดวน Room Spray** | หัวน้ำหอม กลิ่นดอกลำดวน (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 41 | 204 | `41-4-85ML` | **ทับทิม Room Spray** | หัวน้ำหอม กลิ่นทับทิม (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 42 | 209 | `42-4-85ML` | **มะละกอสุก Room Spray** | หัวน้ำหอม กลิ่นมะละกอสุก (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 43 | 214 | `43-4-85ML` | **ดอกราชาวดี Room Spray** | หัวน้ำหอม กลิ่นดอกราชาวดี (฿1.30) | ฿16.66 | ฿25.00 | **฿41.66** | ฿150.00 | +฿108.34 | **72.2%** |
|
||||||
|
| 44 | 219 | `44-4-85ML` | **กลิ่นลูกพีช Room Spray** | หัวน้ำหอม กลิ่นลูกพีช (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
|
| 45 | 224 | `45-4-85ML` | **กลิ่นองุ่น Room Spray** | หัวน้ำหอม กลิ่นองุ่น (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** |
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Shopee Marketplace Product Matching & Mapping Ledger
|
||||||
|
|
||||||
|
* **Generated At:** 2026-10-18T07:17:25.420Z
|
||||||
|
* **Target Server:** `https://api.homaroma.shop/api` (Production)
|
||||||
|
* **Total Shopee Products:** 253
|
||||||
|
* **Successfully Mapped:** 217 (85.8%)
|
||||||
|
* **Held / Requires Decision:** 36 (14.2%)
|
||||||
|
|
||||||
|
## 1. Held / Unmapped Items (ต้องตัดสินใจเพิ่มเติม)
|
||||||
|
|
||||||
|
| ID | ชื่อสินค้า Shopee | Variation | ราคาเฉลี่ย | สาเหตุที่ Hold |
|
||||||
|
|:---|:---|:---|:---|:---|
|
||||||
|
| 619 | น้ำมันหอมระเหย สำหรับ เครื่องพ่นไอน้ำ เครื่องพ่นอโรม่า ขนาด 30 ml | เมลอน ญี่ปุ่น | ฿77.13 | ไม่พบชื่อกลิ่นที่ตรงกับ Master Catalog (เช่น เมลอน หรือกลิ่นที่ยังไม่มีในระบบ) |
|
||||||
|
| 649 | น้ำมันหอมระเหย สำหรับ เครื่องพ่นไอน้ำ เครื่องพ่นอโรม่า ขนาด 30 ml | เมลอน ญี่ปุ่น,30 ml | ฿89.00 | ไม่พบชื่อกลิ่นที่ตรงกับ Master Catalog (เช่น เมลอน หรือกลิ่นที่ยังไม่มีในระบบ) |
|
||||||
|
| 651 | น้ำมันหอมระเหย สำหรับ เครื่องพ่นไอน้ำ เครื่องพ่นอโรม่า ขนาด 30 ml | เมลอน ญี่ปุ่น,120 ml | ฿190.00 | ไม่พบชื่อกลิ่นที่ตรงกับ Master Catalog (เช่น เมลอน หรือกลิ่นที่ยังไม่มีในระบบ) |
|
||||||
|
| 804 | น้ำมันหอมระเหย กลิ่นดอกโมก สำหรับเครื่องพ่นไอน้ำ ช่วยปรับอากาศ | - | ฿180.00 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้าหรือ Variation |
|
||||||
|
| 664 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ไทยสปา | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 698 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ซากุระ | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 767 | HOM AROMA กลิ่นลาเวนเดอร์ lavender น้ำมันหอมระหย ออยโอโซน สำหรับเครื่องพ่น เครื่องพ่นไอน้ำ | - | ฿88.00 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้าหรือ Variation |
|
||||||
|
| 692 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ยูคาลิปตัส | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 687 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | แคนตาลูป | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 730 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ลาเวนเดอร์ | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 705 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | แซนด้าวูด | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 704 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | เฟรชลาเวนเดอร์ | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 691 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | โอเชี่ยน | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 693 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | โรสเมรี่ | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 686 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | เมล่อนญี่ปุ่น | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 761 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | แอปเปิ้ลแดง | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 741 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | เปเปอร์มิ้นต์ | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 760 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | มะกรูด | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 756 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | มะลิ | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 755 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ตะไตร้ | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 759 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | เซเว่นซี | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 754 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | กุหลาบชมพู | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 753 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ดอกปีบ | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 752 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | มะนาว | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 751 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ส้ม | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 758 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ดอกแก้ว | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 757 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ดอกอัญชัน | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 750 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ลิลลี่ | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 749 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | วนิลลา | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 747 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | กุหลาบแดง | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 748 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ผลไม้รวม | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 746 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | แอปเปิ้ลเขียว | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 745 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | หมากฝรั่ง | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 744 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ดอกโมก | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 743 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ลีลาวดี | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
| 742 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ดอกบัว | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
|
||||||
|
|
||||||
|
## 2. Sample of Successfully Mapped Items (ตัวอย่างที่เชื่อมโยงสำเร็จ)
|
||||||
|
|
||||||
|
| ID | Shopee SKU | กลิ่น | ขนาด | Master SKU | Master Variant | มี BOM หรือยัง |
|
||||||
|
|:---|:---|:---|:---|:---|:---|:---|
|
||||||
|
| 563 | M1015 | เปเปอร์มิ้น | 120ml | `8-1-120ML` | เปเปอร์มิ้น Oil Ozone (120ml) [8-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 575 | M1002 | ตะไคร้ | 120ml | `1-1-120ML` | ตะไคร้ Oil Ozone (120ml) [1-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 601 | 30-ml- | ลาเวนเดอร์ | 30ml | `2-1-30ML` | ลาเวนเดอร์ Oil Ozone (30ml) [2-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 589 | 30-ml- | ยูคาลิปตัส | 30ml | `6-1-30ML` | ยูคาลิปตัส Oil Ozone (30ml) [6-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 572 | M1015 | เปเปอร์มิ้น | 30ml | `8-1-30ML` | เปเปอร์มิ้น Oil Ozone (30ml) [8-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 591 | 30-ml- | โอเชี่ยน | 30ml | `9-1-30ML` | โอเชี่ยน Oil Ozone (30ml) [9-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 608 | 30-ml- | มะลิ | 30ml | `5-1-30ML` | มะลิ Oil Ozone (30ml) [5-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 627 | M1001 | ลาเวนเดอร์ | 120ml | `2-1-120ML` | ลาเวนเดอร์ Oil Ozone (120ml) [2-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 590 | 30-ml- | ซากุระ | 30ml | `4-1-30ML` | ซากุระ Oil Ozone (30ml) [4-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 559 | M1005 | โอโซน | 120ml | `29-1-120ML` | โอโซน Oil Ozone (120ml) [29-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 611 | 30-ml- | ตะไคร้ | 30ml | `1-1-30ML` | ตะไคร้ Oil Ozone (30ml) [1-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 557 | M1015 | เปเปอร์มิ้น | 60ml | `8-1-60ML` | เปเปอร์มิ้น Oil Ozone (60ml) [8-1-60ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 604 | 30-ml- | เปเปอร์มิ้น | 30ml | `8-1-30ML` | เปเปอร์มิ้น Oil Ozone (30ml) [8-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 607 | 30-ml- | ลาเวนเดอร์ | 30ml | `2-1-30ML` | ลาเวนเดอร์ Oil Ozone (30ml) [2-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 606 | 30-ml- | กุหลาบแดง | 30ml | `22-1-30ML` | กุหลาบแดง Oil Ozone (30ml) [22-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 602 | 30-ml- | ดอกโมก | 30ml | `12-1-30ML` | ดอกโมก Oil Ozone (30ml) [12-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 636 | M1015 | เปเปอร์มิ้น | 85ml | `8-1-85ML` | เปเปอร์มิ้น Oil Ozone (85ml) [8-1-85ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 612 | 30-ml- | ผลไม้รวม | 30ml | `24-1-30ML` | ผลไม้รวม Oil Ozone (30ml) [24-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 620 | 30-ml- | แคนตาลูป | 30ml | `16-1-30ML` | แคนตาลูป Oil Ozone (30ml) [16-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 603 | M1004 | มะลิ | 120ml | `5-1-120ML` | มะลิ Oil Ozone (120ml) [5-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 249 | M1001 | ลาเวนเดอร์ | 120ml | `2-1-120ML` | ลาเวนเดอร์ Oil Ozone (120ml) [2-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 615 | 30-ml- | วานิลลา | 30ml | `3-1-30ML` | วานิลลา Oil Ozone (30ml) [3-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 558 | M1003 | โอโซน | 120ml | `29-1-120ML` | โอโซน Oil Ozone (120ml) [29-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 616 | 30-ml- | ดอกแก้ว | 30ml | `11-1-30ML` | ดอกแก้ว Oil Ozone (30ml) [11-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 678 | 1652052234 | โอโซน | 120ml | `29-1-120ML` | โอโซน Oil Ozone (120ml) [29-1-120ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 625 | 30-ml- | ดอกราตรี | 30ml | `39-1-30ML` | ดอกราตรี Oil Ozone (30ml) [39-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 626 | 30-ml- | กลิ่นองุ่น | 30ml | `45-1-30ML` | กลิ่นองุ่น Oil Ozone (30ml) [45-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 734 | 1652058083 | โอโซน | 60ml | `29-1-60ML` | โอโซน Oil Ozone (60ml) [29-1-60ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 622 | 30-ml- | แอปเปิ้ลเขียว | 30ml | `18-1-30ML` | แอปเปิ้ลเขียว Oil Ozone (30ml) [18-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| 610 | 30-ml- | มะนาว | 30ml | `15-1-30ML` | มะนาว Oil Ozone (30ml) [15-1-30ML] | ✓ มี BOM แล้ว |
|
||||||
|
| ... | ... | ... | ... | ... | *(แสดง 30 จากทั้งหมด 217 รายการ)* | ... |
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# สรุปปัญหา ข้อผิดพลาด และ Checklist การสร้าง Tenant / Subdomain ใหม่
|
# สรุปปัญหา ข้อผิดพลาด และ Checklist การสร้าง Tenant / Subdomain ใหม่
|
||||||
|
|
||||||
> **Historical operational record:** This postmortem preserves the facts of a 2026 tenant-provisioning incident. Existing HomAroma identifiers are legacy infrastructure references, not DBK Platform branding.
|
> **Historical operational record:** This postmortem preserves the facts of a 2026 tenant-provisioning incident. Existing HomAroma identifiers are legacy infrastructure references, not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||||
(Tenant Provisioning Post-Mortem & Standard Operating Procedure)
|
(Tenant Provisioning Post-Mortem & Standard Operating Procedure)
|
||||||
|
|
||||||
บันทึกข้อผิดพลาดจากการสร้าง Showcase Subdomains (`wellness`, `fashion`, `beauty`, `moto`) บน `kittisak.com` เมื่อวันที่ 24 กันยายน 2026 เพื่อใช้เป็น Checklist ป้องกันการทำผิดพลาดซ้ำในอนาคต
|
บันทึกข้อผิดพลาดจากการสร้าง Showcase Subdomains (`wellness`, `fashion`, `beauty`, `moto`) บน `kittisak.com` เมื่อวันที่ 24 กันยายน 2026 เพื่อใช้เป็น Checklist ป้องกันการทำผิดพลาดซ้ำในอนาคต
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import PageBuilderPage from './pages/PageBuilderPage'
|
|||||||
import PageReorderPage from './pages/PageReorderPage'
|
import PageReorderPage from './pages/PageReorderPage'
|
||||||
import CmsMockupPage from './pages/CmsMockupPage'
|
import CmsMockupPage from './pages/CmsMockupPage'
|
||||||
import RawMaterialsPage from './pages/RawMaterialsPage'
|
import RawMaterialsPage from './pages/RawMaterialsPage'
|
||||||
|
import RawMaterialDetailPage from './pages/RawMaterialDetailPage'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import InstallSeedPage from './pages/InstallSeedPage'
|
import InstallSeedPage from './pages/InstallSeedPage'
|
||||||
import ProtectedRoute from './components/ProtectedRoute'
|
import ProtectedRoute from './components/ProtectedRoute'
|
||||||
@@ -78,6 +79,9 @@ import MarketplaceCrmPage from './pages/crm/MarketplaceCrmPage'
|
|||||||
import MarketplaceCustomerDetailPage from './pages/crm/MarketplaceCustomerDetailPage'
|
import MarketplaceCustomerDetailPage from './pages/crm/MarketplaceCustomerDetailPage'
|
||||||
import PosPage from './pages/PosPage'
|
import PosPage from './pages/PosPage'
|
||||||
import UserLlmPromptsPage from './pages/UserLlmPromptsPage'
|
import UserLlmPromptsPage from './pages/UserLlmPromptsPage'
|
||||||
|
import CostProfitabilityMatrixPage from './pages/profitability/CostProfitabilityMatrixPage'
|
||||||
|
import OmnichannelMarginPage from './pages/profitability/OmnichannelMarginPage'
|
||||||
|
import MrpPlanningPage from './pages/profitability/MrpPlanningPage'
|
||||||
import { StoreSettingsProvider } from './contexts/StoreSettingsContext'
|
import { StoreSettingsProvider } from './contexts/StoreSettingsContext'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -123,6 +127,11 @@ function App() {
|
|||||||
<Route path="/promotions" element={<PromotionsPage />} />
|
<Route path="/promotions" element={<PromotionsPage />} />
|
||||||
<Route path="/coupons" element={<CouponsPage />} />
|
<Route path="/coupons" element={<CouponsPage />} />
|
||||||
<Route path="/raw-materials" element={<RawMaterialsPage />} />
|
<Route path="/raw-materials" element={<RawMaterialsPage />} />
|
||||||
|
<Route path="/raw-materials/new" element={<RawMaterialDetailPage />} />
|
||||||
|
<Route path="/raw-materials/:id" element={<RawMaterialDetailPage />} />
|
||||||
|
<Route path="/profitability" element={<CostProfitabilityMatrixPage />} />
|
||||||
|
<Route path="/omnichannel-margin" element={<OmnichannelMarginPage />} />
|
||||||
|
<Route path="/mrp-planning" element={<MrpPlanningPage />} />
|
||||||
<Route path="/settings" element={<StoreSettingsPage />} />
|
<Route path="/settings" element={<StoreSettingsPage />} />
|
||||||
<Route path="/settings/brand-intelligence" element={<BrandIntelligencePage />} />
|
<Route path="/settings/brand-intelligence" element={<BrandIntelligencePage />} />
|
||||||
<Route path="/brand-intelligence" element={<BrandIntelligencePage />} />
|
<Route path="/brand-intelligence" element={<BrandIntelligencePage />} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { API_BASE } from '@homaroma/types';
|
import { API_BASE } from '@homaroma/types';
|
||||||
import { X, Upload, Loader2, Image as ImageIcon, Video } from 'lucide-react';
|
import { X, Upload, Loader2, Image as ImageIcon, Video } from 'lucide-react';
|
||||||
@@ -105,7 +106,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect, mediaType
|
|||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||||
<div className="bg-night-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-5xl h-[85vh] flex flex-col overflow-hidden animate-fade-in relative">
|
<div className="bg-night-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-5xl h-[85vh] flex flex-col overflow-hidden animate-fade-in relative">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -196,6 +197,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect, mediaType
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import {
|
import {
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Copy,
|
Copy,
|
||||||
@@ -35,6 +36,8 @@ const GUIDE_OVERVIEW_MARKDOWN = `# คู่มือการใช้ AI/LLM
|
|||||||
### 1. Root Items (เมนูแถวบนสุด - Level 1)
|
### 1. Root Items (เมนูแถวบนสุด - Level 1)
|
||||||
- **label**: ออบเจกต์ข้อความรองรับ 8 ภาษา \`{ th, en, ja, zh, vi, ko, lo, it }\`
|
- **label**: ออบเจกต์ข้อความรองรับ 8 ภาษา \`{ th, en, ja, zh, vi, ko, lo, it }\`
|
||||||
- **url**: ลิงก์ปลายทาง เช่น \`/category/reed-diffuser\` หรือ \`/collections/all\`
|
- **url**: ลิงก์ปลายทาง เช่น \`/category/reed-diffuser\` หรือ \`/collections/all\`
|
||||||
|
- **layout**: (ไม่บังคับ) \`'columns'\` (Mega Menu คอลัมน์) | \`'tabbed'\` (Tabbed Catalog แท็บซ้าย) | \`'visual-cards'\` (การ์ดรูปภาพหรูหรา 4 ใบ) | \`'classic'\` (Dropdown แนวตั้งเรียบหรู กะทัดรัด)
|
||||||
|
- **layoutConfig**: (ไม่บังคับ) ออบเจกต์ตั้งค่าเพิ่มเติม \`{ columnCount: 2 | 3 | 4, showFeatured: boolean }\`
|
||||||
- **rootImageUrl**: URL รูปภาพหลักฝั่งซ้ายสุดของเมกะเมนู (อัตราส่วน 1:1 จัตุรัส ขนาดแนะนำ 800x800)
|
- **rootImageUrl**: URL รูปภาพหลักฝั่งซ้ายสุดของเมกะเมนู (อัตราส่วน 1:1 จัตุรัส ขนาดแนะนำ 800x800)
|
||||||
- **columns**: อาร์เรย์ของคอลัมน์เมนูย่อย (Level 2)
|
- **columns**: อาร์เรย์ของคอลัมน์เมนูย่อย (Level 2)
|
||||||
- **featured**: การ์ดสินค้า/ไฮไลต์โปรโมตพิเศษที่แสดงฝั่งขวาสุดของเมกะเมนู
|
- **featured**: การ์ดสินค้า/ไฮไลต์โปรโมตพิเศษที่แสดงฝั่งขวาสุดของเมกะเมนู
|
||||||
@@ -304,7 +307,7 @@ export default function MenuJsonGuideModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-5 animate-fade-in">
|
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-5 animate-fade-in">
|
||||||
<div className="w-full max-w-5xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col max-h-[92vh] overflow-hidden animate-zoom-in">
|
<div className="w-full max-w-5xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col max-h-[92vh] overflow-hidden animate-zoom-in">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -491,6 +494,7 @@ export default function MenuJsonGuideModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
FileJson,
|
||||||
|
Upload,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
Layers,
|
||||||
|
Sparkles
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface ProductBomImportModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
variants: any[];
|
||||||
|
rawMaterials: any[];
|
||||||
|
onApply: (updatedVariants: any[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductBomImportModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
variants,
|
||||||
|
rawMaterials,
|
||||||
|
onApply,
|
||||||
|
}: ProductBomImportModalProps) {
|
||||||
|
const [jsonInput, setJsonInput] = useState('');
|
||||||
|
|
||||||
|
// Real-time Parser & Matcher
|
||||||
|
const parsedResult = useMemo(() => {
|
||||||
|
if (!jsonInput.trim()) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonInput);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'ข้อมูลต้องเป็น JSON Array [ { ... }, { ... } ]',
|
||||||
|
matchedVariants: [],
|
||||||
|
unmatchedCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.length === 0) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'JSON Array ว่างเปล่า ไม่มีข้อมูลสูตร',
|
||||||
|
matchedVariants: [],
|
||||||
|
unmatchedCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched: any[] = [];
|
||||||
|
let unmatchedCount = 0;
|
||||||
|
|
||||||
|
for (const entry of parsed) {
|
||||||
|
// 1. Match Variant by ID or SKU or Option
|
||||||
|
const variant = variants.find((v) => {
|
||||||
|
if (entry.variantId && Number(v.id) === Number(entry.variantId)) return true;
|
||||||
|
if (entry.sku && v.sku && String(v.sku).trim().toLowerCase() === String(entry.sku).trim().toLowerCase()) return true;
|
||||||
|
if (entry.option1 && v.option1 && String(v.option1).trim().toLowerCase() === String(entry.option1).trim().toLowerCase()) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!variant) {
|
||||||
|
unmatchedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Match BOM items to RawMaterials
|
||||||
|
const rawItems = Array.isArray(entry.bomItems) ? entry.bomItems : [];
|
||||||
|
const resolvedBomItems: any[] = [];
|
||||||
|
let totalCost = 0;
|
||||||
|
|
||||||
|
for (const b of rawItems) {
|
||||||
|
const qty = Number(b.quantity) || 0;
|
||||||
|
if (qty <= 0) continue;
|
||||||
|
|
||||||
|
// Find raw material by ID or Name
|
||||||
|
const rm = rawMaterials.find((m) => {
|
||||||
|
if (b.rawMaterialId && Number(m.id) === Number(b.rawMaterialId)) return true;
|
||||||
|
if (b.name && String(m.name).trim().toLowerCase() === String(b.name).trim().toLowerCase()) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rm) {
|
||||||
|
const lineCost = (Number(rm.pricePerUnit) || 0) * qty;
|
||||||
|
totalCost += lineCost;
|
||||||
|
resolvedBomItems.push({
|
||||||
|
rawMaterialId: rm.id,
|
||||||
|
name: rm.name,
|
||||||
|
unit: rm.unit,
|
||||||
|
quantity: qty,
|
||||||
|
pricePerUnit: rm.pricePerUnit,
|
||||||
|
lineCost,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Unresolved material ID or name
|
||||||
|
resolvedBomItems.push({
|
||||||
|
rawMaterialId: Number(b.rawMaterialId) || 0,
|
||||||
|
name: b.name || `Material #${b.rawMaterialId}`,
|
||||||
|
unit: 'unit',
|
||||||
|
quantity: qty,
|
||||||
|
pricePerUnit: 0,
|
||||||
|
lineCost: 0,
|
||||||
|
unknown: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const price = Number(variant.price) || 0;
|
||||||
|
const profit = price - totalCost;
|
||||||
|
const margin = price > 0 ? (profit / price) * 100 : 0;
|
||||||
|
|
||||||
|
matched.push({
|
||||||
|
targetVariant: variant,
|
||||||
|
resolvedBomItems,
|
||||||
|
totalCost,
|
||||||
|
price,
|
||||||
|
profit,
|
||||||
|
margin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matched.length === 0) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'ไม่พบ Variant ในระบบที่ตรงกับ ID หรือ SKU ใน JSON ที่วางมา',
|
||||||
|
matchedVariants: [],
|
||||||
|
unmatchedCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
error: null,
|
||||||
|
matchedVariants: matched,
|
||||||
|
unmatchedCount,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `รูปแบบ JSON ไม่ถูกต้อง: ${err.message}`,
|
||||||
|
matchedVariants: [],
|
||||||
|
unmatchedCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [jsonInput, variants, rawMaterials]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
if (!parsedResult || !parsedResult.valid) return;
|
||||||
|
|
||||||
|
// Deep copy current variants
|
||||||
|
const newVariants = JSON.parse(JSON.stringify(variants));
|
||||||
|
|
||||||
|
for (const match of parsedResult.matchedVariants) {
|
||||||
|
const idx = newVariants.findIndex((v: any) => v.id === match.targetVariant.id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
// Map to exact BOM structure expected by ProductBomPage
|
||||||
|
newVariants[idx].bomItems = match.resolvedBomItems.map((b: any) => ({
|
||||||
|
rawMaterialId: b.rawMaterialId,
|
||||||
|
quantity: b.quantity,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onApply(newVariants);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-night-950/80 backdrop-blur-sm transition-opacity"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Container */}
|
||||||
|
<div className="relative w-full max-w-4xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden max-h-[92vh] animate-zoom-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5 bg-night-950/40">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400">
|
||||||
|
<FileJson className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-xl font-bold text-white">Import BOM from LLM JSON</h2>
|
||||||
|
<span className="text-xs px-2.5 py-0.5 rounded-full bg-cyan-500/10 text-cyan-300 border border-cyan-500/20 font-bold">
|
||||||
|
Auto-Match & Calculate
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
วาง JSON ที่ได้รับจาก LLM ระบบจะจับคู่ Variant และคำนวณต้นทุนให้ก่อนกรอกลงตาราง
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Body */}
|
||||||
|
<div className="p-6 overflow-y-auto space-y-6 flex-1">
|
||||||
|
{/* Textarea Input */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<label className="font-bold text-slate-300 flex items-center gap-1.5">
|
||||||
|
<Sparkles className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
วางข้อความ JSON ที่ได้จาก AI (Ctrl+V):
|
||||||
|
</label>
|
||||||
|
{jsonInput && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setJsonInput('')}
|
||||||
|
className="text-rose-400 hover:text-rose-300 font-medium"
|
||||||
|
>
|
||||||
|
ล้างข้อความ
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={jsonInput}
|
||||||
|
onChange={(e) => setJsonInput(e.target.value)}
|
||||||
|
rows={8}
|
||||||
|
placeholder={`[\n {\n "variantId": 3206,\n "sku": "4-1-30ML",\n "bomItems": [\n { "rawMaterialId": 4, "quantity": 2.4 },\n { "rawMaterialId": 91, "quantity": 27.6 }\n ]\n }\n]`}
|
||||||
|
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-4 text-slate-200 placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 transition-all resize-y leading-relaxed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Validation Status Banner */}
|
||||||
|
{parsedResult && (
|
||||||
|
<div
|
||||||
|
className={`p-4 rounded-2xl border text-xs flex items-center justify-between ${
|
||||||
|
parsedResult.valid
|
||||||
|
? 'bg-emerald-950/30 border-emerald-500/30 text-emerald-300'
|
||||||
|
: 'bg-rose-950/30 border-rose-500/30 text-rose-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
{parsedResult.valid ? (
|
||||||
|
<CheckCircle2 className="w-5 h-5 shrink-0 text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-5 h-5 shrink-0 text-rose-400" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{parsedResult.valid ? (
|
||||||
|
<>
|
||||||
|
ตรวจพบสูตร BOM ที่ตรงกับสินค้า <strong>{parsedResult.matchedVariants.length}</strong> ตัวเลือก
|
||||||
|
{parsedResult.unmatchedCount > 0 && ` (ข้ามที่ไม่ตรง ${parsedResult.unmatchedCount} รายการ)`}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
parsedResult.error
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{parsedResult.valid && (
|
||||||
|
<span className="text-[11px] px-2 py-0.5 rounded-md bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-medium">
|
||||||
|
พร้อมนำเข้า
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Live Preview Cards */}
|
||||||
|
{parsedResult?.valid && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span className="font-bold text-white flex items-center gap-1.5">
|
||||||
|
<Layers className="w-4 h-4 text-cyan-400" />
|
||||||
|
พรีวิวการจัดสูตรและคำนวณต้นทุน (BOM Preview):
|
||||||
|
</span>
|
||||||
|
<span>จำนวน {parsedResult.matchedVariants.length} Variants</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 max-h-[280px] overflow-y-auto pr-1">
|
||||||
|
{parsedResult.matchedVariants.map((item: any, idx: number) => {
|
||||||
|
const v = item.targetVariant;
|
||||||
|
const label = [v.option1, v.option2, v.option3].filter(Boolean).join(' / ') || 'Default';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={v.id || idx}
|
||||||
|
className="p-4 rounded-2xl bg-white/[0.02] border border-white/5 hover:border-white/10 transition-all space-y-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-white/5 pb-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-6 h-6 rounded-lg bg-cyan-500/10 text-cyan-400 text-xs font-mono font-bold flex items-center justify-center">
|
||||||
|
#{v.id}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold text-white">{label}</span>
|
||||||
|
{v.sku && (
|
||||||
|
<span className="text-[11px] font-mono text-slate-500 bg-white/5 px-2 py-0.5 rounded-md">
|
||||||
|
SKU: {v.sku}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-xs font-mono">
|
||||||
|
<span className="text-slate-400">
|
||||||
|
ราคาขาย: <strong className="text-white">฿{item.price.toLocaleString()}</strong>
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-400">
|
||||||
|
ต้นทุน BOM: <strong className="text-amber-400">฿{item.totalCost.toFixed(2)}</strong>
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-400">
|
||||||
|
กำไร:{' '}
|
||||||
|
<strong className={item.profit >= 0 ? 'text-emerald-400' : 'text-rose-400'}>
|
||||||
|
฿{item.profit.toFixed(2)} ({item.margin.toFixed(1)}%)
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ingredients Breakdown */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
|
{item.resolvedBomItems.map((b: any, bIdx: number) => (
|
||||||
|
<div
|
||||||
|
key={bIdx}
|
||||||
|
className={`p-2.5 rounded-xl border text-xs flex items-center justify-between ${
|
||||||
|
b.unknown
|
||||||
|
? 'bg-amber-950/20 border-amber-500/30 text-amber-300'
|
||||||
|
: 'bg-night-950/60 border-white/5 text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-0.5 min-w-0 pr-2">
|
||||||
|
<p className="font-medium truncate text-white text-[11px]">
|
||||||
|
{b.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-500">
|
||||||
|
ID: #{b.rawMaterialId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<span className="font-mono font-bold text-cyan-400">
|
||||||
|
{b.quantity} {b.unit}
|
||||||
|
</span>
|
||||||
|
<p className="text-[10px] text-slate-500 font-mono">
|
||||||
|
฿{b.lineCost.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-6 py-4 border-t border-white/5 bg-night-950/40 flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
เมื่อกด Apply ข้อมูลจะถูกกรอกลงตารางบนหน้าเว็บทันทีเพื่อให้คุณตรวจสอบก่อนกดบันทึก
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-5 py-2.5 rounded-xl text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||||
|
>
|
||||||
|
ยกเลิก
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={!parsedResult?.valid}
|
||||||
|
className="btn-primary text-sm px-6 py-2.5 flex items-center gap-2 shadow-lg shadow-cyan-500/20 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
<span>กรอกลงตาราง ({parsedResult?.matchedVariants?.length || 0} รายการ)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Bot,
|
||||||
|
Sparkles,
|
||||||
|
FileCode,
|
||||||
|
Package,
|
||||||
|
Layers,
|
||||||
|
Code2
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface ProductBomPromptModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
productId: number | string;
|
||||||
|
productName: string;
|
||||||
|
variants: any[];
|
||||||
|
rawMaterials: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductBomPromptModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
productId,
|
||||||
|
productName,
|
||||||
|
variants,
|
||||||
|
rawMaterials,
|
||||||
|
}: ProductBomPromptModalProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<'prompt' | 'context' | 'schema'>('prompt');
|
||||||
|
|
||||||
|
// Generate full prompt text for LLM
|
||||||
|
const generatedPrompt = useMemo(() => {
|
||||||
|
const variantLines = variants.map((v) => {
|
||||||
|
const opts = [v.option1, v.option2, v.option3].filter(Boolean).join(' / ') || 'Base Variant';
|
||||||
|
return ` - Variant ID: ${v.id} | SKU: ${v.sku || '-'} | ตัวเลือก: "${opts}" | ราคาขาย: ฿${Number(v.price || 0).toLocaleString()}`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const rawMaterialLines = rawMaterials.map((rm) => {
|
||||||
|
return ` - [ID: ${rm.id}] "${rm.name}" | หน่วย: ${rm.unit} | ต้นทุน: ฿${Number(rm.pricePerUnit || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })}`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
return `คุณคือ Senior Manufacturing & Bill of Materials (BOM) Specialist ของระบบ E-Commerce (DBK Platform)
|
||||||
|
|
||||||
|
[บริบทข้อมูลสินค้า]:
|
||||||
|
- รหัสสินค้า (Product ID): ${productId}
|
||||||
|
- ชื่อสินค้า: "${productName}"
|
||||||
|
|
||||||
|
[รายการตัวเลือกสินค้า (Variants ที่ต้องกำหนดสูตร BOM)]:
|
||||||
|
${variantLines}
|
||||||
|
|
||||||
|
[คลังรายการวัตถุดิบในระบบ (Raw Materials ที่สามารถนำมาผูกสูตรได้)]:
|
||||||
|
${rawMaterialLines}
|
||||||
|
|
||||||
|
[ภารกิจของคุณ]:
|
||||||
|
กรุณาจัดทำสูตรการผลิต (BOM Items) ให้กับแต่ละ Variant ของสินค้า รหัส #${productId} ตามคำสั่ง/สัดส่วนที่ระบุ
|
||||||
|
และส่งคืนผลลัพธ์เป็น **Valid JSON Array** ล้วนๆ ตามข้อกำหนดดังต่อไปนี้เท่านั้น:
|
||||||
|
|
||||||
|
[ข้อกำหนดผลลัพธ์ JSON Format ที่ต้องส่งกลับ]:
|
||||||
|
\`\`\`json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"variantId": <ตัวเลข ID ของ Variant>,
|
||||||
|
"sku": "<รหัส SKU>",
|
||||||
|
"bomItems": [
|
||||||
|
{
|
||||||
|
"rawMaterialId": <ตัวเลข ID ของวัตถุดิบจากคลังด้านบน>,
|
||||||
|
"name": "<ชื่อวัตถุดิบ>",
|
||||||
|
"quantity": <จำนวนปริมาณตัวเลข (ห้ามเป็น string)>
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
\`\`\`
|
||||||
|
*(คำสั่ง/สูตรเฉพาะ: ระบุสัดส่วนการผสม วัตถุดิบบรรจุภัณฑ์ และปริมาณที่ต้องการต่อ 1 ชิ้น)*`;
|
||||||
|
}, [productId, productName, variants, rawMaterials]);
|
||||||
|
|
||||||
|
// Context JSON
|
||||||
|
const contextJson = useMemo(() => {
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
product: {
|
||||||
|
id: Number(productId),
|
||||||
|
name: productName,
|
||||||
|
variantsCount: variants.length,
|
||||||
|
variants: variants.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
sku: v.sku,
|
||||||
|
option1: v.option1,
|
||||||
|
option2: v.option2,
|
||||||
|
option3: v.option3,
|
||||||
|
price: v.price,
|
||||||
|
currentBomCount: v.bomItems?.length || 0,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
rawMaterialsCount: rawMaterials.length,
|
||||||
|
rawMaterials: rawMaterials.map((rm) => ({
|
||||||
|
id: rm.id,
|
||||||
|
name: rm.name,
|
||||||
|
unit: rm.unit,
|
||||||
|
pricePerUnit: rm.pricePerUnit,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}, [productId, productName, variants, rawMaterials]);
|
||||||
|
|
||||||
|
// Target schema sample
|
||||||
|
const schemaSample = useMemo(() => {
|
||||||
|
const firstVariant = variants[0];
|
||||||
|
const sampleItem = rawMaterials[0];
|
||||||
|
return JSON.stringify(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
variantId: firstVariant ? firstVariant.id : 1,
|
||||||
|
sku: firstVariant?.sku || 'SAMPLE-SKU',
|
||||||
|
bomItems: [
|
||||||
|
{
|
||||||
|
rawMaterialId: sampleItem ? sampleItem.id : 1,
|
||||||
|
name: sampleItem?.name || 'ตัวอย่างวัตถุดิบ',
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}, [variants, rawMaterials]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleCopy = async (textToCopy: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(textToCopy);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-night-950/80 backdrop-blur-sm transition-opacity"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Container */}
|
||||||
|
<div className="relative w-full max-w-4xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden max-h-[92vh] animate-zoom-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5 bg-night-950/40">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-400">
|
||||||
|
<Bot className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-xl font-bold text-white">LLM Prompt Generator for BOM</h2>
|
||||||
|
<span className="text-xs px-2.5 py-0.5 rounded-full bg-purple-500/10 text-purple-300 border border-purple-500/20 font-mono font-bold">
|
||||||
|
Product #{productId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
สร้างชุดคำสั่ง Prompt พร้อมรหัสสินค้า, Variants และรายการวัตถุดิบทั้งหมดสำหรับส่งให้ LLM
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Stats Bar */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 px-6 py-3 bg-night-950/30 border-b border-white/5 text-xs">
|
||||||
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
|
<Package className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span>สินค้า: <strong className="text-white">{productName}</strong></span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
|
<Layers className="w-4 h-4 text-purple-400" />
|
||||||
|
<span>Variants: <strong className="text-white">{variants.length} ตัวเลือก</strong></span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
|
<Code2 className="w-4 h-4 text-emerald-400" />
|
||||||
|
<span>วัตถุดิบในคลัง: <strong className="text-white">{rawMaterials.length} รายการ</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Tabs */}
|
||||||
|
<div className="flex border-b border-white/5 px-6 pt-2 bg-night-950/20">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('prompt')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
|
||||||
|
activeTab === 'prompt'
|
||||||
|
? 'border-purple-500 text-purple-400'
|
||||||
|
: 'border-transparent text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
<span>ชุดคำสั่ง Prompt สำหรับ LLM</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('context')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
|
||||||
|
activeTab === 'context'
|
||||||
|
? 'border-purple-500 text-purple-400'
|
||||||
|
: 'border-transparent text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FileCode className="w-4 h-4" />
|
||||||
|
<span>ข้อมูลดิบ (Context JSON)</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('schema')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
|
||||||
|
activeTab === 'schema'
|
||||||
|
? 'border-purple-500 text-purple-400'
|
||||||
|
: 'border-transparent text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Code2 className="w-4 h-4" />
|
||||||
|
<span>Target Output Format</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Body */}
|
||||||
|
<div className="p-6 overflow-y-auto space-y-4 flex-1">
|
||||||
|
{activeTab === 'prompt' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
คัดลอกข้อความด้านล่างนี้ไปวางใน <strong>ChatGPT, Claude หรือ Gemini</strong>:
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopy(generatedPrompt)}
|
||||||
|
className="px-4 py-2 rounded-xl bg-purple-500/10 hover:bg-purple-500/20 text-purple-300 border border-purple-500/30 text-xs font-bold flex items-center gap-2 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
|
||||||
|
<span>{copied ? 'คัดลอก Prompt สำเร็จ!' : 'คัดลอก Prompt ทั้งหมด'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={generatedPrompt}
|
||||||
|
rows={16}
|
||||||
|
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-5 text-slate-200 focus:outline-none resize-y leading-relaxed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'context' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
โครงสร้างข้อมูล Product Master, Variants และ Raw Materials ในรูปแบบ JSON:
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopy(contextJson)}
|
||||||
|
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 border border-white/10 text-xs font-bold flex items-center gap-2 transition-all"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
|
||||||
|
<span>{copied ? 'คัดลอกแล้ว' : 'คัดลอก Context JSON'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={contextJson}
|
||||||
|
rows={16}
|
||||||
|
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-5 text-slate-300 focus:outline-none resize-y leading-relaxed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'schema' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 rounded-2xl bg-cyan-950/20 border border-cyan-500/20 text-xs text-slate-300 space-y-1">
|
||||||
|
<p className="font-bold text-cyan-300">💡 โครงสร้างผลลัพธ์ที่ออกแบบไว้สำหรับรองรับการ Copy วาง (Paste to Import):</p>
|
||||||
|
<p className="text-slate-400">
|
||||||
|
เมื่อ LLM ได้รับข้อมูลและส่งกลับมาในรูปแบบนี้ คุณจะสามารถนำข้อความ JSON มาวางเพื่อบันทึก BOM เข้าสู่ Variant ทั้งหมดได้ทันที
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={schemaSample}
|
||||||
|
rows={12}
|
||||||
|
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-5 text-emerald-400 focus:outline-none resize-y leading-relaxed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-6 py-4 border-t border-white/5 bg-night-950/40 flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
ระบบจัดเตรียมข้อมูลอัตโนมัติจากฐานข้อมูลจริงของสินค้า #{productId}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-5 py-2.5 rounded-xl text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||||
|
>
|
||||||
|
ปิดหน้าต่าง
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopy(generatedPrompt)}
|
||||||
|
className="btn-primary text-sm px-6 py-2.5 flex items-center gap-2 shadow-lg shadow-purple-500/20 bg-purple-600 hover:bg-purple-500 text-white"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||||
|
<span>{copied ? 'คัดลอกสำเร็จแล้ว' : 'คัดลอก Prompt ไปใช้งาน'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import {
|
import {
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Copy,
|
Copy,
|
||||||
@@ -613,7 +614,7 @@ export default function ProductJsonGuideModal({ isOpen, onClose, onOpenImport }:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-5 animate-fade-in">
|
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-5 animate-fade-in">
|
||||||
<div className="w-full max-w-5xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col max-h-[92vh] overflow-hidden animate-zoom-in">
|
<div className="w-full max-w-5xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col max-h-[92vh] overflow-hidden animate-zoom-in">
|
||||||
|
|
||||||
@@ -792,6 +793,7 @@ export default function ProductJsonGuideModal({ isOpen, onClose, onOpenImport }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
import { useState, useMemo, useRef, type ChangeEvent } from 'react';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
FileJson,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Loader2,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { API_BASE, type RawMaterial } from '@homaroma/types';
|
||||||
|
|
||||||
|
interface RawMaterialBulkJsonModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
materials: RawMaterial[];
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_TEMPLATE = [
|
||||||
|
{
|
||||||
|
name: "ขวดแก้วสีชา 100ml (Amber Glass Bottle)",
|
||||||
|
unit: "piece",
|
||||||
|
pricePerUnit: 18.50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ก้านไม้ไฟเบอร์สีดำ 6 ก้าน (Black Fiber Reeds)",
|
||||||
|
unit: "pack",
|
||||||
|
pricePerUnit: 7.00
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "น้ำมันหอมระเหย ลาเวนเดอร์บริสุทธิ์ (Pure Lavender Oil)",
|
||||||
|
unit: "kg",
|
||||||
|
pricePerUnit: 2400.00
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "เบสกระจายความหอม MMB (Diffuser Base)",
|
||||||
|
unit: "liter",
|
||||||
|
pricePerUnit: 350.00
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function RawMaterialBulkJsonModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
materials,
|
||||||
|
onSuccess,
|
||||||
|
}: RawMaterialBulkJsonModalProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<'import' | 'export'>('import');
|
||||||
|
const [copiedTemplate, setCopiedTemplate] = useState(false);
|
||||||
|
const [copiedExport, setCopiedExport] = useState(false);
|
||||||
|
const [importJsonText, setImportJsonText] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [resultMessage, setResultMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
// Parse & validate input text in real time
|
||||||
|
const parsedPreview = useMemo(() => {
|
||||||
|
if (!importJsonText.trim()) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(importJsonText);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return { valid: false, error: 'ข้อมูลต้องเป็น JSON Array [ { ... }, { ... } ]', count: 0, items: [] };
|
||||||
|
}
|
||||||
|
if (parsed.length === 0) {
|
||||||
|
return { valid: false, error: 'Array ว่างเปล่า ไม่มีรายการวัตถุดิบ', count: 0, items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidItem = parsed.find((item: any) => !item || typeof item !== 'object' || !item.name);
|
||||||
|
if (invalidItem) {
|
||||||
|
return { valid: false, error: 'พบรายการที่ไม่มีฟิลด์ "name" หรือข้อมูลไม่สมบูรณ์', count: 0, items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, error: null, count: parsed.length, items: parsed };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { valid: false, error: `รูปแบบ JSON ไม่ถูกต้อง: ${err.message}`, count: 0, items: [] };
|
||||||
|
}
|
||||||
|
}, [importJsonText]);
|
||||||
|
|
||||||
|
// Export JSON string
|
||||||
|
const exportJsonString = useMemo(() => {
|
||||||
|
const cleanList = materials.map((m) => ({
|
||||||
|
name: m.name,
|
||||||
|
unit: m.unit,
|
||||||
|
pricePerUnit: m.pricePerUnit,
|
||||||
|
}));
|
||||||
|
return JSON.stringify(cleanList, null, 2);
|
||||||
|
}, [materials]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleCopyTemplate = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(JSON.stringify(SAMPLE_TEMPLATE, null, 2));
|
||||||
|
setCopiedTemplate(true);
|
||||||
|
setTimeout(() => setCopiedTemplate(false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyExport = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(exportJsonString);
|
||||||
|
setCopiedExport(true);
|
||||||
|
setTimeout(() => setCopiedExport(false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadExport = () => {
|
||||||
|
const blob = new Blob([exportJsonString], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `raw-materials-${new Date().toISOString().split('T')[0]}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const content = event.target?.result as string;
|
||||||
|
setImportJsonText(content);
|
||||||
|
setResultMessage(null);
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportSubmit = async () => {
|
||||||
|
if (!parsedPreview || !parsedPreview.valid) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setResultMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/raw-materials/bulk`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(parsedPreview.items),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.message || 'นำเข้าข้อมูลไม่สำเร็จ');
|
||||||
|
}
|
||||||
|
|
||||||
|
setResultMessage({
|
||||||
|
type: 'success',
|
||||||
|
text: `นำเข้าและปรับปรุงข้อมูล (Upsert) สำเร็จ ${data.count || parsedPreview.count} รายการ`,
|
||||||
|
});
|
||||||
|
setImportJsonText('');
|
||||||
|
setTimeout(() => {
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
}, 1200);
|
||||||
|
} catch (err: any) {
|
||||||
|
setResultMessage({
|
||||||
|
type: 'error',
|
||||||
|
text: err.message || 'เกิดข้อผิดพลาดในการเชื่อมต่อเซิร์ฟเวอร์',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-night-950/80 backdrop-blur-sm transition-opacity"
|
||||||
|
onClick={() => !submitting && onClose()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Container */}
|
||||||
|
<div className="relative w-full max-w-3xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden max-h-[90vh] animate-zoom-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5 bg-night-950/40">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400">
|
||||||
|
<FileJson className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
Raw Materials JSON Tool
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-cyan-500/10 text-cyan-300 border border-cyan-500/20">
|
||||||
|
Bulk Upsert
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
นำเข้าและส่งออกข้อมูลวัตถุดิบ รองรับการอัปเดตข้อมูลเดิมอัตโนมัติเมื่อชื่อซ้ำ
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={submitting}
|
||||||
|
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Tabs */}
|
||||||
|
<div className="flex border-b border-white/5 px-6 pt-2 bg-night-950/20">
|
||||||
|
<button
|
||||||
|
onClick={() => { setActiveTab('import'); setResultMessage(null); }}
|
||||||
|
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
|
||||||
|
activeTab === 'import'
|
||||||
|
? 'border-cyan-500 text-cyan-400'
|
||||||
|
: 'border-transparent text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
<span>นำเข้า JSON (Bulk Import)</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setActiveTab('export'); setResultMessage(null); }}
|
||||||
|
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
|
||||||
|
activeTab === 'export'
|
||||||
|
? 'border-cyan-500 text-cyan-400'
|
||||||
|
: 'border-transparent text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
<span>ส่งออก JSON (Export {materials.length} รายการ)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Body */}
|
||||||
|
<div className="p-6 overflow-y-auto space-y-6 flex-1">
|
||||||
|
{activeTab === 'import' ? (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Guidance Notice */}
|
||||||
|
<div className="p-4 rounded-2xl bg-cyan-950/20 border border-cyan-500/20 text-xs text-cyan-200/90 flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 font-bold text-cyan-300">
|
||||||
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
|
กลไกอัจฉริยะ (Upsert Automation)
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-300 leading-relaxed">
|
||||||
|
วาง JSON Array รายการวัตถุดิบที่ต้องการ หากพบ <strong>name</strong> ที่มีอยู่แล้วในระบบ
|
||||||
|
ระบบจะ <strong>Update</strong> หน่วยนับและราคาต้นทุนให้ทันที หากยังไม่มีจะสร้างรายการใหม่
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyTemplate}
|
||||||
|
className="shrink-0 px-3 py-1.5 rounded-lg bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 font-medium flex items-center gap-1.5 transition-all text-xs"
|
||||||
|
>
|
||||||
|
{copiedTemplate ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||||
|
<span>{copiedTemplate ? 'คัดลอกแล้ว' : 'คัดลอก Template'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* JSON Input Area */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<label className="font-bold text-slate-300">วาง JSON Payload:</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
accept=".json"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="text-cyan-400 hover:text-cyan-300 flex items-center gap-1 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
<span>อัปโหลดไฟล์ .json</span>
|
||||||
|
</button>
|
||||||
|
{importJsonText && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setImportJsonText(''); setResultMessage(null); }}
|
||||||
|
className="text-rose-400 hover:text-rose-300 ml-2 font-medium"
|
||||||
|
>
|
||||||
|
ล้างข้อมูล
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={importJsonText}
|
||||||
|
onChange={(e) => {
|
||||||
|
setImportJsonText(e.target.value);
|
||||||
|
setResultMessage(null);
|
||||||
|
}}
|
||||||
|
rows={9}
|
||||||
|
placeholder={`[\n {\n "name": "ขวดแก้ว 100ml",\n "unit": "piece",\n "pricePerUnit": 18.5\n }\n]`}
|
||||||
|
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-4 text-slate-200 placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 transition-all resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live Preview / Validation Status */}
|
||||||
|
{parsedPreview && (
|
||||||
|
<div
|
||||||
|
className={`p-3.5 rounded-xl border text-xs flex items-center gap-2.5 ${
|
||||||
|
parsedPreview.valid
|
||||||
|
? 'bg-emerald-950/30 border-emerald-500/30 text-emerald-300'
|
||||||
|
: 'bg-rose-950/30 border-rose-500/30 text-rose-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{parsedPreview.valid ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
JSON ถูกต้อง — ตรวจพบวัตถุดิบทั้งหมด <strong>{parsedPreview.count}</strong> รายการ พร้อมนำเข้าสู่ระบบ
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||||
|
<span>{parsedPreview.error}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Server Result Message */}
|
||||||
|
{resultMessage && (
|
||||||
|
<div
|
||||||
|
className={`p-3.5 rounded-xl border text-xs flex items-center gap-2.5 ${
|
||||||
|
resultMessage.type === 'success'
|
||||||
|
? 'bg-emerald-950/40 border-emerald-500/40 text-emerald-300'
|
||||||
|
: 'bg-rose-950/40 border-rose-500/40 text-rose-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{resultMessage.type === 'success' ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span>{resultMessage.text}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>วัตถุดิบทั้งหมดในระบบ ({materials.length} รายการ):</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyExport}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 font-medium flex items-center gap-1.5 transition-all"
|
||||||
|
>
|
||||||
|
{copiedExport ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||||
|
<span>{copiedExport ? 'คัดลอกแล้ว' : 'คัดลอก JSON'}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDownloadExport}
|
||||||
|
className="btn-primary text-xs px-3 py-1.5 flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
<span>ดาวน์โหลด .json</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={exportJsonString}
|
||||||
|
rows={12}
|
||||||
|
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-4 text-slate-300 focus:outline-none resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-6 py-4 border-t border-white/5 bg-night-950/40 flex items-center justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-5 py-2.5 rounded-xl text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||||
|
>
|
||||||
|
ยกเลิก
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{activeTab === 'import' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleImportSubmit}
|
||||||
|
disabled={submitting || !parsedPreview?.valid}
|
||||||
|
className="btn-primary text-sm px-6 py-2.5 flex items-center gap-2 disabled:opacity-40 disabled:cursor-not-allowed shadow-lg shadow-cyan-500/10"
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span>กำลังบันทึกข้อมูล...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
<span>ยืนยันการนำเข้า ({parsedPreview?.count || 0} รายการ)</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
const isInProduct = ['/categories', '/brands', '/products', '/product-attributes', '/raw-materials'].some(p => location.pathname.startsWith(p))
|
const isInProduct = ['/categories', '/brands', '/products', '/product-attributes', '/raw-materials', '/profitability', '/omnichannel-margin', '/mrp-planning'].some(p => location.pathname.startsWith(p))
|
||||||
const isInSales = ['/orders', '/promotions', '/coupons', '/global-analytics'].some(p => location.pathname.startsWith(p))
|
const isInSales = ['/orders', '/promotions', '/coupons', '/global-analytics'].some(p => location.pathname.startsWith(p))
|
||||||
const isRepeatCustomers = location.pathname.startsWith('/business-analytics/repeat-customer')
|
const isRepeatCustomers = location.pathname.startsWith('/business-analytics/repeat-customer')
|
||||||
const isInBusinessAnalytics = location.pathname.startsWith('/business-analytics') && !isRepeatCustomers
|
const isInBusinessAnalytics = location.pathname.startsWith('/business-analytics') && !isRepeatCustomers
|
||||||
@@ -97,6 +97,9 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
|
|||||||
{ icon: Package, label: t('nav.products', 'Products'), to: '/products' },
|
{ icon: Package, label: t('nav.products', 'Products'), to: '/products' },
|
||||||
{ icon: Ruler, label: t('nav.productAttributes', 'Product Attributes'), to: '/product-attributes' },
|
{ icon: Ruler, label: t('nav.productAttributes', 'Product Attributes'), to: '/product-attributes' },
|
||||||
{ icon: PackageOpen, label: t('nav.rawMaterials', 'Raw Materials'), to: '/raw-materials' },
|
{ icon: PackageOpen, label: t('nav.rawMaterials', 'Raw Materials'), to: '/raw-materials' },
|
||||||
|
{ icon: Calculator, label: t('nav.profitability', 'Cost & Profitability'), to: '/profitability' },
|
||||||
|
{ icon: Layers, label: t('nav.omnichannelMargin', 'Omnichannel Margin'), to: '/omnichannel-margin' },
|
||||||
|
{ icon: BrainCircuit, label: t('nav.mrpPlanning', 'MRP & Material Demand'), to: '/mrp-planning' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const SALES_MENU = [
|
const SALES_MENU = [
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
@@ -143,7 +144,61 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
// 2.9 APPLE_SHOWCASE
|
// 2.96 VDO_FRAME_SCRUBBER
|
||||||
|
{
|
||||||
|
type: 'VDO_FRAME_SCRUBBER',
|
||||||
|
category: 'hero',
|
||||||
|
title: 'VDO FRAME SCRUBBER (Scrollytelling Video)',
|
||||||
|
description: 'Cinema-quality video frame sequence (432 frames) with scroll scrub, direct drag, range slider, and play/pause at 24fps without memory leaks.',
|
||||||
|
badge: 'Video Frames · 4K',
|
||||||
|
renderPreview: () => (
|
||||||
|
<div className="relative w-full aspect-[21/9] rounded-xl overflow-hidden bg-[#0e0e0d] flex items-center justify-between p-3 border border-[#d5ff63]/30">
|
||||||
|
<div className="space-y-1 max-w-[65%] z-10">
|
||||||
|
<span className="text-[6px] uppercase font-mono font-bold text-[#d5ff63] tracking-widest flex items-center gap-1">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-[#d5ff63] animate-ping" /> FRAME SCRUBBER
|
||||||
|
</span>
|
||||||
|
<h5 className="text-[10px] font-black text-white leading-tight">
|
||||||
|
FROM VIDEO TO INTERACTIVE
|
||||||
|
</h5>
|
||||||
|
<div className="flex items-center gap-2 text-[6px] text-slate-400 font-mono">
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-white/10 text-white">432 FRAMES</span>
|
||||||
|
<span>00:18.0s @ 24FPS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-black/80 border border-[#d5ff63]/40 flex items-center justify-center text-[#d5ff63] shadow-xl shrink-0">
|
||||||
|
<Play className="w-5 h-5 fill-current ml-0.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
// 2.95 INTERACTIVE_360_SHOWCASE
|
||||||
|
{
|
||||||
|
type: 'INTERACTIVE_360_SHOWCASE',
|
||||||
|
category: 'hero',
|
||||||
|
title: 'INTERACTIVE 360° TURNTABLE SHOWCASE',
|
||||||
|
description: 'Ultra-fast 2.5D Sprite Sheet 360° turntable, scroll-driven rotation, interactive drag-to-spin, and seamless macro detail zoom transition.',
|
||||||
|
badge: '3D Web · Hot',
|
||||||
|
renderPreview: () => (
|
||||||
|
<div className="relative w-full aspect-[21/9] rounded-xl overflow-hidden bg-[#0f0f0e] flex items-center justify-between p-3 border border-[#d5ff63]/30">
|
||||||
|
<div className="space-y-1 max-w-[60%] z-10">
|
||||||
|
<span className="text-[6px] uppercase font-mono font-bold text-[#d5ff63] tracking-widest">
|
||||||
|
360° TURNTABLE + MACRO ZOOM
|
||||||
|
</span>
|
||||||
|
<h5 className="text-[10px] font-black text-white leading-tight">
|
||||||
|
PRECISION TO EVERY ANGLE
|
||||||
|
</h5>
|
||||||
|
<div className="flex items-center gap-1 text-[6px] text-slate-400 font-mono">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-[#d5ff63]" />
|
||||||
|
<span>16-Angle Turntable Scrub</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-14 h-14 rounded-full bg-black/60 border border-[#d5ff63]/40 flex items-center justify-center p-1 shadow-2xl shrink-0">
|
||||||
|
<span className="text-[9px] font-bold font-mono text-[#d5ff63]">360°</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
// 2.9 APPLE_SHOWCASE
|
||||||
{
|
{
|
||||||
type: 'APPLE_SHOWCASE',
|
type: 'APPLE_SHOWCASE',
|
||||||
category: 'hero',
|
category: 'hero',
|
||||||
@@ -798,7 +853,7 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
|
|||||||
return matchesCategory && matchesSearch;
|
return matchesCategory && matchesSearch;
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 sm:p-6 animate-fade-in">
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 sm:p-6 animate-fade-in">
|
||||||
<div className="bg-night-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-6xl flex flex-col h-[90vh] max-h-[850px] overflow-hidden text-slate-200">
|
<div className="bg-night-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-6xl flex flex-col h-[90vh] max-h-[850px] overflow-hidden text-slate-200">
|
||||||
{/* Modal Header */}
|
{/* Modal Header */}
|
||||||
@@ -924,6 +979,7 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Upload,
|
||||||
|
Film,
|
||||||
|
CheckCircle,
|
||||||
|
Loader2,
|
||||||
|
Trash2,
|
||||||
|
FolderOpen,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { API_BASE } from '@homaroma/types'
|
||||||
|
|
||||||
|
export interface FrameSequenceItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
folderPath: string
|
||||||
|
pattern: string
|
||||||
|
padding: number
|
||||||
|
totalFrames: number
|
||||||
|
fps: number
|
||||||
|
durationSeconds: number
|
||||||
|
posterUrl: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSelectSequence: (seq: FrameSequenceItem) => void
|
||||||
|
selectedSlug?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FrameSequenceModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSelectSequence,
|
||||||
|
selectedSlug,
|
||||||
|
}: Props) {
|
||||||
|
const [sequences, setSequences] = useState<FrameSequenceItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Upload Form State
|
||||||
|
const [showUploadForm, setShowUploadForm] = useState(false)
|
||||||
|
const [uploadName, setUploadName] = useState('')
|
||||||
|
const [uploadFps, setUploadFps] = useState('24')
|
||||||
|
const [zipFile, setZipFile] = useState<File | null>(null)
|
||||||
|
|
||||||
|
const fetchSequences = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/frame-sequences`)
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
setSequences(data.sequences || [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load frame sequences', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
fetchSequences()
|
||||||
|
setShowUploadForm(false)
|
||||||
|
setUploadError(null)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const handleUploadZip = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!zipFile) {
|
||||||
|
setUploadError('กรุณาเลือกไฟล์ .ZIP ที่มีรูปเฟรม')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploading(true)
|
||||||
|
setUploadError(null)
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', zipFile)
|
||||||
|
if (uploadName) formData.append('name', uploadName)
|
||||||
|
formData.append('fps', uploadFps)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/frame-sequences/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok || data.error) {
|
||||||
|
throw new Error(data.error || 'Upload failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchSequences()
|
||||||
|
setShowUploadForm(false)
|
||||||
|
setZipFile(null)
|
||||||
|
setUploadName('')
|
||||||
|
|
||||||
|
if (data.sequence) {
|
||||||
|
onSelectSequence(data.sequence)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setUploadError(err.message || 'เกิดข้อผิดพลาดในการอัปโหลด ZIP')
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (slug: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบชุดเฟรม "${slug}"?`)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/frame-sequences/${slug}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setSequences((prev) => prev.filter((s) => s.slug !== slug))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete sequence', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 animate-fade-in">
|
||||||
|
<div className="bg-[#121211] border border-white/10 rounded-3xl shadow-2xl w-full max-w-4xl flex flex-col max-h-[85vh] overflow-hidden text-slate-200">
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="p-6 border-b border-white/5 flex items-center justify-between shrink-0 bg-black/40">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-[#d5ff63]/10 border border-[#d5ff63]/30 flex items-center justify-center text-[#d5ff63]">
|
||||||
|
<Film className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-white flex items-center gap-2">
|
||||||
|
<span>คลังชุดวิดีโอเฟรม (Frame Sequence Library)</span>
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[#d5ff63]/20 text-[#d5ff63] font-mono">
|
||||||
|
แยกจาก Media ปกติ
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
เลือกชุดเฟรมภาพแบบ 360° / Scrollytelling โดยไม่ปะปนกับคลังภาพสินค้าทั่วไป
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowUploadForm(!showUploadForm)}
|
||||||
|
className="px-4 py-2 rounded-xl bg-white/10 hover:bg-white/20 text-xs font-bold text-white flex items-center gap-2 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5 text-[#d5ff63]" />
|
||||||
|
<span>{showUploadForm ? 'ดูรายการที่มีอยู่' : 'อัปโหลด ZIP ชุดใหม่'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Body */}
|
||||||
|
<div className="p-6 flex-1 overflow-y-auto space-y-6">
|
||||||
|
{/* Upload Form Accordion */}
|
||||||
|
{showUploadForm && (
|
||||||
|
<form
|
||||||
|
onSubmit={handleUploadZip}
|
||||||
|
className="p-5 rounded-2xl bg-black/60 border border-[#d5ff63]/30 space-y-4 animate-fade-in"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-xs font-bold text-[#d5ff63] uppercase tracking-wider">
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
<span>อัปโหลดชุดเฟรมภาพด้วยไฟล์ .ZIP</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
บีบอัดโฟลเดอร์ภาพ เช่น <code className="text-slate-200">frame-001.webp</code> ถึง{' '}
|
||||||
|
<code className="text-slate-200">frame-432.webp</code> เป็นไฟล์ .zip แล้วอัปโหลด ระบบจะคลายไฟล์ให้อัตโนมัติ
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{uploadError && (
|
||||||
|
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/30 text-xs text-red-400">
|
||||||
|
{uploadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-bold uppercase text-slate-400 block mb-1">
|
||||||
|
ชื่อชุดวิดีโอ (Name)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={uploadName}
|
||||||
|
onChange={(e) => setUploadName(e.target.value)}
|
||||||
|
placeholder="เช่น Chrome Sprocket 18s Spin"
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-bold uppercase text-slate-400 block mb-1">
|
||||||
|
ความเร็วเฟรมต่อวินาที (FPS)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={uploadFps}
|
||||||
|
onChange={(e) => setUploadFps(e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
>
|
||||||
|
<option value="24">24 FPS (Cinematic Video - ค่ามาตรฐาน)</option>
|
||||||
|
<option value="30">30 FPS (Standard Video)</option>
|
||||||
|
<option value="60">60 FPS (Ultra Smooth 3D)</option>
|
||||||
|
<option value="12">12 FPS (Stop Motion)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-bold uppercase text-slate-400 block mb-1">
|
||||||
|
ไฟล์ .ZIP ที่มีรูปเฟรม
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".zip"
|
||||||
|
onChange={(e) => setZipFile(e.target.files?.[0] || null)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowUploadForm(false)}
|
||||||
|
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-xs text-slate-300"
|
||||||
|
>
|
||||||
|
ยกเลิก
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isUploading || !zipFile}
|
||||||
|
className="px-6 py-2 rounded-xl bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e] disabled:opacity-50 flex items-center gap-2 cursor-pointer shadow-lg"
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
<span>กำลังแตกไฟล์และจัดเก็บ...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
<span>เริ่มอัปโหลดและประมวลผล ZIP</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sequence Cards Grid */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-20 flex flex-col items-center justify-center gap-3 text-slate-500">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-[#d5ff63]" />
|
||||||
|
<span className="text-xs">กำลังโหลดชุดวิดีโอเฟรม...</span>
|
||||||
|
</div>
|
||||||
|
) : sequences.length === 0 ? (
|
||||||
|
<div className="py-20 text-center space-y-3">
|
||||||
|
<FolderOpen className="w-12 h-12 text-slate-600 mx-auto" />
|
||||||
|
<div className="text-sm font-bold text-slate-300">ยังไม่มีชุด Frame Sequence ในระบบ</div>
|
||||||
|
<p className="text-xs text-slate-500 max-w-sm mx-auto">
|
||||||
|
คุณสามารถกดปุ่ม "อัปโหลด ZIP ชุดใหม่" ด้านบนเพื่อนำชุดเฟรมภาพเข้ามาใช้งานได้ทันที
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{sequences.map((seq) => {
|
||||||
|
const isSelected = selectedSlug === seq.slug
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={seq.id}
|
||||||
|
onClick={() => {
|
||||||
|
onSelectSequence(seq)
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
className={`relative group rounded-2xl bg-black/40 border transition-all p-3 space-y-3 cursor-pointer ${
|
||||||
|
isSelected
|
||||||
|
? 'border-[#d5ff63] ring-1 ring-[#d5ff63] shadow-lg shadow-[#d5ff63]/10'
|
||||||
|
: 'border-white/10 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Poster Thumbnail */}
|
||||||
|
<div className="relative aspect-video rounded-xl bg-slate-900 overflow-hidden border border-white/5">
|
||||||
|
<img
|
||||||
|
src={seq.posterUrl}
|
||||||
|
alt={seq.name}
|
||||||
|
className="w-full h-full object-contain p-2"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-2 left-2 px-2 py-0.5 rounded bg-black/80 backdrop-blur-md text-[9px] font-mono text-white">
|
||||||
|
{seq.totalFrames} FRAMES • {seq.durationSeconds}s
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute top-2 right-2 w-6 h-6 rounded-full bg-[#d5ff63] text-black flex items-center justify-center shadow-lg">
|
||||||
|
<CheckCircle className="w-4 h-4 fill-black text-[#d5ff63]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h4 className="text-sm font-bold text-white group-hover:text-[#d5ff63] transition-colors truncate">
|
||||||
|
{seq.name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-[10px] font-mono text-slate-500 truncate">
|
||||||
|
{seq.folderPath}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Bar */}
|
||||||
|
<div className="flex items-center justify-between pt-1 border-t border-white/5 text-[10px]">
|
||||||
|
<span className="font-mono text-slate-400">
|
||||||
|
{seq.fps} FPS
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => handleDelete(seq.slug, e)}
|
||||||
|
title="ลบชุดนี้"
|
||||||
|
className="p-1 rounded hover:bg-red-500/20 text-slate-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-3 py-1 rounded-lg bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e]"
|
||||||
|
>
|
||||||
|
เลือกชุดนี้
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Sparkles,
|
||||||
|
Upload,
|
||||||
|
Package,
|
||||||
|
ExternalLink,
|
||||||
|
RotateCcw,
|
||||||
|
Layers,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { API_BASE } from '@homaroma/types'
|
||||||
|
|
||||||
|
interface SpecItem {
|
||||||
|
number?: string
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
updatePayloadValue: (field: string, value: unknown) => void
|
||||||
|
onBrowseMedia?: (onSelect: (url: string) => void) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SPECS: SpecItem[] = [
|
||||||
|
{
|
||||||
|
number: '01',
|
||||||
|
title: 'โครเมียมขัดเงาระดับพรีเมียม',
|
||||||
|
subtitle: 'POLISHED CHROME FINISH',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: '02',
|
||||||
|
title: 'เจาะรูกลึงลดน้ำหนักตามหลักอากาศพลศาสตร์',
|
||||||
|
subtitle: 'LIGHTWEIGHT PRECISION CUTOUTS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: '03',
|
||||||
|
title: 'โปรไฟล์ฟันเฟือง 428H แม่นยำระดับไมครอน',
|
||||||
|
subtitle: '428H PRECISION TOOTH PROFILE',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Interactive360ShowcaseForm({
|
||||||
|
payload,
|
||||||
|
updatePayloadValue,
|
||||||
|
onBrowseMedia,
|
||||||
|
}: Props) {
|
||||||
|
const [productsList, setProductsList] = useState<any[]>([])
|
||||||
|
const [loadingProducts, setLoadingProducts] = useState(false)
|
||||||
|
|
||||||
|
const spriteSheetUrl = (payload.spriteSheetUrl as string) || '/images/3d/sprocket-views.png'
|
||||||
|
const detailImageUrl = (payload.detailImageUrl as string) || '/images/3d/sprocket-detail.png'
|
||||||
|
const eyebrow = (payload.eyebrow as string) || ''
|
||||||
|
const title = (payload.title as string) || ''
|
||||||
|
const subtitle = (payload.subtitle as string) || ''
|
||||||
|
const detailKicker = (payload.detailKicker as string) || ''
|
||||||
|
const detailTitle = (payload.detailTitle as string) || ''
|
||||||
|
const detailCaption = (payload.detailCaption as string) || ''
|
||||||
|
const productName = (payload.productName as string) || ''
|
||||||
|
const productBadge = (payload.productBadge as string) || ''
|
||||||
|
const accentColor = (payload.accentColor as string) || '#d5ff63'
|
||||||
|
const theme = (payload.theme as string) || 'dark'
|
||||||
|
const endCtaTitle = (payload.endCtaTitle as string) || ''
|
||||||
|
const endCtaButtonText = (payload.endCtaButtonText as string) || ''
|
||||||
|
const endCtaLink = (payload.endCtaLink as string) || ''
|
||||||
|
const selectedProductId = payload.productId ? String(payload.productId) : ''
|
||||||
|
|
||||||
|
const rawSpecs = Array.isArray(payload.specs) ? (payload.specs as SpecItem[]) : DEFAULT_SPECS
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true
|
||||||
|
const fetchCatalog = async () => {
|
||||||
|
setLoadingProducts(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/products?limit=100`)
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
const items = data.data || data.products || (Array.isArray(data) ? data : [])
|
||||||
|
if (isMounted) setProductsList(items)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load products in Interactive360ShowcaseForm', err)
|
||||||
|
} finally {
|
||||||
|
if (isMounted) setLoadingProducts(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchCatalog()
|
||||||
|
return () => { isMounted = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Auto-seed defaults if brand new block
|
||||||
|
useEffect(() => {
|
||||||
|
if (!payload.spriteSheetUrl) updatePayloadValue('spriteSheetUrl', '/images/3d/sprocket-views.png')
|
||||||
|
if (!payload.detailImageUrl) updatePayloadValue('detailImageUrl', '/images/3d/sprocket-detail.png')
|
||||||
|
if (!payload.eyebrow) updatePayloadValue('eyebrow', 'ENGINEERED TO MOVE')
|
||||||
|
if (!payload.title) updatePayloadValue('title', 'เหล็กหนึ่งชิ้น ที่เล่าได้ทุกองศา')
|
||||||
|
if (!payload.subtitle) updatePayloadValue('subtitle', 'สเตอร์จักรยานยนต์โครเมียม ดีไซน์น้ำหนักเบา ให้ทุกการหมุนมีจังหวะที่แม่นยำ พร้อมสัมผัสรายละเอียดระดับ Ultra-Macro')
|
||||||
|
if (!payload.detailKicker) updatePayloadValue('detailKicker', 'FINISHED TO THE EDGE')
|
||||||
|
if (!payload.detailTitle) updatePayloadValue('detailTitle', 'ฟันแน่น แข็งแรง ผิวเรียบทุกขอบ')
|
||||||
|
if (!payload.detailCaption) updatePayloadValue('detailCaption', 'ผิวชุบโครเมียมขัดเงา สะท้อนแสงเงางาม ทนทานต่อการเสียดสี และป้องกันสนิมในทุกสภาพการใช้งาน')
|
||||||
|
if (!payload.productName) updatePayloadValue('productName', 'Chrome Sprocket 428H')
|
||||||
|
if (!payload.productBadge) updatePayloadValue('productBadge', 'MONO / 01')
|
||||||
|
if (!payload.endCtaTitle) updatePayloadValue('endCtaTitle', 'พร้อมยกระดับสมรรถนะการขับขี่?')
|
||||||
|
if (!payload.endCtaButtonText) updatePayloadValue('endCtaButtonText', 'สั่งซื้อสินค้าตอนนี้')
|
||||||
|
if (!payload.specs) updatePayloadValue('specs', DEFAULT_SPECS)
|
||||||
|
if (!payload.theme) updatePayloadValue('theme', 'dark')
|
||||||
|
if (!payload.accentColor) updatePayloadValue('accentColor', '#d5ff63')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSelectProduct = (productIdStr: string) => {
|
||||||
|
if (!productIdStr) {
|
||||||
|
updatePayloadValue('productId', null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const pid = Number(productIdStr)
|
||||||
|
updatePayloadValue('productId', pid)
|
||||||
|
|
||||||
|
const prod = productsList.find((p) => p.id === pid)
|
||||||
|
if (prod) {
|
||||||
|
const prodName = typeof prod.name === 'string' ? prod.name : prod.name?.th || prod.name?.en || ''
|
||||||
|
if (prodName) updatePayloadValue('productName', prodName)
|
||||||
|
const slug = prod.slug || `product-${pid}`
|
||||||
|
updatePayloadValue('endCtaLink', `/products/${slug}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyPreset = () => {
|
||||||
|
updatePayloadValue('spriteSheetUrl', '/images/3d/sprocket-views.png')
|
||||||
|
updatePayloadValue('detailImageUrl', '/images/3d/sprocket-detail.png')
|
||||||
|
updatePayloadValue('eyebrow', 'ENGINEERED TO MOVE')
|
||||||
|
updatePayloadValue('title', 'เหล็กหนึ่งชิ้น ที่เล่าได้ทุกองศา')
|
||||||
|
updatePayloadValue('subtitle', 'สเตอร์จักรยานยนต์โครเมียม ดีไซน์น้ำหนักเบา ให้ทุกการหมุนมีจังหวะที่แม่นยำ พร้อมสัมผัสรายละเอียดระดับ Ultra-Macro')
|
||||||
|
updatePayloadValue('detailKicker', 'FINISHED TO THE EDGE')
|
||||||
|
updatePayloadValue('detailTitle', 'ฟันแน่น แข็งแรง ผิวเรียบทุกขอบ')
|
||||||
|
updatePayloadValue('detailCaption', 'ผิวชุบโครเมียมขัดเงา สะท้อนแสงเงางาม ทนทานต่อการเสียดสี และป้องกันสนิมในทุกสภาพการใช้งาน')
|
||||||
|
updatePayloadValue('productName', 'Chrome Sprocket 428H')
|
||||||
|
updatePayloadValue('productBadge', 'MONO / 01')
|
||||||
|
updatePayloadValue('endCtaTitle', 'พร้อมยกระดับสมรรถนะการขับขี่?')
|
||||||
|
updatePayloadValue('endCtaButtonText', 'สั่งซื้อสินค้าตอนนี้')
|
||||||
|
updatePayloadValue('specs', DEFAULT_SPECS)
|
||||||
|
updatePayloadValue('theme', 'dark')
|
||||||
|
updatePayloadValue('accentColor', '#d5ff63')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateSpec = (idx: number, field: keyof SpecItem, val: string) => {
|
||||||
|
const updated = [...rawSpecs]
|
||||||
|
updated[idx] = { ...updated[idx], [field]: val }
|
||||||
|
updatePayloadValue('specs', updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 text-slate-200">
|
||||||
|
{/* ── Preset & Binding Banner ── */}
|
||||||
|
<div className="p-4 rounded-2xl bg-gradient-to-r from-lime-500/10 via-slate-900/40 to-transparent border border-lime-500/30 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-lime-400 font-bold text-xs uppercase tracking-wider">
|
||||||
|
<Sparkles className="w-4 h-4 text-lime-400" />
|
||||||
|
<span>3D Interactive Turntable & Macro Zoom Showcase</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
แสดงผลสเตอร์/ชิ้นส่วน 360 องศาด้วย Sprite Sheet + Scroll Scrollytelling + ซูม Macro Detail อัตโนมัติ
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApplyPreset}
|
||||||
|
className="px-4 py-2 rounded-xl bg-lime-400 text-slate-950 font-bold text-xs hover:bg-lime-300 transition-all flex items-center gap-1.5 shrink-0 shadow-lg cursor-pointer"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
<span>รีเซ็ต / โหลด Mockup สเตอร์โครเมียม</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 1-Click Product Linking ── */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-bold text-slate-200 uppercase tracking-wider">
|
||||||
|
<Package className="w-4 h-4 text-lime-400" />
|
||||||
|
<span>ผูกกับสินค้าในระบบ (1-Click Bind Product)</span>
|
||||||
|
</div>
|
||||||
|
{selectedProductId && (
|
||||||
|
<span className="text-[10px] text-lime-400 font-mono">
|
||||||
|
Product ID: #{selectedProductId}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={selectedProductId}
|
||||||
|
onChange={(e) => handleSelectProduct(e.target.value)}
|
||||||
|
disabled={loadingProducts}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
>
|
||||||
|
<option value="">-- ไม่ระบุ (ใช้เป็นหน้า Brand/Product Story อิสระ) --</option>
|
||||||
|
{productsList.map((p) => {
|
||||||
|
const nameStr = typeof p.name === 'string' ? p.name : p.name?.th || p.name?.en || 'Unnamed'
|
||||||
|
return (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
#{p.id} - {nameStr}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-1">
|
||||||
|
เมื่อเลือกสินค้า ระบบจะดึงชื่อและตั้งค่าลิงก์ปุ่มสั่งซื้ออัตโนมัติ
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={endCtaLink}
|
||||||
|
onChange={(e) => updatePayloadValue('endCtaLink', e.target.value)}
|
||||||
|
placeholder="ลิงก์ปลายทางเมื่อกดสั่งซื้อ เช่น /products/chain-sprocket-p1"
|
||||||
|
className="admin-input flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
{endCtaLink && (
|
||||||
|
<a
|
||||||
|
href={endCtaLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="p-2 rounded-lg bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Visual Media Uploads (Sprite Sheet + Macro Detail) ── */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Sprite Sheet 16-Angles */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-3">
|
||||||
|
<label className="text-xs font-bold text-slate-200 uppercase tracking-wider block">
|
||||||
|
1. Sprite Sheet 360° (ภาพรวม 16 มุมมอง 4x4 Grid)
|
||||||
|
</label>
|
||||||
|
{spriteSheetUrl ? (
|
||||||
|
<div className="relative aspect-video rounded-xl bg-black border border-white/10 overflow-hidden group">
|
||||||
|
<img
|
||||||
|
src={spriteSheetUrl}
|
||||||
|
alt="360 Sprite Sheet"
|
||||||
|
className="w-full h-full object-contain p-2"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 flex items-center justify-center gap-2 transition-opacity">
|
||||||
|
{onBrowseMedia && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onBrowseMedia((url) => updatePayloadValue('spriteSheetUrl', url))}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-lime-400 text-black text-xs font-bold cursor-pointer"
|
||||||
|
>
|
||||||
|
เปลี่ยนรูป
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onClick={() => onBrowseMedia && onBrowseMedia((url) => updatePayloadValue('spriteSheetUrl', url))}
|
||||||
|
className="aspect-video rounded-xl border border-dashed border-white/20 bg-black/40 flex flex-col items-center justify-center gap-2 cursor-pointer hover:border-lime-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Upload className="w-6 h-6 text-slate-500" />
|
||||||
|
<span className="text-xs text-slate-400">คลิกเพื่อเลือกภาพ Sprite Sheet</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={spriteSheetUrl}
|
||||||
|
onChange={(e) => updatePayloadValue('spriteSheetUrl', e.target.value)}
|
||||||
|
placeholder="/images/3d/sprocket-views.png"
|
||||||
|
className="admin-input w-full text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Macro Detail Shot */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-3">
|
||||||
|
<label className="text-xs font-bold text-slate-200 uppercase tracking-wider block">
|
||||||
|
2. Macro Detail Shot (ภาพซูมระยะประชิดระดับ 4K)
|
||||||
|
</label>
|
||||||
|
{detailImageUrl ? (
|
||||||
|
<div className="relative aspect-video rounded-xl bg-black border border-white/10 overflow-hidden group">
|
||||||
|
<img
|
||||||
|
src={detailImageUrl}
|
||||||
|
alt="Macro Detail"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 flex items-center justify-center gap-2 transition-opacity">
|
||||||
|
{onBrowseMedia && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onBrowseMedia((url) => updatePayloadValue('detailImageUrl', url))}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-lime-400 text-black text-xs font-bold cursor-pointer"
|
||||||
|
>
|
||||||
|
เปลี่ยนรูป
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onClick={() => onBrowseMedia && onBrowseMedia((url) => updatePayloadValue('detailImageUrl', url))}
|
||||||
|
className="aspect-video rounded-xl border border-dashed border-white/20 bg-black/40 flex flex-col items-center justify-center gap-2 cursor-pointer hover:border-lime-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Upload className="w-6 h-6 text-slate-500" />
|
||||||
|
<span className="text-xs text-slate-400">คลิกเพื่อเลือกภาพ Macro Detail</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={detailImageUrl}
|
||||||
|
onChange={(e) => updatePayloadValue('detailImageUrl', e.target.value)}
|
||||||
|
placeholder="/images/3d/sprocket-detail.png"
|
||||||
|
className="admin-input w-full text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Headline & Intro Texts ── */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-4">
|
||||||
|
<div className="text-xs font-bold text-slate-200 uppercase tracking-wider">
|
||||||
|
ข้อความหัวเรื่อง (Header & Intro)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
หมวด/ป้ายกำกับ (Eyebrow Tag)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={eyebrow}
|
||||||
|
onChange={(e) => updatePayloadValue('eyebrow', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs font-mono"
|
||||||
|
placeholder="ENGINEERED TO MOVE"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
ชื่อสินค้า (Product Name)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={productName}
|
||||||
|
onChange={(e) => updatePayloadValue('productName', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="Chrome Sprocket 428H"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
ซีรีส์สินค้า (Badge / Series)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={productBadge}
|
||||||
|
onChange={(e) => updatePayloadValue('productBadge', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs font-mono"
|
||||||
|
placeholder="MONO / 01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
หัวเรื่องหลัก (Main Title)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => updatePayloadValue('title', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="เหล็กหนึ่งชิ้น ที่เล่าได้ทุกองศา"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
คำบรรยายเกริ่นนำ (Intro Subtitle)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={subtitle}
|
||||||
|
onChange={(e) => updatePayloadValue('subtitle', e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="สเตอร์จักรยานยนต์โครเมียม ดีไซน์น้ำหนักเบา ให้ทุกการหมุนมีจังหวะที่แม่นยำ"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Macro Zoom Detail Overlay Texts ── */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-4">
|
||||||
|
<div className="text-xs font-bold text-lime-400 uppercase tracking-wider flex items-center gap-2">
|
||||||
|
<Layers className="w-4 h-4" />
|
||||||
|
<span>ข้อความส่วนซูม Macro Detail (แสดงเมื่อเลื่อนจอลงถึงช่วงท้าย)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
Kicker Tag
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={detailKicker}
|
||||||
|
onChange={(e) => updatePayloadValue('detailKicker', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs font-mono"
|
||||||
|
placeholder="FINISHED TO THE EDGE"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
Headline ตอนซูม
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={detailTitle}
|
||||||
|
onChange={(e) => updatePayloadValue('detailTitle', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs font-bold"
|
||||||
|
placeholder="ฟันแน่น แข็งแรง ผิวเรียบทุกขอบ"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
คำอธิบายรายละเอียด
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={detailCaption}
|
||||||
|
onChange={(e) => updatePayloadValue('detailCaption', e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="ผิวชุบโครเมียมขัดเงา สะท้อนแสงเงางาม ทนทานต่อการเสียดสี และป้องกันสนิมในทุกสภาพการใช้งาน"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 3-Spec Highlights Grid ── */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-4">
|
||||||
|
<div className="text-xs font-bold text-slate-200 uppercase tracking-wider">
|
||||||
|
จุดเด่นด้านสมรรถนะ 3 หัวข้อ (Technical Pillars)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{rawSpecs.map((spec, idx) => (
|
||||||
|
<div key={idx} className="p-3 rounded-xl bg-black/40 border border-white/10 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-mono font-bold text-lime-400">
|
||||||
|
#{idx + 1}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={spec.number || `0${idx + 1}`}
|
||||||
|
onChange={(e) => handleUpdateSpec(idx, 'number', e.target.value)}
|
||||||
|
className="admin-input w-16 text-center text-xs font-mono"
|
||||||
|
placeholder={`0${idx + 1}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={spec.title}
|
||||||
|
onChange={(e) => handleUpdateSpec(idx, 'title', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs font-bold"
|
||||||
|
placeholder="หัวข้อสเปก"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={spec.subtitle || ''}
|
||||||
|
onChange={(e) => handleUpdateSpec(idx, 'subtitle', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs font-mono text-slate-400"
|
||||||
|
placeholder="SUBTITLE EN"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Theme & CTA Config ── */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 rounded-2xl bg-slate-900/60 border border-white/5">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
สี Accent Highlight
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={accentColor}
|
||||||
|
onChange={(e) => updatePayloadValue('accentColor', e.target.value)}
|
||||||
|
className="w-8 h-8 rounded border-0 cursor-pointer bg-transparent"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={accentColor}
|
||||||
|
onChange={(e) => updatePayloadValue('accentColor', e.target.value)}
|
||||||
|
className="admin-input flex-1 text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
หัวข้อแบนเนอร์สั่งซื้อ (CTA Headline)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={endCtaTitle}
|
||||||
|
onChange={(e) => updatePayloadValue('endCtaTitle', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="พร้อมยกระดับสมรรถนะการขับขี่?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
ข้อความปุ่มสั่งซื้อ (CTA Button)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={endCtaButtonText}
|
||||||
|
onChange={(e) => updatePayloadValue('endCtaButtonText', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="สั่งซื้อสินค้าตอนนี้"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
โทนพื้นหลัง (Theme)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={theme}
|
||||||
|
onChange={(e) => updatePayloadValue('theme', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
>
|
||||||
|
<option value="dark">Dark Theater (#0f0f0e - แนะนำ)</option>
|
||||||
|
<option value="light">Light Studio (#f8f9fa)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { X, Search, Loader2 } from 'lucide-react';
|
import { X, Search, Loader2 } from 'lucide-react';
|
||||||
import { API_BASE } from '@homaroma/types';
|
import { API_BASE } from '@homaroma/types';
|
||||||
import { getAdminDisplayString } from '../../utils/localization';
|
import { getAdminDisplayString } from '../../utils/localization';
|
||||||
@@ -63,7 +64,7 @@ export default function ProductPickerModal({
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||||
<div className="absolute inset-0 bg-night-900/80 backdrop-blur-sm" onClick={onClose}></div>
|
<div className="absolute inset-0 bg-night-900/80 backdrop-blur-sm" onClick={onClose}></div>
|
||||||
<div className="relative bg-slate-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden flex flex-col h-[80vh]">
|
<div className="relative bg-slate-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden flex flex-col h-[80vh]">
|
||||||
@@ -160,6 +161,7 @@ export default function ProductPickerModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Film,
|
||||||
|
Package,
|
||||||
|
ExternalLink,
|
||||||
|
RotateCcw,
|
||||||
|
CheckCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { API_BASE } from '@homaroma/types'
|
||||||
|
import FrameSequenceModal, { type FrameSequenceItem } from './FrameSequenceModal'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
updatePayloadValue: (field: string, value: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VdoFrameScrubberForm({
|
||||||
|
payload,
|
||||||
|
updatePayloadValue,
|
||||||
|
}: Props) {
|
||||||
|
const [productsList, setProductsList] = useState<any[]>([])
|
||||||
|
const [loadingProducts, setLoadingProducts] = useState(false)
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
|
|
||||||
|
const folderPath = (payload.folderPath as string) || '/frame-sequences/chrome-sprocket-428h'
|
||||||
|
const pattern = (payload.pattern as string) || 'frame-{index}.webp'
|
||||||
|
const totalFrames = Number(payload.totalFrames) || 432
|
||||||
|
const fps = Number(payload.fps) || 24
|
||||||
|
const durationSeconds = Number(payload.durationSeconds) || 18
|
||||||
|
|
||||||
|
const eyebrow = (payload.eyebrow as string) || ''
|
||||||
|
const title = (payload.title as string) || ''
|
||||||
|
const subtitle = (payload.subtitle as string) || ''
|
||||||
|
const instruction = (payload.instruction as string) || ''
|
||||||
|
const productName = (payload.productName as string) || ''
|
||||||
|
const productBadge = (payload.productBadge as string) || ''
|
||||||
|
const accentColor = (payload.accentColor as string) || '#d5ff63'
|
||||||
|
const theme = (payload.theme as string) || 'dark'
|
||||||
|
const endCtaTitle = (payload.endCtaTitle as string) || ''
|
||||||
|
const endCtaButtonText = (payload.endCtaButtonText as string) || ''
|
||||||
|
const endCtaLink = (payload.endCtaLink as string) || ''
|
||||||
|
const selectedProductId = payload.productId ? String(payload.productId) : ''
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true
|
||||||
|
const fetchCatalog = async () => {
|
||||||
|
setLoadingProducts(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/products?limit=100`)
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
const items = data.data || data.products || (Array.isArray(data) ? data : [])
|
||||||
|
if (isMounted) setProductsList(items)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load products in VdoFrameScrubberForm', err)
|
||||||
|
} finally {
|
||||||
|
if (isMounted) setLoadingProducts(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchCatalog()
|
||||||
|
return () => { isMounted = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Auto-seed defaults on brand new block
|
||||||
|
useEffect(() => {
|
||||||
|
if (!payload.folderPath) updatePayloadValue('folderPath', '/frame-sequences/chrome-sprocket-428h')
|
||||||
|
if (!payload.pattern) updatePayloadValue('pattern', 'frame-{index}.webp')
|
||||||
|
if (!payload.padding) updatePayloadValue('padding', 3)
|
||||||
|
if (!payload.totalFrames) updatePayloadValue('totalFrames', 432)
|
||||||
|
if (!payload.fps) updatePayloadValue('fps', 24)
|
||||||
|
if (!payload.durationSeconds) updatePayloadValue('durationSeconds', 18)
|
||||||
|
if (!payload.eyebrow) updatePayloadValue('eyebrow', 'FROM VIDEO TO INTERACTIVE')
|
||||||
|
if (!payload.title) updatePayloadValue('title', 'หนึ่งวิดีโอ หมุนดูได้ทีละเฟรม')
|
||||||
|
if (!payload.subtitle) updatePayloadValue('subtitle', 'รวมวิดีโอ 18 วินาทีเป็น 432 เฟรม ลากภาพหรือเลื่อนหน้าเว็บเพื่อหมุนต่อ แล้วซูมดูฟันสเตอร์')
|
||||||
|
if (!payload.instruction) updatePayloadValue('instruction', 'ลากซ้าย–ขวา หรือเลื่อนหน้าเว็บ')
|
||||||
|
if (!payload.productName) updatePayloadValue('productName', 'SPROCKET / FRAME SCRUBBER')
|
||||||
|
if (!payload.productBadge) updatePayloadValue('productBadge', '18 SEC / 432 EXTRACTED FRAMES')
|
||||||
|
if (!payload.endCtaTitle) updatePayloadValue('endCtaTitle', 'ทุกมุม อยู่ในมือคุณ พร้อมยกระดับการขับขี่')
|
||||||
|
if (!payload.endCtaButtonText) updatePayloadValue('endCtaButtonText', 'สั่งซื้อสินค้าตอนนี้')
|
||||||
|
if (!payload.theme) updatePayloadValue('theme', 'dark')
|
||||||
|
if (!payload.accentColor) updatePayloadValue('accentColor', '#d5ff63')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSelectProduct = (productIdStr: string) => {
|
||||||
|
if (!productIdStr) {
|
||||||
|
updatePayloadValue('productId', null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const pid = Number(productIdStr)
|
||||||
|
updatePayloadValue('productId', pid)
|
||||||
|
|
||||||
|
const prod = productsList.find((p) => p.id === pid)
|
||||||
|
if (prod) {
|
||||||
|
const prodName = typeof prod.name === 'string' ? prod.name : prod.name?.th || prod.name?.en || ''
|
||||||
|
if (prodName) updatePayloadValue('productName', prodName)
|
||||||
|
const slug = prod.slug || `product-${pid}`
|
||||||
|
updatePayloadValue('endCtaLink', `/products/${slug}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyPreset = () => {
|
||||||
|
updatePayloadValue('folderPath', '/frame-sequences/chrome-sprocket-428h')
|
||||||
|
updatePayloadValue('pattern', 'frame-{index}.webp')
|
||||||
|
updatePayloadValue('padding', 3)
|
||||||
|
updatePayloadValue('totalFrames', 432)
|
||||||
|
updatePayloadValue('fps', 24)
|
||||||
|
updatePayloadValue('durationSeconds', 18)
|
||||||
|
updatePayloadValue('eyebrow', 'FROM VIDEO TO INTERACTIVE')
|
||||||
|
updatePayloadValue('title', 'หนึ่งวิดีโอ หมุนดูได้ทีละเฟรม')
|
||||||
|
updatePayloadValue('subtitle', 'รวมวิดีโอ 18 วินาทีเป็น 432 เฟรม ลากภาพหรือเลื่อนหน้าเว็บเพื่อหมุนต่อ แล้วซูมดูฟันสเตอร์')
|
||||||
|
updatePayloadValue('instruction', 'ลากซ้าย–ขวา หรือเลื่อนหน้าเว็บ')
|
||||||
|
updatePayloadValue('productName', 'SPROCKET / FRAME SCRUBBER')
|
||||||
|
updatePayloadValue('productBadge', '18 SEC / 432 EXTRACTED FRAMES')
|
||||||
|
updatePayloadValue('endCtaTitle', 'ทุกมุม อยู่ในมือคุณ พร้อมยกระดับการขับขี่')
|
||||||
|
updatePayloadValue('endCtaButtonText', 'สั่งซื้อสินค้าตอนนี้')
|
||||||
|
updatePayloadValue('theme', 'dark')
|
||||||
|
updatePayloadValue('accentColor', '#d5ff63')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSequenceSelected = (seq: FrameSequenceItem) => {
|
||||||
|
updatePayloadValue('folderPath', seq.folderPath)
|
||||||
|
updatePayloadValue('pattern', seq.pattern || 'frame-{index}.webp')
|
||||||
|
updatePayloadValue('padding', seq.padding || 3)
|
||||||
|
updatePayloadValue('totalFrames', seq.totalFrames)
|
||||||
|
updatePayloadValue('fps', seq.fps || 24)
|
||||||
|
updatePayloadValue('durationSeconds', seq.durationSeconds || parseFloat((seq.totalFrames / 24).toFixed(1)))
|
||||||
|
updatePayloadValue('productBadge', `${seq.durationSeconds}S / ${seq.totalFrames} FRAMES`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 text-slate-200">
|
||||||
|
{/* ── Preset Banner ── */}
|
||||||
|
<div className="p-4 rounded-2xl bg-gradient-to-r from-[#d5ff63]/10 via-slate-900/40 to-transparent border border-[#d5ff63]/30 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-[#d5ff63] font-bold text-xs uppercase tracking-wider">
|
||||||
|
<Film className="w-4 h-4 text-[#d5ff63]" />
|
||||||
|
<span>VDO Frame-by-Frame Interactive Scrubber</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
แสดงผลวิดีโอแบบ Frame Sequence ควบคุมเฟรมตามการเลื่อนหน้าเว็บ (Scroll) และการลาก (Drag Scrub)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApplyPreset}
|
||||||
|
className="px-4 py-2 rounded-xl bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e] transition-all flex items-center gap-1.5 shrink-0 shadow-lg cursor-pointer"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
<span>รีเซ็ต / โหลดตัวอย่าง Chrome Sprocket (432 เฟรม)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Sequence Collection Selector (Isolated Library) ── */}
|
||||||
|
<div className="p-5 rounded-2xl bg-slate-900/70 border border-white/5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-bold text-white uppercase tracking-wider">
|
||||||
|
<Film className="w-4 h-4 text-[#d5ff63]" />
|
||||||
|
<span>ชุดวิดีโอเฟรมที่เลือกใช้งาน (Frame Sequence Collection)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
className="px-4 py-1.5 rounded-xl bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e] transition-all flex items-center gap-1.5 cursor-pointer shadow-md"
|
||||||
|
>
|
||||||
|
<Film className="w-3.5 h-3.5" />
|
||||||
|
<span>เลือก / อัปโหลดชุด ZIP ใหม่</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Sequence Card */}
|
||||||
|
<div className="p-4 rounded-xl bg-black/50 border border-white/10 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-20 aspect-video rounded-lg bg-slate-800 overflow-hidden border border-white/10 shrink-0">
|
||||||
|
<img
|
||||||
|
src={`${folderPath}/frame-001.webp`}
|
||||||
|
alt="Sequence Poster"
|
||||||
|
className="w-full h-full object-contain p-1"
|
||||||
|
onError={(e) => {
|
||||||
|
// Fallback if not yet loaded
|
||||||
|
(e.target as HTMLImageElement).src = '/images/3d/sprocket-detail.png'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-bold text-white">
|
||||||
|
{folderPath.split('/').pop()?.replace(/[-_]/g, ' ').toUpperCase() || 'FRAME SEQUENCE'}
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-400 text-[9px] font-mono flex items-center gap-1">
|
||||||
|
<CheckCircle className="w-2.5 h-2.5" /> พร้อมใช้งาน
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-slate-400 font-mono">
|
||||||
|
<span>{totalFrames} FRAMES</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{fps} FPS</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{durationSeconds} วินาที</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-slate-500 font-mono">
|
||||||
|
โฟลเดอร์: {folderPath} (รูปแบบ: {pattern})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
className="text-xs text-[#d5ff63] hover:underline font-mono"
|
||||||
|
>
|
||||||
|
เปลี่ยนชุด →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 1-Click Product Linking ── */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-bold text-slate-200 uppercase tracking-wider">
|
||||||
|
<Package className="w-4 h-4 text-[#d5ff63]" />
|
||||||
|
<span>ผูกกับสินค้าในระบบ (1-Click Bind Product)</span>
|
||||||
|
</div>
|
||||||
|
{selectedProductId && (
|
||||||
|
<span className="text-[10px] text-[#d5ff63] font-mono">
|
||||||
|
Product ID: #{selectedProductId}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={selectedProductId}
|
||||||
|
onChange={(e) => handleSelectProduct(e.target.value)}
|
||||||
|
disabled={loadingProducts}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
>
|
||||||
|
<option value="">-- ไม่ระบุ (ใช้เป็นหน้า Brand/Product Story อิสระ) --</option>
|
||||||
|
{productsList.map((p) => {
|
||||||
|
const nameStr = typeof p.name === 'string' ? p.name : p.name?.th || p.name?.en || 'Unnamed'
|
||||||
|
return (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
#{p.id} - {nameStr}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={endCtaLink}
|
||||||
|
onChange={(e) => updatePayloadValue('endCtaLink', e.target.value)}
|
||||||
|
placeholder="ลิงก์ปลายทางเมื่อกดสั่งซื้อ เช่น /products/chain-sprocket-p1"
|
||||||
|
className="admin-input flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
{endCtaLink && (
|
||||||
|
<a
|
||||||
|
href={endCtaLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="p-2 rounded-lg bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Headline & Header Texts ── */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-4">
|
||||||
|
<div className="text-xs font-bold text-slate-200 uppercase tracking-wider">
|
||||||
|
ข้อความหัวเรื่อง (Header & Story Intro)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
หมวด/ป้ายกำกับ (Eyebrow Tag)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={eyebrow}
|
||||||
|
onChange={(e) => updatePayloadValue('eyebrow', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs font-mono"
|
||||||
|
placeholder="FROM VIDEO TO INTERACTIVE"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
ป้ายซีรีส์ (Badge / Tag)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={productBadge}
|
||||||
|
onChange={(e) => updatePayloadValue('productBadge', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs font-mono"
|
||||||
|
placeholder="18 SEC / 432 FRAMES"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
ชื่อสินค้า (Product Name)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={productName}
|
||||||
|
onChange={(e) => updatePayloadValue('productName', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="SPROCKET / FRAME SCRUBBER"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
คำแนะนำการเลื่อน (Instruction Cue)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={instruction}
|
||||||
|
onChange={(e) => updatePayloadValue('instruction', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="ลากซ้าย–ขวา หรือเลื่อนหน้าเว็บ"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
หัวเรื่องหลัก (Main Title)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => updatePayloadValue('title', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="หนึ่งวิดีโอ หมุนดูได้ทีละเฟรม"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
คำบรรยายเกริ่นนำ (Intro Subtitle)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={subtitle}
|
||||||
|
onChange={(e) => updatePayloadValue('subtitle', e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="รวมวิดีโอ 18 วินาทีเป็น 432 เฟรม ลากภาพหรือเลื่อนหน้าเว็บเพื่อหมุนต่อ แล้วซูมดูฟันสเตอร์"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Theme, Accent, and Conversion CTA ── */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 p-4 rounded-2xl bg-slate-900/60 border border-white/5">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
สี Accent Highlight
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={accentColor}
|
||||||
|
onChange={(e) => updatePayloadValue('accentColor', e.target.value)}
|
||||||
|
className="w-8 h-8 rounded border-0 cursor-pointer bg-transparent"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={accentColor}
|
||||||
|
onChange={(e) => updatePayloadValue('accentColor', e.target.value)}
|
||||||
|
className="admin-input flex-1 text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
โทนพื้นหลัง (Theme)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={theme}
|
||||||
|
onChange={(e) => updatePayloadValue('theme', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
>
|
||||||
|
<option value="dark">Dark Theater (#0e0e0d - แนะนำ)</option>
|
||||||
|
<option value="light">Light Studio (#f8f9fa)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
หัวข้อแบนเนอร์สั่งซื้อ (CTA Headline)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={endCtaTitle}
|
||||||
|
onChange={(e) => updatePayloadValue('endCtaTitle', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="ทุกมุม อยู่ในมือคุณ"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
ข้อความปุ่มสั่งซื้อ (CTA Button)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={endCtaButtonText}
|
||||||
|
onChange={(e) => updatePayloadValue('endCtaButtonText', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="สั่งซื้อสินค้าตอนนี้"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal for Frame Sequence Collection */}
|
||||||
|
<FrameSequenceModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
onSelectSequence={handleSequenceSelected}
|
||||||
|
selectedSlug={folderPath.split('/').pop()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Receipt,
|
||||||
|
DollarSign,
|
||||||
|
Wallet,
|
||||||
|
BadgePercent,
|
||||||
|
Coins,
|
||||||
|
Package,
|
||||||
|
Truck,
|
||||||
|
ExternalLink,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertTriangle,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
TrendingUp,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { TIKTOK_SHOPS } from '../../pages/TiktokOrderPage';
|
||||||
|
|
||||||
|
function fmt(val: number | null | undefined) {
|
||||||
|
if (val == null) return '0.00';
|
||||||
|
return new Intl.NumberFormat('th-TH', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TiktokFeeBreakdownModalProps {
|
||||||
|
order: any;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TiktokFeeBreakdownModal({ order, onClose }: TiktokFeeBreakdownModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
// Keyboard navigation & body scroll lock
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
// Prevent background page from scrolling
|
||||||
|
const originalOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
document.body.style.overflow = originalOverflow;
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
if (!order || typeof document === 'undefined') return null;
|
||||||
|
|
||||||
|
const settlement = order.settlement;
|
||||||
|
const isSettled = order.isSettled || !!settlement;
|
||||||
|
const shopCfg = TIKTOK_SHOPS.find((s) => s.id === order.shop) || TIKTOK_SHOPS[0];
|
||||||
|
|
||||||
|
// Financial figures (Settlement fees are usually stored as negative numbers from TikTok reports)
|
||||||
|
const grossRevenue = settlement?.grossRevenue ?? settlement?.subtotalAfterSellerDiscount ?? order.orderAmount ?? 0;
|
||||||
|
const sellerDiscount = Math.abs(settlement?.sellerDiscount ?? 0);
|
||||||
|
const subtotalBeforeDiscount = settlement?.subtotalBeforeDiscount ?? (grossRevenue + sellerDiscount);
|
||||||
|
|
||||||
|
const commissionFee = Math.abs(settlement?.commissionFee ?? order.commissionFee ?? 0);
|
||||||
|
const affiliateCommission = Math.abs(settlement?.affiliateCommission ?? order.affiliateFee ?? 0);
|
||||||
|
const affiliatePartnerFee = Math.abs(settlement?.affiliatePartnerFee ?? 0);
|
||||||
|
const totalAffiliate = affiliateCommission + affiliatePartnerFee;
|
||||||
|
const orderFee = Math.abs(settlement?.orderFee ?? 0);
|
||||||
|
const growthSupportFee = Math.abs(settlement?.growthSupportFee ?? 0);
|
||||||
|
const shippingFeeActual = Math.abs(settlement?.shippingFeeActual ?? 0);
|
||||||
|
const platformShippingDiscount = Math.abs(settlement?.platformShippingDiscount ?? 0);
|
||||||
|
const netShippingToSeller = Math.max(0, shippingFeeActual - platformShippingDiscount);
|
||||||
|
|
||||||
|
// Total deductions
|
||||||
|
const rawTotalFee = settlement?.totalFee != null
|
||||||
|
? Math.abs(settlement.totalFee)
|
||||||
|
: (commissionFee + totalAffiliate + orderFee + growthSupportFee + netShippingToSeller);
|
||||||
|
|
||||||
|
// Net payout
|
||||||
|
const netSettlement = settlement?.settlementAmount ?? order.netSettlement ?? (grossRevenue - commissionFee - totalAffiliate);
|
||||||
|
|
||||||
|
// BOM & Net Profit
|
||||||
|
const totalBomCost = order.orderTotalBomCost || 0;
|
||||||
|
const hasMissingBom = order.hasIncompleteBom || (order.missingBomCount ?? 0) > 0;
|
||||||
|
const realNetProfit = netSettlement - totalBomCost;
|
||||||
|
const marginOnSales = grossRevenue > 0 ? (realNetProfit / grossRevenue) * 100 : 0;
|
||||||
|
const markupOnCost = totalBomCost > 0 ? (realNetProfit / totalBomCost) * 100 : 0;
|
||||||
|
const realNetMargin = marginOnSales;
|
||||||
|
|
||||||
|
const handleCopyOrderId = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigator.clipboard.writeText(order.orderId);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[9999] flex items-center justify-center p-3 sm:p-5 md:p-6 bg-black/80 backdrop-blur-md animate-fade-in"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-slate-900 border border-white/15 rounded-3xl w-full max-w-5xl xl:max-w-6xl max-h-[92vh] flex flex-col shadow-2xl shadow-black/90 overflow-hidden text-white"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/[0.02]">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-violet-500/20 to-cyan-500/20 border border-violet-500/30 flex items-center justify-center shadow-lg shadow-violet-500/10 shrink-0">
|
||||||
|
<Receipt className="w-5 h-5 text-violet-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h3 className="font-bold text-white text-base">
|
||||||
|
{t('tiktokOrders.feeModalTitle', 'TikTok Fee & Settlement Breakdown')}
|
||||||
|
</h3>
|
||||||
|
{isSettled ? (
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 inline-flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
{t('tiktokOrders.settlementCompleteBadge', 'โอนเงินแล้ว')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/15 text-amber-400 border border-amber-500/30 inline-flex items-center gap-1">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
{t('tiktokOrders.settlementPendingBadge', 'รอ Settlement')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-400 mt-0.5 flex-wrap">
|
||||||
|
<span className="font-mono text-rose-400 font-bold">#{order.orderId}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyOrderId}
|
||||||
|
className="text-slate-400 hover:text-white p-0.5 rounded transition-colors inline-flex items-center gap-1 text-[11px]"
|
||||||
|
title={t('tiktokOrders.copyOrderId', 'คัดลอก Order ID')}
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-3 h-3 text-emerald-400" /> : <Copy className="w-3 h-3" />}
|
||||||
|
{copied && <span className="text-[10px] text-emerald-400 font-sans">{t('tiktokOrders.copied', 'คัดลอกแล้ว')}</span>}
|
||||||
|
</button>
|
||||||
|
<span>•</span>
|
||||||
|
<span className={`font-semibold ${shopCfg.color}`}>{shopCfg.label}</span>
|
||||||
|
{order.buyerUsername && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="text-slate-300">ผู้ซื้อ: {order.buyerUsername}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{order.createdTime && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="text-slate-400 font-mono">{order.createdTime.slice(0, 16)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-all ml-2"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable Content */}
|
||||||
|
<div className="p-6 space-y-5 overflow-y-auto">
|
||||||
|
{/* Pending Settlement Notice if not settled */}
|
||||||
|
{!settlement && (
|
||||||
|
<div className="p-3.5 rounded-2xl bg-amber-500/10 border border-amber-500/30 flex items-start gap-3 text-amber-300 text-xs">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="font-bold text-amber-200">
|
||||||
|
{t('tiktokOrders.settlementPendingAlert', 'คำสั่งซื้อนี้ยังไม่มีข้อมูลใบแจ้งยอด Settlement')}
|
||||||
|
</p>
|
||||||
|
<p className="text-amber-300/80 leading-relaxed text-[11px]">
|
||||||
|
{t(
|
||||||
|
'tiktokOrders.settlementPendingDesc',
|
||||||
|
'ค่าคอมมิชชั่นและตัวเลขด้านล่างเป็นการคำนวณเบื้องต้นจากข้อมูล Order จนกว่าจะมีการ Import ไฟล์ Settlement XLSX จาก TikTok Seller Center'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 5 Financial Summary Cards (Compact Banner) */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2.5">
|
||||||
|
{/* 1. Revenue */}
|
||||||
|
<div className="p-3.5 rounded-2xl bg-white/5 border border-white/10 flex flex-col justify-between min-h-[96px]">
|
||||||
|
<div className="flex items-center justify-between text-slate-400 text-[11px]">
|
||||||
|
<span className="truncate">{t('tiktokOrders.orderRevenueLabel', 'ยอดขายสินค้า')}</span>
|
||||||
|
<DollarSign className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold font-mono text-white mt-1">฿{fmt(grossRevenue)}</p>
|
||||||
|
<span className="text-[10px] text-slate-500 truncate" title={sellerDiscount > 0 ? `ลด ฿${fmt(sellerDiscount)} จาก ฿${fmt(subtotalBeforeDiscount)}` : 'รวมส่วนลดร้านค้าแล้ว'}>
|
||||||
|
{sellerDiscount > 0 ? `ลด ฿${fmt(sellerDiscount)} จาก ฿${fmt(subtotalBeforeDiscount)}` : 'รวมส่วนลดแล้ว'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. TikTok Deductions */}
|
||||||
|
<div className="p-3.5 rounded-2xl bg-rose-500/10 border border-rose-500/20 flex flex-col justify-between min-h-[96px]">
|
||||||
|
<div className="flex items-center justify-between text-rose-300 text-[11px]">
|
||||||
|
<span className="truncate">{t('tiktokOrders.tiktokDeductionsLabel', 'TikTok หักรวม')}</span>
|
||||||
|
<BadgePercent className="w-3.5 h-3.5 text-rose-400 shrink-0" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold font-mono text-rose-400 mt-1">-฿{fmt(rawTotalFee)}</p>
|
||||||
|
<span className="text-[10px] text-rose-400/80 truncate">
|
||||||
|
{grossRevenue > 0 ? `${((rawTotalFee / grossRevenue) * 100).toFixed(1)}% ของยอดขาย` : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Net Payout */}
|
||||||
|
<div className="p-3.5 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex flex-col justify-between min-h-[96px]">
|
||||||
|
<div className="flex items-center justify-between text-cyan-300 text-[11px]">
|
||||||
|
<span className="truncate">{t('tiktokOrders.netPayoutLabel', 'ยอดโอนสุทธิ')}</span>
|
||||||
|
<Wallet className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold font-mono text-cyan-400 mt-1">฿{fmt(netSettlement)}</p>
|
||||||
|
<span className="text-[10px] text-cyan-400/80 truncate">
|
||||||
|
{settlement?.orderSettledTime ? `โอนเมื่อ ${settlement.orderSettledTime.slice(0, 10)}` : 'รอสรุปยอดโอน'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 4. Product Cost (BOM) */}
|
||||||
|
<div className="p-3.5 rounded-2xl bg-amber-500/10 border border-amber-500/20 flex flex-col justify-between min-h-[96px]">
|
||||||
|
<div className="flex items-center justify-between text-amber-300 text-[11px]">
|
||||||
|
<span className="truncate">{t('tiktokOrders.cardBomCost', 'ต้นทุนสินค้า (BOM)')}</span>
|
||||||
|
<Coins className="w-3.5 h-3.5 text-amber-400 shrink-0" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold font-mono text-amber-400 mt-1">฿{fmt(totalBomCost)}</p>
|
||||||
|
<span className="text-[10px] text-amber-400/80 truncate">
|
||||||
|
{hasMissingBom
|
||||||
|
? '⚠️ มีสินค้ายังไม่ใส่ BOM'
|
||||||
|
: grossRevenue > 0
|
||||||
|
? `${((totalBomCost / grossRevenue) * 100).toFixed(1)}% ของยอดขาย`
|
||||||
|
: 'BOM ครบ 100%'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5. Net Profit (with Margin % on sales and Markup % on cost) */}
|
||||||
|
<div className={`p-3.5 rounded-2xl border flex flex-col justify-between min-h-[96px] ${
|
||||||
|
realNetProfit >= 0 ? 'bg-emerald-500/10 border-emerald-500/25' : 'bg-rose-500/10 border-rose-500/25'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center justify-between text-[11px]">
|
||||||
|
<span className={`truncate font-semibold ${realNetProfit >= 0 ? 'text-emerald-300' : 'text-rose-300'}`}>
|
||||||
|
{t('tiktokOrders.cardNetProfit', 'กำไรสุทธิ')}
|
||||||
|
</span>
|
||||||
|
<TrendingUp className={`w-3.5 h-3.5 shrink-0 ${realNetProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`} />
|
||||||
|
</div>
|
||||||
|
<p className={`text-lg font-bold font-mono mt-1 ${realNetProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||||
|
฿{fmt(realNetProfit)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] font-mono leading-tight flex-wrap">
|
||||||
|
<span className={realNetProfit >= 0 ? 'text-emerald-300 font-semibold' : 'text-rose-300 font-semibold'}>
|
||||||
|
กำไร/ขาย {marginOnSales.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500">•</span>
|
||||||
|
<span className={realNetProfit >= 0 ? 'text-emerald-300 font-semibold' : 'text-rose-300 font-semibold'}>
|
||||||
|
กำไร/ทุน {markupOnCost >= 0 ? '+' : ''}{markupOnCost.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2-Column Wide Landscape Grid Layout */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5 items-start">
|
||||||
|
{/* Left Column (7 Cols): TikTok Platform Fees Breakdown */}
|
||||||
|
<div className="lg:col-span-7 p-4 sm:p-5 rounded-2xl bg-black/30 border border-white/10 space-y-3.5">
|
||||||
|
<div className="flex items-center justify-between pb-2.5 border-b border-white/10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BadgePercent className="w-4 h-4 text-violet-400" />
|
||||||
|
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-300">
|
||||||
|
{t('tiktokOrders.feeBreakdownTitle', 'Breakdown รายการหักค่าใช้จ่ายของ TikTok')}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono font-bold text-rose-400">-฿{fmt(rawTotalFee)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2.5 text-xs">
|
||||||
|
{/* Commission Fee */}
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-xl bg-violet-500/10 border border-violet-500/20">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-violet-400 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-violet-200">
|
||||||
|
{t('tiktokOrders.tiktokCommissionFee', 'ค่าคอมมิชชั่น TikTok Shop (Commission Fee)')}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-violet-300/70">
|
||||||
|
{t('tiktokOrders.tiktokCommissionDesc', 'หักตามอัตราหมวดหมู่สินค้าของแพลตฟอร์ม')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono font-bold text-violet-400 text-sm">
|
||||||
|
{commissionFee > 0 ? `-฿${fmt(commissionFee)}` : '฿0.00'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Affiliate Creator Commission */}
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-xl bg-cyan-500/10 border border-cyan-500/20">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-cyan-400 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-cyan-200">
|
||||||
|
{t('tiktokOrders.affiliateCommissionFee', 'ค่าคอมมิชชั่น Creator แอฟฟิลิเอต (Affiliate)')}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-cyan-300/70">
|
||||||
|
{t('tiktokOrders.affiliateCommissionDesc', 'จ่ายให้ครีเอเตอร์/นายหน้าที่แปะลิงก์หรือไลฟ์')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono font-bold text-cyan-400 text-sm">
|
||||||
|
{affiliateCommission > 0 ? `-฿${fmt(affiliateCommission)}` : '฿0.00'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Affiliate Partner Fee (TAP) */}
|
||||||
|
{(affiliatePartnerFee > 0 || settlement) && (
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/5 text-slate-300">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-cyan-400/60" />
|
||||||
|
<span>{t('tiktokOrders.affiliatePartnerFee', 'ค่าคอมมิชชั่น Partner แอฟฟิลิเอต (TAP)')}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-cyan-300 font-semibold">
|
||||||
|
{affiliatePartnerFee > 0 ? `-฿${fmt(affiliatePartnerFee)}` : '฿0.00'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Order / Transaction Fee */}
|
||||||
|
{(orderFee > 0 || settlement) && (
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/5 text-slate-300">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
||||||
|
<span>{t('tiktokOrders.orderFee', 'ค่าธรรมเนียมคำสั่งซื้อ/ธุรกรรม (Order Fee)')}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-amber-400 font-semibold">
|
||||||
|
{orderFee > 0 ? `-฿${fmt(orderFee)}` : '฿0.00'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Growth Support Fee */}
|
||||||
|
{growthSupportFee > 0 && (
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/5 text-slate-300">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-blue-400" />
|
||||||
|
<span>{t('tiktokOrders.growthSupportFee', 'ค่าธรรมเนียมสนับสนุนการเติบโต (Growth Support)')}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-blue-400 font-semibold">
|
||||||
|
-฿{fmt(growthSupportFee)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Shipping Breakdown */}
|
||||||
|
{(shippingFeeActual > 0 || platformShippingDiscount > 0) && (
|
||||||
|
<div className="p-3 rounded-xl bg-white/[0.02] border border-white/5 space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-slate-300 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Truck className="w-3.5 h-3.5 text-blue-400" />
|
||||||
|
<span>{t('tiktokOrders.shippingBreakdown', 'ค่าจัดส่งขนส่ง')}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono font-semibold text-slate-200">
|
||||||
|
{netShippingToSeller > 0 ? `-฿${fmt(netShippingToSeller)}` : t('tiktokOrders.shippingFreeForSeller', 'ร้านค้าไม่เสียค่าส่ง')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[11px] pl-5 text-slate-400">
|
||||||
|
<span>
|
||||||
|
{t('tiktokOrders.shippingBreakdownDetail', {
|
||||||
|
actual: fmt(shippingFeeActual),
|
||||||
|
subsidy: fmt(platformShippingDiscount),
|
||||||
|
defaultValue: `ค่าส่งจริง ฿${fmt(shippingFeeActual)} | TikTok อุดหนุน -฿${fmt(platformShippingDiscount)}`
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column (5 Cols): BOM Profitability & Order Items */}
|
||||||
|
<div className="lg:col-span-5 space-y-4">
|
||||||
|
{/* BOM Profitability Analysis Card */}
|
||||||
|
<div
|
||||||
|
className={`p-4 sm:p-5 rounded-2xl border space-y-3 ${
|
||||||
|
hasMissingBom ? 'bg-rose-500/10 border-rose-500/25' : 'bg-emerald-500/10 border-emerald-500/25'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between pb-2 border-b border-white/10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Coins className={`w-4 h-4 ${hasMissingBom ? 'text-rose-400' : 'text-emerald-400'}`} />
|
||||||
|
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-200">
|
||||||
|
{t('tiktokOrders.bomProfitAnalysis', 'วิเคราะห์กำไรสุทธิจริงหลังหักต้นทุน BOM')}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
{hasMissingBom ? (
|
||||||
|
<span className="text-[10px] font-bold text-rose-300 px-2 py-0.5 rounded-full bg-rose-500/20 border border-rose-500/30">
|
||||||
|
{t('tiktokOrders.missingBomCount', { count: order.missingBomCount })}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] font-bold text-emerald-300 px-2 py-0.5 rounded-full bg-emerald-500/20 border border-emerald-500/30">
|
||||||
|
{t('tiktokOrders.bomComplete100', 'BOM ครบ 100%')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div className="flex justify-between items-center text-slate-300">
|
||||||
|
<span>{t('tiktokOrders.netPayoutLabel', 'ยอดเงินที่ TikTok โอนให้')}</span>
|
||||||
|
<span className="font-mono font-bold text-white">฿{fmt(netSettlement)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center text-amber-300">
|
||||||
|
<span>{t('tiktokOrders.totalBomCostLabel', 'หัก ต้นทุนสินค้าตามสูตร BOM')}</span>
|
||||||
|
<span className="font-mono font-bold">
|
||||||
|
{totalBomCost > 0 ? `-฿${fmt(totalBomCost)}` : '฿0.00'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2.5 border-t border-white/10 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-white text-sm">
|
||||||
|
{t('tiktokOrders.realNetProfitLabel', 'กำไรสุทธิแท้จริง (Real Net Profit)')}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-400">
|
||||||
|
{t('tiktokOrders.realNetMarginLabel', 'คิดเป็นอัตรากำไร')}:{' '}
|
||||||
|
<span className="font-mono font-bold text-slate-200">{realNetMargin.toFixed(1)}%</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className={`text-2xl font-bold font-mono ${realNetProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||||
|
฿{fmt(realNetProfit)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Order Items Snippet */}
|
||||||
|
{order.items && order.items.length > 0 && (
|
||||||
|
<div className="p-4 rounded-2xl bg-black/20 border border-white/10 space-y-2.5">
|
||||||
|
<div className="flex items-center justify-between text-xs font-bold text-slate-400 pb-1 border-b border-white/5">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Package className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
<span>
|
||||||
|
{t('tiktokOrders.orderItemsTitle', 'รายการสินค้าในออเดอร์นี้')} ({order.items.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-white/5 max-h-48 overflow-y-auto pr-1">
|
||||||
|
{order.items.map((item: any, idx: number) => (
|
||||||
|
<div key={idx} className="py-2 flex items-center justify-between gap-3 text-xs">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-slate-200 font-medium truncate">{item.productName}</p>
|
||||||
|
<p className="text-[11px] text-slate-400 font-mono">
|
||||||
|
{item.variation ? `[${item.variation}] ` : ''}จำนวน: {item.quantity} ชิ้น
|
||||||
|
{item.masterSku ? ` • Master: ${item.masterSku}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0 font-mono">
|
||||||
|
<p className="text-white font-bold">
|
||||||
|
฿{fmt(item.subtotalAfterDiscount || item.unitOriginalPrice * (item.quantity || 1))}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-amber-400">BOM: ฿{fmt(item.totalBomCost || 0)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-t border-white/10 bg-white/[0.02]">
|
||||||
|
<Link
|
||||||
|
to={`/tiktok-order/${encodeURIComponent(order.orderId)}`}
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-rose-500/15 hover:bg-rose-500/25 border border-rose-500/30 text-rose-300 font-bold text-xs transition-colors"
|
||||||
|
>
|
||||||
|
<span>{t('tiktokOrders.viewFullOrderDetail', 'เปิดดูคำสั่งซื้อฉบับเต็ม')}</span>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-6 py-2 rounded-xl bg-white/10 hover:bg-white/15 text-white font-bold text-xs transition-colors"
|
||||||
|
>
|
||||||
|
{t('tiktokOrders.closeModal', 'ปิด')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -116,18 +116,18 @@
|
|||||||
/* Animations */
|
/* Animations */
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; transform: translateY(10px); }
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes zoomIn {
|
@keyframes zoomIn {
|
||||||
from { opacity: 0; transform: scale(0.95); }
|
from { opacity: 0; transform: scale(0.95); }
|
||||||
to { opacity: 1; transform: scale(1); }
|
to { opacity: 1; transform: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-fade-in {
|
.animate-fade-in {
|
||||||
animation: fadeIn 0.4s ease-out forwards;
|
animation: fadeIn 0.4s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-zoom-in {
|
.animate-zoom-in {
|
||||||
animation: zoomIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
animation: zoomIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
}
|
}
|
||||||
@@ -454,6 +454,41 @@
|
|||||||
"thAffiliate": "Affiliate (฿)",
|
"thAffiliate": "Affiliate (฿)",
|
||||||
"thNetProfit": "Net Profit",
|
"thNetProfit": "Net Profit",
|
||||||
"thActions": "Actions",
|
"thActions": "Actions",
|
||||||
|
"viewFeeBreakdown": "Click to view TikTok fee breakdown",
|
||||||
|
"feeModalTitle": "TikTok Fee & Settlement Breakdown",
|
||||||
|
"feeModalSub": "Detailed breakdown of TikTok Shop commission, affiliate fees, and net payout",
|
||||||
|
"settlementPendingAlert": "This order does not have a TikTok Settlement statement yet",
|
||||||
|
"settlementPendingDesc": "Commissions and fees below are preliminary estimates from order data until a Settlement XLSX file is imported from TikTok Seller Center.",
|
||||||
|
"settlementCompleteBadge": "Settled & Transferred",
|
||||||
|
"settlementPendingBadge": "Pending Settlement",
|
||||||
|
"orderRevenueLabel": "Product Revenue",
|
||||||
|
"tiktokDeductionsLabel": "Total TikTok Deductions",
|
||||||
|
"netPayoutLabel": "Net Payout",
|
||||||
|
"cardBomCost": "Product Cost (BOM)",
|
||||||
|
"cardNetProfit": "Net Profit",
|
||||||
|
"profitOnSales": "Profit/Sales",
|
||||||
|
"profitOnCost": "Profit/Cost",
|
||||||
|
"feeBreakdownTitle": "TikTok Platform Fee Deductions Breakdown",
|
||||||
|
"tiktokCommissionFee": "TikTok Shop Commission Fee",
|
||||||
|
"tiktokCommissionDesc": "Platform category commission rate deduction",
|
||||||
|
"affiliateCommissionFee": "Creator Affiliate Commission Fee",
|
||||||
|
"affiliateCommissionDesc": "Commission paid to creators/affiliates",
|
||||||
|
"affiliatePartnerFee": "Affiliate Partner Fee (TAP)",
|
||||||
|
"orderFee": "Order / Transaction Fee",
|
||||||
|
"growthSupportFee": "Growth Support Fee",
|
||||||
|
"shippingBreakdown": "Shipping & Logistics Fees",
|
||||||
|
"shippingBreakdownDetail": "Actual Shipping ฿{{actual}} | Platform Subsidy -฿{{subsidy}}",
|
||||||
|
"shippingFreeForSeller": "Seller shipping covered",
|
||||||
|
"sellerDiscountLabel": "Seller Discount",
|
||||||
|
"bomProfitAnalysis": "Real Net Profit Analysis (After BOM Cost)",
|
||||||
|
"totalBomCostLabel": "Deduct: BOM Material Cost",
|
||||||
|
"realNetProfitLabel": "Real Net Profit",
|
||||||
|
"realNetMarginLabel": "Net Margin",
|
||||||
|
"orderItemsTitle": "Items in this Order",
|
||||||
|
"viewFullOrderDetail": "View Full Order Detail",
|
||||||
|
"closeModal": "Close",
|
||||||
|
"copyOrderId": "Copy Order ID",
|
||||||
|
"copied": "Copied",
|
||||||
"processingData": "Processing data...",
|
"processingData": "Processing data...",
|
||||||
"noOrders": "No data available",
|
"noOrders": "No data available",
|
||||||
"settledTransferred": "Settled ฿{{amount}}",
|
"settledTransferred": "Settled ฿{{amount}}",
|
||||||
|
|||||||
@@ -450,6 +450,41 @@
|
|||||||
"thAffiliate": "Affiliate (฿)",
|
"thAffiliate": "Affiliate (฿)",
|
||||||
"thNetProfit": "純利益",
|
"thNetProfit": "純利益",
|
||||||
"thActions": "操作",
|
"thActions": "操作",
|
||||||
|
"viewFeeBreakdown": "クリックしてTikTok手数料明細を表示",
|
||||||
|
"feeModalTitle": "TikTok 手数料・精算内訳 (Breakdown)",
|
||||||
|
"feeModalSub": "TikTok Shop手数料、アフィリエイト報酬、純利益の詳細内訳",
|
||||||
|
"settlementPendingAlert": "この注文にはまだTikTok精算明細データがありません",
|
||||||
|
"settlementPendingDesc": "以下の手数料と数値は注文データに基づく概算です。TikTokセラーセンターから精算XLSXがインポートされると確定値に更新されます。",
|
||||||
|
"settlementCompleteBadge": "振込完了 (Settled)",
|
||||||
|
"settlementPendingBadge": "精算待ち",
|
||||||
|
"orderRevenueLabel": "商品売上高",
|
||||||
|
"tiktokDeductionsLabel": "TikTok 控除合計",
|
||||||
|
"netPayoutLabel": "振込受取額 (Net Payout)",
|
||||||
|
"cardBomCost": "商品原価 (BOM)",
|
||||||
|
"cardNetProfit": "純利益",
|
||||||
|
"profitOnSales": "売上利益率",
|
||||||
|
"profitOnCost": "原価利益率",
|
||||||
|
"feeBreakdownTitle": "TikTok プラットフォーム控除の内訳",
|
||||||
|
"tiktokCommissionFee": "TikTok Shop プラットフォーム手数料 (Commission)",
|
||||||
|
"tiktokCommissionDesc": "カテゴリ別プラットフォーム手数料率による控除",
|
||||||
|
"affiliateCommissionFee": "クリエイターアフィリエイト報酬 (Affiliate)",
|
||||||
|
"affiliateCommissionDesc": "紹介・ライブ配信クリエイターへの支払報酬",
|
||||||
|
"affiliatePartnerFee": "パートナー手数料 (TAP)",
|
||||||
|
"orderFee": "注文・決済処理手数料 (Order Fee)",
|
||||||
|
"growthSupportFee": "成長支援手数料 (Growth Support)",
|
||||||
|
"shippingBreakdown": "配送・物流手数料内訳",
|
||||||
|
"shippingBreakdownDetail": "実送料 ฿{{actual}} | プラットフォーム補助 -฿{{subsidy}}",
|
||||||
|
"shippingFreeForSeller": "ショップ負担なし",
|
||||||
|
"sellerDiscountLabel": "ショップ割引",
|
||||||
|
"bomProfitAnalysis": "BOM原価控除後の実質純利益分析",
|
||||||
|
"totalBomCostLabel": "控除:BOM原料および包材原価",
|
||||||
|
"realNetProfitLabel": "実質純利益 (Real Net Profit)",
|
||||||
|
"realNetMarginLabel": "純利益率",
|
||||||
|
"orderItemsTitle": "注文内商品一覧",
|
||||||
|
"viewFullOrderDetail": "注文詳細画面を開く",
|
||||||
|
"closeModal": "閉じる",
|
||||||
|
"copyOrderId": "注文番号をコピー",
|
||||||
|
"copied": "コピー完了",
|
||||||
"processingData": "データを処理中...",
|
"processingData": "データを処理中...",
|
||||||
"noOrders": "データがありません",
|
"noOrders": "データがありません",
|
||||||
"settledTransferred": "振込済 ฿{{amount}}",
|
"settledTransferred": "振込済 ฿{{amount}}",
|
||||||
|
|||||||
@@ -454,6 +454,41 @@
|
|||||||
"thAffiliate": "Affiliate (฿)",
|
"thAffiliate": "Affiliate (฿)",
|
||||||
"thNetProfit": "กำไรสุทธิ",
|
"thNetProfit": "กำไรสุทธิ",
|
||||||
"thActions": "การกระทำ",
|
"thActions": "การกระทำ",
|
||||||
|
"viewFeeBreakdown": "คลิกดู Breakdown ค่าธรรมเนียม TikTok",
|
||||||
|
"feeModalTitle": "TikTok Fee & Settlement Breakdown",
|
||||||
|
"feeModalSub": "รายละเอียดค่าธรรมเนียม TikTok Shop, Affiliate และกำไรสุทธิ",
|
||||||
|
"settlementPendingAlert": "คำสั่งซื้อนี้ยังไม่มีข้อมูลใบแจ้งยอด Settlement",
|
||||||
|
"settlementPendingDesc": "ค่าคอมมิชชั่นและตัวเลขด้านล่างเป็นการคำนวณเบื้องต้นจากข้อมูล Order จนกว่าจะมีการ Import ไฟล์ Settlement XLSX จาก TikTok Seller Center",
|
||||||
|
"settlementCompleteBadge": "โอนเงินแล้ว",
|
||||||
|
"settlementPendingBadge": "รอ Settlement",
|
||||||
|
"orderRevenueLabel": "ยอดขายสินค้า",
|
||||||
|
"tiktokDeductionsLabel": "TikTok หักรวม",
|
||||||
|
"netPayoutLabel": "ยอดโอนสุทธิ",
|
||||||
|
"cardBomCost": "ต้นทุนสินค้า (BOM)",
|
||||||
|
"cardNetProfit": "กำไรสุทธิ",
|
||||||
|
"profitOnSales": "กำไร/ยอดขาย",
|
||||||
|
"profitOnCost": "กำไร/ทุน",
|
||||||
|
"feeBreakdownTitle": "Breakdown รายการหักค่าใช้จ่ายของ TikTok",
|
||||||
|
"tiktokCommissionFee": "ค่าคอมมิชชั่น TikTok Shop (Commission Fee)",
|
||||||
|
"tiktokCommissionDesc": "หักตามอัตราหมวดหมู่สินค้าของแพลตฟอร์ม",
|
||||||
|
"affiliateCommissionFee": "ค่าคอมมิชชั่น Creator แอฟฟิลิเอต (Affiliate)",
|
||||||
|
"affiliateCommissionDesc": "จ่ายให้ครีเอเตอร์/นายหน้าที่แปะลิงก์หรือไลฟ์",
|
||||||
|
"affiliatePartnerFee": "ค่าคอมมิชชั่น Partner แอฟฟิลิเอต (TAP)",
|
||||||
|
"orderFee": "ค่าธรรมเนียมคำสั่งซื้อ/ธุรกรรม (Order Fee)",
|
||||||
|
"growthSupportFee": "ค่าธรรมเนียมสนับสนุนการเติบโต (Growth Support)",
|
||||||
|
"shippingBreakdown": "ค่าจัดส่งขนส่ง",
|
||||||
|
"shippingBreakdownDetail": "ค่าส่งจริง ฿{{actual}} | TikTok อุดหนุน -฿{{subsidy}}",
|
||||||
|
"shippingFreeForSeller": "ร้านค้าไม่เสียค่าส่ง",
|
||||||
|
"sellerDiscountLabel": "ส่วนลดจากร้านค้า",
|
||||||
|
"bomProfitAnalysis": "วิเคราะห์กำไรสุทธิจริงหลังหักต้นทุน BOM",
|
||||||
|
"totalBomCostLabel": "หัก ต้นทุนสินค้าตามสูตร BOM",
|
||||||
|
"realNetProfitLabel": "กำไรสุทธิแท้จริง (Real Net Profit)",
|
||||||
|
"realNetMarginLabel": "คิดเป็นอัตรากำไร",
|
||||||
|
"orderItemsTitle": "รายการสินค้าในออเดอร์นี้",
|
||||||
|
"viewFullOrderDetail": "เปิดดูคำสั่งซื้อฉบับเต็ม",
|
||||||
|
"closeModal": "ปิด",
|
||||||
|
"copyOrderId": "คัดลอก Order ID",
|
||||||
|
"copied": "คัดลอกแล้ว",
|
||||||
"processingData": "กำลังประมวลผลข้อมูล...",
|
"processingData": "กำลังประมวลผลข้อมูล...",
|
||||||
"noOrders": "ยังไม่มีข้อมูล",
|
"noOrders": "ยังไม่มีข้อมูล",
|
||||||
"settledTransferred": "โอนแล้ว ฿{{amount}}",
|
"settledTransferred": "โอนแล้ว ฿{{amount}}",
|
||||||
|
|||||||
@@ -450,6 +450,41 @@
|
|||||||
"thAffiliate": "Affiliate (฿)",
|
"thAffiliate": "Affiliate (฿)",
|
||||||
"thNetProfit": "净利润",
|
"thNetProfit": "净利润",
|
||||||
"thActions": "操作",
|
"thActions": "操作",
|
||||||
|
"viewFeeBreakdown": "点击查看 TikTok 费用明细",
|
||||||
|
"feeModalTitle": "TikTok 费用与结算明细",
|
||||||
|
"feeModalSub": "TikTok Shop 佣金、达人佣金与净利润详细拆解",
|
||||||
|
"settlementPendingAlert": "该订单暂无 TikTok 结算对账单数据",
|
||||||
|
"settlementPendingDesc": "以下佣金与费用为基于订单数据的预估,待从 TikTok 卖家中心导入结算 XLSX 文件后更新。",
|
||||||
|
"settlementCompleteBadge": "已完成打款",
|
||||||
|
"settlementPendingBadge": "等待结算",
|
||||||
|
"orderRevenueLabel": "商品销售额",
|
||||||
|
"tiktokDeductionsLabel": "TikTok 扣款合计",
|
||||||
|
"netPayoutLabel": "最终打款金额",
|
||||||
|
"cardBomCost": "商品成本 (BOM)",
|
||||||
|
"cardNetProfit": "净利润",
|
||||||
|
"profitOnSales": "利润率(销售)",
|
||||||
|
"profitOnCost": "利润率(成本)",
|
||||||
|
"feeBreakdownTitle": "TikTok 平台扣款明细",
|
||||||
|
"tiktokCommissionFee": "TikTok Shop 平台佣金 (Commission Fee)",
|
||||||
|
"tiktokCommissionDesc": "按平台品类佣金率扣除",
|
||||||
|
"affiliateCommissionFee": "达人/分销佣金 (Affiliate Commission)",
|
||||||
|
"affiliateCommissionDesc": "支付给带货达人的佣金",
|
||||||
|
"affiliatePartnerFee": "机构/合作伙伴佣金 (TAP)",
|
||||||
|
"orderFee": "订单/交易手续费 (Order Fee)",
|
||||||
|
"growthSupportFee": "成长支持费 (Growth Support Fee)",
|
||||||
|
"shippingBreakdown": "物流运费明细",
|
||||||
|
"shippingBreakdownDetail": "实际运费 ฿{{actual}} | 平台补贴 -฿{{subsidy}}",
|
||||||
|
"shippingFreeForSeller": "商家免运费",
|
||||||
|
"sellerDiscountLabel": "商家折扣",
|
||||||
|
"bomProfitAnalysis": "扣除 BOM 成本后真实净利润分析",
|
||||||
|
"totalBomCostLabel": "扣除:BOM 原料及包装成本",
|
||||||
|
"realNetProfitLabel": "真实净利润 (Real Net Profit)",
|
||||||
|
"realNetMarginLabel": "净利润率",
|
||||||
|
"orderItemsTitle": "订单内商品列表",
|
||||||
|
"viewFullOrderDetail": "查看订单完整详情",
|
||||||
|
"closeModal": "关闭",
|
||||||
|
"copyOrderId": "复制订单号",
|
||||||
|
"copied": "已复制",
|
||||||
"processingData": "正在处理数据...",
|
"processingData": "正在处理数据...",
|
||||||
"noOrders": "暂无数据",
|
"noOrders": "暂无数据",
|
||||||
"settledTransferred": "已打款 ฿{{amount}}",
|
"settledTransferred": "已打款 ฿{{amount}}",
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import { getAuthHeaders } from '../utils/auth';
|
|||||||
|
|
||||||
import CinematicStoryScrollForm from '../components/page-builder/CinematicStoryScrollForm';
|
import CinematicStoryScrollForm from '../components/page-builder/CinematicStoryScrollForm';
|
||||||
import AppleShowcaseForm from '../components/page-builder/AppleShowcaseForm';
|
import AppleShowcaseForm from '../components/page-builder/AppleShowcaseForm';
|
||||||
|
import Interactive360ShowcaseForm from '../components/page-builder/Interactive360ShowcaseForm';
|
||||||
|
import VdoFrameScrubberForm from '../components/page-builder/VdoFrameScrubberForm';
|
||||||
import HeroForm from '../components/page-builder/HeroForm';
|
import HeroForm from '../components/page-builder/HeroForm';
|
||||||
import AnnouncementBarForm from '../components/page-builder/AnnouncementBarForm';
|
import AnnouncementBarForm from '../components/page-builder/AnnouncementBarForm';
|
||||||
import NewsletterForm from '../components/page-builder/NewsletterForm';
|
import NewsletterForm from '../components/page-builder/NewsletterForm';
|
||||||
@@ -1469,6 +1471,8 @@ export default function PageBuilderPage() {
|
|||||||
onChange={(e) => handleTypeChange(e.target.value)}
|
onChange={(e) => handleTypeChange(e.target.value)}
|
||||||
className="admin-input w-full bg-slate-900 border-white/10"
|
className="admin-input w-full bg-slate-900 border-white/10"
|
||||||
>
|
>
|
||||||
|
<option value="VDO_FRAME_SCRUBBER">🎬 VDO FRAME SCRUBBER (432 Frames Video Scrub)</option>
|
||||||
|
<option value="INTERACTIVE_360_SHOWCASE">⚡ INTERACTIVE 360° TURNTABLE (3D Web Sprocket)</option>
|
||||||
<option value="CINEMATIC_STORY_SCROLL">✨ CINEMATIC STORY SCROLL (Tobaki Scrollytelling)</option>
|
<option value="CINEMATIC_STORY_SCROLL">✨ CINEMATIC STORY SCROLL (Tobaki Scrollytelling)</option>
|
||||||
<option value="APPLE_SHOWCASE">✨ APPLE KEYNOTE SHOWCASE (Apple Style Tour)</option>
|
<option value="APPLE_SHOWCASE">✨ APPLE KEYNOTE SHOWCASE (Apple Style Tour)</option>
|
||||||
<option value="HERO">HERO (Campaign Intro)</option>
|
<option value="HERO">HERO (Campaign Intro)</option>
|
||||||
@@ -1593,6 +1597,24 @@ export default function PageBuilderPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editingSection.type?.toUpperCase() === 'VDO_FRAME_SCRUBBER' && (
|
||||||
|
<VdoFrameScrubberForm
|
||||||
|
payload={editingSection.payload || {}}
|
||||||
|
updatePayloadValue={updatePayloadValue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingSection.type?.toUpperCase() === 'INTERACTIVE_360_SHOWCASE' && (
|
||||||
|
<Interactive360ShowcaseForm
|
||||||
|
payload={editingSection.payload || {}}
|
||||||
|
updatePayloadValue={updatePayloadValue}
|
||||||
|
onBrowseMedia={(cb) => {
|
||||||
|
setCustomMediaCallback(() => cb);
|
||||||
|
setIsMediaModalOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{editingSection.type?.toUpperCase() === 'CINEMATIC_STORY_SCROLL' && (
|
{editingSection.type?.toUpperCase() === 'CINEMATIC_STORY_SCROLL' && (
|
||||||
<CinematicStoryScrollForm
|
<CinematicStoryScrollForm
|
||||||
payload={editingSection.payload || {}}
|
payload={editingSection.payload || {}}
|
||||||
|
|||||||
@@ -11,9 +11,13 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Coins,
|
Coins,
|
||||||
Layers,
|
Layers,
|
||||||
Calculator
|
Calculator,
|
||||||
|
Bot,
|
||||||
|
FileJson
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { API_BASE, type Product } from '@homaroma/types'
|
import { API_BASE, type Product } from '@homaroma/types'
|
||||||
|
import ProductBomPromptModal from '../components/ProductBomPromptModal'
|
||||||
|
import ProductBomImportModal from '../components/ProductBomImportModal'
|
||||||
|
|
||||||
function fmtMoney(val: number | null | undefined): string {
|
function fmtMoney(val: number | null | undefined): string {
|
||||||
if (val == null || isNaN(val)) return '฿0.00'
|
if (val == null || isNaN(val)) return '฿0.00'
|
||||||
@@ -38,6 +42,14 @@ export default function ProductBomPage() {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
|
const [isPromptModalOpen, setIsPromptModalOpen] = useState(false)
|
||||||
|
const [isImportModalOpen, setIsImportModalOpen] = useState(false)
|
||||||
|
|
||||||
|
const handleApplyBomJson = (updatedVariants: any[]) => {
|
||||||
|
setVariants(updatedVariants)
|
||||||
|
setSuccess(true)
|
||||||
|
setTimeout(() => setSuccess(false), 3500)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -143,9 +155,14 @@ export default function ProductBomPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const productName =
|
const productName =
|
||||||
typeof product.name === 'string'
|
(typeof product.name === 'string' && product.name.trim())
|
||||||
? product.name
|
? product.name.trim()
|
||||||
: (product.name as any)?.th || (product.name as any)?.en || 'Unknown Product'
|
: (product.name as any)?.th ||
|
||||||
|
(product.name as any)?.en ||
|
||||||
|
(typeof product.name === 'object' && Object.values(product.name || {}).find((v) => typeof v === 'string' && (v as string).trim()) as string) ||
|
||||||
|
((product as any).brand?.name
|
||||||
|
? `${(product as any).brand?.name} ${(product as any).category?.name || ''}`.trim()
|
||||||
|
: `Product #${product.id}`)
|
||||||
|
|
||||||
// Summary Metrics Across All Variants
|
// Summary Metrics Across All Variants
|
||||||
const totalVariantsCount = variants.length
|
const totalVariantsCount = variants.length
|
||||||
@@ -177,6 +194,24 @@ export default function ProductBomPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsPromptModalOpen(true)}
|
||||||
|
className="px-4 py-2.5 rounded-xl bg-purple-500/10 hover:bg-purple-500/20 text-purple-300 border border-purple-500/30 text-sm font-bold flex items-center gap-2 transition-all shadow-sm"
|
||||||
|
title="สร้าง Prompt สำหรับส่งให้ LLM คิดและตอบกลับเป็นสูตร BOM"
|
||||||
|
>
|
||||||
|
<Bot className="w-4 h-4 text-purple-400" />
|
||||||
|
<span>Get Prompt</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsImportModalOpen(true)}
|
||||||
|
className="px-4 py-2.5 rounded-xl bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 text-sm font-bold flex items-center gap-2 transition-all shadow-sm"
|
||||||
|
title="วาง JSON จาก LLM เพื่อกรอกสูตร BOM ลงในตารางอัตโนมัติ"
|
||||||
|
>
|
||||||
|
<FileJson className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span>Import BOM JSON</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/products')}
|
onClick={() => navigate('/products')}
|
||||||
className="px-5 py-2.5 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 text-sm font-bold text-slate-300 transition-all"
|
className="px-5 py-2.5 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 text-sm font-bold text-slate-300 transition-all"
|
||||||
@@ -534,6 +569,25 @@ export default function ProductBomPage() {
|
|||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* LLM Prompt Generator Modal */}
|
||||||
|
<ProductBomPromptModal
|
||||||
|
isOpen={isPromptModalOpen}
|
||||||
|
onClose={() => setIsPromptModalOpen(false)}
|
||||||
|
productId={id || ''}
|
||||||
|
productName={productName}
|
||||||
|
variants={variants}
|
||||||
|
rawMaterials={rawMaterials}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* LLM JSON Import Modal */}
|
||||||
|
<ProductBomImportModal
|
||||||
|
isOpen={isImportModalOpen}
|
||||||
|
onClose={() => setIsImportModalOpen(false)}
|
||||||
|
variants={variants}
|
||||||
|
rawMaterials={rawMaterials}
|
||||||
|
onApply={handleApplyBomJson}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
import { useState, useEffect, type FormEvent } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
PackageOpen,
|
||||||
|
Boxes,
|
||||||
|
ExternalLink,
|
||||||
|
Layers,
|
||||||
|
Sparkles,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { API_BASE } from '@homaroma/types';
|
||||||
|
|
||||||
|
const COMMON_UNITS = [
|
||||||
|
{ label: 'ชิ้น (piece)', value: 'piece' },
|
||||||
|
{ label: 'กก. (kg)', value: 'kg' },
|
||||||
|
{ label: 'กรัม (g)', value: 'g' },
|
||||||
|
{ label: 'ลิตร (liter)', value: 'liter' },
|
||||||
|
{ label: 'มล. (ml)', value: 'ml' },
|
||||||
|
{ label: 'แพ็ค (pack)', value: 'pack' },
|
||||||
|
{ label: 'กล่อง (box)', value: 'box' },
|
||||||
|
{ label: 'หลอด (tube)', value: 'tube' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function RawMaterialDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const isNew = !id || id === 'new';
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(!isNew);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [toast, setToast] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
// Form Fields
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [unit, setUnit] = useState('piece');
|
||||||
|
const [pricePerUnit, setPricePerUnit] = useState<number>(0);
|
||||||
|
const [materialData, setMaterialData] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNew) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
fetch(`${API_BASE}/raw-materials/${id}`)
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error('ไม่พบข้อมูลวัตถุดิบ');
|
||||||
|
return await res.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
setMaterialData(data);
|
||||||
|
setName(data.name || '');
|
||||||
|
setUnit(data.unit || 'piece');
|
||||||
|
setPricePerUnit(Number(data.pricePerUnit) || 0);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
setToast({ type: 'error', text: err.message || 'เกิดข้อผิดพลาดในการโหลดข้อมูล' });
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id, isNew]);
|
||||||
|
|
||||||
|
const showToast = (type: 'success' | 'error', text: string) => {
|
||||||
|
setToast({ type, text });
|
||||||
|
setTimeout(() => setToast(null), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (e?: FormEvent) => {
|
||||||
|
if (e) e.preventDefault();
|
||||||
|
if (!name.trim()) {
|
||||||
|
showToast('error', 'กรุณาระบุชื่อวัตถุดิบ');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!unit.trim()) {
|
||||||
|
showToast('error', 'กรุณาระบุหน่วยนับ');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pricePerUnit < 0) {
|
||||||
|
showToast('error', 'ราคาต้นทุนต่อหน่วยต้องไม่ติดลบ');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: name.trim(),
|
||||||
|
unit: unit.trim(),
|
||||||
|
pricePerUnit: Number(pricePerUnit) || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = isNew ? `${API_BASE}/raw-materials` : `${API_BASE}/raw-materials/${id}`;
|
||||||
|
const method = isNew ? 'POST' : 'PUT';
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.message || 'บันทึกข้อมูลไม่สำเร็จ');
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('success', isNew ? 'สร้างวัตถุดิบใหม่สำเร็จ' : 'บันทึกการแก้ไขสำเร็จ');
|
||||||
|
|
||||||
|
if (isNew && data.id) {
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate(`/raw-materials/${data.id}`, { replace: true });
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast('error', err.message || 'เกิดข้อผิดพลาดในการบันทึก');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบวัตถุดิบ "${name}"?`)) return;
|
||||||
|
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/raw-materials/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || 'ไม่สามารถลบวัตถุดิบนี้ได้');
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate('/raw-materials', { replace: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast('error', err.message || 'เกิดข้อผิดพลาดในการลบ');
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[400px] space-y-4">
|
||||||
|
<Loader2 className="w-8 h-8 text-cyan-400 animate-spin" />
|
||||||
|
<p className="text-slate-400 text-sm">กำลังโหลดข้อมูลวัตถุดิบ...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bomItems = materialData?.bomItems || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-fade-in max-w-5xl mx-auto pb-16">
|
||||||
|
{/* Toast Notification */}
|
||||||
|
{toast && (
|
||||||
|
<div
|
||||||
|
className={`fixed bottom-6 right-6 z-50 p-4 rounded-2xl border shadow-2xl flex items-center gap-3 animate-slide-up ${
|
||||||
|
toast.type === 'success'
|
||||||
|
? 'bg-emerald-950/90 border-emerald-500/40 text-emerald-200'
|
||||||
|
: 'bg-rose-950/90 border-rose-500/40 text-rose-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{toast.type === 'success' ? (
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-5 h-5 text-rose-400" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium">{toast.text}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top Breadcrumb & Navigation */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||||
|
<Link to="/raw-materials" className="hover:text-white transition-colors flex items-center gap-1">
|
||||||
|
<PackageOpen className="w-3.5 h-3.5" />
|
||||||
|
<span>Raw Materials</span>
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-cyan-400 font-medium">
|
||||||
|
{isNew ? 'เพิ่มวัตถุดิบใหม่' : name || `ID #${id}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/raw-materials')}
|
||||||
|
className="p-2.5 rounded-2xl bg-white/5 border border-white/10 hover:bg-white/10 text-slate-300 hover:text-white transition-all shadow-sm"
|
||||||
|
title="ย้อนกลับไปหน้ารายการ"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-white flex items-center gap-2.5">
|
||||||
|
{isNew ? 'เพิ่มวัตถุดิบใหม่' : `แก้ไขวัตถุดิบ`}
|
||||||
|
{!isNew && (
|
||||||
|
<span className="text-xs px-2.5 py-1 rounded-full bg-cyan-500/10 text-cyan-300 border border-cyan-500/20 font-mono">
|
||||||
|
ID: #{id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs md:text-sm text-slate-400">
|
||||||
|
{isNew
|
||||||
|
? 'สร้างรายการวัตถุดิบสำหรับใช้คำนวณต้นทุนสินค้าและผูกสูตรผลิต (BOM)'
|
||||||
|
: 'กำหนดข้อมูลราคาต้นทุนและหน่วยนับสำหรับใช้ในสูตรการผลิตสินค้า'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{!isNew && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting || saving}
|
||||||
|
className="px-4 py-2.5 rounded-xl border border-rose-500/30 text-rose-400 hover:bg-rose-500/10 hover:border-rose-500/50 font-medium text-sm flex items-center gap-2 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||||
|
<span>ลบวัตถุดิบ</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSave()}
|
||||||
|
disabled={saving}
|
||||||
|
className="btn-primary px-6 py-2.5 flex items-center gap-2 text-sm shadow-lg shadow-cyan-500/10"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
<span>{saving ? 'กำลังบันทึก...' : 'บันทึกข้อมูล'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
{/* Left Column: Form Details (2 cols) */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<form onSubmit={handleSave} className="admin-card space-y-6 border-white/10 shadow-2xl p-6 md:p-8">
|
||||||
|
<div className="flex items-center gap-2 pb-4 border-b border-white/5">
|
||||||
|
<Sparkles className="w-4 h-4 text-cyan-400" />
|
||||||
|
<h2 className="text-base font-bold text-white uppercase tracking-wider text-xs">
|
||||||
|
ข้อมูลวัตถุดิบ (Material Information)
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Material Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center justify-between">
|
||||||
|
<span>ชื่อวัตถุดิบ (Material Name) <span className="text-rose-400">*</span></span>
|
||||||
|
<span className="text-[10px] text-slate-500 font-normal">ห้ามซ้ำกับรายการอื่นในระบบ</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="เช่น ขวดแก้ว 100ml, น้ำมันหอมระเหย ลาเวนเดอร์, ก้านไม้ไฟเบอร์"
|
||||||
|
className="admin-input w-full text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unit Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider">
|
||||||
|
หน่วยนับ (Unit of Measurement) <span className="text-rose-400">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={unit}
|
||||||
|
onChange={(e) => setUnit(e.target.value)}
|
||||||
|
placeholder="เช่น piece, kg, liter, pack, ml"
|
||||||
|
className="admin-input w-full text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Quick Preset Unit Chips */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span className="text-[11px] text-slate-400">เลือกหน่วยที่ใช้บ่อย:</span>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{COMMON_UNITS.map((u) => (
|
||||||
|
<button
|
||||||
|
key={u.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUnit(u.value)}
|
||||||
|
className={`text-xs px-2.5 py-1 rounded-lg border transition-all ${
|
||||||
|
unit === u.value
|
||||||
|
? 'bg-cyan-500/20 border-cyan-500/40 text-cyan-300 font-bold'
|
||||||
|
: 'bg-white/5 border-white/5 text-slate-400 hover:text-white hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{u.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cost Price per Unit */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center justify-between">
|
||||||
|
<span>ต้นทุนต่อหน่วย (Cost per Unit) <span className="text-rose-400">*</span></span>
|
||||||
|
<span className="text-[10px] text-slate-500 font-normal">ใช้ในการคำนวณสูตร BOM สินค้า</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 font-bold text-sm">
|
||||||
|
฿
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
value={pricePerUnit === 0 ? '' : pricePerUnit}
|
||||||
|
onChange={(e) => setPricePerUnit(parseFloat(e.target.value) || 0)}
|
||||||
|
placeholder="0.00"
|
||||||
|
className="admin-input w-full pl-9 font-mono text-emerald-400 font-bold text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-500">
|
||||||
|
ระบุราคาต้นทุนเฉลี่ยต่อ 1 {unit || 'หน่วย'} เช่น 18.50 บาท / ขวด
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: BOM Linked Products & Summary (1 col) */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary Card */}
|
||||||
|
<div className="admin-card border-white/10 shadow-xl p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-2 pb-3 border-b border-white/5">
|
||||||
|
<Layers className="w-4 h-4 text-cyan-400" />
|
||||||
|
<h3 className="text-xs font-bold text-white uppercase tracking-wider">
|
||||||
|
สรุปภาพรวม (Overview)
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
<div className="flex justify-between py-1.5 border-b border-white/5 text-slate-300">
|
||||||
|
<span className="text-slate-400">สถานะ:</span>
|
||||||
|
<span className="font-semibold text-emerald-400">พร้อมใช้งาน</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-1.5 border-b border-white/5 text-slate-300">
|
||||||
|
<span className="text-slate-400">หน่วยนับ:</span>
|
||||||
|
<span className="font-mono font-bold text-white">{unit || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-1.5 border-b border-white/5 text-slate-300">
|
||||||
|
<span className="text-slate-400">ต้นทุน / หน่วย:</span>
|
||||||
|
<span className="font-mono font-bold text-emerald-400">
|
||||||
|
฿{Number(pricePerUnit || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isNew && materialData?.updatedAt && (
|
||||||
|
<div className="flex justify-between py-1.5 text-slate-300">
|
||||||
|
<span className="text-slate-400">อัปเดตล่าสุด:</span>
|
||||||
|
<span className="text-slate-400">
|
||||||
|
{new Date(materialData.updatedAt).toLocaleDateString('th-TH')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BOM Usage Section */}
|
||||||
|
{!isNew && (
|
||||||
|
<div className="admin-card border-white/10 shadow-xl p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-white/5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Boxes className="w-4 h-4 text-cyan-400" />
|
||||||
|
<h3 className="text-xs font-bold text-white uppercase tracking-wider">
|
||||||
|
สูตรสินค้าที่ใช้วัตถุดิบนี้ ({bomItems.length})
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/products/bom"
|
||||||
|
className="text-[11px] text-cyan-400 hover:text-cyan-300 flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<span>จัดการ BOM</span>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bomItems.length === 0 ? (
|
||||||
|
<div className="py-6 text-center space-y-2">
|
||||||
|
<Boxes className="w-8 h-8 text-slate-700 mx-auto" />
|
||||||
|
<p className="text-xs text-slate-400 italic">
|
||||||
|
ยังไม่มีสูตรสินค้า (BOM) ที่ผูกกับวัตถุดิบนี้
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-slate-500">
|
||||||
|
สามารถไปผูกวัตถุดิบเข้ากับ Variant สินค้าได้ที่เมนู "BOM สูตรผลิต"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2.5 max-h-[300px] overflow-y-auto pr-1">
|
||||||
|
{bomItems.map((item: any, idx: number) => {
|
||||||
|
const product = item.productVariant?.product;
|
||||||
|
const variant = item.productVariant;
|
||||||
|
const variantLabel = [variant?.option1, variant?.option2, variant?.option3]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' / ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id || idx}
|
||||||
|
className="p-3 rounded-xl bg-white/[0.02] border border-white/5 space-y-1 hover:border-white/10 transition-all"
|
||||||
|
>
|
||||||
|
<p className="text-xs font-bold text-white line-clamp-1">
|
||||||
|
{product?.name || `Product #${variant?.productId}`}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-slate-400">
|
||||||
|
<span>{variantLabel ? `ตัวเลือก: ${variantLabel}` : 'Base Variant'}</span>
|
||||||
|
<span className="font-mono text-cyan-400 font-bold">
|
||||||
|
{item.quantity} {unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,227 +1,395 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { Plus, Edit3, Trash2, Loader2, PackageOpen } from 'lucide-react'
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { API_BASE, type RawMaterial } from '@homaroma/types'
|
import {
|
||||||
|
Plus,
|
||||||
|
Edit3,
|
||||||
|
Trash2,
|
||||||
|
PackageOpen,
|
||||||
|
Search,
|
||||||
|
FileJson,
|
||||||
|
Boxes,
|
||||||
|
Layers,
|
||||||
|
Sparkles,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { API_BASE, type RawMaterial } from '@homaroma/types';
|
||||||
|
import RawMaterialBulkJsonModal from '../components/RawMaterialBulkJsonModal';
|
||||||
|
|
||||||
export default function RawMaterialsPage() {
|
export default function RawMaterialsPage() {
|
||||||
const [materials, setMaterials] = useState<RawMaterial[]>([])
|
const navigate = useNavigate();
|
||||||
const [loading, setLoading] = useState(true)
|
const [materials, setMaterials] = useState<RawMaterial[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isBulkModalOpen, setIsBulkModalOpen] = useState(false);
|
||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
// Inline Price Editing State
|
||||||
const [editingId, setEditingId] = useState<number | null>(null)
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [editingPrice, setEditingPrice] = useState<string>('');
|
||||||
|
const [savingId, setSavingId] = useState<number | null>(null);
|
||||||
|
|
||||||
const [name, setName] = useState('')
|
const startEditing = (item: RawMaterial) => {
|
||||||
const [unit, setUnit] = useState('')
|
setEditingId(item.id);
|
||||||
const [pricePerUnit, setPricePerUnit] = useState<number>(0)
|
setEditingPrice(String(item.pricePerUnit ?? ''));
|
||||||
const [saving, setSaving] = useState(false)
|
};
|
||||||
|
|
||||||
|
const cancelEditing = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditingPrice('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSavePrice = async (item: RawMaterial) => {
|
||||||
|
const val = parseFloat(editingPrice);
|
||||||
|
if (isNaN(val) || val < 0) {
|
||||||
|
alert('กรุณาระบุราคาต้นทุนให้ถูกต้อง (ต้องเป็นตัวเลขมากกว่าหรือเท่ากับ 0)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingId(item.id);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/raw-materials/${item.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
pricePerUnit: val,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || 'บันทึกราคาไม่สำเร็จ');
|
||||||
|
}
|
||||||
|
|
||||||
|
setMaterials((prev) =>
|
||||||
|
prev.map((m) => (m.id === item.id ? { ...m, pricePerUnit: val } : m))
|
||||||
|
);
|
||||||
|
setEditingId(null);
|
||||||
|
setEditingPrice('');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
alert(err.message || 'เกิดข้อผิดพลาดในการบันทึกราคา');
|
||||||
|
} finally {
|
||||||
|
setSavingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchMaterials = async () => {
|
const fetchMaterials = async () => {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/raw-materials`)
|
const res = await fetch(`${API_BASE}/raw-materials`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setMaterials(await res.json())
|
setMaterials(await res.json());
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch raw materials', err)
|
console.error('Failed to fetch raw materials', err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMaterials()
|
fetchMaterials();
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const handleOpenModal = (material?: RawMaterial) => {
|
const handleDelete = async (id: number, name: string) => {
|
||||||
if (material) {
|
if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบวัตถุดิบ "${name}"?`)) return;
|
||||||
setEditingId(material.id)
|
|
||||||
setName(material.name)
|
|
||||||
setUnit(material.unit)
|
|
||||||
setPricePerUnit(material.pricePerUnit)
|
|
||||||
} else {
|
|
||||||
setEditingId(null)
|
|
||||||
setName('')
|
|
||||||
setUnit('')
|
|
||||||
setPricePerUnit(0)
|
|
||||||
}
|
|
||||||
setIsModalOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!name || !unit || pricePerUnit <= 0) return alert('Please fill all fields')
|
|
||||||
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
try {
|
||||||
const payload = { name, unit, pricePerUnit }
|
const res = await fetch(`${API_BASE}/raw-materials/${id}`, { method: 'DELETE' });
|
||||||
const url = editingId
|
|
||||||
? `${API_BASE}/raw-materials/${editingId}`
|
|
||||||
: `${API_BASE}/raw-materials`
|
|
||||||
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: editingId ? 'PUT' : 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setIsModalOpen(false)
|
fetchMaterials();
|
||||||
fetchMaterials()
|
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to save raw material')
|
const data = await res.json();
|
||||||
|
alert(data.message || 'ไม่สามารถลบวัตถุดิบนี้ได้');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err);
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
// Filtered materials by search query
|
||||||
if (!confirm('Are you sure you want to delete this raw material?')) return
|
const filteredMaterials = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) return materials;
|
||||||
|
const q = searchQuery.toLowerCase().trim();
|
||||||
|
return materials.filter(
|
||||||
|
(m) =>
|
||||||
|
m.name.toLowerCase().includes(q) ||
|
||||||
|
m.unit.toLowerCase().includes(q) ||
|
||||||
|
String(m.id).includes(q)
|
||||||
|
);
|
||||||
|
}, [materials, searchQuery]);
|
||||||
|
|
||||||
try {
|
// Aggregate metrics
|
||||||
const res = await fetch(`${API_BASE}/raw-materials/${id}`, { method: 'DELETE' })
|
const metrics = useMemo(() => {
|
||||||
if (res.ok) {
|
const totalCount = materials.length;
|
||||||
fetchMaterials()
|
const avgCost = totalCount > 0
|
||||||
}
|
? materials.reduce((sum, m) => sum + (Number(m.pricePerUnit) || 0), 0) / totalCount
|
||||||
} catch (err) {
|
: 0;
|
||||||
console.error(err)
|
const units = new Set(materials.map((m) => m.unit.toLowerCase())).size;
|
||||||
}
|
return { totalCount, avgCost, units };
|
||||||
}
|
}, [materials]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-fade-in">
|
<div className="space-y-8 animate-fade-in pb-12">
|
||||||
|
{/* Page Header */}
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-3xl font-bold">Raw Materials (วัตถุดิบ)</h1>
|
<div className="flex items-center gap-3">
|
||||||
<p className="text-slate-500 text-sm">Manage inventory and costs for BOM calculation.</p>
|
<h1 className="text-3xl font-bold text-white">Raw Materials (วัตถุดิบ)</h1>
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-cyan-500/10 text-cyan-400 border border-cyan-500/20">
|
||||||
|
{materials.length} รายการ
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-400 text-sm">
|
||||||
|
จัดการรายการวัตถุดิบ หน่วยนับ และต้นทุนสำหรับผูกสูตรผลิตสินค้า (BOM)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Bulk JSON Import / Export Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsBulkModalOpen(true)}
|
||||||
|
className="px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 font-medium text-sm flex items-center gap-2 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
<FileJson className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span>Import / Export JSON</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Add Material (Navigates to Full Page Route) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/raw-materials/new')}
|
||||||
|
className="btn-primary flex items-center gap-2 text-sm shadow-lg shadow-cyan-500/10"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>เพิ่มวัตถุดิบ</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => handleOpenModal()} className="btn-primary flex items-center gap-2">
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
Add Material
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics Row */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div className="admin-card p-5 border-white/5 bg-night-900/60 flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400 shrink-0">
|
||||||
|
<PackageOpen className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">วัตถุดิบทั้งหมด</p>
|
||||||
|
<p className="text-2xl font-bold text-white font-mono mt-0.5">{metrics.totalCount} <span className="text-xs font-normal text-slate-400">รายการ</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-card p-5 border-white/5 bg-night-900/60 flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400 shrink-0">
|
||||||
|
<Sparkles className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">ต้นทุนเฉลี่ย / หน่วย</p>
|
||||||
|
<p className="text-2xl font-bold text-emerald-400 font-mono mt-0.5">
|
||||||
|
฿{metrics.avgCost.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-card p-5 border-white/5 bg-night-900/60 flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-400 shrink-0">
|
||||||
|
<Layers className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">ประเภทหน่วยนับ</p>
|
||||||
|
<p className="text-2xl font-bold text-white font-mono mt-0.5">{metrics.units} <span className="text-xs font-normal text-slate-400">แบบ</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="relative w-full sm:w-80">
|
||||||
|
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="ค้นหาตามชื่อ หรือ หน่วยนับ..."
|
||||||
|
className="admin-input w-full pl-10 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-slate-400">
|
||||||
|
แสดง <strong>{filteredMaterials.length}</strong> จากทั้งหมด {materials.length} รายการ
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data Table */}
|
||||||
<div className="admin-card !p-0 overflow-hidden border-white/10 shadow-2xl">
|
<div className="admin-card !p-0 overflow-hidden border-white/10 shadow-2xl">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left border-collapse">
|
<table className="w-full text-left border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-white/[0.02] border-b border-white/5">
|
<tr className="bg-white/[0.02] border-b border-white/5">
|
||||||
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Material Name</th>
|
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 w-16">ID</th>
|
||||||
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Unit</th>
|
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">ชื่อวัตถุดิบ (Material Name)</th>
|
||||||
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Cost Per Unit</th>
|
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">หน่วยนับ (Unit)</th>
|
||||||
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 text-right">Actions</th>
|
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">ต้นทุนต่อหน่วย (Cost)</th>
|
||||||
|
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 text-right">การจัดการ</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-white/[0.03]">
|
<tbody className="divide-y divide-white/[0.03]">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
Array.from({ length: 3 }).map((_, i) => (
|
Array.from({ length: 4 }).map((_, i) => (
|
||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
<td colSpan={4} className="px-6 py-8">
|
<td colSpan={5} className="px-6 py-8">
|
||||||
<div className="h-6 w-full bg-white/5 animate-pulse rounded-lg" />
|
<div className="h-6 w-full bg-white/5 animate-pulse rounded-lg" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
) : materials.length === 0 ? (
|
) : filteredMaterials.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="px-6 py-20 text-center space-y-3">
|
<td colSpan={5} className="px-6 py-20 text-center space-y-3">
|
||||||
<PackageOpen className="w-10 h-10 text-slate-700 mx-auto" />
|
<PackageOpen className="w-10 h-10 text-slate-700 mx-auto" />
|
||||||
<p className="text-slate-500 font-medium italic">No raw materials found.</p>
|
<p className="text-slate-400 font-medium">
|
||||||
</td>
|
{searchQuery ? 'ไม่พบวัตถุดิบที่ตรงกับคำค้นหา' : 'ยังไม่มีข้อมูลวัตถุดิบในระบบ'}
|
||||||
</tr>
|
</p>
|
||||||
) : materials.map((item) => (
|
<div className="flex items-center justify-center gap-3 pt-2">
|
||||||
<tr key={item.id} className="hover:bg-white/[0.01] transition-colors">
|
<button
|
||||||
<td className="px-6 py-4">
|
type="button"
|
||||||
<p className="text-sm font-bold text-white">{item.name}</p>
|
onClick={() => setIsBulkModalOpen(true)}
|
||||||
</td>
|
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-xs font-medium border border-white/10 transition-colors"
|
||||||
<td className="px-6 py-4">
|
>
|
||||||
<span className="inline-block px-2 py-1 rounded-md bg-night-950 border border-white/5 text-slate-400 text-[10px] uppercase font-bold tracking-wider">
|
นำเข้าผ่าน JSON
|
||||||
{item.unit}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<p className="text-sm font-bold text-emerald-400">฿{item.pricePerUnit.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-right">
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<button onClick={() => handleOpenModal(item)} className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-cyan-glow transition-all">
|
|
||||||
<Edit3 className="w-4 h-4" />
|
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => handleDelete(item.id)} className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-rose-500 transition-all">
|
<button
|
||||||
<Trash2 className="w-4 h-4" />
|
type="button"
|
||||||
|
onClick={() => navigate('/raw-materials/new')}
|
||||||
|
className="btn-primary text-xs px-4 py-2"
|
||||||
|
>
|
||||||
|
เพิ่มวัตถุดิบใหม่
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
) : (
|
||||||
|
filteredMaterials.map((item) => (
|
||||||
|
<tr key={item.id} className="hover:bg-white/[0.015] transition-colors group">
|
||||||
|
<td className="px-6 py-4 text-xs font-mono text-slate-500">
|
||||||
|
#{item.id}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400 shrink-0">
|
||||||
|
<Boxes className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to={`/raw-materials/${item.id}`}
|
||||||
|
className="text-sm font-bold text-white hover:text-cyan-400 transition-colors"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="inline-block px-2.5 py-1 rounded-md bg-night-950 border border-white/5 text-slate-300 text-xs font-mono font-medium">
|
||||||
|
{item.unit}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
{editingId === item.id ? (
|
||||||
|
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-xs text-slate-500 font-mono font-bold">
|
||||||
|
฿
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
autoFocus
|
||||||
|
value={editingPrice}
|
||||||
|
onChange={(e) => setEditingPrice(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSavePrice(item);
|
||||||
|
if (e.key === 'Escape') cancelEditing();
|
||||||
|
}}
|
||||||
|
disabled={savingId === item.id}
|
||||||
|
className="w-28 pl-6 pr-2 py-1 text-sm font-bold font-mono text-emerald-400 bg-black/80 border border-cyan-500/60 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500/40 shadow-inner"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={savingId === item.id}
|
||||||
|
onClick={() => handleSavePrice(item)}
|
||||||
|
className="p-1.5 rounded-lg bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-300 border border-emerald-500/40 transition-all disabled:opacity-50"
|
||||||
|
title="บันทึกราคา (Enter)"
|
||||||
|
>
|
||||||
|
{savingId === item.id ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={savingId === item.id}
|
||||||
|
onClick={cancelEditing}
|
||||||
|
className="p-1.5 rounded-lg bg-white/5 hover:bg-rose-500/20 text-slate-400 hover:text-rose-300 border border-white/10 transition-all disabled:opacity-50"
|
||||||
|
title="ยกเลิก (Esc)"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onClick={() => startEditing(item)}
|
||||||
|
className="group/price inline-flex items-center gap-2 cursor-pointer px-2.5 py-1 -ml-2.5 rounded-lg hover:bg-white/5 border border-transparent hover:border-white/10 transition-all"
|
||||||
|
title="คลิกที่นี่เพื่อแก้ไขราคาต่อหน่วย (Inline Edit)"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-bold text-emerald-400 font-mono">
|
||||||
|
฿{Number(item.pricePerUnit).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
<Pencil className="w-3 h-3 text-slate-500 opacity-0 group-hover/price:opacity-100 transition-opacity" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{/* Edit Button navigates to Full Page Route */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(`/raw-materials/${item.id}`)}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-white/5 hover:bg-cyan-500/10 text-slate-400 hover:text-cyan-400 border border-white/5 hover:border-cyan-500/20 text-xs font-medium transition-all flex items-center gap-1.5"
|
||||||
|
title="แก้ไขรายละเอียด"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-3.5 h-3.5" />
|
||||||
|
<span>แก้ไข</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(item.id, item.name)}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-rose-500/10 text-slate-500 hover:text-rose-400 transition-all"
|
||||||
|
title="ลบรายการ"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isModalOpen && (
|
{/* Bulk JSON Import / Export Modal */}
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<RawMaterialBulkJsonModal
|
||||||
<div className="absolute inset-0 bg-night-950/80 backdrop-blur-sm" onClick={() => !saving && setIsModalOpen(false)} />
|
isOpen={isBulkModalOpen}
|
||||||
<div className="relative w-full max-w-md bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-zoom-in p-6 space-y-6">
|
onClose={() => setIsBulkModalOpen(false)}
|
||||||
<h3 className="text-xl font-bold">{editingId ? 'Edit Material' : 'Add New Material'}</h3>
|
materials={materials}
|
||||||
|
onSuccess={fetchMaterials}
|
||||||
<div className="space-y-4">
|
/>
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Material Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={name}
|
|
||||||
onChange={e => setName(e.target.value)}
|
|
||||||
className="admin-input w-full"
|
|
||||||
placeholder="e.g. Alcohol, Glass Bottle 50ml"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Unit</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={unit}
|
|
||||||
onChange={e => setUnit(e.target.value)}
|
|
||||||
className="admin-input w-full"
|
|
||||||
placeholder="e.g. kg, liter, piece"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Price per Unit (฿)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={pricePerUnit || ''}
|
|
||||||
onChange={e => setPricePerUnit(Number(e.target.value))}
|
|
||||||
className="admin-input w-full"
|
|
||||||
placeholder="0.00"
|
|
||||||
step="0.01"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-white/5">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsModalOpen(false)}
|
|
||||||
disabled={saving}
|
|
||||||
className="px-4 py-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 font-medium transition-all"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="btn-primary px-6 py-2 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
||||||
{saving ? 'Saving...' : 'Save Material'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import { API_BASE } from '@homaroma/types';
|
import { API_BASE } from '@homaroma/types';
|
||||||
import { TIKTOK_SHOPS } from './TiktokOrderPage';
|
import { TIKTOK_SHOPS } from './TiktokOrderPage';
|
||||||
import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils';
|
import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils';
|
||||||
|
import { TiktokFeeBreakdownModal } from '../components/tiktok-order/TiktokFeeBreakdownModal';
|
||||||
|
|
||||||
function authHeaders() {
|
function authHeaders() {
|
||||||
return {
|
return {
|
||||||
@@ -66,6 +67,7 @@ export default function TiktokOrderDetailPage() {
|
|||||||
const [order, setOrder] = useState<any | null>(null);
|
const [order, setOrder] = useState<any | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showFeeModal, setShowFeeModal] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!orderId) return;
|
if (!orderId) return;
|
||||||
@@ -410,19 +412,35 @@ export default function TiktokOrderDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center text-violet-300">
|
<button
|
||||||
<span className="text-slate-400">{t('tiktokOrderDetail.commissionSummary')}</span>
|
type="button"
|
||||||
<span className="font-mono font-semibold">
|
onClick={() => setShowFeeModal(true)}
|
||||||
|
className="w-full flex justify-between items-center text-violet-300 hover:bg-violet-500/10 p-1.5 -mx-1.5 rounded-lg transition-colors cursor-pointer group"
|
||||||
|
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||||
|
>
|
||||||
|
<span className="text-slate-400 group-hover:text-slate-200 text-xs flex items-center gap-1">
|
||||||
|
{t('tiktokOrderDetail.commissionSummary')}
|
||||||
|
<Receipt className="w-3 h-3 text-violet-400/50 group-hover:text-violet-300 transition-colors" />
|
||||||
|
</span>
|
||||||
|
<span className="font-mono font-semibold text-xs text-violet-400 group-hover:underline">
|
||||||
{order.commissionFee > 0 ? `-฿${fmt(order.commissionFee)}` : '฿0.00'}
|
{order.commissionFee > 0 ? `-฿${fmt(order.commissionFee)}` : '฿0.00'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<div className="flex justify-between items-center text-cyan-300">
|
<button
|
||||||
<span className="text-slate-400">{t('tiktokOrderDetail.affiliateSummary')}</span>
|
type="button"
|
||||||
<span className="font-mono font-semibold">
|
onClick={() => setShowFeeModal(true)}
|
||||||
|
className="w-full flex justify-between items-center text-cyan-300 hover:bg-cyan-500/10 p-1.5 -mx-1.5 rounded-lg transition-colors cursor-pointer group"
|
||||||
|
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||||
|
>
|
||||||
|
<span className="text-slate-400 group-hover:text-slate-200 text-xs flex items-center gap-1">
|
||||||
|
{t('tiktokOrderDetail.affiliateSummary')}
|
||||||
|
<Receipt className="w-3 h-3 text-cyan-400/50 group-hover:text-cyan-300 transition-colors" />
|
||||||
|
</span>
|
||||||
|
<span className="font-mono font-semibold text-xs text-cyan-400 group-hover:underline">
|
||||||
{order.affiliateFee > 0 ? `-฿${fmt(order.affiliateFee)}` : '฿0.00'}
|
{order.affiliateFee > 0 ? `-฿${fmt(order.affiliateFee)}` : '฿0.00'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<div className="pt-3 border-t border-white/10 flex justify-between items-center">
|
<div className="pt-3 border-t border-white/10 flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
@@ -527,6 +545,13 @@ export default function TiktokOrderDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showFeeModal && (
|
||||||
|
<TiktokFeeBreakdownModal
|
||||||
|
order={order}
|
||||||
|
onClose={() => setShowFeeModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,10 +7,11 @@ import {
|
|||||||
ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign,
|
ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign,
|
||||||
Coins, ChevronLeft, ChevronRight, ExternalLink, Store, Trash2,
|
Coins, ChevronLeft, ChevronRight, ExternalLink, Store, Trash2,
|
||||||
FileSpreadsheet, AlertTriangle, CheckCircle2, Files, Search, X, RotateCcw,
|
FileSpreadsheet, AlertTriangle, CheckCircle2, Files, Search, X, RotateCcw,
|
||||||
BadgePercent, Wallet, Receipt
|
BadgePercent, Wallet, Receipt, Info
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { API_BASE } from '@homaroma/types';
|
import { API_BASE } from '@homaroma/types';
|
||||||
import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils';
|
import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils';
|
||||||
|
import { TiktokFeeBreakdownModal } from '../components/tiktok-order/TiktokFeeBreakdownModal';
|
||||||
|
|
||||||
// ── Shop Config ───────────────────────────────────────────────────────────────
|
// ── Shop Config ───────────────────────────────────────────────────────────────
|
||||||
export const TIKTOK_SHOPS = [
|
export const TIKTOK_SHOPS = [
|
||||||
@@ -164,6 +165,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('all');
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
const [bomFilter, setBomFilter] = useState('all');
|
const [bomFilter, setBomFilter] = useState('all');
|
||||||
|
const [selectedFeeOrder, setSelectedFeeOrder] = useState<any | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -396,8 +398,24 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
|
|||||||
<th className="px-4 py-3.5 whitespace-nowrap">{t('tiktokOrders.thSkusBom')}</th>
|
<th className="px-4 py-3.5 whitespace-nowrap">{t('tiktokOrders.thSkusBom')}</th>
|
||||||
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thOrderAmount')}</th>
|
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thOrderAmount')}</th>
|
||||||
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thBomCost')}</th>
|
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thBomCost')}</th>
|
||||||
<th className="px-4 py-3.5 whitespace-nowrap text-right text-violet-400">{t('tiktokOrders.thCommission')}</th>
|
<th
|
||||||
<th className="px-4 py-3.5 whitespace-nowrap text-right text-cyan-400">{t('tiktokOrders.thAffiliate')}</th>
|
className="px-4 py-3.5 whitespace-nowrap text-right text-violet-400 cursor-help"
|
||||||
|
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1 justify-end">
|
||||||
|
{t('tiktokOrders.thCommission')}
|
||||||
|
<Info className="w-3 h-3 text-violet-400/50" />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-4 py-3.5 whitespace-nowrap text-right text-cyan-400 cursor-help"
|
||||||
|
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1 justify-end">
|
||||||
|
{t('tiktokOrders.thAffiliate')}
|
||||||
|
<Info className="w-3 h-3 text-cyan-400/50" />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thNetProfit')}</th>
|
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thNetProfit')}</th>
|
||||||
<th className="px-4 py-3.5 text-center" />
|
<th className="px-4 py-3.5 text-center" />
|
||||||
</tr>
|
</tr>
|
||||||
@@ -507,20 +525,42 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
|
|||||||
|
|
||||||
{/* Commission (฿) */}
|
{/* Commission (฿) */}
|
||||||
<td className="px-4 py-3.5 text-right font-mono whitespace-nowrap">
|
<td className="px-4 py-3.5 text-right font-mono whitespace-nowrap">
|
||||||
{order.commissionFee > 0 ? (
|
<button
|
||||||
<span className="text-violet-400 font-bold">-฿{fmt(order.commissionFee)}</span>
|
type="button"
|
||||||
) : (
|
onClick={(e) => {
|
||||||
<span className="text-slate-500 font-mono text-[11px]">-</span>
|
e.stopPropagation();
|
||||||
)}
|
setSelectedFeeOrder(order);
|
||||||
|
}}
|
||||||
|
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||||
|
className="inline-flex items-center gap-1 group/btn px-2 py-1 rounded-lg hover:bg-violet-500/15 border border-transparent hover:border-violet-500/30 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
{order.commissionFee > 0 ? (
|
||||||
|
<span className="text-violet-400 font-bold group-hover/btn:underline">-฿{fmt(order.commissionFee)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-500 font-mono text-[11px] group-hover/btn:text-slate-300">-</span>
|
||||||
|
)}
|
||||||
|
<Receipt className="w-3 h-3 text-violet-400/40 group-hover/btn:text-violet-300 transition-colors" />
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Affiliate (฿) */}
|
{/* Affiliate (฿) */}
|
||||||
<td className="px-4 py-3.5 text-right font-mono whitespace-nowrap">
|
<td className="px-4 py-3.5 text-right font-mono whitespace-nowrap">
|
||||||
{order.affiliateFee > 0 ? (
|
<button
|
||||||
<span className="text-cyan-400 font-bold">-฿{fmt(order.affiliateFee)}</span>
|
type="button"
|
||||||
) : (
|
onClick={(e) => {
|
||||||
<span className="text-slate-500 font-mono text-[11px]">-</span>
|
e.stopPropagation();
|
||||||
)}
|
setSelectedFeeOrder(order);
|
||||||
|
}}
|
||||||
|
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||||
|
className="inline-flex items-center gap-1 group/btn px-2 py-1 rounded-lg hover:bg-cyan-500/15 border border-transparent hover:border-cyan-500/30 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
{order.affiliateFee > 0 ? (
|
||||||
|
<span className="text-cyan-400 font-bold group-hover/btn:underline">-฿{fmt(order.affiliateFee)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-500 font-mono text-[11px] group-hover/btn:text-slate-300">-</span>
|
||||||
|
)}
|
||||||
|
<Receipt className="w-3 h-3 text-cyan-400/40 group-hover/btn:text-cyan-300 transition-colors" />
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Profit */}
|
{/* Profit */}
|
||||||
@@ -553,6 +593,14 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Fee Breakdown Modal */}
|
||||||
|
{selectedFeeOrder && (
|
||||||
|
<TiktokFeeBreakdownModal
|
||||||
|
order={selectedFeeOrder}
|
||||||
|
onClose={() => setSelectedFeeOrder(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { API_BASE, type Menu } from '@homaroma/types'
|
import { API_BASE, type Menu } from '@homaroma/types'
|
||||||
import { Save, Loader2, Plus, Trash2, ChevronUp, ChevronDown, Copy, Check, Download, Upload, Star, Pencil, Sparkles, Code2, X } from 'lucide-react'
|
import { Save, Loader2, Plus, Trash2, ChevronUp, ChevronDown, Copy, Check, Download, Upload, Star, Pencil, Sparkles, Code2, X, LayoutGrid, ListFilter, Layers, Image as ImageIcon } from 'lucide-react'
|
||||||
import { ICON_REGISTRY } from '../../utils/iconRegistry'
|
import { ICON_REGISTRY } from '../../utils/iconRegistry'
|
||||||
import FocalPointPicker from '../../components/page-builder/FocalPointPicker'
|
import FocalPointPicker from '../../components/page-builder/FocalPointPicker'
|
||||||
import MenuJsonGuideModal from '../../components/MenuJsonGuideModal'
|
import MenuJsonGuideModal from '../../components/MenuJsonGuideModal'
|
||||||
|
import MediaLibraryModal from '../../components/MediaLibraryModal'
|
||||||
|
|
||||||
const IconSelect = ({ value, onChange }: { value?: string, onChange: (val: string) => void }) => {
|
const IconSelect = ({ value, onChange }: { value?: string, onChange: (val: string) => void }) => {
|
||||||
return (
|
return (
|
||||||
@@ -30,7 +32,7 @@ type MenuEditorPageProps = {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
|
const FeaturedCardEditorFields = ({ featured, onChange, onOpenMediaPicker }: any) => {
|
||||||
const [activeTab, setActiveTab] = useState<'content' | 'media'>('content');
|
const [activeTab, setActiveTab] = useState<'content' | 'media'>('content');
|
||||||
const [activeLang, setActiveLang] = useState('th');
|
const [activeLang, setActiveLang] = useState('th');
|
||||||
|
|
||||||
@@ -54,8 +56,28 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
|
|||||||
['ctaLabel', 'CTA label', 'View details'],
|
['ctaLabel', 'CTA label', 'View details'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const isEnabled = featured.enabled !== false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-xl bg-white/5 border border-white/5">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-bold text-white">Enable Featured Card</div>
|
||||||
|
<div className="text-[10px] text-slate-400">เปิดหรือปิดการแสดงการ์ดโปรโมตใน Mega Menu</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateFeatured('enabled', !isEnabled)}
|
||||||
|
className={`px-3 py-1 text-xs font-bold rounded-lg border transition-colors ${
|
||||||
|
isEnabled
|
||||||
|
? 'border-amber-400/40 bg-amber-400/20 text-amber-300'
|
||||||
|
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isEnabled ? 'Enabled (เปิด)' : 'Disabled (ปิด)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 rounded-xl bg-white/5 p-1">
|
<div className="grid grid-cols-2 rounded-xl bg-white/5 p-1">
|
||||||
{[
|
{[
|
||||||
['content', 'Content'],
|
['content', 'Content'],
|
||||||
@@ -136,22 +158,59 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 rounded-xl border border-white/10 bg-white/5 p-4">
|
<div className="space-y-4 rounded-xl border border-white/10 bg-white/5 p-4">
|
||||||
<div className="aspect-[4/3] overflow-hidden rounded-xl border border-white/10 bg-white/5 flex items-center justify-center">
|
<div
|
||||||
|
onClick={() => onOpenMediaPicker && onOpenMediaPicker((url: string) => updateFeatured('imageUrl', url))}
|
||||||
|
className="aspect-[4/3] relative overflow-hidden rounded-xl border-2 border-dashed border-white/10 hover:border-cyan-glow/50 bg-night-950/40 flex items-center justify-center cursor-pointer group transition-all"
|
||||||
|
title="คลิกเพื่อเลือกรูปจากคลังภาพ (Media Library)"
|
||||||
|
>
|
||||||
{featured.imageUrl ? (
|
{featured.imageUrl ? (
|
||||||
<img src={featured.imageUrl} alt="Featured preview" className="h-full w-full object-cover" />
|
<>
|
||||||
|
<img src={featured.imageUrl} alt="Featured preview" className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-300" />
|
||||||
|
<div className="absolute inset-0 bg-night-950/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1.5 backdrop-blur-xs">
|
||||||
|
<ImageIcon className="w-6 h-6 text-cyan-glow" />
|
||||||
|
<span className="text-xs font-bold text-white">เปลี่ยนรูปจากคลังภาพ</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="px-4 text-center text-xs text-slate-600">No featured image</span>
|
<div className="flex flex-col items-center gap-2 text-slate-500 group-hover:text-cyan-glow transition-colors py-4">
|
||||||
|
<ImageIcon className="w-8 h-8" />
|
||||||
|
<span className="text-xs font-bold">เลือกรูปจากคลังภาพ (Images Lib)</span>
|
||||||
|
<span className="text-[10px] text-slate-600">คลิกที่นี่เพื่อเปิดคลังสื่อของระบบ</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[10px] uppercase font-bold text-slate-500">Featured Image URL</label>
|
<div className="flex items-center justify-between">
|
||||||
<input
|
<label className="text-[10px] uppercase font-bold text-slate-500">Featured Image URL</label>
|
||||||
className="admin-input w-full text-xs py-1 px-2"
|
{onOpenMediaPicker && (
|
||||||
value={featured.imageUrl || ''}
|
<button
|
||||||
placeholder="https://cdn.example.com/product.webp"
|
type="button"
|
||||||
onChange={e => updateFeatured('imageUrl', e.target.value || null)}
|
onClick={() => onOpenMediaPicker((url: string) => updateFeatured('imageUrl', url))}
|
||||||
/>
|
className="text-[10px] font-bold text-cyan-glow hover:underline flex items-center gap-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
<ImageIcon className="w-3 h-3" /> เลือกจากคลังภาพ
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="admin-input w-full text-xs py-1 px-2"
|
||||||
|
value={featured.imageUrl || ''}
|
||||||
|
placeholder="https://cdn.example.com/product.webp"
|
||||||
|
onChange={e => updateFeatured('imageUrl', e.target.value || null)}
|
||||||
|
/>
|
||||||
|
{featured.imageUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateFeatured('imageUrl', null)}
|
||||||
|
className="px-2.5 py-1 text-xs text-rose-400 hover:bg-rose-500/10 rounded border border-white/10 shrink-0 cursor-pointer"
|
||||||
|
title="ลบรูปภาพ"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{featured.imageUrl && (
|
{featured.imageUrl && (
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
@@ -188,7 +247,7 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const FeaturedCardSidePanel = ({ node, onChange }: any) => {
|
const FeaturedCardSidePanel = ({ node, onChange, onOpenMediaPicker }: any) => {
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return (
|
return (
|
||||||
<div className="admin-card p-6 space-y-3">
|
<div className="admin-card p-6 space-y-3">
|
||||||
@@ -223,12 +282,13 @@ const FeaturedCardSidePanel = ({ node, onChange }: any) => {
|
|||||||
<FeaturedCardEditorFields
|
<FeaturedCardEditorFields
|
||||||
featured={featured}
|
featured={featured}
|
||||||
onChange={(newFeatured: any) => onChange({ ...node, featured: newFeatured })}
|
onChange={(newFeatured: any) => onChange({ ...node, featured: newFeatured })}
|
||||||
|
onOpenMediaPicker={onOpenMediaPicker}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured, isFeaturedSelected }: any) => {
|
const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured, isFeaturedSelected, onOpenMediaPicker }: any) => {
|
||||||
const childrenKey = type === 'root' ? 'columns' : 'links';
|
const childrenKey = type === 'root' ? 'columns' : 'links';
|
||||||
const labelKey = type === 'column' ? 'title' : 'label';
|
const labelKey = type === 'column' ? 'title' : 'label';
|
||||||
const hasUrl = type !== 'column';
|
const hasUrl = type !== 'column';
|
||||||
@@ -300,21 +360,56 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
|
|||||||
{/* Image URL field */}
|
{/* Image URL field */}
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{/* Preview thumbnail */}
|
{/* Preview thumbnail */}
|
||||||
<div className="w-14 h-14 shrink-0 rounded-lg overflow-hidden border border-white/10 bg-white/5 flex items-center justify-center">
|
<div
|
||||||
|
onClick={() => onOpenMediaPicker && onOpenMediaPicker((url: string) => onChange({ ...node, [imageFieldKey]: url }))}
|
||||||
|
className="w-14 h-14 shrink-0 rounded-lg overflow-hidden border border-white/10 hover:border-cyan-glow/50 bg-white/5 flex items-center justify-center cursor-pointer group relative transition-colors"
|
||||||
|
title="คลิกเพื่อเลือกรูปจากคลังภาพ (Media Library)"
|
||||||
|
>
|
||||||
{imageValue ? (
|
{imageValue ? (
|
||||||
<img src={imageValue} alt="preview" className="w-full h-full object-cover" />
|
<>
|
||||||
|
<img src={imageValue} alt="preview" className="w-full h-full object-cover" />
|
||||||
|
<div className="absolute inset-0 bg-night-950/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<ImageIcon className="w-4 h-4 text-cyan-glow" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[9px] text-slate-600 text-center leading-tight px-1">No Image</span>
|
<div className="flex flex-col items-center justify-center text-slate-600 group-hover:text-cyan-glow transition-colors">
|
||||||
|
<ImageIcon className="w-4 h-4 mb-0.5" />
|
||||||
|
<span className="text-[8px] text-center leading-tight">Library</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 space-y-1">
|
<div className="flex-1 space-y-1">
|
||||||
<label className="text-[10px] uppercase font-bold text-slate-500">{imageFieldLabel}</label>
|
<div className="flex items-center justify-between">
|
||||||
<input
|
<label className="text-[10px] uppercase font-bold text-slate-500">{imageFieldLabel}</label>
|
||||||
className="admin-input w-full text-xs py-1 px-2"
|
{onOpenMediaPicker && (
|
||||||
value={imageValue}
|
<button
|
||||||
placeholder="https://cdn.example.com/image.webp"
|
type="button"
|
||||||
onChange={e => onChange({ ...node, [imageFieldKey]: e.target.value || null })}
|
onClick={() => onOpenMediaPicker((url: string) => onChange({ ...node, [imageFieldKey]: url }))}
|
||||||
/>
|
className="text-[10px] font-bold text-cyan-glow hover:underline flex items-center gap-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
<ImageIcon className="w-3 h-3" /> เลือกจากคลังภาพ
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="admin-input w-full text-xs py-1 px-2"
|
||||||
|
value={imageValue}
|
||||||
|
placeholder="https://cdn.example.com/image.webp"
|
||||||
|
onChange={e => onChange({ ...node, [imageFieldKey]: e.target.value || null })}
|
||||||
|
/>
|
||||||
|
{imageValue && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({ ...node, [imageFieldKey]: null })}
|
||||||
|
className="px-2 py-1 text-xs text-rose-400 hover:bg-rose-500/10 rounded border border-white/10 shrink-0 cursor-pointer"
|
||||||
|
title="ลบรูปภาพ"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{type !== 'root' && (
|
{type !== 'root' && (
|
||||||
<IconSelect
|
<IconSelect
|
||||||
value={node.icon}
|
value={node.icon}
|
||||||
@@ -325,30 +420,193 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{type === 'root' && (
|
{type === 'root' && (
|
||||||
<div className="rounded-xl border border-amber-400/20 bg-amber-400/5 p-4">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between gap-4">
|
{/* Layout Mode Selector */}
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-3">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-400/10 text-amber-300">
|
<div className="flex items-center justify-between">
|
||||||
<Star className="w-5 h-5" />
|
<label className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">
|
||||||
|
Sub-Menu Layout Preset
|
||||||
|
</label>
|
||||||
|
<span className="text-[10px] text-cyan-glow font-medium">
|
||||||
|
{node.layout === 'classic'
|
||||||
|
? 'Classic Dropdown'
|
||||||
|
: node.layout === 'tabbed'
|
||||||
|
? 'Tabbed Hierarchy'
|
||||||
|
: node.layout === 'visual-cards'
|
||||||
|
? 'Visual Cards'
|
||||||
|
: `Mega Menu (${node.layoutConfig?.columnCount || 2} Cols)`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({ ...node, layout: 'columns' })}
|
||||||
|
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
|
||||||
|
(!node.layout || node.layout === 'columns')
|
||||||
|
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
|
||||||
|
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="w-4 h-4 shrink-0 text-cyan-glow" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-bold leading-tight truncate">Column Grid</div>
|
||||||
|
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">2-4 คอลัมน์</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({ ...node, layout: 'tabbed' })}
|
||||||
|
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
|
||||||
|
node.layout === 'tabbed'
|
||||||
|
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
|
||||||
|
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Layers className="w-4 h-4 shrink-0 text-cyan-glow" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-bold leading-tight truncate">Tabbed Catalog</div>
|
||||||
|
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">แท็บซ้าย + ลิงก์ขวา</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({ ...node, layout: 'visual-cards' })}
|
||||||
|
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
|
||||||
|
node.layout === 'visual-cards'
|
||||||
|
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
|
||||||
|
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 shrink-0 text-cyan-glow" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-bold leading-tight truncate">Visual Cards</div>
|
||||||
|
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">การ์ดรูปถ่ายหรูหรา</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({ ...node, layout: 'classic' })}
|
||||||
|
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
|
||||||
|
node.layout === 'classic'
|
||||||
|
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
|
||||||
|
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ListFilter className="w-4 h-4 shrink-0 text-cyan-glow" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-bold leading-tight truncate">Classic Dropdown</div>
|
||||||
|
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">แนวตั้งมินิมอล</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submenu Layout Modifier / Description */}
|
||||||
|
{(!node.layout || node.layout === 'columns') && (
|
||||||
|
<div className="pt-3 border-t border-white/5 space-y-3">
|
||||||
|
{/* Column Count */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-slate-300">จำนวนคอลัมน์ (Columns)</span>
|
||||||
|
<div className="flex rounded-lg bg-black/40 p-1 border border-white/5">
|
||||||
|
{[2, 3, 4].map((count) => {
|
||||||
|
const currentCols = node.layoutConfig?.columnCount || 2;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={count}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({
|
||||||
|
...node,
|
||||||
|
layoutConfig: { ...(node.layoutConfig || {}), columnCount: count }
|
||||||
|
})}
|
||||||
|
className={`px-3 py-1 text-xs font-bold rounded-md transition-colors ${
|
||||||
|
currentCols === count
|
||||||
|
? 'bg-cyan-glow text-night-900'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{count} Cols
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle Featured Card */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-slate-300">แสดง Featured Card</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const isCurrentlyShown = node.layoutConfig?.showFeatured !== false && featured.enabled !== false;
|
||||||
|
const nextState = !isCurrentlyShown;
|
||||||
|
onChange({
|
||||||
|
...node,
|
||||||
|
layoutConfig: { ...(node.layoutConfig || {}), showFeatured: nextState },
|
||||||
|
featured: { ...featured, enabled: nextState }
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1 text-xs font-bold rounded-lg border transition-colors ${
|
||||||
|
(node.layoutConfig?.showFeatured !== false && featured.enabled !== false)
|
||||||
|
? 'border-amber-400/40 bg-amber-400/20 text-amber-300'
|
||||||
|
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{(node.layoutConfig?.showFeatured !== false && featured.enabled !== false) ? 'ON (เปิด)' : 'OFF (ปิด)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
)}
|
||||||
<h3 className="text-xs font-bold uppercase tracking-widest text-amber-200">Featured card</h3>
|
|
||||||
<p className="truncate text-[11px] text-slate-500">
|
{node.layout === 'tabbed' && (
|
||||||
{featured.enabled === false ? 'Disabled' : featuredTitle}
|
<div className="pt-3 border-t border-white/5 text-xs text-slate-400 leading-relaxed bg-white/2 p-2.5 rounded-lg">
|
||||||
</p>
|
💡 <strong className="text-slate-200">Tabbed Hierarchy:</strong> แต่ละ Column จะทำหน้าที่เป็นแท็บหมวดหมู่ฝั่งซ้าย เมื่อเอาเมาส์ Hover แท็บใด รายการ Links ย่อยจะปรากฏฝั่งขวาทันที เหมาะสำหรับร้านค้าที่มีหมวดหมู่แคตตาล็อกลึก (เช่น อะไหล่ยนต์, เครื่องใช้ไฟฟ้า, ไอที)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{node.layout === 'visual-cards' && (
|
||||||
|
<div className="pt-3 border-t border-white/5 text-xs text-slate-400 leading-relaxed bg-white/2 p-2.5 rounded-lg">
|
||||||
|
✨ <strong className="text-slate-200">Visual Image Cards:</strong> แสดงเป็นการ์ดรูปถ่าย 4 ใบแนวนอน โดยดึงรูปภาพจาก Column Icon URL หรือ Hover Image ของลิงก์แรก เหมาะสำหรับสินค้าที่เน้นความงามและดีไซน์ (เช่น ก้านไม้หอม, เทียนหอม, คอลเลกชันแฟชั่น)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{node.layout === 'classic' && (
|
||||||
|
<div className="pt-3 border-t border-white/5 text-xs text-slate-400 leading-relaxed bg-white/2 p-2.5 rounded-lg">
|
||||||
|
📄 <strong className="text-slate-200">Classic Dropdown:</strong> แสดงเป็นกล่องแนวตั้งกะทัดรัดใต้ปุ่มเมนูบน Navbar เหมาะสำหรับเมนูข้อมูลทั่วไปที่มี 3-7 ลิงก์ (เช่น เกี่ยวกับเรา, ศูนย์บริการ, ข่าวสาร)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Featured Card editor trigger (only shown when in columns mode and featured card is enabled) */}
|
||||||
|
{(!node.layout || node.layout === 'columns') && (node.layoutConfig?.showFeatured !== false && featured.enabled !== false) && (
|
||||||
|
<div className="rounded-xl border border-amber-400/20 bg-amber-400/5 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-400/10 text-amber-300">
|
||||||
|
<Star className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-widest text-amber-200">Featured card</h3>
|
||||||
|
<p className="truncate text-[11px] text-slate-500">
|
||||||
|
{featuredTitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEditFeatured}
|
||||||
|
className={`flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-xs font-bold transition-colors ${
|
||||||
|
isFeaturedSelected ? 'bg-amber-300 text-night-900' : 'bg-amber-400/10 text-amber-200 hover:bg-amber-400/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
{isFeaturedSelected ? 'Editing' : 'Edit'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
)}
|
||||||
type="button"
|
|
||||||
onClick={onEditFeatured}
|
|
||||||
className={`flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-xs font-bold transition-colors ${
|
|
||||||
isFeaturedSelected ? 'bg-amber-300 text-night-900' : 'bg-amber-400/10 text-amber-200 hover:bg-amber-400/20'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
|
||||||
{isFeaturedSelected ? 'Editing' : 'Edit'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -383,6 +641,7 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
|
|||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
onChange={(newChild: any) => handleUpdateChild(i, newChild)}
|
onChange={(newChild: any) => handleUpdateChild(i, newChild)}
|
||||||
onRemove={() => handleRemoveChild(i)}
|
onRemove={() => handleRemoveChild(i)}
|
||||||
|
onOpenMediaPicker={onOpenMediaPicker}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -406,6 +665,13 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
|
|||||||
const [pastedJsonText, setPastedJsonText] = useState('')
|
const [pastedJsonText, setPastedJsonText] = useState('')
|
||||||
const [pasteError, setPasteError] = useState<string | null>(null)
|
const [pasteError, setPasteError] = useState<string | null>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
|
||||||
|
const [mediaSelectCallback, setMediaSelectCallback] = useState<((url: string) => void) | null>(null)
|
||||||
|
|
||||||
|
const handleOpenMediaPicker = (onSelectUrl: (url: string) => void) => {
|
||||||
|
setMediaSelectCallback(() => onSelectUrl)
|
||||||
|
setIsMediaModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -708,6 +974,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
|
|||||||
}}
|
}}
|
||||||
onEditFeatured={() => setSelectedFeaturedIndex(idx)}
|
onEditFeatured={() => setSelectedFeaturedIndex(idx)}
|
||||||
isFeaturedSelected={safeFeaturedIndex === idx}
|
isFeaturedSelected={safeFeaturedIndex === idx}
|
||||||
|
onOpenMediaPicker={handleOpenMediaPicker}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -829,6 +1096,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
|
|||||||
<FeaturedCardSidePanel
|
<FeaturedCardSidePanel
|
||||||
node={selectedFeaturedNode}
|
node={selectedFeaturedNode}
|
||||||
onChange={handleUpdateFeaturedNode}
|
onChange={handleUpdateFeaturedNode}
|
||||||
|
onOpenMediaPicker={handleOpenMediaPicker}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-card p-6 space-y-4">
|
<div className="admin-card p-6 space-y-4">
|
||||||
@@ -884,7 +1152,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Paste JSON Modal */}
|
{/* Paste JSON Modal */}
|
||||||
{showPasteModal && (
|
{showPasteModal && createPortal(
|
||||||
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-4 animate-fade-in">
|
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-4 animate-fade-in">
|
||||||
<div className="w-full max-w-2xl bg-night-900 border border-white/10 rounded-2xl shadow-2xl p-6 space-y-4 animate-zoom-in">
|
<div className="w-full max-w-2xl bg-night-900 border border-white/10 rounded-2xl shadow-2xl p-6 space-y-4 animate-zoom-in">
|
||||||
<div className="flex items-center justify-between border-b border-white/10 pb-3">
|
<div className="flex items-center justify-between border-b border-white/10 pb-3">
|
||||||
@@ -932,7 +1200,8 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* LLM Mega Menu Guide Modal */}
|
{/* LLM Mega Menu Guide Modal */}
|
||||||
@@ -942,6 +1211,23 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
|
|||||||
onOpenPasteJson={() => setShowPasteModal(true)}
|
onOpenPasteJson={() => setShowPasteModal(true)}
|
||||||
onOpenImportFile={() => fileInputRef.current?.click()}
|
onOpenImportFile={() => fileInputRef.current?.click()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Media Library Modal */}
|
||||||
|
<MediaLibraryModal
|
||||||
|
isOpen={isMediaModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsMediaModalOpen(false)
|
||||||
|
setMediaSelectCallback(null)
|
||||||
|
}}
|
||||||
|
onSelect={(url) => {
|
||||||
|
if (mediaSelectCallback) {
|
||||||
|
mediaSelectCallback(url)
|
||||||
|
}
|
||||||
|
setIsMediaModalOpen(false)
|
||||||
|
setMediaSelectCallback(null)
|
||||||
|
}}
|
||||||
|
mediaType="image"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,692 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Download,
|
||||||
|
TrendingUp,
|
||||||
|
Percent,
|
||||||
|
Coins,
|
||||||
|
Package,
|
||||||
|
Layers,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
RefreshCw,
|
||||||
|
SlidersHorizontal,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { API_BASE } from '@homaroma/types';
|
||||||
|
import ProfitabilityHeaderTabs from './ProfitabilityHeaderTabs';
|
||||||
|
|
||||||
|
function fmtMoney(val: number | null | undefined): string {
|
||||||
|
if (val == null || isNaN(val)) return '฿0.00';
|
||||||
|
return (
|
||||||
|
'฿' +
|
||||||
|
new Intl.NumberFormat('th-TH', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(val)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPercent(val: number | null | undefined): string {
|
||||||
|
if (val == null || isNaN(val)) return '0.0%';
|
||||||
|
return `${val.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BomComponent {
|
||||||
|
id: number;
|
||||||
|
rawMaterialId: number;
|
||||||
|
name: string;
|
||||||
|
unit: string;
|
||||||
|
unitPrice: number;
|
||||||
|
quantity: number;
|
||||||
|
totalCost: number;
|
||||||
|
isLiquid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VariantProfitItem {
|
||||||
|
id: number;
|
||||||
|
sku: string;
|
||||||
|
productId: number;
|
||||||
|
productName: string;
|
||||||
|
categoryName: string;
|
||||||
|
brandName: string;
|
||||||
|
optionsStr: string;
|
||||||
|
retailPrice: number;
|
||||||
|
liquidCost: number;
|
||||||
|
packagingCost: number;
|
||||||
|
totalBomCost: number;
|
||||||
|
grossProfit: number;
|
||||||
|
grossMargin: number;
|
||||||
|
marginHealth: 'high' | 'mid' | 'low';
|
||||||
|
maxDiscountPercent: number;
|
||||||
|
bomCount: number;
|
||||||
|
bomItems: BomComponent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryBreakdown {
|
||||||
|
categoryName: string;
|
||||||
|
variantsCount: number;
|
||||||
|
avgPrice: number;
|
||||||
|
avgCost: number;
|
||||||
|
avgMargin: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MatrixKpis {
|
||||||
|
totalVariants: number;
|
||||||
|
configuredBomVariants: number;
|
||||||
|
avgGrossMargin: number;
|
||||||
|
totalCatalogRevenueAtFullList: number;
|
||||||
|
totalCatalogBomCost: number;
|
||||||
|
highMarginCount: number;
|
||||||
|
midMarginCount: number;
|
||||||
|
lowMarginCount: number;
|
||||||
|
categoryBreakdown: CategoryBreakdown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CostProfitabilityMatrixPage() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [data, setData] = useState<{ kpis: MatrixKpis; items: VariantProfitItem[] } | null>(null);
|
||||||
|
|
||||||
|
// Filters & Search
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||||
|
const [selectedMargin, setSelectedMargin] = useState('all');
|
||||||
|
const [sortBy, setSortBy] = useState('margin_desc');
|
||||||
|
const [expandedVariantId, setExpandedVariantId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Cost Sensitivity Simulator
|
||||||
|
const [isSimulatorOpen, setIsSimulatorOpen] = useState(false);
|
||||||
|
const [fragranceShiftPercent, setFragranceShiftPercent] = useState(0); // e.g., +10%
|
||||||
|
const [solventShiftPercent, setSolventShiftPercent] = useState(0); // e.g., +15%
|
||||||
|
const [packagingShiftPercent, setPackagingShiftPercent] = useState(0);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
limit: '600',
|
||||||
|
search,
|
||||||
|
category: selectedCategory,
|
||||||
|
margin: selectedMargin,
|
||||||
|
sortBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/profitability/matrix?${params.toString()}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
|
||||||
|
const json = await res.json();
|
||||||
|
setData(json);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load matrix data:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [selectedCategory, selectedMargin, sortBy]);
|
||||||
|
|
||||||
|
// Handle live search with debounce
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
fetchData();
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
// Simulated items when what-if sliders are adjusted
|
||||||
|
const displayItems = useMemo(() => {
|
||||||
|
if (!data?.items) return [];
|
||||||
|
if (fragranceShiftPercent === 0 && solventShiftPercent === 0 && packagingShiftPercent === 0) {
|
||||||
|
return data.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.items.map((item) => {
|
||||||
|
let simLiquidCost = 0;
|
||||||
|
let simPackagingCost = 0;
|
||||||
|
|
||||||
|
const simBomItems = item.bomItems.map((b) => {
|
||||||
|
let multiplier = 1;
|
||||||
|
if (b.name.includes('หัวน้ำหอม')) {
|
||||||
|
multiplier = 1 + fragranceShiftPercent / 100;
|
||||||
|
} else if (b.name.includes('DPG') || b.name.includes('DPM')) {
|
||||||
|
multiplier = 1 + solventShiftPercent / 100;
|
||||||
|
} else {
|
||||||
|
multiplier = 1 + packagingShiftPercent / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCost = Math.round(b.totalCost * multiplier * 100) / 100;
|
||||||
|
if (b.isLiquid) simLiquidCost += newCost;
|
||||||
|
else simPackagingCost += newCost;
|
||||||
|
|
||||||
|
return { ...b, totalCost: newCost };
|
||||||
|
});
|
||||||
|
|
||||||
|
const simTotalCost = Math.round((simLiquidCost + simPackagingCost) * 100) / 100;
|
||||||
|
const simGrossProfit = Math.round((item.retailPrice - simTotalCost) * 100) / 100;
|
||||||
|
const simMargin =
|
||||||
|
item.retailPrice > 0 ? Math.round(((item.retailPrice - simTotalCost) / item.retailPrice) * 1000) / 10 : 0;
|
||||||
|
|
||||||
|
let marginHealth: 'high' | 'mid' | 'low' = 'mid';
|
||||||
|
if (simMargin >= 70) marginHealth = 'high';
|
||||||
|
else if (simMargin < 55) marginHealth = 'low';
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
liquidCost: simLiquidCost,
|
||||||
|
packagingCost: simPackagingCost,
|
||||||
|
totalBomCost: simTotalCost,
|
||||||
|
grossProfit: simGrossProfit,
|
||||||
|
grossMargin: simMargin,
|
||||||
|
marginHealth,
|
||||||
|
bomItems: simBomItems,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [data?.items, fragranceShiftPercent, solventShiftPercent, packagingShiftPercent]);
|
||||||
|
|
||||||
|
// Export to CSV
|
||||||
|
const handleExportCsv = () => {
|
||||||
|
if (!displayItems.length) return;
|
||||||
|
const headers = [
|
||||||
|
'SKU',
|
||||||
|
'Product Name',
|
||||||
|
'Category',
|
||||||
|
'Size/Options',
|
||||||
|
'Retail Price',
|
||||||
|
'Liquid Cost',
|
||||||
|
'Packaging Cost',
|
||||||
|
'Total BOM Cost',
|
||||||
|
'Gross Profit',
|
||||||
|
'Gross Margin %',
|
||||||
|
'Max Safe Discount %',
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = displayItems.map((i) => [
|
||||||
|
i.sku,
|
||||||
|
`"${i.productName.replace(/"/g, '""')}"`,
|
||||||
|
`"${i.categoryName}"`,
|
||||||
|
`"${i.optionsStr}"`,
|
||||||
|
i.retailPrice,
|
||||||
|
i.liquidCost,
|
||||||
|
i.packagingCost,
|
||||||
|
i.totalBomCost,
|
||||||
|
i.grossProfit,
|
||||||
|
`${i.grossMargin}%`,
|
||||||
|
`${i.maxDiscountPercent}%`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csvContent = '\uFEFF' + [headers.join(','), ...rows.map((e) => e.join(','))].join('\n');
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', `HomAroma_BOM_Profitability_Matrix_${new Date().toISOString().slice(0, 10)}.csv`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
const kpis = data?.kpis;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Shared Header Navigation */}
|
||||||
|
<ProfitabilityHeaderTabs
|
||||||
|
title="Cost & Profitability Matrix"
|
||||||
|
subtitle="ตารางวิเคราะห์โครงสร้างต้นทุนวัตถุดิบ (BOM), ราคาขายปลีก และอัตรากำไรขั้นต้น (Gross Profit & Margin) ครบทั้งแคตตาล็อก"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Executive KPI Cards ────────────────────────────────────────── */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{/* Total Master Variants */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm relative overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Master Variants & BOM</span>
|
||||||
|
<div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-400">
|
||||||
|
<Package className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-3xl font-extrabold text-white">{kpis?.totalVariants || 0}</span>
|
||||||
|
<span className="text-xs font-semibold text-emerald-400">
|
||||||
|
BOM {kpis?.configuredBomVariants === kpis?.totalVariants ? '100% ครบ' : `${kpis?.configuredBomVariants} รายการ`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-2">ครอบคลุมทั้ง 5 กลุ่มสินค้าหลักในระบบ</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Average Gross Margin */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm relative overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Average Catalog Margin</span>
|
||||||
|
<div className="p-2 rounded-xl bg-emerald-500/10 text-emerald-400">
|
||||||
|
<Percent className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-3xl font-extrabold text-emerald-400">
|
||||||
|
{kpis ? fmtPercent(kpis.avgGrossMargin) : '0.0%'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold text-slate-400">Gross Margin</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-slate-800 h-1.5 rounded-full mt-3 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-gradient-to-r from-emerald-500 to-teal-400 h-full rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${Math.min(100, kpis?.avgGrossMargin || 0)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Catalog Value vs BOM Cost */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">List Value vs BOM Cost</span>
|
||||||
|
<div className="p-2 rounded-xl bg-blue-500/10 text-blue-400">
|
||||||
|
<Coins className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-bold text-white flex items-center justify-between">
|
||||||
|
<span className="text-slate-400 text-xs">Retail:</span>
|
||||||
|
<span>{fmtMoney(kpis?.totalCatalogRevenueAtFullList)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-rose-300 flex items-center justify-between mt-1">
|
||||||
|
<span className="text-slate-400 text-xs">Total BOM:</span>
|
||||||
|
<span>{fmtMoney(kpis?.totalCatalogBomCost)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Margin Health Breakdown */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Margin Distribution</span>
|
||||||
|
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-400">
|
||||||
|
<TrendingUp className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<span className="px-2.5 py-1 rounded-lg bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 text-xs font-bold flex-1 text-center">
|
||||||
|
🟢 {kpis?.highMarginCount || 0} >70%
|
||||||
|
</span>
|
||||||
|
<span className="px-2.5 py-1 rounded-lg bg-amber-500/15 text-amber-400 border border-amber-500/30 text-xs font-bold flex-1 text-center">
|
||||||
|
🟡 {kpis?.midMarginCount || 0} 55-70%
|
||||||
|
</span>
|
||||||
|
<span className="px-2.5 py-1 rounded-lg bg-rose-500/15 text-rose-400 border border-rose-500/30 text-xs font-bold flex-1 text-center">
|
||||||
|
🔴 {kpis?.lowMarginCount || 0} <55%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Category Quick Summary Pill Bar ────────────────────────────── */}
|
||||||
|
{kpis?.categoryBreakdown && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||||
|
{kpis.categoryBreakdown.map((cat) => {
|
||||||
|
const isSelected = selectedCategory.toLowerCase() === cat.categoryName.toLowerCase();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cat.categoryName}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedCategory(isSelected ? 'all' : cat.categoryName)}
|
||||||
|
className={`p-3.5 rounded-2xl border text-left transition-all duration-200 ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-cyan-500/15 border-cyan-500/50 shadow-md shadow-cyan-500/10'
|
||||||
|
: 'bg-slate-900/80 border-white/10 hover:border-white/20 hover:bg-slate-800/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-xs font-bold text-slate-300 truncate">{cat.categoryName}</span>
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-white/10 text-slate-300 font-mono">
|
||||||
|
{cat.variantsCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between mt-2">
|
||||||
|
<span className="text-xs text-slate-400">Avg Cost ฿{cat.avgCost}</span>
|
||||||
|
<span className="text-sm font-extrabold text-emerald-400">{cat.avgMargin}%</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── What-If Cost Sensitivity Simulator Accordion ───────────────── */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl overflow-hidden shadow-lg backdrop-blur-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsSimulatorOpen(!isSimulatorOpen)}
|
||||||
|
className="w-full px-5 py-4 flex items-center justify-between hover:bg-white/5 transition-all text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-400 border border-purple-500/20">
|
||||||
|
<SlidersHorizontal className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm md:text-base font-bold text-white flex items-center gap-2">
|
||||||
|
What-If Cost Sensitivity Simulator (จำลองต้นทุนวัตถุดิบผันผวน)
|
||||||
|
{(fragranceShiftPercent !== 0 || solventShiftPercent !== 0 || packagingShiftPercent !== 0) && (
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-purple-500 text-white animate-pulse">
|
||||||
|
Active Sim
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
ทดลองปรับราคาน้ำหอม, ตัวทำละลาย (DPG/DPM) หรือบรรจุภัณฑ์ เพื่อดูผลกระทบต่อ Gross Margin ทั้งแคตตาล็อกทันที
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
|
{isSimulatorOpen ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isSimulatorOpen && (
|
||||||
|
<div className="p-5 border-t border-white/10 bg-slate-950/60 space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{/* Fragrance Shift */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-xs font-semibold">
|
||||||
|
<span className="text-slate-300">หัวน้ำหอม (Fragrance Oil)</span>
|
||||||
|
<span className={fragranceShiftPercent > 0 ? 'text-rose-400' : fragranceShiftPercent < 0 ? 'text-emerald-400' : 'text-slate-400'}>
|
||||||
|
{fragranceShiftPercent > 0 ? `+${fragranceShiftPercent}%` : `${fragranceShiftPercent}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="-30"
|
||||||
|
max="50"
|
||||||
|
step="5"
|
||||||
|
value={fragranceShiftPercent}
|
||||||
|
onChange={(e) => setFragranceShiftPercent(parseInt(e.target.value))}
|
||||||
|
className="w-full accent-purple-500 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-[10px] text-slate-500">
|
||||||
|
<span>-30%</span>
|
||||||
|
<span>0% (ปัจจุบัน)</span>
|
||||||
|
<span>+50%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Solvent Shift */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-xs font-semibold">
|
||||||
|
<span className="text-slate-300">ตัวทำละลาย (DPG / DPM)</span>
|
||||||
|
<span className={solventShiftPercent > 0 ? 'text-rose-400' : solventShiftPercent < 0 ? 'text-emerald-400' : 'text-slate-400'}>
|
||||||
|
{solventShiftPercent > 0 ? `+${solventShiftPercent}%` : `${solventShiftPercent}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="-30"
|
||||||
|
max="50"
|
||||||
|
step="5"
|
||||||
|
value={solventShiftPercent}
|
||||||
|
onChange={(e) => setSolventShiftPercent(parseInt(e.target.value))}
|
||||||
|
className="w-full accent-cyan-500 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-[10px] text-slate-500">
|
||||||
|
<span>-30%</span>
|
||||||
|
<span>0% (ปัจจุบัน)</span>
|
||||||
|
<span>+50%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Packaging Shift */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-xs font-semibold">
|
||||||
|
<span className="text-slate-300">บรรจุภัณฑ์ (ขวด / ก้าน / ฝา / สเปรย์)</span>
|
||||||
|
<span className={packagingShiftPercent > 0 ? 'text-rose-400' : packagingShiftPercent < 0 ? 'text-emerald-400' : 'text-slate-400'}>
|
||||||
|
{packagingShiftPercent > 0 ? `+${packagingShiftPercent}%` : `${packagingShiftPercent}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="-30"
|
||||||
|
max="50"
|
||||||
|
step="5"
|
||||||
|
value={packagingShiftPercent}
|
||||||
|
onChange={(e) => setPackagingShiftPercent(parseInt(e.target.value))}
|
||||||
|
className="w-full accent-blue-500 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-[10px] text-slate-500">
|
||||||
|
<span>-30%</span>
|
||||||
|
<span>0% (ปัจจุบัน)</span>
|
||||||
|
<span>+50%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t border-white/5">
|
||||||
|
<span className="text-xs text-slate-400 italic">
|
||||||
|
* ค่าที่ปรับเปลี่ยนนี้เป็นเพียงการจำลองผลแบบเรียลไทม์บนหน้าจอ ไม่มีการบันทึกทับฐานข้อมูลจริง
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFragranceShiftPercent(0);
|
||||||
|
setSolventShiftPercent(0);
|
||||||
|
setPackagingShiftPercent(0);
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-xs font-semibold text-slate-300 transition-all flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
รีเซ็ตค่าจำลอง
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Filter Bar & Actions ────────────────────────────────────────── */}
|
||||||
|
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3 bg-slate-900/90 border border-white/10 rounded-2xl p-4 shadow-lg backdrop-blur-sm">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1 min-w-[240px]">
|
||||||
|
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="ค้นหาชื่อสินค้า, กลิ่น, SKU หรือขนาด..."
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-sm text-white placeholder-slate-400 focus:outline-none focus:border-cyan-500 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdowns */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{/* Category Dropdown */}
|
||||||
|
<select
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||||
|
className="px-3 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-xs md:text-sm text-white focus:outline-none focus:border-cyan-500"
|
||||||
|
>
|
||||||
|
<option value="all">ทุกหมวดหมู่ (All Categories)</option>
|
||||||
|
{kpis?.categoryBreakdown?.map((c) => (
|
||||||
|
<option key={c.categoryName} value={c.categoryName}>
|
||||||
|
{c.categoryName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Margin Health Filter */}
|
||||||
|
<select
|
||||||
|
value={selectedMargin}
|
||||||
|
onChange={(e) => setSelectedMargin(e.target.value)}
|
||||||
|
className="px-3 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-xs md:text-sm text-white focus:outline-none focus:border-cyan-500"
|
||||||
|
>
|
||||||
|
<option value="all">ทุกระดับมาร์จิ้น (All Margins)</option>
|
||||||
|
<option value="high">🟢 High Margin (>= 70%)</option>
|
||||||
|
<option value="mid">🟡 Mid Margin (55% - 70%)</option>
|
||||||
|
<option value="low">🔴 Low Margin (< 55%)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Sort By */}
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value)}
|
||||||
|
className="px-3 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-xs md:text-sm text-white focus:outline-none focus:border-cyan-500"
|
||||||
|
>
|
||||||
|
<option value="margin_desc">มาร์จิ้นสูงสุด (Margin % สูงสุด)</option>
|
||||||
|
<option value="margin_asc">มาร์จิ้นต่ำสุด (Margin % ต่ำสุด)</option>
|
||||||
|
<option value="profit_desc">กำไรบาทสูงสุด (GP บาทสูงสุด)</option>
|
||||||
|
<option value="profit_asc">กำไรบาทต่ำสุด (GP บาทต่ำสุด)</option>
|
||||||
|
<option value="cost_desc">ต้นทุน BOM สูงสุด</option>
|
||||||
|
<option value="cost_asc">ต้นทุน BOM ต่ำสุด</option>
|
||||||
|
<option value="price_desc">ราคาขายสูงสุด</option>
|
||||||
|
<option value="price_asc">ราคาขายต่ำสุด</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Export CSV */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExportCsv}
|
||||||
|
className="px-4 py-2.5 rounded-xl bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 text-xs md:text-sm font-bold flex items-center gap-2 transition-all"
|
||||||
|
title="ดาวน์โหลดข้อมูลเป็นไฟล์ Excel / CSV"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
<span>Export CSV</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Main Data Table ─────────────────────────────────────────────── */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl overflow-hidden shadow-xl backdrop-blur-sm">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-950/80 border-b border-white/10 text-[11px] font-bold text-slate-400 uppercase tracking-wider">
|
||||||
|
<th className="py-4 px-4 w-10"></th>
|
||||||
|
<th className="py-4 px-4">สินค้า & Variant</th>
|
||||||
|
<th className="py-4 px-4">หมวดหมู่</th>
|
||||||
|
<th className="py-4 px-4 text-center">ขนาด</th>
|
||||||
|
<th className="py-4 px-4 text-right">ราคาขายปลีก</th>
|
||||||
|
<th className="py-4 px-4 text-right">ค่าน้ำยา (Liquid)</th>
|
||||||
|
<th className="py-4 px-4 text-right">ค่าแพ็กเกจ (Pack)</th>
|
||||||
|
<th className="py-4 px-4 text-right bg-white/5">ต้นทุนรวม (COGS)</th>
|
||||||
|
<th className="py-4 px-4 text-right">กำไร (GP ฿)</th>
|
||||||
|
<th className="py-4 px-4 text-right">Gross Margin %</th>
|
||||||
|
<th className="py-4 px-4 text-center">Max Safe Disc.</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5 text-xs md:text-sm">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={11} className="py-12 text-center text-slate-400">
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin mx-auto text-cyan-400 mb-2" />
|
||||||
|
กำลังคำนวณและประมวลผลข้อมูล BOM แคตตาล็อก...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : displayItems.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={11} className="py-12 text-center text-slate-400">
|
||||||
|
ไม่พบรายการสินค้าที่ตรงกับเงื่อนไขการค้นหา
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
displayItems.map((item) => {
|
||||||
|
const isExpanded = expandedVariantId === item.id;
|
||||||
|
let badgeClass = 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30';
|
||||||
|
if (item.marginHealth === 'mid') badgeClass = 'bg-amber-500/15 text-amber-400 border-amber-500/30';
|
||||||
|
else if (item.marginHealth === 'low') badgeClass = 'bg-rose-500/15 text-rose-400 border-rose-500/30';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={item.id}>
|
||||||
|
<tr
|
||||||
|
onClick={() => setExpandedVariantId(isExpanded ? null : item.id)}
|
||||||
|
className={`hover:bg-white/5 transition-all cursor-pointer ${
|
||||||
|
isExpanded ? 'bg-white/5' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="py-3.5 px-4 text-slate-400 text-center">
|
||||||
|
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<div className="font-bold text-white leading-tight">{item.productName}</div>
|
||||||
|
<div className="text-[11px] font-mono text-cyan-400 mt-0.5">{item.sku}</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-slate-300">{item.categoryName}</td>
|
||||||
|
<td className="py-3.5 px-4 text-center">
|
||||||
|
<span className="px-2 py-0.5 rounded-md bg-white/10 text-slate-200 text-xs font-mono font-medium">
|
||||||
|
{item.optionsStr || '-'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-bold text-white">{fmtMoney(item.retailPrice)}</td>
|
||||||
|
<td className="py-3.5 px-4 text-right text-slate-300 font-mono">{fmtMoney(item.liquidCost)}</td>
|
||||||
|
<td className="py-3.5 px-4 text-right text-slate-300 font-mono">{fmtMoney(item.packagingCost)}</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-rose-300 bg-white/5">
|
||||||
|
{fmtMoney(item.totalBomCost)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-bold text-emerald-400 font-mono">
|
||||||
|
+{fmtMoney(item.grossProfit)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right">
|
||||||
|
<span
|
||||||
|
className={`px-2.5 py-1 rounded-full text-xs font-bold border inline-block min-w-[65px] text-center font-mono ${badgeClass}`}
|
||||||
|
>
|
||||||
|
{fmtPercent(item.grossMargin)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-center">
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 rounded-md bg-blue-500/10 text-blue-400 border border-blue-500/20 text-xs font-bold font-mono"
|
||||||
|
title="ลดราคาได้สูงสุดเท่านี้ก่อนที่มาร์จิ้นจะตกต่ำกว่า 40%"
|
||||||
|
>
|
||||||
|
ลดได้ {item.maxDiscountPercent}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Expandable Recipe Row */}
|
||||||
|
{isExpanded && (
|
||||||
|
<tr className="bg-slate-950/60 border-y border-cyan-500/20">
|
||||||
|
<td colSpan={11} className="py-4 px-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-xs font-bold uppercase tracking-wider text-cyan-400 flex items-center gap-1.5">
|
||||||
|
<Layers className="w-3.5 h-3.5" />
|
||||||
|
แจกแจงโครงสร้างสูตร BOM สำหรับ {item.productName} ({item.optionsStr})
|
||||||
|
</h4>
|
||||||
|
<span className="text-xs text-slate-400 font-mono">
|
||||||
|
วัตถุดิบทั้งหมด {item.bomItems.length} รายการ
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2.5">
|
||||||
|
{item.bomItems.map((bi) => (
|
||||||
|
<div
|
||||||
|
key={bi.id}
|
||||||
|
className="p-2.5 rounded-xl bg-slate-900 border border-white/10 flex items-center justify-between text-xs"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-200">{bi.name}</p>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-0.5 font-mono">
|
||||||
|
{bi.quantity} {bi.unit} @ {fmtMoney(bi.unitPrice)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-bold text-white font-mono">{fmtMoney(bi.totalCost)}</p>
|
||||||
|
<span
|
||||||
|
className={`text-[9px] px-1.5 py-0.5 rounded font-semibold ${
|
||||||
|
bi.isLiquid
|
||||||
|
? 'bg-purple-500/10 text-purple-400'
|
||||||
|
: 'bg-blue-500/10 text-blue-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{bi.isLiquid ? 'Liquid' : 'Pack'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Calendar, Clock, ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface DateFilterState {
|
||||||
|
period: string; // 'all' | '7d' | '30d' | '90d' | 'this_month' | 'last_month' | 'custom'
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: DateFilterState;
|
||||||
|
onChange: (val: DateFilterState) => void;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DateRangeSelector({ value, onChange }: Props) {
|
||||||
|
const [isCustomOpen, setIsCustomOpen] = useState(value.period === 'custom');
|
||||||
|
const [customStart, setCustomStart] = useState(value.startDate || '');
|
||||||
|
const [customEnd, setCustomEnd] = useState(value.endDate || '');
|
||||||
|
|
||||||
|
const presets = [
|
||||||
|
{ id: 'all', label: 'ทั้งหมด (All Time)' },
|
||||||
|
{ id: '7d', label: '7 วันล่าสุด' },
|
||||||
|
{ id: '30d', label: '30 วันล่าสุด' },
|
||||||
|
{ id: '90d', label: '90 วันล่าสุด' },
|
||||||
|
{ id: 'this_month', label: 'เดือนนี้' },
|
||||||
|
{ id: 'last_month', label: 'เดือนที่แล้ว' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleSelectPreset = (id: string) => {
|
||||||
|
setIsCustomOpen(false);
|
||||||
|
onChange({ period: id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyCustom = () => {
|
||||||
|
if (!customStart) return;
|
||||||
|
onChange({
|
||||||
|
period: 'custom',
|
||||||
|
startDate: customStart,
|
||||||
|
endDate: customEnd,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1.5 p-1 rounded-xl bg-slate-800/80 border border-white/10 text-xs">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-cyan-400 ml-2" />
|
||||||
|
<span className="text-[11px] font-bold text-slate-400 mr-1 hidden sm:inline">ช่วงเวลา:</span>
|
||||||
|
{presets.map((p) => {
|
||||||
|
const isActive = value.period === p.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectPreset(p.id)}
|
||||||
|
className={`px-2.5 py-1.5 rounded-lg font-semibold transition-all ${
|
||||||
|
isActive
|
||||||
|
? 'bg-cyan-500 text-white shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsCustomOpen(!isCustomOpen)}
|
||||||
|
className={`px-2.5 py-1.5 rounded-lg font-semibold flex items-center gap-1 transition-all ${
|
||||||
|
value.period === 'custom' || isCustomOpen
|
||||||
|
? 'bg-purple-500 text-white shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Calendar className="w-3.5 h-3.5" />
|
||||||
|
<span>กำหนดเอง</span>
|
||||||
|
<ChevronDown className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Date Range Popover / Inline */}
|
||||||
|
{isCustomOpen && (
|
||||||
|
<div className="flex items-center gap-2 p-1.5 rounded-xl bg-slate-900 border border-purple-500/40 text-xs shadow-xl animate-fade-in">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={customStart}
|
||||||
|
onChange={(e) => setCustomStart(e.target.value)}
|
||||||
|
className="px-2 py-1 rounded-lg bg-slate-800 border border-white/10 text-white focus:outline-none focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
<span className="text-slate-400">ถึง</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={customEnd}
|
||||||
|
onChange={(e) => setCustomEnd(e.target.value)}
|
||||||
|
className="px-2 py-1 rounded-lg bg-slate-800 border border-white/10 text-white focus:outline-none focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApplyCustom}
|
||||||
|
disabled={!customStart}
|
||||||
|
className="px-3 py-1 rounded-lg bg-purple-500 hover:bg-purple-600 text-white font-bold transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
ใช้ตัวกรอง
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Download,
|
||||||
|
Package,
|
||||||
|
RefreshCw,
|
||||||
|
Box,
|
||||||
|
Flame,
|
||||||
|
Calendar,
|
||||||
|
TrendingUp,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { API_BASE } from '@homaroma/types';
|
||||||
|
import ProfitabilityHeaderTabs from './ProfitabilityHeaderTabs';
|
||||||
|
import DateRangeSelector, { type DateFilterState } from './DateRangeSelector';
|
||||||
|
|
||||||
|
function fmtMoney(val: number | null | undefined): string {
|
||||||
|
if (val == null || isNaN(val)) return '฿0.00';
|
||||||
|
return (
|
||||||
|
'฿' +
|
||||||
|
new Intl.NumberFormat('th-TH', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(val)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtNum(val: number | null | undefined): string {
|
||||||
|
if (val == null || isNaN(val)) return '0';
|
||||||
|
return new Intl.NumberFormat('th-TH').format(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopProductUsage {
|
||||||
|
name: string;
|
||||||
|
qty: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawMaterialUsageItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
unit: string;
|
||||||
|
pricePerUnit: number;
|
||||||
|
isLiquid: boolean;
|
||||||
|
linkedVariantsCount: number;
|
||||||
|
totalConsumedQty: number;
|
||||||
|
totalConsumedCost: number;
|
||||||
|
dailyBurnRate: number;
|
||||||
|
forecast30DaysQty: number;
|
||||||
|
forecast30DaysCost: number;
|
||||||
|
topProducts: TopProductUsage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PeriodInfo {
|
||||||
|
period: string;
|
||||||
|
label: string;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
daysCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MrpKpis {
|
||||||
|
totalRawMaterials: number;
|
||||||
|
totalMaterialCostConsumed: number;
|
||||||
|
topFragrancesByCost: RawMaterialUsageItem[];
|
||||||
|
topPackagingByCost: RawMaterialUsageItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MrpPlanningPage() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [data, setData] = useState<{
|
||||||
|
periodInfo: PeriodInfo;
|
||||||
|
kpis: MrpKpis;
|
||||||
|
categories: {
|
||||||
|
fragrances: RawMaterialUsageItem[];
|
||||||
|
solvents: RawMaterialUsageItem[];
|
||||||
|
packaging: RawMaterialUsageItem[];
|
||||||
|
};
|
||||||
|
allMaterials: RawMaterialUsageItem[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState<'all' | 'fragrances' | 'solvents' | 'packaging'>('all');
|
||||||
|
const [dateFilter, setDateFilter] = useState<DateFilterState>({ period: '30d' }); // Default 30d for MRP
|
||||||
|
const [sortBy, setSortBy] = useState('cost_desc');
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
period: dateFilter.period,
|
||||||
|
...(dateFilter.startDate ? { startDate: dateFilter.startDate } : {}),
|
||||||
|
...(dateFilter.endDate ? { endDate: dateFilter.endDate } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/profitability/mrp?${params.toString()}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
|
||||||
|
const json = await res.json();
|
||||||
|
setData(json);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load MRP data:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [dateFilter]);
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
let list: RawMaterialUsageItem[] = [];
|
||||||
|
|
||||||
|
if (activeTab === 'fragrances') list = data.categories.fragrances;
|
||||||
|
else if (activeTab === 'solvents') list = data.categories.solvents;
|
||||||
|
else if (activeTab === 'packaging') list = data.categories.packaging;
|
||||||
|
else list = data.allMaterials;
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
const s = search.toLowerCase();
|
||||||
|
list = list.filter((m) => m.name.toLowerCase().includes(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...list];
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
if (sortBy === 'cost_desc') return b.totalConsumedCost - a.totalConsumedCost;
|
||||||
|
if (sortBy === 'forecast_desc') return b.forecast30DaysCost - a.forecast30DaysCost;
|
||||||
|
if (sortBy === 'qty_desc') return b.totalConsumedQty - a.totalConsumedQty;
|
||||||
|
if (sortBy === 'name_asc') return a.name.localeCompare(b.name);
|
||||||
|
if (sortBy === 'price_desc') return b.pricePerUnit - a.pricePerUnit;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}, [data, activeTab, search, sortBy]);
|
||||||
|
|
||||||
|
// Total forecast procurement budget
|
||||||
|
const totalForecastBudget = useMemo(() => {
|
||||||
|
if (!data?.allMaterials) return 0;
|
||||||
|
return data.allMaterials.reduce((sum, m) => sum + m.forecast30DaysCost, 0);
|
||||||
|
}, [data?.allMaterials]);
|
||||||
|
|
||||||
|
const handleExportCsv = () => {
|
||||||
|
if (!filteredItems.length) return;
|
||||||
|
const headers = [
|
||||||
|
'Raw Material ID',
|
||||||
|
'Material Name',
|
||||||
|
'Unit',
|
||||||
|
'Unit Cost (THB)',
|
||||||
|
'Type',
|
||||||
|
'Linked BOM Formulas',
|
||||||
|
`Consumed Qty (${data?.periodInfo?.label || 'Period'})`,
|
||||||
|
'Consumed Cost (THB)',
|
||||||
|
'Daily Burn Rate',
|
||||||
|
'30-Day Forecast Reorder Qty',
|
||||||
|
'30-Day Forecast Budget (THB)',
|
||||||
|
'Top Consumer Products',
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = filteredItems.map((i) => [
|
||||||
|
i.id,
|
||||||
|
`"${i.name.replace(/"/g, '""')}"`,
|
||||||
|
i.unit,
|
||||||
|
i.pricePerUnit,
|
||||||
|
i.isLiquid ? 'Liquid' : 'Packaging',
|
||||||
|
i.linkedVariantsCount,
|
||||||
|
i.totalConsumedQty,
|
||||||
|
i.totalConsumedCost,
|
||||||
|
i.dailyBurnRate,
|
||||||
|
i.forecast30DaysQty,
|
||||||
|
i.forecast30DaysCost,
|
||||||
|
`"${i.topProducts.map((p) => `${p.name} (${p.qty} ${i.unit})`).join('; ')}"`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csvContent = '\uFEFF' + [headers.join(','), ...rows.map((e) => e.join(','))].join('\n');
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', `HomAroma_MRP_Material_Demand_${dateFilter.period}_${new Date().toISOString().slice(0, 10)}.csv`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
const kpis = data?.kpis;
|
||||||
|
const periodInfo = data?.periodInfo;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Shared Header Navigation */}
|
||||||
|
<ProfitabilityHeaderTabs
|
||||||
|
title="MRP & Material Demand Planning"
|
||||||
|
subtitle="Reverse BOM Explosion: คำนวณปริมาณการใช้วัตถุดิบจริงย้อนกลับตามช่วงเวลา เพื่อหาอัตราการใช้ต่อวัน (Daily Burn Rate) และพยากรณ์การสั่งซื้อล่วงหน้า 30 วัน"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Filter Bar: Date Range + Search ────────────────────────────── */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3 bg-slate-900/90 border border-white/10 rounded-2xl p-4 shadow-lg backdrop-blur-sm">
|
||||||
|
{/* Date Range Selector */}
|
||||||
|
<DateRangeSelector value={dateFilter} onChange={setDateFilter} />
|
||||||
|
|
||||||
|
{/* Active Period Indicator */}
|
||||||
|
{periodInfo && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-slate-800 border border-cyan-500/30 text-xs font-semibold text-cyan-400">
|
||||||
|
<Calendar className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span>ช่วงวิเคราะห์สต็อก: {periodInfo.label}</span>
|
||||||
|
{periodInfo.daysCount && (
|
||||||
|
<span className="text-[10px] text-slate-400">({periodInfo.daysCount} วัน)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Executive KPI Cards ────────────────────────────────────────── */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{/* Total Raw Materials */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Active Raw Materials</span>
|
||||||
|
<div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-400">
|
||||||
|
<Box className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-extrabold text-white">{kpis?.totalRawMaterials || 0} รายการ</div>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-2">
|
||||||
|
หัวน้ำหอม 45 กลิ่น + ตัวทำละลาย DPG/DPM + บรรจุภัณฑ์
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Material Cost Consumed in Period */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Consumed Cost ({periodInfo?.label || 'Period'})</span>
|
||||||
|
<div className="p-2 rounded-xl bg-rose-500/10 text-rose-400">
|
||||||
|
<Flame className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-extrabold text-rose-400">{fmtMoney(kpis?.totalMaterialCostConsumed)}</div>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-2">มูลค่ายอดใช้วัตถุดิบจริงในช่วงเวลาที่เลือก</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 30-Day Forecast Procurement Budget */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">30-Day Forecast Budget</span>
|
||||||
|
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-400">
|
||||||
|
<TrendingUp className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-extrabold text-purple-400">{fmtMoney(totalForecastBudget)}</div>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-2">งบประมาณสั่งซื้อวัตถุดิบ 30 วันข้างหน้า (อิง Run-rate)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Packaging Burn */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Top Volume Driver</span>
|
||||||
|
<div className="p-2 rounded-xl bg-emerald-500/10 text-emerald-400">
|
||||||
|
<Package className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{kpis?.topPackagingByCost?.[0] ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-white truncate">{kpis.topPackagingByCost[0].name}</div>
|
||||||
|
<div className="flex items-baseline justify-between mt-1 text-xs">
|
||||||
|
<span className="text-slate-400 font-mono">
|
||||||
|
{fmtNum(kpis.topPackagingByCost[0].totalConsumedQty)} {kpis.topPackagingByCost[0].unit}
|
||||||
|
</span>
|
||||||
|
<span className="font-bold text-emerald-400 font-mono">
|
||||||
|
{fmtMoney(kpis.topPackagingByCost[0].totalConsumedCost)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-slate-500">-</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Category Sub-Tabs & Search ──────────────────────────────────── */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 bg-slate-900/90 border border-white/10 rounded-2xl p-3 shadow-lg backdrop-blur-sm">
|
||||||
|
{/* Sub Tabs */}
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('all')}
|
||||||
|
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all ${
|
||||||
|
activeTab === 'all'
|
||||||
|
? 'bg-cyan-500 text-white shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
ทั้งหมด ({data?.allMaterials?.length || 0})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('fragrances')}
|
||||||
|
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all ${
|
||||||
|
activeTab === 'fragrances'
|
||||||
|
? 'bg-purple-500 text-white shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🌸 หัวน้ำหอม ({data?.categories.fragrances?.length || 0})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('solvents')}
|
||||||
|
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all ${
|
||||||
|
activeTab === 'solvents'
|
||||||
|
? 'bg-blue-500 text-white shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🧪 ตัวทำละลาย DPG/DPM ({data?.categories.solvents?.length || 0})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('packaging')}
|
||||||
|
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all ${
|
||||||
|
activeTab === 'packaging'
|
||||||
|
? 'bg-emerald-500 text-white shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
📦 บรรจุภัณฑ์ ขวด/ก้าน/ฝา ({data?.categories.packaging?.length || 0})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Controls */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative min-w-[200px]">
|
||||||
|
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="ค้นหาชื่อวัตถุดิบ..."
|
||||||
|
className="w-full pl-9 pr-3 py-1.5 rounded-xl bg-slate-800 border border-white/10 text-xs text-white placeholder-slate-400 focus:outline-none focus:border-cyan-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort */}
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value)}
|
||||||
|
className="px-3 py-1.5 rounded-xl bg-slate-800 border border-white/10 text-xs text-white focus:outline-none focus:border-cyan-500"
|
||||||
|
>
|
||||||
|
<option value="cost_desc">มูลค่าที่ใช้สูงสุด (Consumed Cost)</option>
|
||||||
|
<option value="forecast_desc">งบพยากรณ์ 30 วันสูงสุด (Forecast Budget)</option>
|
||||||
|
<option value="qty_desc">ปริมาณที่ใช้สูงสุด (Volume Qty)</option>
|
||||||
|
<option value="price_desc">ราคาต่อหน่วยสูงสุด</option>
|
||||||
|
<option value="name_asc">ชื่อ ก-ฮ (A-Z)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Export CSV */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExportCsv}
|
||||||
|
className="px-3 py-1.5 rounded-xl bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 text-xs font-bold flex items-center gap-1.5 transition-all"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
<span>Export CSV</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Main MRP Table ─────────────────────────────────────────────── */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl overflow-hidden shadow-xl backdrop-blur-sm">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-950/80 border-b border-white/10 text-[11px] font-bold text-slate-400 uppercase tracking-wider">
|
||||||
|
<th className="py-4 px-4">ชื่อวัตถุดิบ (Raw Material)</th>
|
||||||
|
<th className="py-4 px-4 text-center">ประเภท</th>
|
||||||
|
<th className="py-4 px-4 text-right">ราคาต่อหน่วย</th>
|
||||||
|
<th className="py-4 px-4 text-right">ยอดใช้ในรอบ ({periodInfo?.label || 'Period'})</th>
|
||||||
|
<th className="py-4 px-4 text-right bg-white/5">มูลค่าใช้จริง</th>
|
||||||
|
<th className="py-4 px-4 text-right">Daily Burn Rate</th>
|
||||||
|
<th className="py-4 px-4 text-right bg-purple-500/10 text-purple-300">
|
||||||
|
พยากรณ์สั่งซื้อ 30 วัน
|
||||||
|
</th>
|
||||||
|
<th className="py-4 px-4 text-right bg-purple-500/10 text-purple-300">
|
||||||
|
งบสั่งซื้อ 30 วัน
|
||||||
|
</th>
|
||||||
|
<th className="py-4 px-4">สินค้าหลักที่ใช้ (Top Consumers)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5 text-xs md:text-sm">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={9} className="py-12 text-center text-slate-400">
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin mx-auto text-cyan-400 mb-2" />
|
||||||
|
กำลังประมวลผลการคำนวณวัตถุดิบและพยากรณ์สต็อกล่วงหน้า...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : filteredItems.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={9} className="py-12 text-center text-slate-400">
|
||||||
|
ไม่พบรายการวัตถุดิบที่มียอดใช้ในช่วงเวลาที่เลือก
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredItems.map((item) => {
|
||||||
|
return (
|
||||||
|
<tr key={item.id} className="hover:bg-white/5 transition-all">
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<div className="font-bold text-white">{item.name}</div>
|
||||||
|
<div className="text-[11px] text-slate-400 font-mono">
|
||||||
|
ID: #{item.id} • {item.linkedVariantsCount} สูตร
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-center">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded-md text-[10px] font-bold ${
|
||||||
|
item.isLiquid
|
||||||
|
? 'bg-purple-500/10 text-purple-400 border border-purple-500/20'
|
||||||
|
: 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.isLiquid ? 'Liquid' : 'Packaging'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-mono text-white">
|
||||||
|
{fmtMoney(item.pricePerUnit)} / {item.unit}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">
|
||||||
|
{fmtNum(item.totalConsumedQty)} {item.unit}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-rose-300 bg-white/5">
|
||||||
|
{fmtMoney(item.totalConsumedCost)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-mono text-cyan-400 font-semibold">
|
||||||
|
{fmtNum(item.dailyBurnRate)} {item.unit}/วัน
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-purple-300 bg-purple-500/5">
|
||||||
|
{fmtNum(item.forecast30DaysQty)} {item.unit}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-purple-200 bg-purple-500/5">
|
||||||
|
{fmtMoney(item.forecast30DaysCost)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 max-w-[240px]">
|
||||||
|
{item.topProducts.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{item.topProducts.map((p, pIdx) => (
|
||||||
|
<div key={pIdx} className="text-[11px] text-slate-300 flex items-center justify-between">
|
||||||
|
<span className="truncate pr-2">{p.name}</span>
|
||||||
|
<span className="text-cyan-400 font-mono shrink-0">
|
||||||
|
{fmtNum(p.qty)} {item.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-500">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,657 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Download,
|
||||||
|
ShoppingBag,
|
||||||
|
TrendingUp,
|
||||||
|
Percent,
|
||||||
|
Coins,
|
||||||
|
RefreshCw,
|
||||||
|
Calendar,
|
||||||
|
Receipt,
|
||||||
|
ShieldCheck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { API_BASE } from '@homaroma/types';
|
||||||
|
import ProfitabilityHeaderTabs from './ProfitabilityHeaderTabs';
|
||||||
|
import DateRangeSelector, { type DateFilterState } from './DateRangeSelector';
|
||||||
|
|
||||||
|
function fmtMoney(val: number | null | undefined): string {
|
||||||
|
if (val == null || isNaN(val)) return '฿0.00';
|
||||||
|
return (
|
||||||
|
'฿' +
|
||||||
|
new Intl.NumberFormat('th-TH', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(val)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtNum(val: number | null | undefined): string {
|
||||||
|
if (val == null || isNaN(val)) return '0';
|
||||||
|
return new Intl.NumberFormat('th-TH').format(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPercent(val: number | null | undefined): string {
|
||||||
|
if (val == null || isNaN(val)) return '0.0%';
|
||||||
|
return `${val.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OmnichannelItem {
|
||||||
|
id: number;
|
||||||
|
channel: string;
|
||||||
|
shop: string;
|
||||||
|
marketplaceSku: string;
|
||||||
|
marketplaceProductName: string;
|
||||||
|
variation: string;
|
||||||
|
avgSellingPrice: number;
|
||||||
|
totalSoldQty: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
masterVariantId: number;
|
||||||
|
masterSku: string;
|
||||||
|
productName: string;
|
||||||
|
categoryName: string;
|
||||||
|
unitBomCost: number;
|
||||||
|
totalBomCost: number;
|
||||||
|
platformFee: number;
|
||||||
|
effectiveFeePercent: number;
|
||||||
|
feeSource: 'actual' | 'hybrid' | 'fallback_30pct';
|
||||||
|
netRevenue: number;
|
||||||
|
netProfit: number;
|
||||||
|
netMarginPercent: number;
|
||||||
|
directPrice: number;
|
||||||
|
directMarginPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChannelStat {
|
||||||
|
soldQty: number;
|
||||||
|
revenue: number;
|
||||||
|
platformFee: number;
|
||||||
|
bomCost: number;
|
||||||
|
netProfit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PeriodInfo {
|
||||||
|
period: string;
|
||||||
|
label: string;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
daysCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OmnichannelKpis {
|
||||||
|
totalMappedProducts: number;
|
||||||
|
totalUnitsSold: number;
|
||||||
|
totalGrossRevenue: number;
|
||||||
|
totalPlatformFee: number;
|
||||||
|
totalEstNetRevenue: number;
|
||||||
|
totalBOMCost: number;
|
||||||
|
totalEstNetProfit: number;
|
||||||
|
overallNetMargin: number;
|
||||||
|
effectiveFeeRate: number;
|
||||||
|
actualFeeAmount: number;
|
||||||
|
fallbackFeeAmount: number;
|
||||||
|
actualOrdersCount: number;
|
||||||
|
fallbackOrdersCount: number;
|
||||||
|
channelStats: Record<string, ChannelStat>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OmnichannelMarginPage() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [data, setData] = useState<{
|
||||||
|
periodInfo: PeriodInfo;
|
||||||
|
feePolicy?: { mode: string; fallbackPercent: number; description: string };
|
||||||
|
kpis: OmnichannelKpis;
|
||||||
|
items: OmnichannelItem[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [channelFilter, setChannelFilter] = useState<'all' | 'shopee' | 'lazada' | 'tiktok'>('all');
|
||||||
|
const [dateFilter, setDateFilter] = useState<DateFilterState>({ period: 'all' });
|
||||||
|
const [sortBy, setSortBy] = useState('revenue_desc');
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
channel: channelFilter,
|
||||||
|
search,
|
||||||
|
period: dateFilter.period,
|
||||||
|
...(dateFilter.startDate ? { startDate: dateFilter.startDate } : {}),
|
||||||
|
...(dateFilter.endDate ? { endDate: dateFilter.endDate } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/profitability/omnichannel?${params.toString()}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
|
||||||
|
const json = await res.json();
|
||||||
|
setData(json);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load omnichannel margin data:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [channelFilter, dateFilter]);
|
||||||
|
|
||||||
|
// Live search debounce
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
fetchData();
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
// Client-side sort
|
||||||
|
const sortedItems = useMemo(() => {
|
||||||
|
if (!data?.items) return [];
|
||||||
|
const items = [...data.items];
|
||||||
|
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (sortBy === 'revenue_desc') return b.totalRevenue - a.totalRevenue;
|
||||||
|
if (sortBy === 'profit_desc') return b.netProfit - a.netProfit;
|
||||||
|
if (sortBy === 'margin_desc') return b.netMarginPercent - a.netMarginPercent;
|
||||||
|
if (sortBy === 'margin_asc') return a.netMarginPercent - b.netMarginPercent;
|
||||||
|
if (sortBy === 'qty_desc') return b.totalSoldQty - a.totalSoldQty;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [data?.items, sortBy]);
|
||||||
|
|
||||||
|
const handleExportCsv = () => {
|
||||||
|
if (!sortedItems.length) return;
|
||||||
|
const headers = [
|
||||||
|
'Channel',
|
||||||
|
'Shop',
|
||||||
|
'Marketplace SKU',
|
||||||
|
'Marketplace Product',
|
||||||
|
'Variation',
|
||||||
|
'Master SKU',
|
||||||
|
'Master Product',
|
||||||
|
'Units Sold',
|
||||||
|
'Avg Price',
|
||||||
|
'Total Revenue',
|
||||||
|
'Platform Fee (THB)',
|
||||||
|
'Effective Fee %',
|
||||||
|
'Fee Source',
|
||||||
|
'Unit BOM Cost',
|
||||||
|
'Total BOM Cost',
|
||||||
|
'Net Revenue',
|
||||||
|
'Net Profit',
|
||||||
|
'Net Margin %',
|
||||||
|
'Direct Price',
|
||||||
|
'Direct Margin %',
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = sortedItems.map((i) => [
|
||||||
|
i.channel,
|
||||||
|
i.shop,
|
||||||
|
i.marketplaceSku,
|
||||||
|
`"${i.marketplaceProductName.replace(/"/g, '""')}"`,
|
||||||
|
`"${i.variation}"`,
|
||||||
|
i.masterSku,
|
||||||
|
`"${i.productName.replace(/"/g, '""')}"`,
|
||||||
|
i.totalSoldQty,
|
||||||
|
i.avgSellingPrice,
|
||||||
|
i.totalRevenue,
|
||||||
|
i.platformFee,
|
||||||
|
`${i.effectiveFeePercent}%`,
|
||||||
|
i.feeSource,
|
||||||
|
i.unitBomCost,
|
||||||
|
i.totalBomCost,
|
||||||
|
i.netRevenue,
|
||||||
|
i.netProfit,
|
||||||
|
`${i.netMarginPercent}%`,
|
||||||
|
i.directPrice,
|
||||||
|
`${i.directMarginPercent}%`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csvContent = '\uFEFF' + [headers.join(','), ...rows.map((e) => e.join(','))].join('\n');
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', `HomAroma_Omnichannel_Net_Margin_${dateFilter.period}_${new Date().toISOString().slice(0, 10)}.csv`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
const kpis = data?.kpis;
|
||||||
|
const periodInfo = data?.periodInfo;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Shared Header Navigation */}
|
||||||
|
<ProfitabilityHeaderTabs
|
||||||
|
title="Omnichannel Net Margin & Contribution"
|
||||||
|
subtitle="วิเคราะห์กำไรสุทธิตามช่องทางขาย (Shopee, Lazada, TikTok, Direct) หลังหักค่าธรรมเนียม Platform และต้นทุนผลิต BOM จริง"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Filter Bar: Date Range + Controls ───────────────────────────── */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3 bg-slate-900/90 border border-white/10 rounded-2xl p-4 shadow-lg backdrop-blur-sm">
|
||||||
|
{/* Date Range Selector */}
|
||||||
|
<DateRangeSelector value={dateFilter} onChange={setDateFilter} />
|
||||||
|
|
||||||
|
{/* Active Period Badge */}
|
||||||
|
{periodInfo && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-slate-800 border border-cyan-500/30 text-xs font-semibold text-cyan-400">
|
||||||
|
<Calendar className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span>ช่วงข้อมูล: {periodInfo.label}</span>
|
||||||
|
{periodInfo.daysCount && (
|
||||||
|
<span className="text-[10px] text-slate-400">({periodInfo.daysCount} วัน)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Secondary Filter Bar */}
|
||||||
|
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3 bg-slate-900/90 border border-white/10 rounded-2xl p-4 shadow-lg backdrop-blur-sm">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1 min-w-[240px]">
|
||||||
|
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="ค้นหาชื่อสินค้าบน Marketplace, Variation หรือ Master SKU..."
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-sm text-white placeholder-slate-400 focus:outline-none focus:border-cyan-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdowns */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{/* Channel Filter */}
|
||||||
|
<select
|
||||||
|
value={channelFilter}
|
||||||
|
onChange={(e) => setChannelFilter(e.target.value as any)}
|
||||||
|
className="px-3 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-xs md:text-sm text-white focus:outline-none focus:border-cyan-500"
|
||||||
|
>
|
||||||
|
<option value="all">ทุกช่องทาง (All Channels)</option>
|
||||||
|
<option value="shopee">Shopee</option>
|
||||||
|
<option value="lazada">Lazada</option>
|
||||||
|
<option value="tiktok">TikTok Shop</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Sort By */}
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value)}
|
||||||
|
className="px-3 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-xs md:text-sm text-white focus:outline-none focus:border-cyan-500"
|
||||||
|
>
|
||||||
|
<option value="revenue_desc">ยอดขายรวมสูงสุด (Revenue)</option>
|
||||||
|
<option value="profit_desc">กำไรสุทธิรวมสูงสุด (Net Profit)</option>
|
||||||
|
<option value="margin_desc">อัตรากำไรสุทธิสูงสุด (Net Margin %)</option>
|
||||||
|
<option value="margin_asc">อัตรากำไรสุทธิต่ำสุด (Risk Margin %)</option>
|
||||||
|
<option value="qty_desc">จำนวนชิ้นที่ขายได้สูงสุด (Units Sold)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Export CSV */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExportCsv}
|
||||||
|
className="px-4 py-2.5 rounded-xl bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 text-xs md:text-sm font-bold flex items-center gap-2 transition-all"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
<span>Export CSV</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Fee Policy Notice Banner ─────────────────────────────────── */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 p-4 rounded-2xl bg-slate-900/90 border border-white/10 shadow-lg backdrop-blur-sm text-xs text-slate-300">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-400">
|
||||||
|
<ShieldCheck className="w-5 h-5 shrink-0" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-bold text-white">Hybrid Fee Policy (Actual + 30% Fallback):</span>{' '}
|
||||||
|
<span className="text-slate-400">
|
||||||
|
Shopee ดึงค่าธรรมเนียมจริงจากรายงานคำสั่งซื้อ, TikTok คำนวณจากยอดโอนจริง (Settlement)
|
||||||
|
สำหรับออเดอร์ที่ยังไม่มีข้อมูลค่าธรรมเนียม จะใช้ตัวคูณปลอดภัย{' '}
|
||||||
|
<strong className="text-amber-400">30% Fallback</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-[11px] font-mono shrink-0">
|
||||||
|
<span className="px-2.5 py-1 rounded-lg bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 font-bold">
|
||||||
|
Actual: {fmtNum(kpis?.actualOrdersCount)} ออเดอร์ ({fmtMoney(kpis?.actualFeeAmount)})
|
||||||
|
</span>
|
||||||
|
<span className="px-2.5 py-1 rounded-lg bg-amber-500/15 text-amber-300 border border-amber-500/30 font-bold">
|
||||||
|
30% Fallback: {fmtNum(kpis?.fallbackOrdersCount)} ออเดอร์ ({fmtMoney(kpis?.fallbackFeeAmount)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Executive KPI Cards (5 Cards) ──────────────────────────────── */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
|
{/* Total Gross Revenue */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Gross Revenue</span>
|
||||||
|
<div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-400">
|
||||||
|
<Coins className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-extrabold text-white">{fmtMoney(kpis?.totalGrossRevenue)}</div>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-2 font-mono">
|
||||||
|
{fmtNum(kpis?.totalUnitsSold)} ชิ้น ({fmtNum(kpis?.totalMappedProducts)} SKU)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total Platform Fee */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Platform Deductions</span>
|
||||||
|
<div className="p-2 rounded-xl bg-amber-500/10 text-amber-400">
|
||||||
|
<Receipt className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-extrabold text-amber-400">{fmtMoney(kpis?.totalPlatformFee)}</div>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-2 font-mono">
|
||||||
|
เฉลี่ยหัก <strong className="text-amber-300 font-bold">~{kpis?.effectiveFeeRate ?? 30}%</strong> ของยอดขาย
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total Product BOM Cost */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Total Product BOM (COGS)</span>
|
||||||
|
<div className="p-2 rounded-xl bg-rose-500/10 text-rose-400">
|
||||||
|
<ShoppingBag className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-extrabold text-rose-400">{fmtMoney(kpis?.totalBOMCost)}</div>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-2">
|
||||||
|
ต้นทุนวัตถุดิบจริงสะสมของสินค้า
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total Est Net Profit */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Est. Net Profit</span>
|
||||||
|
<div className="p-2 rounded-xl bg-emerald-500/10 text-emerald-400">
|
||||||
|
<TrendingUp className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-extrabold text-emerald-400">{fmtMoney(kpis?.totalEstNetProfit)}</div>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-2">
|
||||||
|
หักค่า Platform และต้นทุน BOM แล้ว
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overall Net Margin % */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Net Margin %</span>
|
||||||
|
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-400">
|
||||||
|
<Percent className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-extrabold text-purple-400">{fmtPercent(kpis?.overallNetMargin)}</div>
|
||||||
|
<div className="w-full bg-slate-800 h-1.5 rounded-full mt-3 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-gradient-to-r from-purple-500 to-indigo-400 h-full rounded-full"
|
||||||
|
style={{ width: `${Math.min(100, kpis?.overallNetMargin || 0)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Channel Breakdown Cards ────────────────────────────────────── */}
|
||||||
|
{kpis?.channelStats && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{/* Shopee */}
|
||||||
|
<div
|
||||||
|
onClick={() => setChannelFilter(channelFilter === 'shopee' ? 'all' : 'shopee')}
|
||||||
|
className={`p-5 rounded-2xl border cursor-pointer transition-all ${
|
||||||
|
channelFilter === 'shopee'
|
||||||
|
? 'bg-orange-500/15 border-orange-500 shadow-md shadow-orange-500/10'
|
||||||
|
: 'bg-slate-900/90 border-white/10 hover:border-orange-500/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2.5 py-1 rounded-lg bg-orange-500/20 text-orange-400 text-xs font-extrabold">
|
||||||
|
SHOPEE
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-400">Hom Aroma</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-slate-300 font-mono">
|
||||||
|
{fmtNum(kpis.channelStats.shopee?.soldQty)} ชิ้น
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between mt-3">
|
||||||
|
<span className="text-xs text-slate-400">Revenue:</span>
|
||||||
|
<span className="text-base font-bold text-white font-mono">
|
||||||
|
{fmtMoney(kpis.channelStats.shopee?.revenue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between mt-1">
|
||||||
|
<span className="text-xs text-slate-400">Platform Fee:</span>
|
||||||
|
<span className="text-xs font-mono text-amber-400">
|
||||||
|
{fmtMoney(kpis.channelStats.shopee?.platformFee)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between mt-1 pt-1 border-t border-white/5">
|
||||||
|
<span className="text-xs text-slate-400">Net Profit:</span>
|
||||||
|
<span className="text-base font-bold text-emerald-400 font-mono">
|
||||||
|
{fmtMoney(kpis.channelStats.shopee?.netProfit)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lazada */}
|
||||||
|
<div
|
||||||
|
onClick={() => setChannelFilter(channelFilter === 'lazada' ? 'all' : 'lazada')}
|
||||||
|
className={`p-5 rounded-2xl border cursor-pointer transition-all ${
|
||||||
|
channelFilter === 'lazada'
|
||||||
|
? 'bg-blue-500/15 border-blue-500 shadow-md shadow-blue-500/10'
|
||||||
|
: 'bg-slate-900/90 border-white/10 hover:border-blue-500/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2.5 py-1 rounded-lg bg-blue-500/20 text-blue-400 text-xs font-extrabold">
|
||||||
|
LAZADA
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-400">Hom Aroma</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-slate-300 font-mono">
|
||||||
|
{fmtNum(kpis.channelStats.lazada?.soldQty)} ชิ้น
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between mt-3">
|
||||||
|
<span className="text-xs text-slate-400">Revenue:</span>
|
||||||
|
<span className="text-base font-bold text-white font-mono">
|
||||||
|
{fmtMoney(kpis.channelStats.lazada?.revenue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between mt-1">
|
||||||
|
<span className="text-xs text-slate-400">Platform Fee:</span>
|
||||||
|
<span className="text-xs font-mono text-amber-400">
|
||||||
|
{fmtMoney(kpis.channelStats.lazada?.platformFee)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between mt-1 pt-1 border-t border-white/5">
|
||||||
|
<span className="text-xs text-slate-400">Net Profit:</span>
|
||||||
|
<span className="text-base font-bold text-emerald-400 font-mono">
|
||||||
|
{fmtMoney(kpis.channelStats.lazada?.netProfit)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TikTok Shop */}
|
||||||
|
<div
|
||||||
|
onClick={() => setChannelFilter(channelFilter === 'tiktok' ? 'all' : 'tiktok')}
|
||||||
|
className={`p-5 rounded-2xl border cursor-pointer transition-all ${
|
||||||
|
channelFilter === 'tiktok'
|
||||||
|
? 'bg-pink-500/15 border-pink-500 shadow-md shadow-pink-500/10'
|
||||||
|
: 'bg-slate-900/90 border-white/10 hover:border-pink-500/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2.5 py-1 rounded-lg bg-pink-500/20 text-pink-400 text-xs font-extrabold">
|
||||||
|
TIKTOK SHOP
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-400">Hom Aroma</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-slate-300 font-mono">
|
||||||
|
{fmtNum(kpis.channelStats.tiktok?.soldQty)} ชิ้น
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between mt-3">
|
||||||
|
<span className="text-xs text-slate-400">Revenue:</span>
|
||||||
|
<span className="text-base font-bold text-white font-mono">
|
||||||
|
{fmtMoney(kpis.channelStats.tiktok?.revenue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between mt-1">
|
||||||
|
<span className="text-xs text-slate-400">Platform Fee:</span>
|
||||||
|
<span className="text-xs font-mono text-amber-400">
|
||||||
|
{fmtMoney(kpis.channelStats.tiktok?.platformFee)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between mt-1 pt-1 border-t border-white/5">
|
||||||
|
<span className="text-xs text-slate-400">Net Profit:</span>
|
||||||
|
<span className="text-base font-bold text-emerald-400 font-mono">
|
||||||
|
{fmtMoney(kpis.channelStats.tiktok?.netProfit)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Main Omnichannel Table ─────────────────────────────────────── */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl overflow-hidden shadow-xl backdrop-blur-sm">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-950/80 border-b border-white/10 text-[11px] font-bold text-slate-400 uppercase tracking-wider">
|
||||||
|
<th className="py-4 px-4">Channel</th>
|
||||||
|
<th className="py-4 px-4">Marketplace Listing</th>
|
||||||
|
<th className="py-4 px-4">Linked Master Variant</th>
|
||||||
|
<th className="py-4 px-4 text-center">ขายได้ ({periodInfo?.label || 'All'})</th>
|
||||||
|
<th className="py-4 px-4 text-right">ราคาขายเฉลี่ย</th>
|
||||||
|
<th className="py-4 px-4 text-right">ต้นทุน BOM (Unit)</th>
|
||||||
|
<th className="py-4 px-4 text-right">หัก Fee (%) & ที่มา</th>
|
||||||
|
<th className="py-4 px-4 text-right">Net Rev / Unit</th>
|
||||||
|
<th className="py-4 px-4 text-right bg-white/5">Net Profit / Unit</th>
|
||||||
|
<th className="py-4 px-4 text-right">Net Margin %</th>
|
||||||
|
<th className="py-4 px-4 text-center">Direct vs Channel</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5 text-xs md:text-sm">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={11} className="py-12 text-center text-slate-400">
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin mx-auto text-cyan-400 mb-2" />
|
||||||
|
กำลังประมวลผลข้อมูลกำไรสุทธิสำหรับช่วงเวลา {periodInfo?.label}...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : sortedItems.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={11} className="py-12 text-center text-slate-400">
|
||||||
|
ไม่พบรายการสินค้า Marketplace ที่มียอดขายในช่วงเวลาที่เลือก
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
sortedItems.map((item) => {
|
||||||
|
let chBadge = 'bg-orange-500/20 text-orange-400 border-orange-500/30';
|
||||||
|
if (item.channel === 'lazada') chBadge = 'bg-blue-500/20 text-blue-400 border-blue-500/30';
|
||||||
|
else if (item.channel === 'tiktok') chBadge = 'bg-pink-500/20 text-pink-400 border-pink-500/30';
|
||||||
|
|
||||||
|
const unitNetProfit = Math.round((item.netProfit / (item.totalSoldQty || 1)) * 100) / 100;
|
||||||
|
const unitNetRevenue = Math.round((item.netRevenue / (item.totalSoldQty || 1)) * 100) / 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={item.id} className="hover:bg-white/5 transition-all">
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<span className={`px-2 py-1 rounded-md text-[10px] font-extrabold uppercase border ${chBadge}`}>
|
||||||
|
{item.channel}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 max-w-[280px]">
|
||||||
|
<div className="font-bold text-white truncate" title={item.marketplaceProductName}>
|
||||||
|
{item.marketplaceProductName}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400 flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span className="font-mono text-cyan-400">{item.variation || 'Standard'}</span>
|
||||||
|
{item.marketplaceSku && <span>• {item.marketplaceSku}</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<div className="font-semibold text-slate-200">{item.productName}</div>
|
||||||
|
<div className="text-[11px] font-mono text-slate-400">{item.masterSku}</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-center font-mono font-bold text-white">
|
||||||
|
{fmtNum(item.totalSoldQty)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-mono text-white">
|
||||||
|
{fmtMoney(item.avgSellingPrice)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-mono text-rose-300">
|
||||||
|
{fmtMoney(item.unitBomCost)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right">
|
||||||
|
<div className="font-mono text-slate-200">
|
||||||
|
~{item.effectiveFeePercent}%
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] mt-0.5">
|
||||||
|
{item.feeSource === 'actual' ? (
|
||||||
|
<span className="px-1.5 py-0.5 rounded font-bold bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
|
||||||
|
Actual
|
||||||
|
</span>
|
||||||
|
) : item.feeSource === 'hybrid' ? (
|
||||||
|
<span className="px-1.5 py-0.5 rounded font-bold bg-indigo-500/20 text-indigo-400 border border-indigo-500/30">
|
||||||
|
Hybrid
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-1.5 py-0.5 rounded font-medium bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
||||||
|
30% Fallback
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-mono text-slate-200">
|
||||||
|
{fmtMoney(unitNetRevenue)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-emerald-400 bg-white/5">
|
||||||
|
+{fmtMoney(unitNetProfit)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right">
|
||||||
|
<span
|
||||||
|
className={`px-2.5 py-1 rounded-full text-xs font-bold border inline-block min-w-[65px] text-center font-mono ${
|
||||||
|
item.netMarginPercent >= 60
|
||||||
|
? 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30'
|
||||||
|
: item.netMarginPercent >= 45
|
||||||
|
? 'bg-amber-500/15 text-amber-400 border-amber-500/30'
|
||||||
|
: 'bg-rose-500/15 text-rose-400 border-rose-500/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{fmtPercent(item.netMarginPercent)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-center">
|
||||||
|
<span className="text-[11px] text-slate-400" title="ขายตรงหน้าร้านจะได้มาร์จิ้นสูงกว่านี้">
|
||||||
|
Direct: <strong className="text-cyan-400">{item.directMarginPercent}%</strong>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import { Calculator, Layers, BrainCircuit, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfitabilityHeaderTabs({ title, subtitle }: Props) {
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
to: '/profitability',
|
||||||
|
label: 'Cost & Profitability Matrix',
|
||||||
|
icon: Calculator,
|
||||||
|
description: 'วิเคราะห์ต้นทุน BOM และกำไรแคตตาล็อก',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/omnichannel-margin',
|
||||||
|
label: 'Omnichannel Net Margin',
|
||||||
|
icon: Layers,
|
||||||
|
description: 'วิเคราะห์กำไรสุทธิตามช่องทางขาย',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/mrp-planning',
|
||||||
|
label: 'MRP & Material Demand',
|
||||||
|
icon: BrainCircuit,
|
||||||
|
description: 'คำนวณการใช้วัตถุดิบและสต็อก',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
{/* Title & Badge */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold tracking-wider uppercase bg-cyan-500/10 text-cyan-400 border border-cyan-500/30 flex items-center gap-1">
|
||||||
|
<Sparkles className="w-3 h-3 text-cyan-400" />
|
||||||
|
Profit & Intelligence Suite
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">{title}</h1>
|
||||||
|
<p className="text-xs md:text-sm text-slate-400 mt-1">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modern Navigation Tabs */}
|
||||||
|
<div className="flex flex-wrap gap-2 p-1.5 bg-slate-900/80 backdrop-blur-md rounded-2xl border border-white/10 shadow-lg">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={tab.to}
|
||||||
|
to={tab.to}
|
||||||
|
end
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2.5 px-4 py-2.5 rounded-xl text-xs md:text-sm font-semibold transition-all duration-200 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-gradient-to-r from-cyan-500 to-blue-600 text-white shadow-md shadow-cyan-500/20'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-white/5'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 shrink-0" />
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |