Compare commits

..
30 Commits
Author SHA1 Message Date
Your Name b8bb1d4617 feat(profitability): add date range filtering and 30-day MRP inventory forecast 2026-10-18 19:10:22 +07:00
Your Name c78a898af7 feat(bom): complete 100% master catalog BOM and add Profitability Management Suite
- Configured BOMs for all 585 master variants across 5 categories (Oil Ozone, Aroma Oil, Car Diffuser, Room Spray, Reed Diffuser)
- Mapped 2,111 marketplace products (Shopee and Lazada) to master catalog variants
- Added backend profitability routes (/api/profitability/matrix, /omnichannel, /mrp)
- Added 3 admin pages under Product menu: Cost & Profitability Matrix, Omnichannel Margin, and MRP & Material Demand
- Added comprehensive ledgers and production sync scripts
2026-10-18 18:58:11 +07:00
Your Name fdbbaea021 x 2026-10-18 14:25:29 +07:00
Your Name f49776f9cb x 2026-10-18 13:55:18 +07:00
Your Name 4fcdd8a816 feat(mcp): add BOM management tools (list_products_bom_status, get_product_bom, set_product_bom) 2026-10-18 12:25:35 +07:00
Your Name b030c08f68 feat(raw-materials): add inline price editing with keyboard shortcuts and partial update support 2026-10-18 11:56:38 +07:00
Your Name a5584d0d46 fix(raw-materials): remove invalid sku field from product select and resolve localized name 2026-10-18 11:44:51 +07:00
adminandYour Name a0abfbf306 feat(bom): add BOM JSON import modal with auto-matching and live cost preview (#12)
Co-authored-by: Your Name <you@example.com>
Reviewed-on: #12
2026-10-18 04:24:17 +00:00
antigravity-bot BotandYour Name d8f6d8139f feat(bom): add Get Prompt modal for LLM and fix product name resolution (#11)
### Summary of Changes
- Added 'Get Prompt' button and ProductBomPromptModal dialog on /products/:id/bom.
- Formats product ID, variants, and raw material inventory into a structured prompt for LLMs.
- Fixed backend bug in GET /products/:id?lang=all that wiped product.name when no translations existed.
- Added multi-level fallback in frontend to ensure product name never shows 'Unknown Product'.

---------

Co-authored-by: Your Name <you@example.com>
Reviewed-on: #11
Co-authored-by: antigravity-bot <bot@kittisak.com>
2026-10-18 03:55:15 +00:00
antigravity-bot BotandYour Name a647c9682d feat(raw-materials): add bulk JSON import with upsert and full-page edit routes (#10)
Fixes #9

---------

Co-authored-by: Your Name <you@example.com>
Reviewed-on: #10
Co-authored-by: antigravity-bot <bot@kittisak.com>
2026-10-18 03:30:19 +00:00
antigravity-bot Bot b13f37cf7e Merge pull request 'docs: audit and tidy legacy agent workspaces and historical records' (#8) from docs/historical-agents into master 2026-10-18 02:51:35 +00:00
Your Name 1ce711d960 docs: audit and tidy legacy agent workspaces and historical records 2026-10-18 09:50:58 +07:00
antigravity-bot Bot 809f420fe4 Merge pull request 'docs: label tenant examples and LLM prompt guides' (#7) from docs/tenant-prompts into master 2026-10-18 02:49:34 +00:00
Your Name cbd816592b docs: label tenant examples and LLM prompt guides 2026-10-18 09:48:54 +07:00
antigravity-bot Bot 408f07e27a Merge pull request 'docs: add scope labels to deployment runbooks and postmortem checklists' (#6) from docs/deployment-runbooks into master 2026-10-18 02:44:40 +00:00
Your Name 468b690ac3 docs: add scope labels to deployment runbooks and postmortem checklists 2026-10-18 09:42:04 +07:00
antigravity-bot BotandYour Name 18c12a6a14 docs: modernize platform capability and architecture guides to DBK Platform (#5)
Fixes #1

### Summary of Changes
- Modernized architecture documents to establish DBK Platform as the canonical multi-tenant commerce platform identity, treating HomAroma as the reference tenant.
- Updated frontend comparison to accurately reflect apps/spa (React 19 + Vite) vs apps/website (Next.js 15 SSR) instead of legacy names.
- Cleaned typos in PRISMA7_GUIDE and added cross-references to docs/PLATFORM_OVERVIEW.md.
- Updated docs/DOCUMENTATION_MIGRATION.md completion tracking.

---------

Co-authored-by: Your Name <you@example.com>
Reviewed-on: #5
Co-authored-by: antigravity-bot <bot@kittisak.com>
2026-10-18 01:47:59 +00:00
Your Name ce252dc061 feat(tools): add projects support to gitea helper CLI 2026-10-18 08:18:41 +07:00
Your Name 8a8438096c Test Add Gitea 2026-10-18 08:13:44 +07:00
Your Name 765cdd1fa9 x 2026-10-18 08:01:36 +07:00
unknown dd79519bf7 feat(deploy): add deployment configs, post-receive hook, and nginx reverse proxy for foxxio 2026-10-17 16:22:06 +07:00
unknown 0b6e5310c0 docs: add api.bigbike.ddcsprocket.com gateway nginx config 2026-10-17 16:00:33 +07:00
unknown 1be4657f3f docs: add gateway nginx proxy config for 192.168.1.24 2026-10-17 15:46:12 +07:00
unknown 647428961a x 2026-10-17 14:37:55 +07:00
unknown 731619af9d x 2026-10-17 14:25:27 +07:00
unknown 5ba4afb2de BigBike 2026-10-17 13:34:07 +07:00
unknown 2364aa32b0 chore: add .gitattributes to normalize bun.lock and text line endings 2026-10-17 12:21:16 +07:00
Your Name ca79692b3e fix(backend): enhance sequence discovery from sister website directories and sync in post-receive 2026-10-17 11:26:29 +07:00
Your Name 0bf544720b feat(cms): add VDO Frame Scrubber block with isolated sequence library 2026-10-17 11:23:08 +07:00
Your Name 64f7ecf729 feat(cms): add Interactive 360 Turntable and Macro Zoom Showcase block 2026-10-17 09:25:16 +07:00
976 changed files with 17611 additions and 338 deletions

No files matched your search

+2
View File
@@ -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.
+32
View File
@@ -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.
+11
View File
@@ -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
+2 -2
View File
@@ -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 -1
View File
@@ -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
+57
View File
@@ -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%) |
+2 -2
View File
@@ -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 (แผนที่ประเทศไทย)** เดิม
+58
View File
@@ -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%** |
+3 -3
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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-*`
+123
View File
@@ -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 รายการ)* | ... |
+61
View File
@@ -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%) | ✅ พร้อมขาย |
+4 -4
View File
@@ -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 (ใหม่) |
|---|---|---| |---|---|---|
+1 -1
View File
@@ -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`
+33
View File
@@ -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 ไม่ต้องใช้น้ำมันละลายน้ำ)
+68
View File
@@ -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%) |
+58
View File
@@ -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%** |
+84
View File
@@ -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 -1
View File
@@ -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 ป้องกันการทำผิดพลาดซ้ำในอนาคต
+9
View File
@@ -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>
);
}
+4 -1
View File
@@ -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"
>
เปลี่ยนชุด &rarr;
</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
);
}
+4 -4
View File
@@ -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}}",
+22
View File
@@ -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 || {}}
+58 -4
View File
@@ -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>
);
}
+336 -168
View File
@@ -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>
) );
} }
+33 -8
View File
@@ -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>
); );
} }
+61 -13
View File
@@ -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} &gt;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} &lt;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 (&gt;= 70%)</option>
<option value="mid">🟡 Mid Margin (55% - 70%)</option>
<option value="low">🔴 Low Margin (&lt; 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,568 @@
import { useState, useEffect, useMemo } from 'react';
import {
Search,
Download,
ShoppingBag,
TrendingUp,
Percent,
Coins,
RefreshCw,
Calendar,
} 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;
totalFeePercent: number;
estPlatformFee: number;
netRevenue: number;
netProfit: number;
netMarginPercent: number;
directPrice: number;
directMarginPercent: number;
}
interface ChannelStat {
soldQty: number;
revenue: 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;
totalEstNetRevenue: number;
totalBOMCost: number;
totalEstNetProfit: number;
overallNetMargin: number;
channelStats: Record<string, ChannelStat>;
}
export default function OmnichannelMarginPage() {
const [loading, setLoading] = useState(true);
const [data, setData] = useState<{
periodInfo: PeriodInfo;
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',
'Unit BOM Cost',
'Total BOM Cost',
'Platform Fee %',
'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.unitBomCost,
i.totalBomCost,
`${i.totalFeePercent}%`,
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>
{/* ── Executive KPI Cards ────────────────────────────────────────── */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 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 ({periodInfo?.label || 'All'})</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 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 (หลังหัก Fee)</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 % ({periodInfo?.label || 'All'})</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">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">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">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 font-mono text-slate-400">
~{item.totalFeePercent}%
</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>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Loaded 100 of 976 files, more files were not shown because too many files have changed in this diff. Show more