Compare commits

..
32 Commits
Author SHA1 Message Date
Your Name 9a18463bda feat(profitability): set default period to this_month on omnichannel margin page 2026-10-18 22:00:42 +07:00
adminandYour Name dbc6c513db feat(profitability): hybrid actual fees for Shopee/TikTok with conservative 30% fallback (#13)
Co-authored-by: Your Name <you@example.com>
Reviewed-on: #13
2026-10-18 14:53:31 +00:00
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 17780 additions and 286 deletions

No files matched your search

+2
View File
@@ -1,5 +1,7 @@
# 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
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
> **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.
@@ -138,7 +138,7 @@ This guide explains the dynamic page blocks available in the Page Builder and wh
### `SCENT_NOTES_BREAKDOWN`
- Purpose: Fragrance layer breakdown
- Purpose: Fragrance layer breakdown (reference tenant vertical block; pattern for multi-tier attribute breakdowns)
- Contains:
- `title` localized string optional
- `description` localized string optional
+1 -1
View File
@@ -1,6 +1,6 @@
# 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) จากการประชุมทีม
> อัปเดตล่าสุด: 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
> **Platform capability plan:** Geographic coverage, data availability, and analytics configuration must be enabled and validated per tenant.
สำหรับเมนู Business Analytics บนระบบ HomAroma Admin
> **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 บนระบบ DBK Platform Admin (Multi-tenant & Multi-region)
เอกสารนี้รวบรวมรายละเอียด แหล่งข้อมูล 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
> **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-*`
+1 -1
View File
@@ -1,6 +1,6 @@
# คู่มือการติดตั้งโปรเจกต์ใหม่ (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`
+1 -1
View File
@@ -1,6 +1,6 @@
# 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-*`
+1 -1
View File
@@ -1,6 +1,6 @@
# 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-*`
+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 4, 5, หรือ 6 มาก่อน — ของเก่าพวกนั้น **ใช้ไม่ได้อีกแล้ว**
---
## 1. สิ่งที่เปลี่ยนไปจาก Prisma 6fdss → Prisma 7
## 1. สิ่งที่เปลี่ยนไปจาก 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.
ระบบนำเข้าสินค้าของ HomAroma รองรับการ Import ผ่านไฟล์ `.json` หรือวาง Payload JSON Array ได้โดยตรงผ่าน:
ระบบนำเข้าสินค้าของ DBK Platform (ตัวอย่างข้อมูลแคตตาล็อกสินค้าอ้างอิงจาก Reference Tenant: HomAroma) รองรับการ Import ผ่านไฟล์ `.json` หรือวาง Payload JSON Array ได้โดยตรงผ่าน:
1. **หน้าจอ Admin:** เมนู `/products` -> กดปุ่ม **"Import JSON"**
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 ใหม่
> **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)
บันทึกข้อผิดพลาดจากการสร้าง 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 CmsMockupPage from './pages/CmsMockupPage'
import RawMaterialsPage from './pages/RawMaterialsPage'
import RawMaterialDetailPage from './pages/RawMaterialDetailPage'
import LoginPage from './pages/LoginPage'
import InstallSeedPage from './pages/InstallSeedPage'
import ProtectedRoute from './components/ProtectedRoute'
@@ -78,6 +79,9 @@ import MarketplaceCrmPage from './pages/crm/MarketplaceCrmPage'
import MarketplaceCustomerDetailPage from './pages/crm/MarketplaceCustomerDetailPage'
import PosPage from './pages/PosPage'
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'
function App() {
@@ -123,6 +127,11 @@ function App() {
<Route path="/promotions" element={<PromotionsPage />} />
<Route path="/coupons" element={<CouponsPage />} />
<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/brand-intelligence" element={<BrandIntelligencePage />} />
<Route path="/brand-intelligence" element={<BrandIntelligencePage />} />
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { API_BASE } from '@homaroma/types';
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;
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="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 */}
@@ -196,6 +197,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect, mediaType
)}
</div>
</div>
</div>
</div>,
document.body
);
}
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { createPortal } from 'react-dom'
import {
Sparkles,
Copy,
@@ -35,6 +36,8 @@ const GUIDE_OVERVIEW_MARKDOWN = `# คู่มือการใช้ AI/LLM
### 1. Root Items (เมนูแถวบนสุด - Level 1)
- **label**: ออบเจกต์ข้อความรองรับ 8 ภาษา \`{ th, en, ja, zh, vi, ko, lo, it }\`
- **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)
- **columns**: อาร์เรย์ของคอลัมน์เมนูย่อย (Level 2)
- **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="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 */}
@@ -491,6 +494,7 @@ export default function MenuJsonGuideModal({
</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 { createPortal } from 'react-dom'
import {
Sparkles,
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="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>,
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 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 isRepeatCustomers = location.pathname.startsWith('/business-analytics/repeat-customer')
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: Ruler, label: t('nav.productAttributes', 'Product Attributes'), to: '/product-attributes' },
{ 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 = [
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import {
X,
@@ -142,6 +143,60 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
<div className="relative z-20 w-4 h-4 rounded-full bg-black/50 text-white flex items-center justify-center text-[8px]">›</div>
</div>
)
},
// 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
{
@@ -798,7 +853,7 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
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="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 */}
@@ -924,6 +979,7 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
)}
</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 { createPortal } from 'react-dom';
import { X, Search, Loader2 } from 'lucide-react';
import { API_BASE } from '@homaroma/types';
import { getAdminDisplayString } from '../../utils/localization';
@@ -63,7 +64,7 @@ export default function ProductPickerModal({
onClose();
};
return (
return createPortal(
<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="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>,
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 */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
to { opacity: 1; transform: none; }
}
@keyframes zoomIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
to { opacity: 1; transform: none; }
}
.animate-fade-in {
animation: fadeIn 0.4s ease-out forwards;
animation: fadeIn 0.4s ease-out;
}
.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 (฿)",
"thNetProfit": "Net Profit",
"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...",
"noOrders": "No data available",
"settledTransferred": "Settled ฿{{amount}}",
@@ -450,6 +450,41 @@
"thAffiliate": "Affiliate (฿)",
"thNetProfit": "純利益",
"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": "データを処理中...",
"noOrders": "データがありません",
"settledTransferred": "振込済 ฿{{amount}}",
@@ -454,6 +454,41 @@
"thAffiliate": "Affiliate (฿)",
"thNetProfit": "กำไรสุทธิ",
"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": "กำลังประมวลผลข้อมูล...",
"noOrders": "ยังไม่มีข้อมูล",
"settledTransferred": "โอนแล้ว ฿{{amount}}",
@@ -450,6 +450,41 @@
"thAffiliate": "Affiliate (฿)",
"thNetProfit": "净利润",
"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": "正在处理数据...",
"noOrders": "暂无数据",
"settledTransferred": "已打款 ฿{{amount}}",
+22
View File
@@ -33,6 +33,8 @@ import { getAuthHeaders } from '../utils/auth';
import CinematicStoryScrollForm from '../components/page-builder/CinematicStoryScrollForm';
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 AnnouncementBarForm from '../components/page-builder/AnnouncementBarForm';
import NewsletterForm from '../components/page-builder/NewsletterForm';
@@ -1469,6 +1471,8 @@ export default function PageBuilderPage() {
onChange={(e) => handleTypeChange(e.target.value)}
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="APPLE_SHOWCASE">✨ APPLE KEYNOTE SHOWCASE (Apple Style Tour)</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' && (
<CinematicStoryScrollForm
payload={editingSection.payload || {}}
+58 -4
View File
@@ -11,9 +11,13 @@ import {
ArrowLeft,
Coins,
Layers,
Calculator
Calculator,
Bot,
FileJson
} from 'lucide-react'
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 {
if (val == null || isNaN(val)) return '฿0.00'
@@ -38,6 +42,14 @@ export default function ProductBomPage() {
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
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(() => {
const fetchData = async () => {
@@ -143,9 +155,14 @@ export default function ProductBomPage() {
}
const productName =
typeof product.name === 'string'
? product.name
: (product.name as any)?.th || (product.name as any)?.en || 'Unknown Product'
(typeof product.name === 'string' && product.name.trim())
? product.name.trim()
: (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
const totalVariantsCount = variants.length
@@ -177,6 +194,24 @@ export default function ProductBomPage() {
</div>
<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
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"
@@ -534,6 +569,25 @@ export default function ProductBomPage() {
})
)}
</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>
)
}
@@ -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>
);
}
+327 -159
View File
@@ -1,227 +1,395 @@
import { useState, useEffect } from 'react'
import { Plus, Edit3, Trash2, Loader2, PackageOpen } from 'lucide-react'
import { API_BASE, type RawMaterial } from '@homaroma/types'
import { useState, useEffect, useMemo } from 'react';
import { useNavigate, Link } from 'react-router-dom';
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() {
const [materials, setMaterials] = useState<RawMaterial[]>([])
const [loading, setLoading] = useState(true)
const navigate = useNavigate();
const [materials, setMaterials] = useState<RawMaterial[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [isBulkModalOpen, setIsBulkModalOpen] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false)
const [editingId, setEditingId] = useState<number | null>(null)
// Inline Price Editing State
const [editingId, setEditingId] = useState<number | null>(null);
const [editingPrice, setEditingPrice] = useState<string>('');
const [savingId, setSavingId] = useState<number | null>(null);
const [name, setName] = useState('')
const [unit, setUnit] = useState('')
const [pricePerUnit, setPricePerUnit] = useState<number>(0)
const [saving, setSaving] = useState(false)
const startEditing = (item: RawMaterial) => {
setEditingId(item.id);
setEditingPrice(String(item.pricePerUnit ?? ''));
};
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 () => {
setLoading(true)
setLoading(true);
try {
const res = await fetch(`${API_BASE}/raw-materials`)
const res = await fetch(`${API_BASE}/raw-materials`);
if (res.ok) {
setMaterials(await res.json())
setMaterials(await res.json());
}
} catch (err) {
console.error('Failed to fetch raw materials', err)
console.error('Failed to fetch raw materials', err);
} finally {
setLoading(false)
}
setLoading(false);
}
};
useEffect(() => {
fetchMaterials()
}, [])
fetchMaterials();
}, []);
const handleOpenModal = (material?: RawMaterial) => {
if (material) {
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 {
const payload = { name, unit, pricePerUnit }
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) {
setIsModalOpen(false)
fetchMaterials()
} else {
alert('Failed to save raw material')
}
} catch (err) {
console.error(err)
} finally {
setSaving(false)
}
}
const handleDelete = async (id: number) => {
if (!confirm('Are you sure you want to delete this raw material?')) return
const handleDelete = async (id: number, name: string) => {
if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบวัตถุดิบ "${name}"?`)) return;
try {
const res = await fetch(`${API_BASE}/raw-materials/${id}`, { method: 'DELETE' })
const res = await fetch(`${API_BASE}/raw-materials/${id}`, { method: 'DELETE' });
if (res.ok) {
fetchMaterials()
fetchMaterials();
} else {
const data = await res.json();
alert(data.message || 'ไม่สามารถลบวัตถุดิบนี้ได้');
}
} catch (err) {
console.error(err)
}
console.error(err);
}
};
// Filtered materials by search query
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]);
// Aggregate metrics
const metrics = useMemo(() => {
const totalCount = materials.length;
const avgCost = totalCount > 0
? materials.reduce((sum, m) => sum + (Number(m.pricePerUnit) || 0), 0) / totalCount
: 0;
const units = new Set(materials.map((m) => m.unit.toLowerCase())).size;
return { totalCount, avgCost, units };
}, [materials]);
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="space-y-1">
<h1 className="text-3xl font-bold">Raw Materials (วัตถุดิบ)</h1>
<p className="text-slate-500 text-sm">Manage inventory and costs for BOM calculation.</p>
<div className="flex items-center gap-3">
<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>
<button onClick={() => handleOpenModal()} className="btn-primary flex items-center gap-2">
<Plus className="w-4 h-4" />
Add Material
</button>
<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>
{/* 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="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<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">Unit</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 text-right">Actions</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">ชื่อวัตถุดิบ (Material Name)</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">ต้นทุนต่อหน่วย (Cost)</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 text-right">การจัดการ</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.03]">
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
Array.from({ length: 4 }).map((_, 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" />
</td>
</tr>
))
) : materials.length === 0 ? (
) : filteredMaterials.length === 0 ? (
<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" />
<p className="text-slate-500 font-medium italic">No raw materials found.</p>
<p className="text-slate-400 font-medium">
{searchQuery ? 'ไม่พบวัตถุดิบที่ตรงกับคำค้นหา' : 'ยังไม่มีข้อมูลวัตถุดิบในระบบ'}
</p>
<div className="flex items-center justify-center gap-3 pt-2">
<button
type="button"
onClick={() => setIsBulkModalOpen(true)}
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"
>
นำเข้าผ่าน JSON
</button>
<button
type="button"
onClick={() => navigate('/raw-materials/new')}
className="btn-primary text-xs px-4 py-2"
>
เพิ่มวัตถุดิบใหม่
</button>
</div>
</td>
</tr>
) : materials.map((item) => (
<tr key={item.id} className="hover:bg-white/[0.01] transition-colors">
<td className="px-6 py-4">
<p className="text-sm font-bold text-white">{item.name}</p>
) : (
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">
<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">
<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-4">
<p className="text-sm font-bold text-emerald-400">฿{item.pricePerUnit.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
<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">
<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" />
{/* 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 onClick={() => handleDelete(item.id)} className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-rose-500 transition-all">
<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>
</table>
</div>
</div>
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-night-950/80 backdrop-blur-sm" onClick={() => !saving && setIsModalOpen(false)} />
<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">
<h3 className="text-xl font-bold">{editingId ? 'Edit Material' : 'Add New Material'}</h3>
<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"
{/* Bulk JSON Import / Export Modal */}
<RawMaterialBulkJsonModal
isOpen={isBulkModalOpen}
onClose={() => setIsBulkModalOpen(false)}
materials={materials}
onSuccess={fetchMaterials}
/>
</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>
)
);
}
+33 -8
View File
@@ -21,6 +21,7 @@ import {
import { API_BASE } from '@homaroma/types';
import { TIKTOK_SHOPS } from './TiktokOrderPage';
import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils';
import { TiktokFeeBreakdownModal } from '../components/tiktok-order/TiktokFeeBreakdownModal';
function authHeaders() {
return {
@@ -66,6 +67,7 @@ export default function TiktokOrderDetailPage() {
const [order, setOrder] = useState<any | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showFeeModal, setShowFeeModal] = useState(false);
useEffect(() => {
if (!orderId) return;
@@ -410,19 +412,35 @@ export default function TiktokOrderDetailPage() {
</span>
</div>
<div className="flex justify-between items-center text-violet-300">
<span className="text-slate-400">{t('tiktokOrderDetail.commissionSummary')}</span>
<span className="font-mono font-semibold">
<button
type="button"
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'}
</span>
</div>
</button>
<div className="flex justify-between items-center text-cyan-300">
<span className="text-slate-400">{t('tiktokOrderDetail.affiliateSummary')}</span>
<span className="font-mono font-semibold">
<button
type="button"
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'}
</span>
</div>
</button>
<div className="pt-3 border-t border-white/10 flex justify-between items-center">
<div>
@@ -527,6 +545,13 @@ export default function TiktokOrderDetailPage() {
</div>
</div>
</div>
{showFeeModal && (
<TiktokFeeBreakdownModal
order={order}
onClose={() => setShowFeeModal(false)}
/>
)}
</div>
);
}
+55 -7
View File
@@ -7,10 +7,11 @@ import {
ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign,
Coins, ChevronLeft, ChevronRight, ExternalLink, Store, Trash2,
FileSpreadsheet, AlertTriangle, CheckCircle2, Files, Search, X, RotateCcw,
BadgePercent, Wallet, Receipt
BadgePercent, Wallet, Receipt, Info
} from 'lucide-react';
import { API_BASE } from '@homaroma/types';
import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils';
import { TiktokFeeBreakdownModal } from '../components/tiktok-order/TiktokFeeBreakdownModal';
// ── Shop Config ───────────────────────────────────────────────────────────────
export const TIKTOK_SHOPS = [
@@ -164,6 +165,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [bomFilter, setBomFilter] = useState('all');
const [selectedFeeOrder, setSelectedFeeOrder] = useState<any | null>(null);
useEffect(() => {
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 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 text-violet-400">{t('tiktokOrders.thCommission')}</th>
<th className="px-4 py-3.5 whitespace-nowrap text-right text-cyan-400">{t('tiktokOrders.thAffiliate')}</th>
<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 text-center" />
</tr>
@@ -507,20 +525,42 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
{/* Commission (฿) */}
<td className="px-4 py-3.5 text-right font-mono whitespace-nowrap">
<button
type="button"
onClick={(e) => {
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">-฿{fmt(order.commissionFee)}</span>
<span className="text-violet-400 font-bold group-hover/btn:underline">-฿{fmt(order.commissionFee)}</span>
) : (
<span className="text-slate-500 font-mono text-[11px]">-</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>
{/* Affiliate (฿) */}
<td className="px-4 py-3.5 text-right font-mono whitespace-nowrap">
<button
type="button"
onClick={(e) => {
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">-฿{fmt(order.affiliateFee)}</span>
<span className="text-cyan-400 font-bold group-hover/btn:underline">-฿{fmt(order.affiliateFee)}</span>
) : (
<span className="text-slate-500 font-mono text-[11px]">-</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>
{/* Profit */}
@@ -553,6 +593,14 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
</tbody>
</table>
</div>
{/* Fee Breakdown Modal */}
{selectedFeeOrder && (
<TiktokFeeBreakdownModal
order={selectedFeeOrder}
onClose={() => setSelectedFeeOrder(null)}
/>
)}
</div>
);
}
@@ -1,9 +1,11 @@
import { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
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 FocalPointPicker from '../../components/page-builder/FocalPointPicker'
import MenuJsonGuideModal from '../../components/MenuJsonGuideModal'
import MediaLibraryModal from '../../components/MediaLibraryModal'
const IconSelect = ({ value, onChange }: { value?: string, onChange: (val: string) => void }) => {
return (
@@ -30,7 +32,7 @@ type MenuEditorPageProps = {
description: string;
}
const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
const FeaturedCardEditorFields = ({ featured, onChange, onOpenMediaPicker }: any) => {
const [activeTab, setActiveTab] = useState<'content' | 'media'>('content');
const [activeLang, setActiveLang] = useState('th');
@@ -54,8 +56,28 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
['ctaLabel', 'CTA label', 'View details'],
];
const isEnabled = featured.enabled !== false;
return (
<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">
{[
['content', 'Content'],
@@ -136,22 +158,59 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
</div>
<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 ? (
<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 className="space-y-3">
<div className="space-y-1">
<div className="flex items-center justify-between">
<label className="text-[10px] uppercase font-bold text-slate-500">Featured Image URL</label>
{onOpenMediaPicker && (
<button
type="button"
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>
{featured.imageUrl && (
<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) {
return (
<div className="admin-card p-6 space-y-3">
@@ -223,12 +282,13 @@ const FeaturedCardSidePanel = ({ node, onChange }: any) => {
<FeaturedCardEditorFields
featured={featured}
onChange={(newFeatured: any) => onChange({ ...node, featured: newFeatured })}
onOpenMediaPicker={onOpenMediaPicker}
/>
</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 labelKey = type === 'column' ? 'title' : 'label';
const hasUrl = type !== 'column';
@@ -300,21 +360,56 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
{/* Image URL field */}
<div className="flex items-start gap-3">
{/* 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 ? (
<>
<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 className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<label className="text-[10px] uppercase font-bold text-slate-500">{imageFieldLabel}</label>
{onOpenMediaPicker && (
<button
type="button"
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' && (
<IconSelect
value={node.icon}
@@ -325,6 +420,167 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
</div>
{type === 'root' && (
<div className="space-y-3">
{/* Layout Mode Selector */}
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-3">
<div className="flex items-center justify-between">
<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>
)}
{node.layout === 'tabbed' && (
<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">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">
@@ -334,7 +590,7 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
<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">
{featured.enabled === false ? 'Disabled' : featuredTitle}
{featuredTitle}
</p>
</div>
</div>
@@ -352,6 +608,8 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
</div>
)}
</div>
)}
</div>
{hasUrl && (
<div className="w-48 space-y-1">
@@ -383,6 +641,7 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
depth={depth + 1}
onChange={(newChild: any) => handleUpdateChild(i, newChild)}
onRemove={() => handleRemoveChild(i)}
onOpenMediaPicker={onOpenMediaPicker}
/>
))}
</div>
@@ -406,6 +665,13 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
const [pastedJsonText, setPastedJsonText] = useState('')
const [pasteError, setPasteError] = useState<string | null>(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(() => {
Promise.all([
@@ -708,6 +974,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
}}
onEditFeatured={() => setSelectedFeaturedIndex(idx)}
isFeaturedSelected={safeFeaturedIndex === idx}
onOpenMediaPicker={handleOpenMediaPicker}
/>
))
)}
@@ -829,6 +1096,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
<FeaturedCardSidePanel
node={selectedFeaturedNode}
onChange={handleUpdateFeaturedNode}
onOpenMediaPicker={handleOpenMediaPicker}
/>
) : (
<div className="admin-card p-6 space-y-4">
@@ -884,7 +1152,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
)}
{/* 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="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">
@@ -932,7 +1200,8 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
</button>
</div>
</div>
</div>
</div>,
document.body
)}
{/* LLM Mega Menu Guide Modal */}
@@ -942,6 +1211,23 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
onOpenPasteJson={() => setShowPasteModal(true)}
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>
)
}
@@ -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,657 @@
import { useState, useEffect, useMemo } from 'react';
import {
Search,
Download,
ShoppingBag,
TrendingUp,
Percent,
Coins,
RefreshCw,
Calendar,
Receipt,
ShieldCheck,
} from 'lucide-react';
import { API_BASE } from '@homaroma/types';
import ProfitabilityHeaderTabs from './ProfitabilityHeaderTabs';
import DateRangeSelector, { type DateFilterState } from './DateRangeSelector';
function fmtMoney(val: number | null | undefined): string {
if (val == null || isNaN(val)) return '฿0.00';
return (
'฿' +
new Intl.NumberFormat('th-TH', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(val)
);
}
function fmtNum(val: number | null | undefined): string {
if (val == null || isNaN(val)) return '0';
return new Intl.NumberFormat('th-TH').format(val);
}
function fmtPercent(val: number | null | undefined): string {
if (val == null || isNaN(val)) return '0.0%';
return `${val.toFixed(1)}%`;
}
interface OmnichannelItem {
id: number;
channel: string;
shop: string;
marketplaceSku: string;
marketplaceProductName: string;
variation: string;
avgSellingPrice: number;
totalSoldQty: number;
totalRevenue: number;
masterVariantId: number;
masterSku: string;
productName: string;
categoryName: string;
unitBomCost: number;
totalBomCost: number;
platformFee: number;
effectiveFeePercent: number;
feeSource: 'actual' | 'hybrid' | 'fallback_30pct';
netRevenue: number;
netProfit: number;
netMarginPercent: number;
directPrice: number;
directMarginPercent: number;
}
interface ChannelStat {
soldQty: number;
revenue: number;
platformFee: number;
bomCost: number;
netProfit: number;
}
interface PeriodInfo {
period: string;
label: string;
startDate: string | null;
endDate: string | null;
daysCount: number;
}
interface OmnichannelKpis {
totalMappedProducts: number;
totalUnitsSold: number;
totalGrossRevenue: number;
totalPlatformFee: number;
totalEstNetRevenue: number;
totalBOMCost: number;
totalEstNetProfit: number;
overallNetMargin: number;
effectiveFeeRate: number;
actualFeeAmount: number;
fallbackFeeAmount: number;
actualOrdersCount: number;
fallbackOrdersCount: number;
channelStats: Record<string, ChannelStat>;
}
export default function OmnichannelMarginPage() {
const [loading, setLoading] = useState(true);
const [data, setData] = useState<{
periodInfo: PeriodInfo;
feePolicy?: { mode: string; fallbackPercent: number; description: string };
kpis: OmnichannelKpis;
items: OmnichannelItem[];
} | null>(null);
// Filters
const [search, setSearch] = useState('');
const [channelFilter, setChannelFilter] = useState<'all' | 'shopee' | 'lazada' | 'tiktok'>('all');
const [dateFilter, setDateFilter] = useState<DateFilterState>({ period: 'this_month' });
const [sortBy, setSortBy] = useState('revenue_desc');
const fetchData = async () => {
try {
setLoading(true);
const params = new URLSearchParams({
channel: channelFilter,
search,
period: dateFilter.period,
...(dateFilter.startDate ? { startDate: dateFilter.startDate } : {}),
...(dateFilter.endDate ? { endDate: dateFilter.endDate } : {}),
});
const res = await fetch(`${API_BASE}/profitability/omnichannel?${params.toString()}`);
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
const json = await res.json();
setData(json);
} catch (err) {
console.error('Failed to load omnichannel margin data:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [channelFilter, dateFilter]);
// Live search debounce
useEffect(() => {
const timer = setTimeout(() => {
fetchData();
}, 300);
return () => clearTimeout(timer);
}, [search]);
// Client-side sort
const sortedItems = useMemo(() => {
if (!data?.items) return [];
const items = [...data.items];
items.sort((a, b) => {
if (sortBy === 'revenue_desc') return b.totalRevenue - a.totalRevenue;
if (sortBy === 'profit_desc') return b.netProfit - a.netProfit;
if (sortBy === 'margin_desc') return b.netMarginPercent - a.netMarginPercent;
if (sortBy === 'margin_asc') return a.netMarginPercent - b.netMarginPercent;
if (sortBy === 'qty_desc') return b.totalSoldQty - a.totalSoldQty;
return 0;
});
return items;
}, [data?.items, sortBy]);
const handleExportCsv = () => {
if (!sortedItems.length) return;
const headers = [
'Channel',
'Shop',
'Marketplace SKU',
'Marketplace Product',
'Variation',
'Master SKU',
'Master Product',
'Units Sold',
'Avg Price',
'Total Revenue',
'Platform Fee (THB)',
'Effective Fee %',
'Fee Source',
'Unit BOM Cost',
'Total BOM Cost',
'Net Revenue',
'Net Profit',
'Net Margin %',
'Direct Price',
'Direct Margin %',
];
const rows = sortedItems.map((i) => [
i.channel,
i.shop,
i.marketplaceSku,
`"${i.marketplaceProductName.replace(/"/g, '""')}"`,
`"${i.variation}"`,
i.masterSku,
`"${i.productName.replace(/"/g, '""')}"`,
i.totalSoldQty,
i.avgSellingPrice,
i.totalRevenue,
i.platformFee,
`${i.effectiveFeePercent}%`,
i.feeSource,
i.unitBomCost,
i.totalBomCost,
i.netRevenue,
i.netProfit,
`${i.netMarginPercent}%`,
i.directPrice,
`${i.directMarginPercent}%`,
]);
const csvContent = '\uFEFF' + [headers.join(','), ...rows.map((e) => e.join(','))].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `HomAroma_Omnichannel_Net_Margin_${dateFilter.period}_${new Date().toISOString().slice(0, 10)}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const kpis = data?.kpis;
const periodInfo = data?.periodInfo;
return (
<div className="space-y-6">
{/* Shared Header Navigation */}
<ProfitabilityHeaderTabs
title="Omnichannel Net Margin & Contribution"
subtitle="วิเคราะห์กำไรสุทธิตามช่องทางขาย (Shopee, Lazada, TikTok, Direct) หลังหักค่าธรรมเนียม Platform และต้นทุนผลิต BOM จริง"
/>
{/* ── Filter Bar: Date Range + Controls ───────────────────────────── */}
<div className="space-y-3">
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3 bg-slate-900/90 border border-white/10 rounded-2xl p-4 shadow-lg backdrop-blur-sm">
{/* Date Range Selector */}
<DateRangeSelector value={dateFilter} onChange={setDateFilter} />
{/* Active Period Badge */}
{periodInfo && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-slate-800 border border-cyan-500/30 text-xs font-semibold text-cyan-400">
<Calendar className="w-4 h-4 text-cyan-400" />
<span>ช่วงข้อมูล: {periodInfo.label}</span>
{periodInfo.daysCount && (
<span className="text-[10px] text-slate-400">({periodInfo.daysCount} วัน)</span>
)}
</div>
)}
</div>
{/* Secondary Filter Bar */}
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3 bg-slate-900/90 border border-white/10 rounded-2xl p-4 shadow-lg backdrop-blur-sm">
{/* Search */}
<div className="relative flex-1 min-w-[240px]">
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="ค้นหาชื่อสินค้าบน Marketplace, Variation หรือ Master SKU..."
className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-sm text-white placeholder-slate-400 focus:outline-none focus:border-cyan-500"
/>
</div>
{/* Dropdowns */}
<div className="flex flex-wrap items-center gap-2">
{/* Channel Filter */}
<select
value={channelFilter}
onChange={(e) => setChannelFilter(e.target.value as any)}
className="px-3 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-xs md:text-sm text-white focus:outline-none focus:border-cyan-500"
>
<option value="all">ทุกช่องทาง (All Channels)</option>
<option value="shopee">Shopee</option>
<option value="lazada">Lazada</option>
<option value="tiktok">TikTok Shop</option>
</select>
{/* Sort By */}
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="px-3 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-xs md:text-sm text-white focus:outline-none focus:border-cyan-500"
>
<option value="revenue_desc">ยอดขายรวมสูงสุด (Revenue)</option>
<option value="profit_desc">กำไรสุทธิรวมสูงสุด (Net Profit)</option>
<option value="margin_desc">อัตรากำไรสุทธิสูงสุด (Net Margin %)</option>
<option value="margin_asc">อัตรากำไรสุทธิต่ำสุด (Risk Margin %)</option>
<option value="qty_desc">จำนวนชิ้นที่ขายได้สูงสุด (Units Sold)</option>
</select>
{/* Export CSV */}
<button
type="button"
onClick={handleExportCsv}
className="px-4 py-2.5 rounded-xl bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 text-xs md:text-sm font-bold flex items-center gap-2 transition-all"
>
<Download className="w-4 h-4" />
<span>Export CSV</span>
</button>
</div>
</div>
</div>
{/* ── Fee Policy Notice Banner ─────────────────────────────────── */}
<div className="flex flex-wrap items-center justify-between gap-3 p-4 rounded-2xl bg-slate-900/90 border border-white/10 shadow-lg backdrop-blur-sm text-xs text-slate-300">
<div className="flex items-center gap-2.5">
<div className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-400">
<ShieldCheck className="w-5 h-5 shrink-0" />
</div>
<div>
<span className="font-bold text-white">Hybrid Fee Policy (Actual + 30% Fallback):</span>{' '}
<span className="text-slate-400">
Shopee ดึงค่าธรรมเนียมจริงจากรายงานคำสั่งซื้อ, TikTok คำนวณจากยอดโอนจริง (Settlement)
สำหรับออเดอร์ที่ยังไม่มีข้อมูลค่าธรรมเนียม จะใช้ตัวคูณปลอดภัย{' '}
<strong className="text-amber-400">30% Fallback</strong>
</span>
</div>
</div>
<div className="flex items-center gap-2 text-[11px] font-mono shrink-0">
<span className="px-2.5 py-1 rounded-lg bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 font-bold">
Actual: {fmtNum(kpis?.actualOrdersCount)} ออเดอร์ ({fmtMoney(kpis?.actualFeeAmount)})
</span>
<span className="px-2.5 py-1 rounded-lg bg-amber-500/15 text-amber-300 border border-amber-500/30 font-bold">
30% Fallback: {fmtNum(kpis?.fallbackOrdersCount)} ออเดอร์ ({fmtMoney(kpis?.fallbackFeeAmount)})
</span>
</div>
</div>
{/* ── Executive KPI Cards (5 Cards) ──────────────────────────────── */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
{/* Total Gross Revenue */}
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Gross Revenue</span>
<div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-400">
<Coins className="w-5 h-5" />
</div>
</div>
<div className="text-2xl font-extrabold text-white">{fmtMoney(kpis?.totalGrossRevenue)}</div>
<p className="text-[11px] text-slate-400 mt-2 font-mono">
{fmtNum(kpis?.totalUnitsSold)} ชิ้น ({fmtNum(kpis?.totalMappedProducts)} SKU)
</p>
</div>
{/* Total Platform Fee */}
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Platform Deductions</span>
<div className="p-2 rounded-xl bg-amber-500/10 text-amber-400">
<Receipt className="w-5 h-5" />
</div>
</div>
<div className="text-2xl font-extrabold text-amber-400">{fmtMoney(kpis?.totalPlatformFee)}</div>
<p className="text-[11px] text-slate-400 mt-2 font-mono">
เฉลี่ยหัก <strong className="text-amber-300 font-bold">~{kpis?.effectiveFeeRate ?? 30}%</strong> ของยอดขาย
</p>
</div>
{/* Total Product BOM Cost */}
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Total Product BOM (COGS)</span>
<div className="p-2 rounded-xl bg-rose-500/10 text-rose-400">
<ShoppingBag className="w-5 h-5" />
</div>
</div>
<div className="text-2xl font-extrabold text-rose-400">{fmtMoney(kpis?.totalBOMCost)}</div>
<p className="text-[11px] text-slate-400 mt-2">
ต้นทุนวัตถุดิบจริงสะสมของสินค้า
</p>
</div>
{/* Total Est Net Profit */}
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Est. Net Profit</span>
<div className="p-2 rounded-xl bg-emerald-500/10 text-emerald-400">
<TrendingUp className="w-5 h-5" />
</div>
</div>
<div className="text-2xl font-extrabold text-emerald-400">{fmtMoney(kpis?.totalEstNetProfit)}</div>
<p className="text-[11px] text-slate-400 mt-2">
หักค่า Platform และต้นทุน BOM แล้ว
</p>
</div>
{/* Overall Net Margin % */}
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Net Margin %</span>
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-400">
<Percent className="w-5 h-5" />
</div>
</div>
<div className="text-2xl font-extrabold text-purple-400">{fmtPercent(kpis?.overallNetMargin)}</div>
<div className="w-full bg-slate-800 h-1.5 rounded-full mt-3 overflow-hidden">
<div
className="bg-gradient-to-r from-purple-500 to-indigo-400 h-full rounded-full"
style={{ width: `${Math.min(100, kpis?.overallNetMargin || 0)}%` }}
/>
</div>
</div>
</div>
{/* ── Channel Breakdown Cards ────────────────────────────────────── */}
{kpis?.channelStats && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Shopee */}
<div
onClick={() => setChannelFilter(channelFilter === 'shopee' ? 'all' : 'shopee')}
className={`p-5 rounded-2xl border cursor-pointer transition-all ${
channelFilter === 'shopee'
? 'bg-orange-500/15 border-orange-500 shadow-md shadow-orange-500/10'
: 'bg-slate-900/90 border-white/10 hover:border-orange-500/40'
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="px-2.5 py-1 rounded-lg bg-orange-500/20 text-orange-400 text-xs font-extrabold">
SHOPEE
</span>
<span className="text-xs text-slate-400">Hom Aroma</span>
</div>
<span className="text-xs font-bold text-slate-300 font-mono">
{fmtNum(kpis.channelStats.shopee?.soldQty)} ชิ้น
</span>
</div>
<div className="flex items-baseline justify-between mt-3">
<span className="text-xs text-slate-400">Revenue:</span>
<span className="text-base font-bold text-white font-mono">
{fmtMoney(kpis.channelStats.shopee?.revenue)}
</span>
</div>
<div className="flex items-baseline justify-between mt-1">
<span className="text-xs text-slate-400">Platform Fee:</span>
<span className="text-xs font-mono text-amber-400">
{fmtMoney(kpis.channelStats.shopee?.platformFee)}
</span>
</div>
<div className="flex items-baseline justify-between mt-1 pt-1 border-t border-white/5">
<span className="text-xs text-slate-400">Net Profit:</span>
<span className="text-base font-bold text-emerald-400 font-mono">
{fmtMoney(kpis.channelStats.shopee?.netProfit)}
</span>
</div>
</div>
{/* Lazada */}
<div
onClick={() => setChannelFilter(channelFilter === 'lazada' ? 'all' : 'lazada')}
className={`p-5 rounded-2xl border cursor-pointer transition-all ${
channelFilter === 'lazada'
? 'bg-blue-500/15 border-blue-500 shadow-md shadow-blue-500/10'
: 'bg-slate-900/90 border-white/10 hover:border-blue-500/40'
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="px-2.5 py-1 rounded-lg bg-blue-500/20 text-blue-400 text-xs font-extrabold">
LAZADA
</span>
<span className="text-xs text-slate-400">Hom Aroma</span>
</div>
<span className="text-xs font-bold text-slate-300 font-mono">
{fmtNum(kpis.channelStats.lazada?.soldQty)} ชิ้น
</span>
</div>
<div className="flex items-baseline justify-between mt-3">
<span className="text-xs text-slate-400">Revenue:</span>
<span className="text-base font-bold text-white font-mono">
{fmtMoney(kpis.channelStats.lazada?.revenue)}
</span>
</div>
<div className="flex items-baseline justify-between mt-1">
<span className="text-xs text-slate-400">Platform Fee:</span>
<span className="text-xs font-mono text-amber-400">
{fmtMoney(kpis.channelStats.lazada?.platformFee)}
</span>
</div>
<div className="flex items-baseline justify-between mt-1 pt-1 border-t border-white/5">
<span className="text-xs text-slate-400">Net Profit:</span>
<span className="text-base font-bold text-emerald-400 font-mono">
{fmtMoney(kpis.channelStats.lazada?.netProfit)}
</span>
</div>
</div>
{/* TikTok Shop */}
<div
onClick={() => setChannelFilter(channelFilter === 'tiktok' ? 'all' : 'tiktok')}
className={`p-5 rounded-2xl border cursor-pointer transition-all ${
channelFilter === 'tiktok'
? 'bg-pink-500/15 border-pink-500 shadow-md shadow-pink-500/10'
: 'bg-slate-900/90 border-white/10 hover:border-pink-500/40'
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="px-2.5 py-1 rounded-lg bg-pink-500/20 text-pink-400 text-xs font-extrabold">
TIKTOK SHOP
</span>
<span className="text-xs text-slate-400">Hom Aroma</span>
</div>
<span className="text-xs font-bold text-slate-300 font-mono">
{fmtNum(kpis.channelStats.tiktok?.soldQty)} ชิ้น
</span>
</div>
<div className="flex items-baseline justify-between mt-3">
<span className="text-xs text-slate-400">Revenue:</span>
<span className="text-base font-bold text-white font-mono">
{fmtMoney(kpis.channelStats.tiktok?.revenue)}
</span>
</div>
<div className="flex items-baseline justify-between mt-1">
<span className="text-xs text-slate-400">Platform Fee:</span>
<span className="text-xs font-mono text-amber-400">
{fmtMoney(kpis.channelStats.tiktok?.platformFee)}
</span>
</div>
<div className="flex items-baseline justify-between mt-1 pt-1 border-t border-white/5">
<span className="text-xs text-slate-400">Net Profit:</span>
<span className="text-base font-bold text-emerald-400 font-mono">
{fmtMoney(kpis.channelStats.tiktok?.netProfit)}
</span>
</div>
</div>
</div>
)}
{/* ── Main Omnichannel Table ─────────────────────────────────────── */}
<div className="bg-slate-900/90 border border-white/10 rounded-2xl overflow-hidden shadow-xl backdrop-blur-sm">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-950/80 border-b border-white/10 text-[11px] font-bold text-slate-400 uppercase tracking-wider">
<th className="py-4 px-4">Channel</th>
<th className="py-4 px-4">Marketplace Listing</th>
<th className="py-4 px-4">Linked Master Variant</th>
<th className="py-4 px-4 text-center">ขายได้ ({periodInfo?.label || 'All'})</th>
<th className="py-4 px-4 text-right">ราคาขายเฉลี่ย</th>
<th className="py-4 px-4 text-right">ต้นทุน BOM (Unit)</th>
<th className="py-4 px-4 text-right">หัก Fee (%) &amp; ที่มา</th>
<th className="py-4 px-4 text-right">Net Rev / Unit</th>
<th className="py-4 px-4 text-right bg-white/5">Net Profit / Unit</th>
<th className="py-4 px-4 text-right">Net Margin %</th>
<th className="py-4 px-4 text-center">Direct vs Channel</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5 text-xs md:text-sm">
{loading ? (
<tr>
<td colSpan={11} className="py-12 text-center text-slate-400">
<RefreshCw className="w-6 h-6 animate-spin mx-auto text-cyan-400 mb-2" />
กำลังประมวลผลข้อมูลกำไรสุทธิสำหรับช่วงเวลา {periodInfo?.label}...
</td>
</tr>
) : sortedItems.length === 0 ? (
<tr>
<td colSpan={11} className="py-12 text-center text-slate-400">
ไม่พบรายการสินค้า Marketplace ที่มียอดขายในช่วงเวลาที่เลือก
</td>
</tr>
) : (
sortedItems.map((item) => {
let chBadge = 'bg-orange-500/20 text-orange-400 border-orange-500/30';
if (item.channel === 'lazada') chBadge = 'bg-blue-500/20 text-blue-400 border-blue-500/30';
else if (item.channel === 'tiktok') chBadge = 'bg-pink-500/20 text-pink-400 border-pink-500/30';
const unitNetProfit = Math.round((item.netProfit / (item.totalSoldQty || 1)) * 100) / 100;
const unitNetRevenue = Math.round((item.netRevenue / (item.totalSoldQty || 1)) * 100) / 100;
return (
<tr key={item.id} className="hover:bg-white/5 transition-all">
<td className="py-3.5 px-4">
<span className={`px-2 py-1 rounded-md text-[10px] font-extrabold uppercase border ${chBadge}`}>
{item.channel}
</span>
</td>
<td className="py-3.5 px-4 max-w-[280px]">
<div className="font-bold text-white truncate" title={item.marketplaceProductName}>
{item.marketplaceProductName}
</div>
<div className="text-[11px] text-slate-400 flex items-center gap-1.5 mt-0.5">
<span className="font-mono text-cyan-400">{item.variation || 'Standard'}</span>
{item.marketplaceSku && <span>• {item.marketplaceSku}</span>}
</div>
</td>
<td className="py-3.5 px-4">
<div className="font-semibold text-slate-200">{item.productName}</div>
<div className="text-[11px] font-mono text-slate-400">{item.masterSku}</div>
</td>
<td className="py-3.5 px-4 text-center font-mono font-bold text-white">
{fmtNum(item.totalSoldQty)}
</td>
<td className="py-3.5 px-4 text-right font-mono text-white">
{fmtMoney(item.avgSellingPrice)}
</td>
<td className="py-3.5 px-4 text-right font-mono text-rose-300">
{fmtMoney(item.unitBomCost)}
</td>
<td className="py-3.5 px-4 text-right">
<div className="font-mono text-slate-200">
~{item.effectiveFeePercent}%
</div>
<div className="text-[10px] mt-0.5">
{item.feeSource === 'actual' ? (
<span className="px-1.5 py-0.5 rounded font-bold bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
Actual
</span>
) : item.feeSource === 'hybrid' ? (
<span className="px-1.5 py-0.5 rounded font-bold bg-indigo-500/20 text-indigo-400 border border-indigo-500/30">
Hybrid
</span>
) : (
<span className="px-1.5 py-0.5 rounded font-medium bg-amber-500/15 text-amber-300 border border-amber-500/30">
30% Fallback
</span>
)}
</div>
</td>
<td className="py-3.5 px-4 text-right font-mono text-slate-200">
{fmtMoney(unitNetRevenue)}
</td>
<td className="py-3.5 px-4 text-right font-mono font-bold text-emerald-400 bg-white/5">
+{fmtMoney(unitNetProfit)}
</td>
<td className="py-3.5 px-4 text-right">
<span
className={`px-2.5 py-1 rounded-full text-xs font-bold border inline-block min-w-[65px] text-center font-mono ${
item.netMarginPercent >= 60
? 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30'
: item.netMarginPercent >= 45
? 'bg-amber-500/15 text-amber-400 border-amber-500/30'
: 'bg-rose-500/15 text-rose-400 border-rose-500/30'
}`}
>
{fmtPercent(item.netMarginPercent)}
</span>
</td>
<td className="py-3.5 px-4 text-center">
<span className="text-[11px] text-slate-400" title="ขายตรงหน้าร้านจะได้มาร์จิ้นสูงกว่านี้">
Direct: <strong className="text-cyan-400">{item.directMarginPercent}%</strong>
</span>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { NavLink } from 'react-router-dom';
import { Calculator, Layers, BrainCircuit, Sparkles } from 'lucide-react';
interface Props {
title: string;
subtitle: string;
}
export default function ProfitabilityHeaderTabs({ title, subtitle }: Props) {
const tabs = [
{
to: '/profitability',
label: 'Cost & Profitability Matrix',
icon: Calculator,
description: 'วิเคราะห์ต้นทุน BOM และกำไรแคตตาล็อก',
},
{
to: '/omnichannel-margin',
label: 'Omnichannel Net Margin',
icon: Layers,
description: 'วิเคราะห์กำไรสุทธิตามช่องทางขาย',
},
{
to: '/mrp-planning',
label: 'MRP & Material Demand',
icon: BrainCircuit,
description: 'คำนวณการใช้วัตถุดิบและสต็อก',
},
];
return (
<div className="space-y-4 mb-6">
{/* Title & Badge */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold tracking-wider uppercase bg-cyan-500/10 text-cyan-400 border border-cyan-500/30 flex items-center gap-1">
<Sparkles className="w-3 h-3 text-cyan-400" />
Profit & Intelligence Suite
</span>
</div>
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">{title}</h1>
<p className="text-xs md:text-sm text-slate-400 mt-1">{subtitle}</p>
</div>
</div>
{/* Modern Navigation Tabs */}
<div className="flex flex-wrap gap-2 p-1.5 bg-slate-900/80 backdrop-blur-md rounded-2xl border border-white/10 shadow-lg">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<NavLink
key={tab.to}
to={tab.to}
end
className={({ isActive }) =>
`flex items-center gap-2.5 px-4 py-2.5 rounded-xl text-xs md:text-sm font-semibold transition-all duration-200 ${
isActive
? 'bg-gradient-to-r from-cyan-500 to-blue-600 text-white shadow-md shadow-cyan-500/20'
: 'text-slate-400 hover:text-slate-200 hover:bg-white/5'
}`
}
>
<Icon className="w-4 h-4 shrink-0" />
<span>{tab.label}</span>
</NavLink>
);
})}
</div>
</div>
);
}
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