Compare commits

...
17 Commits
Author SHA1 Message Date
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
54 changed files with 7972 additions and 333 deletions

No files matched your search

+2
View File
@@ -1,5 +1,7 @@
# Original User Request # Original User Request
> **Historical context:** This is an unedited record of an earlier development request for the Split-Screen Editorial Carousel feature. The current platform identity is DBK Platform.
## Initial Request — 2026-06-28T10:50:33Z ## Initial Request — 2026-06-28T10:50:33Z
Implement a Split-Screen Editorial Carousel by extending the existing `EDITORIAL_CAROUSEL` schema and updating the backend, admin, spa, and website apps. Implement a Split-Screen Editorial Carousel by extending the existing `EDITORIAL_CAROUSEL` schema and updating the backend, admin, spa, and website apps.
+32
View File
@@ -0,0 +1,32 @@
# Agent System & Workspaces Directory
> **Agent Infrastructure & Historical Provenance:** This directory houses both active multi-agent skills and historical subagent run workspaces. The platform identity is **DBK Platform**, with **HomAroma** as the reference tenant.
---
## 1. Active Skills (`.agents/skills/`)
The `.agents/skills/` directory contains active domain skill definitions loaded on demand by Antigravity and orchestrator agents. Each skill contains a `SKILL.md` specifying role responsibilities, routing logic, and verification rules:
- **`orchestrator`**: Central project planning, task routing, delegation, and verification.
- **`frontend-lead`**: React 19 SPA & Tailwind CSS development.
- **`frontend-code-auditor`**: Review gate for bug risks, contracts, and regressions.
- **`nextjs-seo-architect`**: Next.js 15 SSR, metadata, canonical URLs, and performance.
- **`backend-mcp-lead`**: Elysia API endpoints, server architecture, and MCP tools.
- **`database-architect`**: Prisma schema, PostgreSQL migrations, and data safety.
- **`cms-architect`**: Dynamic page builder payloads and headless CMS schemas.
- **`cms-content-manager`**: Content management and MCP blackbox testing.
- **`product-manager`**: Product scope, roadmap, and business impact analysis.
- **`ux-ui-designer`**: Design system, interaction models, and responsive layouts.
- **`qa-product-auditor`**: User flows, regression checks, and release readiness.
- **`brand-strategist`**: SEO copywriting, brand voice, and positioning.
- **`theme-compliance-auditor`**: CMS dynamic theme token compliance (avoid hardcoded colors).
*Note: Changes to `.agents/skills/` directly affect agent behavior and should follow the operational rules defined in [`AGENTS.md`](../AGENTS.md).*
---
## 2. Historical Subagent Workspaces
Directories matching `auditor_*`, `worker_*`, `explorer_*`, `reviewer_*`, `challenger_*`, `teamwork_preview_*`, `sentinel`, and `sub_orch_*` are historical workspaces and artifact directories generated during previous autonomous multi-agent sprint sessions.
- **Status:** Historical records preserved for provenance, auditability, and debugging.
- **Usage:** Do not edit active platform rules within historical folders. Refer to [`AGENTS.md`](../AGENTS.md) and [`docs/PLATFORM_OVERVIEW.md`](../docs/PLATFORM_OVERVIEW.md) for current standards.
+2 -2
View File
@@ -1,6 +1,6 @@
# DBK Platform Admin Section Guide # DBK Platform Admin Section Guide
> **Platform scope:** This guide describes shared CMS administration capabilities. Product and content examples are tenant-specific; HomAroma is the reference tenant. > **Platform scope:** This guide describes shared CMS administration capabilities. Product and content examples are tenant-specific; HomAroma is the reference tenant. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
This guide explains the dynamic page blocks available in the Page Builder and what each block contains. This guide explains the dynamic page blocks available in the Page Builder and what each block contains.
@@ -138,7 +138,7 @@ This guide explains the dynamic page blocks available in the Page Builder and wh
### `SCENT_NOTES_BREAKDOWN` ### `SCENT_NOTES_BREAKDOWN`
- Purpose: Fragrance layer breakdown - Purpose: Fragrance layer breakdown (reference tenant vertical block; pattern for multi-tier attribute breakdowns)
- Contains: - Contains:
- `title` localized string optional - `title` localized string optional
- `description` localized string optional - `description` localized string optional
+1 -1
View File
@@ -1,6 +1,6 @@
# Architecture Decisions — DBK Platform # Architecture Decisions — DBK Platform
> **Platform scope:** HomAroma references in this ADR are reference-tenant context unless a decision explicitly names an operational resource. > **Platform scope:** DBK Platform architecture decisions. HomAroma references in this ADR are reference-tenant context unless a decision explicitly names an operational resource. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
> บันทึกการตัดสินใจเชิงสถาปัตยกรรม (ADR) จากการประชุมทีม > บันทึกการตัดสินใจเชิงสถาปัตยกรรม (ADR) จากการประชุมทีม
> อัปเดตล่าสุด: 2026-07-12 > อัปเดตล่าสุด: 2026-07-12
+2 -2
View File
@@ -1,7 +1,7 @@
# แผนการพัฒนาและคู่มือสร้างแผนที่ Malaysia & Vietnam Map # แผนการพัฒนาและคู่มือสร้างแผนที่ Malaysia & Vietnam Map
> **Platform capability plan:** Geographic coverage, data availability, and analytics configuration must be enabled and validated per tenant. > **Platform capability plan:** Geographic coverage, data availability, and analytics configuration are platform capabilities enabled per tenant. HomAroma is the reference tenant. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
สำหรับเมนู Business Analytics บนระบบ HomAroma Admin สำหรับเมนู Business Analytics บนระบบ DBK Platform Admin (Multi-tenant & Multi-region)
เอกสารนี้รวบรวมรายละเอียด แหล่งข้อมูล Open-source Repositories สำหรับดาวน์โหลดไฟล์ GeoJSON สถาปัตยกรรมทางเทคนิค และแผนขั้นตอนการนำไปพัฒนาต่อยอดให้เหมือนกับ **Omnichannel Sales Map (แผนที่ประเทศไทย)** เดิม เอกสารนี้รวบรวมรายละเอียด แหล่งข้อมูล Open-source Repositories สำหรับดาวน์โหลดไฟล์ GeoJSON สถาปัตยกรรมทางเทคนิค และแผนขั้นตอนการนำไปพัฒนาต่อยอดให้เหมือนกับ **Omnichannel Sales Map (แผนที่ประเทศไทย)** เดิม
+3 -3
View File
@@ -1,8 +1,8 @@
# Custom Block Flow Guide # Custom Block Flow Guide — DBK Platform
> **Platform scope:** This guide describes shared CMS block workflows. Content examples belong to their named tenant. > **Platform scope:** This guide describes shared CMS block workflows for DBK Platform. Content examples (e.g. `BakeryCategories`) belong to their named tenant. HomAroma is the reference tenant. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
เอกสารนี้อธิบาย Flow การสร้าง Custom Block สำหรับ HomAroma Page Builder โดยใช้ `BakeryCategories` เป็นตัวอย่างจริง เพื่อให้เพิ่ม block ถัดไปได้โดยไม่ต้อง hardcode section type ใหม่ทุกครั้ง เอกสารนี้อธิบาย Flow การสร้าง Custom Block สำหรับ DBK Platform Page Builder โดยใช้ `BakeryCategories` เป็นตัวอย่าง tenant จริง เพื่อให้เพิ่ม block ถัดไปได้โดยไม่ต้อง hardcode section type ใหม่ทุกครั้ง
## แนวคิดหลัก ## แนวคิดหลัก
+1 -1
View File
@@ -1,6 +1,6 @@
# DDC Deployment Runbook # DDC Deployment Runbook
> **Tenant operational runbook:** This document records DDC deployment configuration. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding. > **Tenant operational runbook:** This document records DDC deployment configuration for DBK Platform. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
เอกสารนี้เป็น runbook เฉพาะลูกค้า `ddc` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/pkh-*` เอกสารนี้เป็น runbook เฉพาะลูกค้า `ddc` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/pkh-*`
+1 -1
View File
@@ -1,6 +1,6 @@
# คู่มือการติดตั้งโปรเจกต์ใหม่ (New Project Deployment Guide) # คู่มือการติดตั้งโปรเจกต์ใหม่ (New Project Deployment Guide)
> **DBK Platform operational guide:** Substitute tenant-specific domains, paths, credentials, and process names. Any `homaroma` identifier shown elsewhere in the repository is a legacy or reference-tenant identifier unless this guide explicitly selects it. > **DBK Platform operational guide:** Substitute tenant-specific domains, paths, credentials, and process names. Any `homaroma` identifier shown elsewhere in the repository is a legacy or reference-tenant identifier unless this guide explicitly selects it. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
เอกสารนี้รวบรวมขั้นตอนการสร้างสภาพแวดล้อม (Environment) และการตั้งค่าระบบสำหรับลูกค้ารายใหม่ (เช่น โปรเจกต์ `pkh`) โดยอ้างอิงจากโครงสร้างระบบปัจจุบันบนเซิร์ฟเวอร์ `kitizone.com` เอกสารนี้รวบรวมขั้นตอนการสร้างสภาพแวดล้อม (Environment) และการตั้งค่าระบบสำหรับลูกค้ารายใหม่ (เช่น โปรเจกต์ `pkh`) โดยอ้างอิงจากโครงสร้างระบบปัจจุบันบนเซิร์ฟเวอร์ `kitizone.com`
+1 -1
View File
@@ -1,6 +1,6 @@
# HuaHin Deployment Runbook # HuaHin Deployment Runbook
> **Tenant operational runbook:** This document records HuaHin deployment configuration. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding. > **Tenant operational runbook:** This document records HuaHin deployment configuration for DBK Platform. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
เอกสารนี้เป็น runbook เฉพาะลูกค้า `huahin` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/ddc-*` เอกสารนี้เป็น runbook เฉพาะลูกค้า `huahin` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/ddc-*`
+1 -1
View File
@@ -1,6 +1,6 @@
# Kittisak Deployment Runbook # Kittisak Deployment Runbook
> **Tenant operational runbook:** This document records Kittisak deployment configuration. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding. > **Tenant operational runbook:** This document records Kittisak deployment configuration for DBK Platform. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
เอกสารนี้เป็น runbook เฉพาะลูกค้า `kittisak` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/ddc-*` / `serverinfo/huahin-*` เอกสารนี้เป็น runbook เฉพาะลูกค้า `kittisak` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/ddc-*` / `serverinfo/huahin-*`
+61
View File
@@ -0,0 +1,61 @@
# รายการสินค้ากลุ่ม Oil Ozone สำหรับกำหนดสูตร BOM (Production)
> **เป้าหมาย:** กำหนดสูตร BOM อัตราส่วน หัวน้ำหอม 8% + DPG 92% + ขวดและฝา 4 บาท (ทุกขนาด: 30ml, 60ml, 85ml, 120ml)
> **อัปเดตล่าสุด:** 18/10/2569 13:05:32
> **จำนวนสินค้าทั้งหมด:** 45 รายการ
## 📦 สรุปสถานะภาพรวม
- **ตั้งค่าเสร็จแล้ว (CONFIGURED):** 45 / 45 รายการ (100%)
- **ยังไม่ได้ตั้งค่า (MISSING):** 0 รายการ
- **ตั้งค่าบางส่วน (PARTIAL):** 0 รายการ
- **พบวัตถุดิบหัวน้ำหอมตรงกลิ่น:** 45 / 45 รายการ
## 📋 ตารางรายละเอียดต้นทุน BOM และอัตรากำไร (Margin)
| # | ID | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุน 30ml (กำไร%) | ต้นทุน 60ml (กำไร%) | ต้นทุน 85ml (กำไร%) | ต้นทุน 120ml (กำไร%) | สถานะ |
| :-: | :-: | :--- | :--- | :-: | :-: | :-: | :-: | :-: |
| 1 | **#1** | [ตะไคร้ Oil Ozone](https://admin.homaroma.shop/products/1/bom) | [ID: 41] หัวน้ำหอม กลิ่นตะไคร้ (฿4.5) | ฿18.94 (72.6%) | ฿33.88 (66.1%) | ฿46.33 (61.4%) | ฿63.76 (57.5%) | ✅ พร้อมขาย |
| 2 | **#6** | [ลาเวนเดอร์ Oil Ozone](https://admin.homaroma.shop/products/6/bom) | [ID: 15] หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
| 3 | **#11** | [วานิลลา Oil Ozone](https://admin.homaroma.shop/products/11/bom) | [ID: 53] หัวน้ำหอม กลิ่นวานิลลา (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 4 | **#16** | [ซากุระ Oil Ozone](https://admin.homaroma.shop/products/16/bom) | [ID: 27] หัวน้ำหอม กลิ่นซากุระ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
| 5 | **#21** | [มะลิ Oil Ozone](https://admin.homaroma.shop/products/21/bom) | [ID: 48] หัวน้ำหอม กลิ่นมะลิ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
| 6 | **#26** | [ยูคาลิปตัส Oil Ozone](https://admin.homaroma.shop/products/26/bom) | [ID: 49] หัวน้ำหอม กลิ่นยูคาลิปตัส (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 7 | **#31** | [เฟรชลาเวนเดอร์ Oil Ozone](https://admin.homaroma.shop/products/31/bom) | [ID: 15] หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
| 8 | **#36** | [เปเปอร์มิ้น Oil Ozone](https://admin.homaroma.shop/products/36/bom) | [ID: 14] หัวน้ำหอม กลิ่นเปเปอร์มิ้น (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 9 | **#41** | [โอเชี่ยน Oil Ozone](https://admin.homaroma.shop/products/41/bom) | [ID: 21] หัวน้ำหอม กลิ่นโอเชี่ยน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 10 | **#46** | [เซเว่นซี Oil Ozone](https://admin.homaroma.shop/products/46/bom) | [ID: 13] หัวน้ำหอม กลิ่นเซเว่นซี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 11 | **#51** | [ดอกแก้ว Oil Ozone](https://admin.homaroma.shop/products/51/bom) | [ID: 28] หัวน้ำหอม กลิ่นดอกแก้ว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 12 | **#56** | [ดอกโมก Oil Ozone](https://admin.homaroma.shop/products/56/bom) | [ID: 29] หัวน้ำหอม กลิ่นดอกโมก (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 13 | **#61** | [ลีลาวดี Oil Ozone](https://admin.homaroma.shop/products/61/bom) | [ID: 51] หัวน้ำหอม กลิ่นลีลาวดี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 14 | **#66** | [ส้ม Oil Ozone](https://admin.homaroma.shop/products/66/bom) | [ID: 54] หัวน้ำหอม กลิ่นส้ม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 15 | **#71** | [มะนาว Oil Ozone](https://admin.homaroma.shop/products/71/bom) | [ID: 46] หัวน้ำหอม กลิ่นมะนาว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 16 | **#76** | [แคนตาลูป Oil Ozone](https://admin.homaroma.shop/products/76/bom) | [ID: 16] หัวน้ำหอม กลิ่นแคนตาลูป (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 17 | **#81** | [แอปเปิ้ลแดง Oil Ozone](https://admin.homaroma.shop/products/81/bom) | [ID: 19] หัวน้ำหอม กลิ่นแอปเปิ้ลแดง (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 18 | **#86** | [แอปเปิ้ลเขียว Oil Ozone](https://admin.homaroma.shop/products/86/bom) | [ID: 18] หัวน้ำหอม กลิ่นแอปเปิ้ลเขียว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 19 | **#91** | [ดอกลิลลี่ Oil Ozone](https://admin.homaroma.shop/products/91/bom) | [ID: 40] หัวน้ำหอม กลิ่นดอกลิลลี่ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 20 | **#96** | [โรสเมรี่ Oil Ozone](https://admin.homaroma.shop/products/96/bom) | [ID: 20] หัวน้ำหอม กลิ่นโรสเมรี่ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 21 | **#101** | [อัญชัน Oil Ozone](https://admin.homaroma.shop/products/101/bom) | [ID: 57] หัวน้ำหอม กลิ่นอัญชัน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 22 | **#106** | [กุหลาบแดง Oil Ozone](https://admin.homaroma.shop/products/106/bom) | [ID: 26] หัวน้ำหอม กลิ่นกุหลาบแดง (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 23 | **#111** | [หมากฝรั่ง Oil Ozone](https://admin.homaroma.shop/products/111/bom) | [ID: 55] หัวน้ำหอม กลิ่นหมากฝรั่ง (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 24 | **#116** | [ผลไม้รวม Oil Ozone](https://admin.homaroma.shop/products/116/bom) | [ID: 44] หัวน้ำหอม กลิ่นผลไม้รวม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 25 | **#121** | [มะกรูด Oil Ozone](https://admin.homaroma.shop/products/121/bom) | [ID: 45] หัวน้ำหอม กลิ่นมะกรูด (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 26 | **#126** | [แซนด้าวูส Oil Ozone](https://admin.homaroma.shop/products/126/bom) | [ID: 17] หัวน้ำหอม กลิ่นแซนด้าวูส (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 27 | **#131** | [ดอกปีบ Oil Ozone](https://admin.homaroma.shop/products/131/bom) | [ID: 34] หัวน้ำหอม กลิ่นดอกปีบ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 28 | **#136** | [ดอกบัว Oil Ozone](https://admin.homaroma.shop/products/136/bom) | [ID: 33] หัวน้ำหอม กลิ่นดอกบัว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 29 | **#141** | [โอโซน Oil Ozone](https://admin.homaroma.shop/products/141/bom) | [ID: 22] หัวน้ำหอม กลิ่นโอโซน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 30 | **#146** | [ไทยสปา Oil Ozone](https://admin.homaroma.shop/products/146/bom) | [ID: 23] หัวน้ำหอม กลิ่นไทยสปา (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 31 | **#151** | [กล้วย Oil Ozone](https://admin.homaroma.shop/products/151/bom) | [ID: 24] หัวน้ำหอม กลิ่นกล้วย (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 32 | **#156** | [กาแฟ Oil Ozone](https://admin.homaroma.shop/products/156/bom) | [ID: 25] หัวน้ำหอม กลิ่นกาแฟ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 33 | **#161** | [ดอกชมนาด Oil Ozone](https://admin.homaroma.shop/products/161/bom) | [ID: 32] หัวน้ำหอม กลิ่นดอกชมนาด (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 34 | **#166** | [กลิ่นน้ำนม Oil Ozone](https://admin.homaroma.shop/products/166/bom) | [ID: 43] หัวน้ำหอม กลิ่นน้ำนม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 35 | **#171** | [ดอกการะเวก Oil Ozone](https://admin.homaroma.shop/products/171/bom) | [ID: 30] หัวน้ำหอม กลิ่นดอกการะเวก (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 36 | **#176** | [ดอกจำปา Oil Ozone](https://admin.homaroma.shop/products/176/bom) | [ID: 31] หัวน้ำหอม กลิ่นดอกจำปา (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 37 | **#181** | [ดอกพิกุล Oil Ozone](https://admin.homaroma.shop/products/181/bom) | [ID: 35] หัวน้ำหอม กลิ่นดอกพิกุล (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 38 | **#186** | [ดอกพุด Oil Ozone](https://admin.homaroma.shop/products/186/bom) | [ID: 36] หัวน้ำหอม กลิ่นดอกพุด (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 39 | **#191** | [ดอกราตรี Oil Ozone](https://admin.homaroma.shop/products/191/bom) | [ID: 38] หัวน้ำหอม กลิ่นดอกราตรี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 40 | **#196** | [ดอกลำดวน Oil Ozone](https://admin.homaroma.shop/products/196/bom) | [ID: 39] หัวน้ำหอม กลิ่นดอกลำดวน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 41 | **#201** | [ทับทิม Oil Ozone](https://admin.homaroma.shop/products/201/bom) | [ID: 42] หัวน้ำหอม กลิ่นทับทิม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 42 | **#206** | [มะละกอสุก Oil Ozone](https://admin.homaroma.shop/products/206/bom) | [ID: 47] หัวน้ำหอม กลิ่นมะละกอสุก (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 43 | **#211** | [ดอกราชาวดี Oil Ozone](https://admin.homaroma.shop/products/211/bom) | [ID: 37] หัวน้ำหอม กลิ่นดอกราชาวดี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 44 | **#216** | [กลิ่นลูกพีช Oil Ozone](https://admin.homaroma.shop/products/216/bom) | [ID: 52] หัวน้ำหอม กลิ่นลูกพีช (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 45 | **#221** | [กลิ่นองุ่น Oil Ozone](https://admin.homaroma.shop/products/221/bom) | [ID: 56] หัวน้ำหอม กลิ่นองุ่น (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
+4 -4
View File
@@ -1,14 +1,14 @@
# Prisma 7 — คู่มือการเชื่อมต่อและทำ Seed (ไม่งงอีกต่อไป) # Prisma 7 — คู่มือการเชื่อมต่อและทำ Seed — DBK Platform
> **Platform engineering guide:** Any HomAroma seed data in this guide is reference-tenant data. Validate the target tenant database and seed strategy before use. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
> **Platform engineering guide:** Any HomAroma seed data in this guide is reference-tenant data. Validate the target tenant database and seed strategy before use.
xx
> **สำคัญมาก:** Prisma 7 เปลี่ยนวิธีการเชื่อมต่อฐานข้อมูลอย่างพื้นฐาน > **สำคัญมาก:** Prisma 7 เปลี่ยนวิธีการเชื่อมต่อฐานข้อมูลอย่างพื้นฐาน
> ถ้าเคยใช้ Prisma 4, 5, หรือ 6 มาก่อน — ของเก่าพวกนั้น **ใช้ไม่ได้อีกแล้ว** > ถ้าเคยใช้ Prisma 4, 5, หรือ 6 มาก่อน — ของเก่าพวกนั้น **ใช้ไม่ได้อีกแล้ว**
--- ---
## 1. สิ่งที่เปลี่ยนไปจาก Prisma 6fdss → Prisma 7 ## 1. สิ่งที่เปลี่ยนไปจาก Prisma 5/6 → Prisma 7
| เรื่อง | Prisma 5/6 (เก่า) | Prisma 7 (ใหม่) | | เรื่อง | Prisma 5/6 (เก่า) | Prisma 7 (ใหม่) |
|---|---|---| |---|---|---|
+1 -1
View File
@@ -2,7 +2,7 @@
> **DBK Platform tenant example:** Schema fields are shared platform contracts. The HomAroma brand, aroma products, categories, and copy below are reference-tenant sample data only. > **DBK Platform tenant example:** Schema fields are shared platform contracts. The HomAroma brand, aroma products, categories, and copy below are reference-tenant sample data only.
ระบบนำเข้าสินค้าของ HomAroma รองรับการ Import ผ่านไฟล์ `.json` หรือวาง Payload JSON Array ได้โดยตรงผ่าน: ระบบนำเข้าสินค้าของ DBK Platform (ตัวอย่างข้อมูลแคตตาล็อกสินค้าอ้างอิงจาก Reference Tenant: HomAroma) รองรับการ Import ผ่านไฟล์ `.json` หรือวาง Payload JSON Array ได้โดยตรงผ่าน:
1. **หน้าจอ Admin:** เมนู `/products` -> กดปุ่ม **"Import JSON"** 1. **หน้าจอ Admin:** เมนู `/products` -> กดปุ่ม **"Import JSON"**
2. **API Endpoint:** `POST /api/products/bulk` หรือ `POST /api/products` 2. **API Endpoint:** `POST /api/products/bulk` หรือ `POST /api/products`
+1 -1
View File
@@ -1,6 +1,6 @@
# สรุปปัญหา ข้อผิดพลาด และ Checklist การสร้าง Tenant / Subdomain ใหม่ # สรุปปัญหา ข้อผิดพลาด และ Checklist การสร้าง Tenant / Subdomain ใหม่
> **Historical operational record:** This postmortem preserves the facts of a 2026 tenant-provisioning incident. Existing HomAroma identifiers are legacy infrastructure references, not DBK Platform branding. > **Historical operational record:** This postmortem preserves the facts of a 2026 tenant-provisioning incident. Existing HomAroma identifiers are legacy infrastructure references, not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
(Tenant Provisioning Post-Mortem & Standard Operating Procedure) (Tenant Provisioning Post-Mortem & Standard Operating Procedure)
บันทึกข้อผิดพลาดจากการสร้าง Showcase Subdomains (`wellness`, `fashion`, `beauty`, `moto`) บน `kittisak.com` เมื่อวันที่ 24 กันยายน 2026 เพื่อใช้เป็น Checklist ป้องกันการทำผิดพลาดซ้ำในอนาคต บันทึกข้อผิดพลาดจากการสร้าง Showcase Subdomains (`wellness`, `fashion`, `beauty`, `moto`) บน `kittisak.com` เมื่อวันที่ 24 กันยายน 2026 เพื่อใช้เป็น Checklist ป้องกันการทำผิดพลาดซ้ำในอนาคต
+3
View File
@@ -14,6 +14,7 @@ import PageBuilderPage from './pages/PageBuilderPage'
import PageReorderPage from './pages/PageReorderPage' import PageReorderPage from './pages/PageReorderPage'
import CmsMockupPage from './pages/CmsMockupPage' import CmsMockupPage from './pages/CmsMockupPage'
import RawMaterialsPage from './pages/RawMaterialsPage' import RawMaterialsPage from './pages/RawMaterialsPage'
import RawMaterialDetailPage from './pages/RawMaterialDetailPage'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
import InstallSeedPage from './pages/InstallSeedPage' import InstallSeedPage from './pages/InstallSeedPage'
import ProtectedRoute from './components/ProtectedRoute' import ProtectedRoute from './components/ProtectedRoute'
@@ -123,6 +124,8 @@ function App() {
<Route path="/promotions" element={<PromotionsPage />} /> <Route path="/promotions" element={<PromotionsPage />} />
<Route path="/coupons" element={<CouponsPage />} /> <Route path="/coupons" element={<CouponsPage />} />
<Route path="/raw-materials" element={<RawMaterialsPage />} /> <Route path="/raw-materials" element={<RawMaterialsPage />} />
<Route path="/raw-materials/new" element={<RawMaterialDetailPage />} />
<Route path="/raw-materials/:id" element={<RawMaterialDetailPage />} />
<Route path="/settings" element={<StoreSettingsPage />} /> <Route path="/settings" element={<StoreSettingsPage />} />
<Route path="/settings/brand-intelligence" element={<BrandIntelligencePage />} /> <Route path="/settings/brand-intelligence" element={<BrandIntelligencePage />} />
<Route path="/brand-intelligence" element={<BrandIntelligencePage />} /> <Route path="/brand-intelligence" element={<BrandIntelligencePage />} />
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { API_BASE } from '@homaroma/types'; import { API_BASE } from '@homaroma/types';
import { X, Upload, Loader2, Image as ImageIcon, Video } from 'lucide-react'; import { X, Upload, Loader2, Image as ImageIcon, Video } from 'lucide-react';
@@ -105,7 +106,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect, mediaType
if (!isOpen) return null; if (!isOpen) return null;
return ( return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"> <div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
<div className="bg-night-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-5xl h-[85vh] flex flex-col overflow-hidden animate-fade-in relative"> <div className="bg-night-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-5xl h-[85vh] flex flex-col overflow-hidden animate-fade-in relative">
{/* Header */} {/* Header */}
@@ -196,6 +197,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect, mediaType
)} )}
</div> </div>
</div> </div>
</div> </div>,
document.body
); );
} }
@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { createPortal } from 'react-dom'
import { import {
Sparkles, Sparkles,
Copy, Copy,
@@ -35,6 +36,8 @@ const GUIDE_OVERVIEW_MARKDOWN = `# คู่มือการใช้ AI/LLM
### 1. Root Items (เมนูแถวบนสุด - Level 1) ### 1. Root Items (เมนูแถวบนสุด - Level 1)
- **label**: ออบเจกต์ข้อความรองรับ 8 ภาษา \`{ th, en, ja, zh, vi, ko, lo, it }\` - **label**: ออบเจกต์ข้อความรองรับ 8 ภาษา \`{ th, en, ja, zh, vi, ko, lo, it }\`
- **url**: ลิงก์ปลายทาง เช่น \`/category/reed-diffuser\` หรือ \`/collections/all\` - **url**: ลิงก์ปลายทาง เช่น \`/category/reed-diffuser\` หรือ \`/collections/all\`
- **layout**: (ไม่บังคับ) \`'columns'\` (Mega Menu คอลัมน์) | \`'tabbed'\` (Tabbed Catalog แท็บซ้าย) | \`'visual-cards'\` (การ์ดรูปภาพหรูหรา 4 ใบ) | \`'classic'\` (Dropdown แนวตั้งเรียบหรู กะทัดรัด)
- **layoutConfig**: (ไม่บังคับ) ออบเจกต์ตั้งค่าเพิ่มเติม \`{ columnCount: 2 | 3 | 4, showFeatured: boolean }\`
- **rootImageUrl**: URL รูปภาพหลักฝั่งซ้ายสุดของเมกะเมนู (อัตราส่วน 1:1 จัตุรัส ขนาดแนะนำ 800x800) - **rootImageUrl**: URL รูปภาพหลักฝั่งซ้ายสุดของเมกะเมนู (อัตราส่วน 1:1 จัตุรัส ขนาดแนะนำ 800x800)
- **columns**: อาร์เรย์ของคอลัมน์เมนูย่อย (Level 2) - **columns**: อาร์เรย์ของคอลัมน์เมนูย่อย (Level 2)
- **featured**: การ์ดสินค้า/ไฮไลต์โปรโมตพิเศษที่แสดงฝั่งขวาสุดของเมกะเมนู - **featured**: การ์ดสินค้า/ไฮไลต์โปรโมตพิเศษที่แสดงฝั่งขวาสุดของเมกะเมนู
@@ -304,7 +307,7 @@ export default function MenuJsonGuideModal({
} }
} }
return ( return createPortal(
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-5 animate-fade-in"> <div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-5 animate-fade-in">
<div className="w-full max-w-5xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col max-h-[92vh] overflow-hidden animate-zoom-in"> <div className="w-full max-w-5xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col max-h-[92vh] overflow-hidden animate-zoom-in">
{/* Header */} {/* Header */}
@@ -491,6 +494,7 @@ export default function MenuJsonGuideModal({
</div> </div>
</div> </div>
</div> </div>
</div> </div>,
document.body
) )
} }
@@ -0,0 +1,390 @@
import { useState, useMemo } from 'react';
import {
X,
FileJson,
Upload,
CheckCircle2,
AlertCircle,
Layers,
Sparkles
} from 'lucide-react';
interface ProductBomImportModalProps {
isOpen: boolean;
onClose: () => void;
variants: any[];
rawMaterials: any[];
onApply: (updatedVariants: any[]) => void;
}
export default function ProductBomImportModal({
isOpen,
onClose,
variants,
rawMaterials,
onApply,
}: ProductBomImportModalProps) {
const [jsonInput, setJsonInput] = useState('');
// Real-time Parser & Matcher
const parsedResult = useMemo(() => {
if (!jsonInput.trim()) return null;
try {
const parsed = JSON.parse(jsonInput);
if (!Array.isArray(parsed)) {
return {
valid: false,
error: 'ข้อมูลต้องเป็น JSON Array [ { ... }, { ... } ]',
matchedVariants: [],
unmatchedCount: 0,
};
}
if (parsed.length === 0) {
return {
valid: false,
error: 'JSON Array ว่างเปล่า ไม่มีข้อมูลสูตร',
matchedVariants: [],
unmatchedCount: 0,
};
}
const matched: any[] = [];
let unmatchedCount = 0;
for (const entry of parsed) {
// 1. Match Variant by ID or SKU or Option
const variant = variants.find((v) => {
if (entry.variantId && Number(v.id) === Number(entry.variantId)) return true;
if (entry.sku && v.sku && String(v.sku).trim().toLowerCase() === String(entry.sku).trim().toLowerCase()) return true;
if (entry.option1 && v.option1 && String(v.option1).trim().toLowerCase() === String(entry.option1).trim().toLowerCase()) return true;
return false;
});
if (!variant) {
unmatchedCount++;
continue;
}
// 2. Match BOM items to RawMaterials
const rawItems = Array.isArray(entry.bomItems) ? entry.bomItems : [];
const resolvedBomItems: any[] = [];
let totalCost = 0;
for (const b of rawItems) {
const qty = Number(b.quantity) || 0;
if (qty <= 0) continue;
// Find raw material by ID or Name
const rm = rawMaterials.find((m) => {
if (b.rawMaterialId && Number(m.id) === Number(b.rawMaterialId)) return true;
if (b.name && String(m.name).trim().toLowerCase() === String(b.name).trim().toLowerCase()) return true;
return false;
});
if (rm) {
const lineCost = (Number(rm.pricePerUnit) || 0) * qty;
totalCost += lineCost;
resolvedBomItems.push({
rawMaterialId: rm.id,
name: rm.name,
unit: rm.unit,
quantity: qty,
pricePerUnit: rm.pricePerUnit,
lineCost,
});
} else {
// Unresolved material ID or name
resolvedBomItems.push({
rawMaterialId: Number(b.rawMaterialId) || 0,
name: b.name || `Material #${b.rawMaterialId}`,
unit: 'unit',
quantity: qty,
pricePerUnit: 0,
lineCost: 0,
unknown: true,
});
}
}
const price = Number(variant.price) || 0;
const profit = price - totalCost;
const margin = price > 0 ? (profit / price) * 100 : 0;
matched.push({
targetVariant: variant,
resolvedBomItems,
totalCost,
price,
profit,
margin,
});
}
if (matched.length === 0) {
return {
valid: false,
error: 'ไม่พบ Variant ในระบบที่ตรงกับ ID หรือ SKU ใน JSON ที่วางมา',
matchedVariants: [],
unmatchedCount,
};
}
return {
valid: true,
error: null,
matchedVariants: matched,
unmatchedCount,
};
} catch (err: any) {
return {
valid: false,
error: `รูปแบบ JSON ไม่ถูกต้อง: ${err.message}`,
matchedVariants: [],
unmatchedCount: 0,
};
}
}, [jsonInput, variants, rawMaterials]);
if (!isOpen) return null;
const handleApply = () => {
if (!parsedResult || !parsedResult.valid) return;
// Deep copy current variants
const newVariants = JSON.parse(JSON.stringify(variants));
for (const match of parsedResult.matchedVariants) {
const idx = newVariants.findIndex((v: any) => v.id === match.targetVariant.id);
if (idx !== -1) {
// Map to exact BOM structure expected by ProductBomPage
newVariants[idx].bomItems = match.resolvedBomItems.map((b: any) => ({
rawMaterialId: b.rawMaterialId,
quantity: b.quantity,
}));
}
}
onApply(newVariants);
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-night-950/80 backdrop-blur-sm transition-opacity"
onClick={onClose}
/>
{/* Modal Container */}
<div className="relative w-full max-w-4xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden max-h-[92vh] animate-zoom-in">
{/* Header */}
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5 bg-night-950/40">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400">
<FileJson className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-white">Import BOM from LLM JSON</h2>
<span className="text-xs px-2.5 py-0.5 rounded-full bg-cyan-500/10 text-cyan-300 border border-cyan-500/20 font-bold">
Auto-Match & Calculate
</span>
</div>
<p className="text-xs text-slate-400">
วาง JSON ที่ได้รับจาก LLM ระบบจะจับคู่ Variant และคำนวณต้นทุนให้ก่อนกรอกลงตาราง
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content Body */}
<div className="p-6 overflow-y-auto space-y-6 flex-1">
{/* Textarea Input */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<label className="font-bold text-slate-300 flex items-center gap-1.5">
<Sparkles className="w-3.5 h-3.5 text-cyan-400" />
วางข้อความ JSON ที่ได้จาก AI (Ctrl+V):
</label>
{jsonInput && (
<button
type="button"
onClick={() => setJsonInput('')}
className="text-rose-400 hover:text-rose-300 font-medium"
>
ล้างข้อความ
</button>
)}
</div>
<textarea
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
rows={8}
placeholder={`[\n {\n "variantId": 3206,\n "sku": "4-1-30ML",\n "bomItems": [\n { "rawMaterialId": 4, "quantity": 2.4 },\n { "rawMaterialId": 91, "quantity": 27.6 }\n ]\n }\n]`}
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-4 text-slate-200 placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 transition-all resize-y leading-relaxed"
/>
</div>
{/* Validation Status Banner */}
{parsedResult && (
<div
className={`p-4 rounded-2xl border text-xs flex items-center justify-between ${
parsedResult.valid
? 'bg-emerald-950/30 border-emerald-500/30 text-emerald-300'
: 'bg-rose-950/30 border-rose-500/30 text-rose-300'
}`}
>
<div className="flex items-center gap-2.5">
{parsedResult.valid ? (
<CheckCircle2 className="w-5 h-5 shrink-0 text-emerald-400" />
) : (
<AlertCircle className="w-5 h-5 shrink-0 text-rose-400" />
)}
<span>
{parsedResult.valid ? (
<>
ตรวจพบสูตร BOM ที่ตรงกับสินค้า <strong>{parsedResult.matchedVariants.length}</strong> ตัวเลือก
{parsedResult.unmatchedCount > 0 && ` (ข้ามที่ไม่ตรง ${parsedResult.unmatchedCount} รายการ)`}
</>
) : (
parsedResult.error
)}
</span>
</div>
{parsedResult.valid && (
<span className="text-[11px] px-2 py-0.5 rounded-md bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-medium">
พร้อมนำเข้า
</span>
)}
</div>
)}
{/* Live Preview Cards */}
{parsedResult?.valid && (
<div className="space-y-3">
<div className="flex items-center justify-between text-xs text-slate-400">
<span className="font-bold text-white flex items-center gap-1.5">
<Layers className="w-4 h-4 text-cyan-400" />
พรีวิวการจัดสูตรและคำนวณต้นทุน (BOM Preview):
</span>
<span>จำนวน {parsedResult.matchedVariants.length} Variants</span>
</div>
<div className="space-y-3 max-h-[280px] overflow-y-auto pr-1">
{parsedResult.matchedVariants.map((item: any, idx: number) => {
const v = item.targetVariant;
const label = [v.option1, v.option2, v.option3].filter(Boolean).join(' / ') || 'Default';
return (
<div
key={v.id || idx}
className="p-4 rounded-2xl bg-white/[0.02] border border-white/5 hover:border-white/10 transition-all space-y-3"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-white/5 pb-2.5">
<div className="flex items-center gap-2">
<span className="w-6 h-6 rounded-lg bg-cyan-500/10 text-cyan-400 text-xs font-mono font-bold flex items-center justify-center">
#{v.id}
</span>
<span className="text-sm font-bold text-white">{label}</span>
{v.sku && (
<span className="text-[11px] font-mono text-slate-500 bg-white/5 px-2 py-0.5 rounded-md">
SKU: {v.sku}
</span>
)}
</div>
<div className="flex items-center gap-4 text-xs font-mono">
<span className="text-slate-400">
ราคาขาย: <strong className="text-white">฿{item.price.toLocaleString()}</strong>
</span>
<span className="text-slate-400">
ต้นทุน BOM: <strong className="text-amber-400">฿{item.totalCost.toFixed(2)}</strong>
</span>
<span className="text-slate-400">
กำไร:{' '}
<strong className={item.profit >= 0 ? 'text-emerald-400' : 'text-rose-400'}>
฿{item.profit.toFixed(2)} ({item.margin.toFixed(1)}%)
</strong>
</span>
</div>
</div>
{/* Ingredients Breakdown */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
{item.resolvedBomItems.map((b: any, bIdx: number) => (
<div
key={bIdx}
className={`p-2.5 rounded-xl border text-xs flex items-center justify-between ${
b.unknown
? 'bg-amber-950/20 border-amber-500/30 text-amber-300'
: 'bg-night-950/60 border-white/5 text-slate-300'
}`}
>
<div className="space-y-0.5 min-w-0 pr-2">
<p className="font-medium truncate text-white text-[11px]">
{b.name}
</p>
<p className="text-[10px] text-slate-500">
ID: #{b.rawMaterialId}
</p>
</div>
<div className="text-right shrink-0">
<span className="font-mono font-bold text-cyan-400">
{b.quantity} {b.unit}
</span>
<p className="text-[10px] text-slate-500 font-mono">
฿{b.lineCost.toFixed(2)}
</p>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-white/5 bg-night-950/40 flex items-center justify-between">
<p className="text-xs text-slate-500">
เมื่อกด Apply ข้อมูลจะถูกกรอกลงตารางบนหน้าเว็บทันทีเพื่อให้คุณตรวจสอบก่อนกดบันทึก
</p>
<div className="flex items-center gap-3">
<button
type="button"
onClick={onClose}
className="px-5 py-2.5 rounded-xl text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
ยกเลิก
</button>
<button
type="button"
onClick={handleApply}
disabled={!parsedResult?.valid}
className="btn-primary text-sm px-6 py-2.5 flex items-center gap-2 shadow-lg shadow-cyan-500/20 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Upload className="w-4 h-4" />
<span>กรอกลงตาราง ({parsedResult?.matchedVariants?.length || 0} รายการ)</span>
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,332 @@
import { useState, useMemo } from 'react';
import {
X,
Copy,
Check,
Bot,
Sparkles,
FileCode,
Package,
Layers,
Code2
} from 'lucide-react';
interface ProductBomPromptModalProps {
isOpen: boolean;
onClose: () => void;
productId: number | string;
productName: string;
variants: any[];
rawMaterials: any[];
}
export default function ProductBomPromptModal({
isOpen,
onClose,
productId,
productName,
variants,
rawMaterials,
}: ProductBomPromptModalProps) {
const [copied, setCopied] = useState(false);
const [activeTab, setActiveTab] = useState<'prompt' | 'context' | 'schema'>('prompt');
// Generate full prompt text for LLM
const generatedPrompt = useMemo(() => {
const variantLines = variants.map((v) => {
const opts = [v.option1, v.option2, v.option3].filter(Boolean).join(' / ') || 'Base Variant';
return ` - Variant ID: ${v.id} | SKU: ${v.sku || '-'} | ตัวเลือก: "${opts}" | ราคาขาย: ฿${Number(v.price || 0).toLocaleString()}`;
}).join('\n');
const rawMaterialLines = rawMaterials.map((rm) => {
return ` - [ID: ${rm.id}] "${rm.name}" | หน่วย: ${rm.unit} | ต้นทุน: ฿${Number(rm.pricePerUnit || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })}`;
}).join('\n');
return `คุณคือ Senior Manufacturing & Bill of Materials (BOM) Specialist ของระบบ E-Commerce (DBK Platform)
[บริบทข้อมูลสินค้า]:
- รหัสสินค้า (Product ID): ${productId}
- ชื่อสินค้า: "${productName}"
[รายการตัวเลือกสินค้า (Variants ที่ต้องกำหนดสูตร BOM)]:
${variantLines}
[คลังรายการวัตถุดิบในระบบ (Raw Materials ที่สามารถนำมาผูกสูตรได้)]:
${rawMaterialLines}
[ภารกิจของคุณ]:
กรุณาจัดทำสูตรการผลิต (BOM Items) ให้กับแต่ละ Variant ของสินค้า รหัส #${productId} ตามคำสั่ง/สัดส่วนที่ระบุ
และส่งคืนผลลัพธ์เป็น **Valid JSON Array** ล้วนๆ ตามข้อกำหนดดังต่อไปนี้เท่านั้น:
[ข้อกำหนดผลลัพธ์ JSON Format ที่ต้องส่งกลับ]:
\`\`\`json
[
{
"variantId": <ตัวเลข ID ของ Variant>,
"sku": "<รหัส SKU>",
"bomItems": [
{
"rawMaterialId": <ตัวเลข ID ของวัตถุดิบจากคลังด้านบน>,
"name": "<ชื่อวัตถุดิบ>",
"quantity": <จำนวนปริมาณตัวเลข (ห้ามเป็น string)>
}
]
}
]
\`\`\`
*(คำสั่ง/สูตรเฉพาะ: ระบุสัดส่วนการผสม วัตถุดิบบรรจุภัณฑ์ และปริมาณที่ต้องการต่อ 1 ชิ้น)*`;
}, [productId, productName, variants, rawMaterials]);
// Context JSON
const contextJson = useMemo(() => {
return JSON.stringify(
{
product: {
id: Number(productId),
name: productName,
variantsCount: variants.length,
variants: variants.map((v) => ({
id: v.id,
sku: v.sku,
option1: v.option1,
option2: v.option2,
option3: v.option3,
price: v.price,
currentBomCount: v.bomItems?.length || 0,
})),
},
rawMaterialsCount: rawMaterials.length,
rawMaterials: rawMaterials.map((rm) => ({
id: rm.id,
name: rm.name,
unit: rm.unit,
pricePerUnit: rm.pricePerUnit,
})),
},
null,
2
);
}, [productId, productName, variants, rawMaterials]);
// Target schema sample
const schemaSample = useMemo(() => {
const firstVariant = variants[0];
const sampleItem = rawMaterials[0];
return JSON.stringify(
[
{
variantId: firstVariant ? firstVariant.id : 1,
sku: firstVariant?.sku || 'SAMPLE-SKU',
bomItems: [
{
rawMaterialId: sampleItem ? sampleItem.id : 1,
name: sampleItem?.name || 'ตัวอย่างวัตถุดิบ',
quantity: 1,
},
],
},
],
null,
2
);
}, [variants, rawMaterials]);
if (!isOpen) return null;
const handleCopy = async (textToCopy: string) => {
try {
await navigator.clipboard.writeText(textToCopy);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
console.error(e);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-night-950/80 backdrop-blur-sm transition-opacity"
onClick={onClose}
/>
{/* Modal Container */}
<div className="relative w-full max-w-4xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden max-h-[92vh] animate-zoom-in">
{/* Header */}
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5 bg-night-950/40">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-400">
<Bot className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-white">LLM Prompt Generator for BOM</h2>
<span className="text-xs px-2.5 py-0.5 rounded-full bg-purple-500/10 text-purple-300 border border-purple-500/20 font-mono font-bold">
Product #{productId}
</span>
</div>
<p className="text-xs text-slate-400">
สร้างชุดคำสั่ง Prompt พร้อมรหัสสินค้า, Variants และรายการวัตถุดิบทั้งหมดสำหรับส่งให้ LLM
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Quick Stats Bar */}
<div className="grid grid-cols-3 gap-4 px-6 py-3 bg-night-950/30 border-b border-white/5 text-xs">
<div className="flex items-center gap-2 text-slate-400">
<Package className="w-4 h-4 text-cyan-400" />
<span>สินค้า: <strong className="text-white">{productName}</strong></span>
</div>
<div className="flex items-center gap-2 text-slate-400">
<Layers className="w-4 h-4 text-purple-400" />
<span>Variants: <strong className="text-white">{variants.length} ตัวเลือก</strong></span>
</div>
<div className="flex items-center gap-2 text-slate-400">
<Code2 className="w-4 h-4 text-emerald-400" />
<span>วัตถุดิบในคลัง: <strong className="text-white">{rawMaterials.length} รายการ</strong></span>
</div>
</div>
{/* Navigation Tabs */}
<div className="flex border-b border-white/5 px-6 pt-2 bg-night-950/20">
<button
onClick={() => setActiveTab('prompt')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
activeTab === 'prompt'
? 'border-purple-500 text-purple-400'
: 'border-transparent text-slate-400 hover:text-white'
}`}
>
<Sparkles className="w-4 h-4" />
<span>ชุดคำสั่ง Prompt สำหรับ LLM</span>
</button>
<button
onClick={() => setActiveTab('context')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
activeTab === 'context'
? 'border-purple-500 text-purple-400'
: 'border-transparent text-slate-400 hover:text-white'
}`}
>
<FileCode className="w-4 h-4" />
<span>ข้อมูลดิบ (Context JSON)</span>
</button>
<button
onClick={() => setActiveTab('schema')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
activeTab === 'schema'
? 'border-purple-500 text-purple-400'
: 'border-transparent text-slate-400 hover:text-white'
}`}
>
<Code2 className="w-4 h-4" />
<span>Target Output Format</span>
</button>
</div>
{/* Content Body */}
<div className="p-6 overflow-y-auto space-y-4 flex-1">
{activeTab === 'prompt' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-400">
คัดลอกข้อความด้านล่างนี้ไปวางใน <strong>ChatGPT, Claude หรือ Gemini</strong>:
</span>
<button
type="button"
onClick={() => handleCopy(generatedPrompt)}
className="px-4 py-2 rounded-xl bg-purple-500/10 hover:bg-purple-500/20 text-purple-300 border border-purple-500/30 text-xs font-bold flex items-center gap-2 transition-all shadow-sm"
>
{copied ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
<span>{copied ? 'คัดลอก Prompt สำเร็จ!' : 'คัดลอก Prompt ทั้งหมด'}</span>
</button>
</div>
<textarea
readOnly
value={generatedPrompt}
rows={16}
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-5 text-slate-200 focus:outline-none resize-y leading-relaxed"
/>
</div>
)}
{activeTab === 'context' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-400">
โครงสร้างข้อมูล Product Master, Variants และ Raw Materials ในรูปแบบ JSON:
</span>
<button
type="button"
onClick={() => handleCopy(contextJson)}
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 border border-white/10 text-xs font-bold flex items-center gap-2 transition-all"
>
{copied ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
<span>{copied ? 'คัดลอกแล้ว' : 'คัดลอก Context JSON'}</span>
</button>
</div>
<textarea
readOnly
value={contextJson}
rows={16}
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-5 text-slate-300 focus:outline-none resize-y leading-relaxed"
/>
</div>
)}
{activeTab === 'schema' && (
<div className="space-y-4">
<div className="p-4 rounded-2xl bg-cyan-950/20 border border-cyan-500/20 text-xs text-slate-300 space-y-1">
<p className="font-bold text-cyan-300">💡 โครงสร้างผลลัพธ์ที่ออกแบบไว้สำหรับรองรับการ Copy วาง (Paste to Import):</p>
<p className="text-slate-400">
เมื่อ LLM ได้รับข้อมูลและส่งกลับมาในรูปแบบนี้ คุณจะสามารถนำข้อความ JSON มาวางเพื่อบันทึก BOM เข้าสู่ Variant ทั้งหมดได้ทันที
</p>
</div>
<textarea
readOnly
value={schemaSample}
rows={12}
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-5 text-emerald-400 focus:outline-none resize-y leading-relaxed"
/>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-white/5 bg-night-950/40 flex items-center justify-between">
<p className="text-xs text-slate-500">
ระบบจัดเตรียมข้อมูลอัตโนมัติจากฐานข้อมูลจริงของสินค้า #{productId}
</p>
<div className="flex items-center gap-3">
<button
type="button"
onClick={onClose}
className="px-5 py-2.5 rounded-xl text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
ปิดหน้าต่าง
</button>
<button
type="button"
onClick={() => handleCopy(generatedPrompt)}
className="btn-primary text-sm px-6 py-2.5 flex items-center gap-2 shadow-lg shadow-purple-500/20 bg-purple-600 hover:bg-purple-500 text-white"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
<span>{copied ? 'คัดลอกสำเร็จแล้ว' : 'คัดลอก Prompt ไปใช้งาน'}</span>
</button>
</div>
</div>
</div>
</div>
);
}
@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { createPortal } from 'react-dom'
import { import {
Sparkles, Sparkles,
Copy, Copy,
@@ -613,7 +614,7 @@ export default function ProductJsonGuideModal({ isOpen, onClose, onOpenImport }:
} }
} }
return ( return createPortal(
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-5 animate-fade-in"> <div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-5 animate-fade-in">
<div className="w-full max-w-5xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col max-h-[92vh] overflow-hidden animate-zoom-in"> <div className="w-full max-w-5xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col max-h-[92vh] overflow-hidden animate-zoom-in">
@@ -792,6 +793,7 @@ export default function ProductJsonGuideModal({ isOpen, onClose, onOpenImport }:
</div> </div>
</div> </div>
</div> </div>,
document.body
) )
} }
@@ -0,0 +1,419 @@
import { useState, useMemo, useRef, type ChangeEvent } from 'react';
import {
X,
Copy,
Check,
Download,
Upload,
FileJson,
AlertCircle,
CheckCircle2,
Loader2,
Sparkles,
} from 'lucide-react';
import { API_BASE, type RawMaterial } from '@homaroma/types';
interface RawMaterialBulkJsonModalProps {
isOpen: boolean;
onClose: () => void;
materials: RawMaterial[];
onSuccess: () => void;
}
const SAMPLE_TEMPLATE = [
{
name: "ขวดแก้วสีชา 100ml (Amber Glass Bottle)",
unit: "piece",
pricePerUnit: 18.50
},
{
name: "ก้านไม้ไฟเบอร์สีดำ 6 ก้าน (Black Fiber Reeds)",
unit: "pack",
pricePerUnit: 7.00
},
{
name: "น้ำมันหอมระเหย ลาเวนเดอร์บริสุทธิ์ (Pure Lavender Oil)",
unit: "kg",
pricePerUnit: 2400.00
},
{
name: "เบสกระจายความหอม MMB (Diffuser Base)",
unit: "liter",
pricePerUnit: 350.00
}
];
export default function RawMaterialBulkJsonModal({
isOpen,
onClose,
materials,
onSuccess,
}: RawMaterialBulkJsonModalProps) {
const [activeTab, setActiveTab] = useState<'import' | 'export'>('import');
const [copiedTemplate, setCopiedTemplate] = useState(false);
const [copiedExport, setCopiedExport] = useState(false);
const [importJsonText, setImportJsonText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [resultMessage, setResultMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
// Parse & validate input text in real time
const parsedPreview = useMemo(() => {
if (!importJsonText.trim()) return null;
try {
const parsed = JSON.parse(importJsonText);
if (!Array.isArray(parsed)) {
return { valid: false, error: 'ข้อมูลต้องเป็น JSON Array [ { ... }, { ... } ]', count: 0, items: [] };
}
if (parsed.length === 0) {
return { valid: false, error: 'Array ว่างเปล่า ไม่มีรายการวัตถุดิบ', count: 0, items: [] };
}
const invalidItem = parsed.find((item: any) => !item || typeof item !== 'object' || !item.name);
if (invalidItem) {
return { valid: false, error: 'พบรายการที่ไม่มีฟิลด์ "name" หรือข้อมูลไม่สมบูรณ์', count: 0, items: [] };
}
return { valid: true, error: null, count: parsed.length, items: parsed };
} catch (err: any) {
return { valid: false, error: `รูปแบบ JSON ไม่ถูกต้อง: ${err.message}`, count: 0, items: [] };
}
}, [importJsonText]);
// Export JSON string
const exportJsonString = useMemo(() => {
const cleanList = materials.map((m) => ({
name: m.name,
unit: m.unit,
pricePerUnit: m.pricePerUnit,
}));
return JSON.stringify(cleanList, null, 2);
}, [materials]);
if (!isOpen) return null;
const handleCopyTemplate = async () => {
try {
await navigator.clipboard.writeText(JSON.stringify(SAMPLE_TEMPLATE, null, 2));
setCopiedTemplate(true);
setTimeout(() => setCopiedTemplate(false), 2000);
} catch (e) {
console.error(e);
}
};
const handleCopyExport = async () => {
try {
await navigator.clipboard.writeText(exportJsonString);
setCopiedExport(true);
setTimeout(() => setCopiedExport(false), 2000);
} catch (e) {
console.error(e);
}
};
const handleDownloadExport = () => {
const blob = new Blob([exportJsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `raw-materials-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
};
const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const content = event.target?.result as string;
setImportJsonText(content);
setResultMessage(null);
};
reader.readAsText(file);
e.target.value = '';
};
const handleImportSubmit = async () => {
if (!parsedPreview || !parsedPreview.valid) return;
setSubmitting(true);
setResultMessage(null);
try {
const res = await fetch(`${API_BASE}/raw-materials/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(parsedPreview.items),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || 'นำเข้าข้อมูลไม่สำเร็จ');
}
setResultMessage({
type: 'success',
text: `นำเข้าและปรับปรุงข้อมูล (Upsert) สำเร็จ ${data.count || parsedPreview.count} รายการ`,
});
setImportJsonText('');
setTimeout(() => {
onSuccess();
onClose();
}, 1200);
} catch (err: any) {
setResultMessage({
type: 'error',
text: err.message || 'เกิดข้อผิดพลาดในการเชื่อมต่อเซิร์ฟเวอร์',
});
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-night-950/80 backdrop-blur-sm transition-opacity"
onClick={() => !submitting && onClose()}
/>
{/* Modal Container */}
<div className="relative w-full max-w-3xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden max-h-[90vh] animate-zoom-in">
{/* Header */}
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5 bg-night-950/40">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400">
<FileJson className="w-5 h-5" />
</div>
<div>
<h2 className="text-xl font-bold text-white flex items-center gap-2">
Raw Materials JSON Tool
<span className="text-xs px-2 py-0.5 rounded-full bg-cyan-500/10 text-cyan-300 border border-cyan-500/20">
Bulk Upsert
</span>
</h2>
<p className="text-xs text-slate-400">
นำเข้าและส่งออกข้อมูลวัตถุดิบ รองรับการอัปเดตข้อมูลเดิมอัตโนมัติเมื่อชื่อซ้ำ
</p>
</div>
</div>
<button
onClick={onClose}
disabled={submitting}
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Navigation Tabs */}
<div className="flex border-b border-white/5 px-6 pt-2 bg-night-950/20">
<button
onClick={() => { setActiveTab('import'); setResultMessage(null); }}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
activeTab === 'import'
? 'border-cyan-500 text-cyan-400'
: 'border-transparent text-slate-400 hover:text-white'
}`}
>
<Upload className="w-4 h-4" />
<span>นำเข้า JSON (Bulk Import)</span>
</button>
<button
onClick={() => { setActiveTab('export'); setResultMessage(null); }}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
activeTab === 'export'
? 'border-cyan-500 text-cyan-400'
: 'border-transparent text-slate-400 hover:text-white'
}`}
>
<Download className="w-4 h-4" />
<span>ส่งออก JSON (Export {materials.length} รายการ)</span>
</button>
</div>
{/* Content Body */}
<div className="p-6 overflow-y-auto space-y-6 flex-1">
{activeTab === 'import' ? (
<div className="space-y-5">
{/* Guidance Notice */}
<div className="p-4 rounded-2xl bg-cyan-950/20 border border-cyan-500/20 text-xs text-cyan-200/90 flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-1.5 font-bold text-cyan-300">
<Sparkles className="w-3.5 h-3.5" />
กลไกอัจฉริยะ (Upsert Automation)
</div>
<p className="text-slate-300 leading-relaxed">
วาง JSON Array รายการวัตถุดิบที่ต้องการ หากพบ <strong>name</strong> ที่มีอยู่แล้วในระบบ
ระบบจะ <strong>Update</strong> หน่วยนับและราคาต้นทุนให้ทันที หากยังไม่มีจะสร้างรายการใหม่
</p>
</div>
<button
type="button"
onClick={handleCopyTemplate}
className="shrink-0 px-3 py-1.5 rounded-lg bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 font-medium flex items-center gap-1.5 transition-all text-xs"
>
{copiedTemplate ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedTemplate ? 'คัดลอกแล้ว' : 'คัดลอก Template'}</span>
</button>
</div>
{/* JSON Input Area */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<label className="font-bold text-slate-300">วาง JSON Payload:</label>
<div className="flex items-center gap-2">
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
accept=".json"
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="text-cyan-400 hover:text-cyan-300 flex items-center gap-1 font-medium transition-colors"
>
<Upload className="w-3.5 h-3.5" />
<span>อัปโหลดไฟล์ .json</span>
</button>
{importJsonText && (
<button
type="button"
onClick={() => { setImportJsonText(''); setResultMessage(null); }}
className="text-rose-400 hover:text-rose-300 ml-2 font-medium"
>
ล้างข้อมูล
</button>
)}
</div>
</div>
<textarea
value={importJsonText}
onChange={(e) => {
setImportJsonText(e.target.value);
setResultMessage(null);
}}
rows={9}
placeholder={`[\n {\n "name": "ขวดแก้ว 100ml",\n "unit": "piece",\n "pricePerUnit": 18.5\n }\n]`}
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-4 text-slate-200 placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 transition-all resize-y"
/>
</div>
{/* Live Preview / Validation Status */}
{parsedPreview && (
<div
className={`p-3.5 rounded-xl border text-xs flex items-center gap-2.5 ${
parsedPreview.valid
? 'bg-emerald-950/30 border-emerald-500/30 text-emerald-300'
: 'bg-rose-950/30 border-rose-500/30 text-rose-300'
}`}
>
{parsedPreview.valid ? (
<>
<CheckCircle2 className="w-4 h-4 shrink-0" />
<span>
JSON ถูกต้อง — ตรวจพบวัตถุดิบทั้งหมด <strong>{parsedPreview.count}</strong> รายการ พร้อมนำเข้าสู่ระบบ
</span>
</>
) : (
<>
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{parsedPreview.error}</span>
</>
)}
</div>
)}
{/* Server Result Message */}
{resultMessage && (
<div
className={`p-3.5 rounded-xl border text-xs flex items-center gap-2.5 ${
resultMessage.type === 'success'
? 'bg-emerald-950/40 border-emerald-500/40 text-emerald-300'
: 'bg-rose-950/40 border-rose-500/40 text-rose-300'
}`}
>
{resultMessage.type === 'success' ? (
<CheckCircle2 className="w-4 h-4 shrink-0" />
) : (
<AlertCircle className="w-4 h-4 shrink-0" />
)}
<span>{resultMessage.text}</span>
</div>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>วัตถุดิบทั้งหมดในระบบ ({materials.length} รายการ):</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCopyExport}
className="px-3 py-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 font-medium flex items-center gap-1.5 transition-all"
>
{copiedExport ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedExport ? 'คัดลอกแล้ว' : 'คัดลอก JSON'}</span>
</button>
<button
type="button"
onClick={handleDownloadExport}
className="btn-primary text-xs px-3 py-1.5 flex items-center gap-1.5"
>
<Download className="w-3.5 h-3.5" />
<span>ดาวน์โหลด .json</span>
</button>
</div>
</div>
<textarea
readOnly
value={exportJsonString}
rows={12}
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-4 text-slate-300 focus:outline-none resize-y"
/>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-white/5 bg-night-950/40 flex items-center justify-end gap-3">
<button
type="button"
onClick={onClose}
disabled={submitting}
className="px-5 py-2.5 rounded-xl text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
ยกเลิก
</button>
{activeTab === 'import' && (
<button
type="button"
onClick={handleImportSubmit}
disabled={submitting || !parsedPreview?.valid}
className="btn-primary text-sm px-6 py-2.5 flex items-center gap-2 disabled:opacity-40 disabled:cursor-not-allowed shadow-lg shadow-cyan-500/10"
>
{submitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>กำลังบันทึกข้อมูล...</span>
</>
) : (
<>
<Upload className="w-4 h-4" />
<span>ยืนยันการนำเข้า ({parsedPreview?.count || 0} รายการ)</span>
</>
)}
</button>
)}
</div>
</div>
</div>
);
}
@@ -1,4 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
X, X,
@@ -852,7 +853,7 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
return matchesCategory && matchesSearch; return matchesCategory && matchesSearch;
}); });
return ( return createPortal(
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 sm:p-6 animate-fade-in"> <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 sm:p-6 animate-fade-in">
<div className="bg-night-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-6xl flex flex-col h-[90vh] max-h-[850px] overflow-hidden text-slate-200"> <div className="bg-night-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-6xl flex flex-col h-[90vh] max-h-[850px] overflow-hidden text-slate-200">
{/* Modal Header */} {/* Modal Header */}
@@ -978,6 +979,7 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
)} )}
</div> </div>
</div> </div>
</div> </div>,
document.body
); );
} }
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X, Search, Loader2 } from 'lucide-react'; import { X, Search, Loader2 } from 'lucide-react';
import { API_BASE } from '@homaroma/types'; import { API_BASE } from '@homaroma/types';
import { getAdminDisplayString } from '../../utils/localization'; import { getAdminDisplayString } from '../../utils/localization';
@@ -63,7 +64,7 @@ export default function ProductPickerModal({
onClose(); onClose();
}; };
return ( return createPortal(
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4"> <div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-night-900/80 backdrop-blur-sm" onClick={onClose}></div> <div className="absolute inset-0 bg-night-900/80 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative bg-slate-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden flex flex-col h-[80vh]"> <div className="relative bg-slate-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden flex flex-col h-[80vh]">
@@ -160,6 +161,7 @@ export default function ProductPickerModal({
</div> </div>
</div> </div>
</div> </div>
</div> </div>,
document.body
); );
} }
@@ -0,0 +1,495 @@
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
X,
Receipt,
DollarSign,
Wallet,
BadgePercent,
Coins,
Package,
Truck,
ExternalLink,
CheckCircle2,
AlertTriangle,
Copy,
Check,
TrendingUp,
} from 'lucide-react';
import { TIKTOK_SHOPS } from '../../pages/TiktokOrderPage';
function fmt(val: number | null | undefined) {
if (val == null) return '0.00';
return new Intl.NumberFormat('th-TH', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(val);
}
interface TiktokFeeBreakdownModalProps {
order: any;
onClose: () => void;
}
export function TiktokFeeBreakdownModal({ order, onClose }: TiktokFeeBreakdownModalProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
// Keyboard navigation & body scroll lock
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
// Prevent background page from scrolling
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = originalOverflow;
};
}, [onClose]);
if (!order || typeof document === 'undefined') return null;
const settlement = order.settlement;
const isSettled = order.isSettled || !!settlement;
const shopCfg = TIKTOK_SHOPS.find((s) => s.id === order.shop) || TIKTOK_SHOPS[0];
// Financial figures (Settlement fees are usually stored as negative numbers from TikTok reports)
const grossRevenue = settlement?.grossRevenue ?? settlement?.subtotalAfterSellerDiscount ?? order.orderAmount ?? 0;
const sellerDiscount = Math.abs(settlement?.sellerDiscount ?? 0);
const subtotalBeforeDiscount = settlement?.subtotalBeforeDiscount ?? (grossRevenue + sellerDiscount);
const commissionFee = Math.abs(settlement?.commissionFee ?? order.commissionFee ?? 0);
const affiliateCommission = Math.abs(settlement?.affiliateCommission ?? order.affiliateFee ?? 0);
const affiliatePartnerFee = Math.abs(settlement?.affiliatePartnerFee ?? 0);
const totalAffiliate = affiliateCommission + affiliatePartnerFee;
const orderFee = Math.abs(settlement?.orderFee ?? 0);
const growthSupportFee = Math.abs(settlement?.growthSupportFee ?? 0);
const shippingFeeActual = Math.abs(settlement?.shippingFeeActual ?? 0);
const platformShippingDiscount = Math.abs(settlement?.platformShippingDiscount ?? 0);
const netShippingToSeller = Math.max(0, shippingFeeActual - platformShippingDiscount);
// Total deductions
const rawTotalFee = settlement?.totalFee != null
? Math.abs(settlement.totalFee)
: (commissionFee + totalAffiliate + orderFee + growthSupportFee + netShippingToSeller);
// Net payout
const netSettlement = settlement?.settlementAmount ?? order.netSettlement ?? (grossRevenue - commissionFee - totalAffiliate);
// BOM & Net Profit
const totalBomCost = order.orderTotalBomCost || 0;
const hasMissingBom = order.hasIncompleteBom || (order.missingBomCount ?? 0) > 0;
const realNetProfit = netSettlement - totalBomCost;
const marginOnSales = grossRevenue > 0 ? (realNetProfit / grossRevenue) * 100 : 0;
const markupOnCost = totalBomCost > 0 ? (realNetProfit / totalBomCost) * 100 : 0;
const realNetMargin = marginOnSales;
const handleCopyOrderId = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(order.orderId);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center p-3 sm:p-5 md:p-6 bg-black/80 backdrop-blur-md animate-fade-in"
onClick={onClose}
>
<div
className="bg-slate-900 border border-white/15 rounded-3xl w-full max-w-5xl xl:max-w-6xl max-h-[92vh] flex flex-col shadow-2xl shadow-black/90 overflow-hidden text-white"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/[0.02]">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-violet-500/20 to-cyan-500/20 border border-violet-500/30 flex items-center justify-center shadow-lg shadow-violet-500/10 shrink-0">
<Receipt className="w-5 h-5 text-violet-400" />
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-bold text-white text-base">
{t('tiktokOrders.feeModalTitle', 'TikTok Fee & Settlement Breakdown')}
</h3>
{isSettled ? (
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 inline-flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" />
{t('tiktokOrders.settlementCompleteBadge', 'โอนเงินแล้ว')}
</span>
) : (
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/15 text-amber-400 border border-amber-500/30 inline-flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
{t('tiktokOrders.settlementPendingBadge', 'รอ Settlement')}
</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-slate-400 mt-0.5 flex-wrap">
<span className="font-mono text-rose-400 font-bold">#{order.orderId}</span>
<button
type="button"
onClick={handleCopyOrderId}
className="text-slate-400 hover:text-white p-0.5 rounded transition-colors inline-flex items-center gap-1 text-[11px]"
title={t('tiktokOrders.copyOrderId', 'คัดลอก Order ID')}
>
{copied ? <Check className="w-3 h-3 text-emerald-400" /> : <Copy className="w-3 h-3" />}
{copied && <span className="text-[10px] text-emerald-400 font-sans">{t('tiktokOrders.copied', 'คัดลอกแล้ว')}</span>}
</button>
<span>•</span>
<span className={`font-semibold ${shopCfg.color}`}>{shopCfg.label}</span>
{order.buyerUsername && (
<>
<span>•</span>
<span className="text-slate-300">ผู้ซื้อ: {order.buyerUsername}</span>
</>
)}
{order.createdTime && (
<>
<span>•</span>
<span className="text-slate-400 font-mono">{order.createdTime.slice(0, 16)}</span>
</>
)}
</div>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-all ml-2"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Scrollable Content */}
<div className="p-6 space-y-5 overflow-y-auto">
{/* Pending Settlement Notice if not settled */}
{!settlement && (
<div className="p-3.5 rounded-2xl bg-amber-500/10 border border-amber-500/30 flex items-start gap-3 text-amber-300 text-xs">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<div className="space-y-0.5">
<p className="font-bold text-amber-200">
{t('tiktokOrders.settlementPendingAlert', 'คำสั่งซื้อนี้ยังไม่มีข้อมูลใบแจ้งยอด Settlement')}
</p>
<p className="text-amber-300/80 leading-relaxed text-[11px]">
{t(
'tiktokOrders.settlementPendingDesc',
'ค่าคอมมิชชั่นและตัวเลขด้านล่างเป็นการคำนวณเบื้องต้นจากข้อมูล Order จนกว่าจะมีการ Import ไฟล์ Settlement XLSX จาก TikTok Seller Center'
)}
</p>
</div>
</div>
)}
{/* 5 Financial Summary Cards (Compact Banner) */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2.5">
{/* 1. Revenue */}
<div className="p-3.5 rounded-2xl bg-white/5 border border-white/10 flex flex-col justify-between min-h-[96px]">
<div className="flex items-center justify-between text-slate-400 text-[11px]">
<span className="truncate">{t('tiktokOrders.orderRevenueLabel', 'ยอดขายสินค้า')}</span>
<DollarSign className="w-3.5 h-3.5 text-slate-400 shrink-0" />
</div>
<p className="text-lg font-bold font-mono text-white mt-1">฿{fmt(grossRevenue)}</p>
<span className="text-[10px] text-slate-500 truncate" title={sellerDiscount > 0 ? `ลด ฿${fmt(sellerDiscount)} จาก ฿${fmt(subtotalBeforeDiscount)}` : 'รวมส่วนลดร้านค้าแล้ว'}>
{sellerDiscount > 0 ? `ลด ฿${fmt(sellerDiscount)} จาก ฿${fmt(subtotalBeforeDiscount)}` : 'รวมส่วนลดแล้ว'}
</span>
</div>
{/* 2. TikTok Deductions */}
<div className="p-3.5 rounded-2xl bg-rose-500/10 border border-rose-500/20 flex flex-col justify-between min-h-[96px]">
<div className="flex items-center justify-between text-rose-300 text-[11px]">
<span className="truncate">{t('tiktokOrders.tiktokDeductionsLabel', 'TikTok หักรวม')}</span>
<BadgePercent className="w-3.5 h-3.5 text-rose-400 shrink-0" />
</div>
<p className="text-lg font-bold font-mono text-rose-400 mt-1">-฿{fmt(rawTotalFee)}</p>
<span className="text-[10px] text-rose-400/80 truncate">
{grossRevenue > 0 ? `${((rawTotalFee / grossRevenue) * 100).toFixed(1)}% ของยอดขาย` : '-'}
</span>
</div>
{/* 3. Net Payout */}
<div className="p-3.5 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex flex-col justify-between min-h-[96px]">
<div className="flex items-center justify-between text-cyan-300 text-[11px]">
<span className="truncate">{t('tiktokOrders.netPayoutLabel', 'ยอดโอนสุทธิ')}</span>
<Wallet className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
</div>
<p className="text-lg font-bold font-mono text-cyan-400 mt-1">฿{fmt(netSettlement)}</p>
<span className="text-[10px] text-cyan-400/80 truncate">
{settlement?.orderSettledTime ? `โอนเมื่อ ${settlement.orderSettledTime.slice(0, 10)}` : 'รอสรุปยอดโอน'}
</span>
</div>
{/* 4. Product Cost (BOM) */}
<div className="p-3.5 rounded-2xl bg-amber-500/10 border border-amber-500/20 flex flex-col justify-between min-h-[96px]">
<div className="flex items-center justify-between text-amber-300 text-[11px]">
<span className="truncate">{t('tiktokOrders.cardBomCost', 'ต้นทุนสินค้า (BOM)')}</span>
<Coins className="w-3.5 h-3.5 text-amber-400 shrink-0" />
</div>
<p className="text-lg font-bold font-mono text-amber-400 mt-1">฿{fmt(totalBomCost)}</p>
<span className="text-[10px] text-amber-400/80 truncate">
{hasMissingBom
? '⚠️ มีสินค้ายังไม่ใส่ BOM'
: grossRevenue > 0
? `${((totalBomCost / grossRevenue) * 100).toFixed(1)}% ของยอดขาย`
: 'BOM ครบ 100%'}
</span>
</div>
{/* 5. Net Profit (with Margin % on sales and Markup % on cost) */}
<div className={`p-3.5 rounded-2xl border flex flex-col justify-between min-h-[96px] ${
realNetProfit >= 0 ? 'bg-emerald-500/10 border-emerald-500/25' : 'bg-rose-500/10 border-rose-500/25'
}`}>
<div className="flex items-center justify-between text-[11px]">
<span className={`truncate font-semibold ${realNetProfit >= 0 ? 'text-emerald-300' : 'text-rose-300'}`}>
{t('tiktokOrders.cardNetProfit', 'กำไรสุทธิ')}
</span>
<TrendingUp className={`w-3.5 h-3.5 shrink-0 ${realNetProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`} />
</div>
<p className={`text-lg font-bold font-mono mt-1 ${realNetProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
฿{fmt(realNetProfit)}
</p>
<div className="flex items-center gap-1.5 text-[10px] font-mono leading-tight flex-wrap">
<span className={realNetProfit >= 0 ? 'text-emerald-300 font-semibold' : 'text-rose-300 font-semibold'}>
กำไร/ขาย {marginOnSales.toFixed(1)}%
</span>
<span className="text-slate-500">•</span>
<span className={realNetProfit >= 0 ? 'text-emerald-300 font-semibold' : 'text-rose-300 font-semibold'}>
กำไร/ทุน {markupOnCost >= 0 ? '+' : ''}{markupOnCost.toFixed(1)}%
</span>
</div>
</div>
</div>
{/* 2-Column Wide Landscape Grid Layout */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5 items-start">
{/* Left Column (7 Cols): TikTok Platform Fees Breakdown */}
<div className="lg:col-span-7 p-4 sm:p-5 rounded-2xl bg-black/30 border border-white/10 space-y-3.5">
<div className="flex items-center justify-between pb-2.5 border-b border-white/10">
<div className="flex items-center gap-2">
<BadgePercent className="w-4 h-4 text-violet-400" />
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-300">
{t('tiktokOrders.feeBreakdownTitle', 'Breakdown รายการหักค่าใช้จ่ายของ TikTok')}
</h4>
</div>
<span className="text-xs font-mono font-bold text-rose-400">-฿{fmt(rawTotalFee)}</span>
</div>
<div className="space-y-2.5 text-xs">
{/* Commission Fee */}
<div className="flex items-center justify-between p-3 rounded-xl bg-violet-500/10 border border-violet-500/20">
<div className="flex items-center gap-2.5">
<span className="w-2.5 h-2.5 rounded-full bg-violet-400 shrink-0" />
<div>
<p className="font-bold text-violet-200">
{t('tiktokOrders.tiktokCommissionFee', 'ค่าคอมมิชชั่น TikTok Shop (Commission Fee)')}
</p>
<p className="text-[10px] text-violet-300/70">
{t('tiktokOrders.tiktokCommissionDesc', 'หักตามอัตราหมวดหมู่สินค้าของแพลตฟอร์ม')}
</p>
</div>
</div>
<span className="font-mono font-bold text-violet-400 text-sm">
{commissionFee > 0 ? `-฿${fmt(commissionFee)}` : '฿0.00'}
</span>
</div>
{/* Affiliate Creator Commission */}
<div className="flex items-center justify-between p-3 rounded-xl bg-cyan-500/10 border border-cyan-500/20">
<div className="flex items-center gap-2.5">
<span className="w-2.5 h-2.5 rounded-full bg-cyan-400 shrink-0" />
<div>
<p className="font-bold text-cyan-200">
{t('tiktokOrders.affiliateCommissionFee', 'ค่าคอมมิชชั่น Creator แอฟฟิลิเอต (Affiliate)')}
</p>
<p className="text-[10px] text-cyan-300/70">
{t('tiktokOrders.affiliateCommissionDesc', 'จ่ายให้ครีเอเตอร์/นายหน้าที่แปะลิงก์หรือไลฟ์')}
</p>
</div>
</div>
<span className="font-mono font-bold text-cyan-400 text-sm">
{affiliateCommission > 0 ? `-฿${fmt(affiliateCommission)}` : '฿0.00'}
</span>
</div>
{/* Affiliate Partner Fee (TAP) */}
{(affiliatePartnerFee > 0 || settlement) && (
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/5 text-slate-300">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-cyan-400/60" />
<span>{t('tiktokOrders.affiliatePartnerFee', 'ค่าคอมมิชชั่น Partner แอฟฟิลิเอต (TAP)')}</span>
</div>
<span className="font-mono text-cyan-300 font-semibold">
{affiliatePartnerFee > 0 ? `-฿${fmt(affiliatePartnerFee)}` : '฿0.00'}
</span>
</div>
)}
{/* Order / Transaction Fee */}
{(orderFee > 0 || settlement) && (
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/5 text-slate-300">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-amber-400" />
<span>{t('tiktokOrders.orderFee', 'ค่าธรรมเนียมคำสั่งซื้อ/ธุรกรรม (Order Fee)')}</span>
</div>
<span className="font-mono text-amber-400 font-semibold">
{orderFee > 0 ? `-฿${fmt(orderFee)}` : '฿0.00'}
</span>
</div>
)}
{/* Growth Support Fee */}
{growthSupportFee > 0 && (
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/5 text-slate-300">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-blue-400" />
<span>{t('tiktokOrders.growthSupportFee', 'ค่าธรรมเนียมสนับสนุนการเติบโต (Growth Support)')}</span>
</div>
<span className="font-mono text-blue-400 font-semibold">
-฿{fmt(growthSupportFee)}
</span>
</div>
)}
{/* Shipping Breakdown */}
{(shippingFeeActual > 0 || platformShippingDiscount > 0) && (
<div className="p-3 rounded-xl bg-white/[0.02] border border-white/5 space-y-1">
<div className="flex items-center justify-between text-slate-300 text-xs">
<div className="flex items-center gap-2">
<Truck className="w-3.5 h-3.5 text-blue-400" />
<span>{t('tiktokOrders.shippingBreakdown', 'ค่าจัดส่งขนส่ง')}</span>
</div>
<span className="font-mono font-semibold text-slate-200">
{netShippingToSeller > 0 ? `-฿${fmt(netShippingToSeller)}` : t('tiktokOrders.shippingFreeForSeller', 'ร้านค้าไม่เสียค่าส่ง')}
</span>
</div>
<div className="flex items-center justify-between text-[11px] pl-5 text-slate-400">
<span>
{t('tiktokOrders.shippingBreakdownDetail', {
actual: fmt(shippingFeeActual),
subsidy: fmt(platformShippingDiscount),
defaultValue: `ค่าส่งจริง ฿${fmt(shippingFeeActual)} | TikTok อุดหนุน -฿${fmt(platformShippingDiscount)}`
})}
</span>
</div>
</div>
)}
</div>
</div>
{/* Right Column (5 Cols): BOM Profitability & Order Items */}
<div className="lg:col-span-5 space-y-4">
{/* BOM Profitability Analysis Card */}
<div
className={`p-4 sm:p-5 rounded-2xl border space-y-3 ${
hasMissingBom ? 'bg-rose-500/10 border-rose-500/25' : 'bg-emerald-500/10 border-emerald-500/25'
}`}
>
<div className="flex items-center justify-between pb-2 border-b border-white/10">
<div className="flex items-center gap-2">
<Coins className={`w-4 h-4 ${hasMissingBom ? 'text-rose-400' : 'text-emerald-400'}`} />
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-200">
{t('tiktokOrders.bomProfitAnalysis', 'วิเคราะห์กำไรสุทธิจริงหลังหักต้นทุน BOM')}
</h4>
</div>
{hasMissingBom ? (
<span className="text-[10px] font-bold text-rose-300 px-2 py-0.5 rounded-full bg-rose-500/20 border border-rose-500/30">
{t('tiktokOrders.missingBomCount', { count: order.missingBomCount })}
</span>
) : (
<span className="text-[10px] font-bold text-emerald-300 px-2 py-0.5 rounded-full bg-emerald-500/20 border border-emerald-500/30">
{t('tiktokOrders.bomComplete100', 'BOM ครบ 100%')}
</span>
)}
</div>
<div className="space-y-2 text-xs">
<div className="flex justify-between items-center text-slate-300">
<span>{t('tiktokOrders.netPayoutLabel', 'ยอดเงินที่ TikTok โอนให้')}</span>
<span className="font-mono font-bold text-white">฿{fmt(netSettlement)}</span>
</div>
<div className="flex justify-between items-center text-amber-300">
<span>{t('tiktokOrders.totalBomCostLabel', 'หัก ต้นทุนสินค้าตามสูตร BOM')}</span>
<span className="font-mono font-bold">
{totalBomCost > 0 ? `-฿${fmt(totalBomCost)}` : '฿0.00'}
</span>
</div>
<div className="pt-2.5 border-t border-white/10 flex justify-between items-center">
<div>
<p className="font-bold text-white text-sm">
{t('tiktokOrders.realNetProfitLabel', 'กำไรสุทธิแท้จริง (Real Net Profit)')}
</p>
<p className="text-[10px] text-slate-400">
{t('tiktokOrders.realNetMarginLabel', 'คิดเป็นอัตรากำไร')}:{' '}
<span className="font-mono font-bold text-slate-200">{realNetMargin.toFixed(1)}%</span>
</p>
</div>
<div className="text-right">
<span className={`text-2xl font-bold font-mono ${realNetProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
฿{fmt(realNetProfit)}
</span>
</div>
</div>
</div>
</div>
{/* Order Items Snippet */}
{order.items && order.items.length > 0 && (
<div className="p-4 rounded-2xl bg-black/20 border border-white/10 space-y-2.5">
<div className="flex items-center justify-between text-xs font-bold text-slate-400 pb-1 border-b border-white/5">
<div className="flex items-center gap-1.5">
<Package className="w-3.5 h-3.5 text-slate-400" />
<span>
{t('tiktokOrders.orderItemsTitle', 'รายการสินค้าในออเดอร์นี้')} ({order.items.length})
</span>
</div>
</div>
<div className="divide-y divide-white/5 max-h-48 overflow-y-auto pr-1">
{order.items.map((item: any, idx: number) => (
<div key={idx} className="py-2 flex items-center justify-between gap-3 text-xs">
<div className="flex-1 min-w-0">
<p className="text-slate-200 font-medium truncate">{item.productName}</p>
<p className="text-[11px] text-slate-400 font-mono">
{item.variation ? `[${item.variation}] ` : ''}จำนวน: {item.quantity} ชิ้น
{item.masterSku ? ` • Master: ${item.masterSku}` : ''}
</p>
</div>
<div className="text-right shrink-0 font-mono">
<p className="text-white font-bold">
฿{fmt(item.subtotalAfterDiscount || item.unitOriginalPrice * (item.quantity || 1))}
</p>
<p className="text-[10px] text-amber-400">BOM: ฿{fmt(item.totalBomCost || 0)}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
{/* Footer Actions */}
<div className="flex items-center justify-between px-6 py-4 border-t border-white/10 bg-white/[0.02]">
<Link
to={`/tiktok-order/${encodeURIComponent(order.orderId)}`}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-rose-500/15 hover:bg-rose-500/25 border border-rose-500/30 text-rose-300 font-bold text-xs transition-colors"
>
<span>{t('tiktokOrders.viewFullOrderDetail', 'เปิดดูคำสั่งซื้อฉบับเต็ม')}</span>
<ExternalLink className="w-3.5 h-3.5" />
</Link>
<button
onClick={onClose}
className="px-6 py-2 rounded-xl bg-white/10 hover:bg-white/15 text-white font-bold text-xs transition-colors"
>
{t('tiktokOrders.closeModal', 'ปิด')}
</button>
</div>
</div>
</div>,
document.body
);
}
+4 -4
View File
@@ -116,18 +116,18 @@
/* Animations */ /* Animations */
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); } from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: none; }
} }
@keyframes zoomIn { @keyframes zoomIn {
from { opacity: 0; transform: scale(0.95); } from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); } to { opacity: 1; transform: none; }
} }
.animate-fade-in { .animate-fade-in {
animation: fadeIn 0.4s ease-out forwards; animation: fadeIn 0.4s ease-out;
} }
.animate-zoom-in { .animate-zoom-in {
animation: zoomIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; animation: zoomIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
} }
@@ -454,6 +454,41 @@
"thAffiliate": "Affiliate (฿)", "thAffiliate": "Affiliate (฿)",
"thNetProfit": "Net Profit", "thNetProfit": "Net Profit",
"thActions": "Actions", "thActions": "Actions",
"viewFeeBreakdown": "Click to view TikTok fee breakdown",
"feeModalTitle": "TikTok Fee & Settlement Breakdown",
"feeModalSub": "Detailed breakdown of TikTok Shop commission, affiliate fees, and net payout",
"settlementPendingAlert": "This order does not have a TikTok Settlement statement yet",
"settlementPendingDesc": "Commissions and fees below are preliminary estimates from order data until a Settlement XLSX file is imported from TikTok Seller Center.",
"settlementCompleteBadge": "Settled & Transferred",
"settlementPendingBadge": "Pending Settlement",
"orderRevenueLabel": "Product Revenue",
"tiktokDeductionsLabel": "Total TikTok Deductions",
"netPayoutLabel": "Net Payout",
"cardBomCost": "Product Cost (BOM)",
"cardNetProfit": "Net Profit",
"profitOnSales": "Profit/Sales",
"profitOnCost": "Profit/Cost",
"feeBreakdownTitle": "TikTok Platform Fee Deductions Breakdown",
"tiktokCommissionFee": "TikTok Shop Commission Fee",
"tiktokCommissionDesc": "Platform category commission rate deduction",
"affiliateCommissionFee": "Creator Affiliate Commission Fee",
"affiliateCommissionDesc": "Commission paid to creators/affiliates",
"affiliatePartnerFee": "Affiliate Partner Fee (TAP)",
"orderFee": "Order / Transaction Fee",
"growthSupportFee": "Growth Support Fee",
"shippingBreakdown": "Shipping & Logistics Fees",
"shippingBreakdownDetail": "Actual Shipping ฿{{actual}} | Platform Subsidy -฿{{subsidy}}",
"shippingFreeForSeller": "Seller shipping covered",
"sellerDiscountLabel": "Seller Discount",
"bomProfitAnalysis": "Real Net Profit Analysis (After BOM Cost)",
"totalBomCostLabel": "Deduct: BOM Material Cost",
"realNetProfitLabel": "Real Net Profit",
"realNetMarginLabel": "Net Margin",
"orderItemsTitle": "Items in this Order",
"viewFullOrderDetail": "View Full Order Detail",
"closeModal": "Close",
"copyOrderId": "Copy Order ID",
"copied": "Copied",
"processingData": "Processing data...", "processingData": "Processing data...",
"noOrders": "No data available", "noOrders": "No data available",
"settledTransferred": "Settled ฿{{amount}}", "settledTransferred": "Settled ฿{{amount}}",
@@ -450,6 +450,41 @@
"thAffiliate": "Affiliate (฿)", "thAffiliate": "Affiliate (฿)",
"thNetProfit": "純利益", "thNetProfit": "純利益",
"thActions": "操作", "thActions": "操作",
"viewFeeBreakdown": "クリックしてTikTok手数料明細を表示",
"feeModalTitle": "TikTok 手数料・精算内訳 (Breakdown)",
"feeModalSub": "TikTok Shop手数料、アフィリエイト報酬、純利益の詳細内訳",
"settlementPendingAlert": "この注文にはまだTikTok精算明細データがありません",
"settlementPendingDesc": "以下の手数料と数値は注文データに基づく概算です。TikTokセラーセンターから精算XLSXがインポートされると確定値に更新されます。",
"settlementCompleteBadge": "振込完了 (Settled)",
"settlementPendingBadge": "精算待ち",
"orderRevenueLabel": "商品売上高",
"tiktokDeductionsLabel": "TikTok 控除合計",
"netPayoutLabel": "振込受取額 (Net Payout)",
"cardBomCost": "商品原価 (BOM)",
"cardNetProfit": "純利益",
"profitOnSales": "売上利益率",
"profitOnCost": "原価利益率",
"feeBreakdownTitle": "TikTok プラットフォーム控除の内訳",
"tiktokCommissionFee": "TikTok Shop プラットフォーム手数料 (Commission)",
"tiktokCommissionDesc": "カテゴリ別プラットフォーム手数料率による控除",
"affiliateCommissionFee": "クリエイターアフィリエイト報酬 (Affiliate)",
"affiliateCommissionDesc": "紹介・ライブ配信クリエイターへの支払報酬",
"affiliatePartnerFee": "パートナー手数料 (TAP)",
"orderFee": "注文・決済処理手数料 (Order Fee)",
"growthSupportFee": "成長支援手数料 (Growth Support)",
"shippingBreakdown": "配送・物流手数料内訳",
"shippingBreakdownDetail": "実送料 ฿{{actual}} | プラットフォーム補助 -฿{{subsidy}}",
"shippingFreeForSeller": "ショップ負担なし",
"sellerDiscountLabel": "ショップ割引",
"bomProfitAnalysis": "BOM原価控除後の実質純利益分析",
"totalBomCostLabel": "控除:BOM原料および包材原価",
"realNetProfitLabel": "実質純利益 (Real Net Profit)",
"realNetMarginLabel": "純利益率",
"orderItemsTitle": "注文内商品一覧",
"viewFullOrderDetail": "注文詳細画面を開く",
"closeModal": "閉じる",
"copyOrderId": "注文番号をコピー",
"copied": "コピー完了",
"processingData": "データを処理中...", "processingData": "データを処理中...",
"noOrders": "データがありません", "noOrders": "データがありません",
"settledTransferred": "振込済 ฿{{amount}}", "settledTransferred": "振込済 ฿{{amount}}",
@@ -454,6 +454,41 @@
"thAffiliate": "Affiliate (฿)", "thAffiliate": "Affiliate (฿)",
"thNetProfit": "กำไรสุทธิ", "thNetProfit": "กำไรสุทธิ",
"thActions": "การกระทำ", "thActions": "การกระทำ",
"viewFeeBreakdown": "คลิกดู Breakdown ค่าธรรมเนียม TikTok",
"feeModalTitle": "TikTok Fee & Settlement Breakdown",
"feeModalSub": "รายละเอียดค่าธรรมเนียม TikTok Shop, Affiliate และกำไรสุทธิ",
"settlementPendingAlert": "คำสั่งซื้อนี้ยังไม่มีข้อมูลใบแจ้งยอด Settlement",
"settlementPendingDesc": "ค่าคอมมิชชั่นและตัวเลขด้านล่างเป็นการคำนวณเบื้องต้นจากข้อมูล Order จนกว่าจะมีการ Import ไฟล์ Settlement XLSX จาก TikTok Seller Center",
"settlementCompleteBadge": "โอนเงินแล้ว",
"settlementPendingBadge": "รอ Settlement",
"orderRevenueLabel": "ยอดขายสินค้า",
"tiktokDeductionsLabel": "TikTok หักรวม",
"netPayoutLabel": "ยอดโอนสุทธิ",
"cardBomCost": "ต้นทุนสินค้า (BOM)",
"cardNetProfit": "กำไรสุทธิ",
"profitOnSales": "กำไร/ยอดขาย",
"profitOnCost": "กำไร/ทุน",
"feeBreakdownTitle": "Breakdown รายการหักค่าใช้จ่ายของ TikTok",
"tiktokCommissionFee": "ค่าคอมมิชชั่น TikTok Shop (Commission Fee)",
"tiktokCommissionDesc": "หักตามอัตราหมวดหมู่สินค้าของแพลตฟอร์ม",
"affiliateCommissionFee": "ค่าคอมมิชชั่น Creator แอฟฟิลิเอต (Affiliate)",
"affiliateCommissionDesc": "จ่ายให้ครีเอเตอร์/นายหน้าที่แปะลิงก์หรือไลฟ์",
"affiliatePartnerFee": "ค่าคอมมิชชั่น Partner แอฟฟิลิเอต (TAP)",
"orderFee": "ค่าธรรมเนียมคำสั่งซื้อ/ธุรกรรม (Order Fee)",
"growthSupportFee": "ค่าธรรมเนียมสนับสนุนการเติบโต (Growth Support)",
"shippingBreakdown": "ค่าจัดส่งขนส่ง",
"shippingBreakdownDetail": "ค่าส่งจริง ฿{{actual}} | TikTok อุดหนุน -฿{{subsidy}}",
"shippingFreeForSeller": "ร้านค้าไม่เสียค่าส่ง",
"sellerDiscountLabel": "ส่วนลดจากร้านค้า",
"bomProfitAnalysis": "วิเคราะห์กำไรสุทธิจริงหลังหักต้นทุน BOM",
"totalBomCostLabel": "หัก ต้นทุนสินค้าตามสูตร BOM",
"realNetProfitLabel": "กำไรสุทธิแท้จริง (Real Net Profit)",
"realNetMarginLabel": "คิดเป็นอัตรากำไร",
"orderItemsTitle": "รายการสินค้าในออเดอร์นี้",
"viewFullOrderDetail": "เปิดดูคำสั่งซื้อฉบับเต็ม",
"closeModal": "ปิด",
"copyOrderId": "คัดลอก Order ID",
"copied": "คัดลอกแล้ว",
"processingData": "กำลังประมวลผลข้อมูล...", "processingData": "กำลังประมวลผลข้อมูล...",
"noOrders": "ยังไม่มีข้อมูล", "noOrders": "ยังไม่มีข้อมูล",
"settledTransferred": "โอนแล้ว ฿{{amount}}", "settledTransferred": "โอนแล้ว ฿{{amount}}",
@@ -450,6 +450,41 @@
"thAffiliate": "Affiliate (฿)", "thAffiliate": "Affiliate (฿)",
"thNetProfit": "净利润", "thNetProfit": "净利润",
"thActions": "操作", "thActions": "操作",
"viewFeeBreakdown": "点击查看 TikTok 费用明细",
"feeModalTitle": "TikTok 费用与结算明细",
"feeModalSub": "TikTok Shop 佣金、达人佣金与净利润详细拆解",
"settlementPendingAlert": "该订单暂无 TikTok 结算对账单数据",
"settlementPendingDesc": "以下佣金与费用为基于订单数据的预估,待从 TikTok 卖家中心导入结算 XLSX 文件后更新。",
"settlementCompleteBadge": "已完成打款",
"settlementPendingBadge": "等待结算",
"orderRevenueLabel": "商品销售额",
"tiktokDeductionsLabel": "TikTok 扣款合计",
"netPayoutLabel": "最终打款金额",
"cardBomCost": "商品成本 (BOM)",
"cardNetProfit": "净利润",
"profitOnSales": "利润率(销售)",
"profitOnCost": "利润率(成本)",
"feeBreakdownTitle": "TikTok 平台扣款明细",
"tiktokCommissionFee": "TikTok Shop 平台佣金 (Commission Fee)",
"tiktokCommissionDesc": "按平台品类佣金率扣除",
"affiliateCommissionFee": "达人/分销佣金 (Affiliate Commission)",
"affiliateCommissionDesc": "支付给带货达人的佣金",
"affiliatePartnerFee": "机构/合作伙伴佣金 (TAP)",
"orderFee": "订单/交易手续费 (Order Fee)",
"growthSupportFee": "成长支持费 (Growth Support Fee)",
"shippingBreakdown": "物流运费明细",
"shippingBreakdownDetail": "实际运费 ฿{{actual}} | 平台补贴 -฿{{subsidy}}",
"shippingFreeForSeller": "商家免运费",
"sellerDiscountLabel": "商家折扣",
"bomProfitAnalysis": "扣除 BOM 成本后真实净利润分析",
"totalBomCostLabel": "扣除:BOM 原料及包装成本",
"realNetProfitLabel": "真实净利润 (Real Net Profit)",
"realNetMarginLabel": "净利润率",
"orderItemsTitle": "订单内商品列表",
"viewFullOrderDetail": "查看订单完整详情",
"closeModal": "关闭",
"copyOrderId": "复制订单号",
"copied": "已复制",
"processingData": "正在处理数据...", "processingData": "正在处理数据...",
"noOrders": "暂无数据", "noOrders": "暂无数据",
"settledTransferred": "已打款 ฿{{amount}}", "settledTransferred": "已打款 ฿{{amount}}",
+58 -4
View File
@@ -11,9 +11,13 @@ import {
ArrowLeft, ArrowLeft,
Coins, Coins,
Layers, Layers,
Calculator Calculator,
Bot,
FileJson
} from 'lucide-react' } from 'lucide-react'
import { API_BASE, type Product } from '@homaroma/types' import { API_BASE, type Product } from '@homaroma/types'
import ProductBomPromptModal from '../components/ProductBomPromptModal'
import ProductBomImportModal from '../components/ProductBomImportModal'
function fmtMoney(val: number | null | undefined): string { function fmtMoney(val: number | null | undefined): string {
if (val == null || isNaN(val)) return '฿0.00' if (val == null || isNaN(val)) return '฿0.00'
@@ -38,6 +42,14 @@ export default function ProductBomPage() {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false) const [success, setSuccess] = useState(false)
const [isPromptModalOpen, setIsPromptModalOpen] = useState(false)
const [isImportModalOpen, setIsImportModalOpen] = useState(false)
const handleApplyBomJson = (updatedVariants: any[]) => {
setVariants(updatedVariants)
setSuccess(true)
setTimeout(() => setSuccess(false), 3500)
}
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
@@ -143,9 +155,14 @@ export default function ProductBomPage() {
} }
const productName = const productName =
typeof product.name === 'string' (typeof product.name === 'string' && product.name.trim())
? product.name ? product.name.trim()
: (product.name as any)?.th || (product.name as any)?.en || 'Unknown Product' : (product.name as any)?.th ||
(product.name as any)?.en ||
(typeof product.name === 'object' && Object.values(product.name || {}).find((v) => typeof v === 'string' && (v as string).trim()) as string) ||
((product as any).brand?.name
? `${(product as any).brand?.name} ${(product as any).category?.name || ''}`.trim()
: `Product #${product.id}`)
// Summary Metrics Across All Variants // Summary Metrics Across All Variants
const totalVariantsCount = variants.length const totalVariantsCount = variants.length
@@ -177,6 +194,24 @@ export default function ProductBomPage() {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button
type="button"
onClick={() => setIsPromptModalOpen(true)}
className="px-4 py-2.5 rounded-xl bg-purple-500/10 hover:bg-purple-500/20 text-purple-300 border border-purple-500/30 text-sm font-bold flex items-center gap-2 transition-all shadow-sm"
title="สร้าง Prompt สำหรับส่งให้ LLM คิดและตอบกลับเป็นสูตร BOM"
>
<Bot className="w-4 h-4 text-purple-400" />
<span>Get Prompt</span>
</button>
<button
type="button"
onClick={() => setIsImportModalOpen(true)}
className="px-4 py-2.5 rounded-xl bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 text-sm font-bold flex items-center gap-2 transition-all shadow-sm"
title="วาง JSON จาก LLM เพื่อกรอกสูตร BOM ลงในตารางอัตโนมัติ"
>
<FileJson className="w-4 h-4 text-cyan-400" />
<span>Import BOM JSON</span>
</button>
<button <button
onClick={() => navigate('/products')} onClick={() => navigate('/products')}
className="px-5 py-2.5 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 text-sm font-bold text-slate-300 transition-all" className="px-5 py-2.5 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 text-sm font-bold text-slate-300 transition-all"
@@ -534,6 +569,25 @@ export default function ProductBomPage() {
}) })
)} )}
</div> </div>
{/* LLM Prompt Generator Modal */}
<ProductBomPromptModal
isOpen={isPromptModalOpen}
onClose={() => setIsPromptModalOpen(false)}
productId={id || ''}
productName={productName}
variants={variants}
rawMaterials={rawMaterials}
/>
{/* LLM JSON Import Modal */}
<ProductBomImportModal
isOpen={isImportModalOpen}
onClose={() => setIsImportModalOpen(false)}
variants={variants}
rawMaterials={rawMaterials}
onApply={handleApplyBomJson}
/>
</div> </div>
) )
} }
@@ -0,0 +1,431 @@
import { useState, useEffect, type FormEvent } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import {
ArrowLeft,
Save,
Trash2,
Loader2,
PackageOpen,
Boxes,
ExternalLink,
Layers,
Sparkles,
CheckCircle2,
AlertCircle
} from 'lucide-react';
import { API_BASE } from '@homaroma/types';
const COMMON_UNITS = [
{ label: 'ชิ้น (piece)', value: 'piece' },
{ label: 'กก. (kg)', value: 'kg' },
{ label: 'กรัม (g)', value: 'g' },
{ label: 'ลิตร (liter)', value: 'liter' },
{ label: 'มล. (ml)', value: 'ml' },
{ label: 'แพ็ค (pack)', value: 'pack' },
{ label: 'กล่อง (box)', value: 'box' },
{ label: 'หลอด (tube)', value: 'tube' },
];
export default function RawMaterialDetailPage() {
const { id } = useParams<{ id: string }>();
const isNew = !id || id === 'new';
const navigate = useNavigate();
const [loading, setLoading] = useState(!isNew);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [toast, setToast] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Form Fields
const [name, setName] = useState('');
const [unit, setUnit] = useState('piece');
const [pricePerUnit, setPricePerUnit] = useState<number>(0);
const [materialData, setMaterialData] = useState<any>(null);
useEffect(() => {
if (isNew) return;
setLoading(true);
fetch(`${API_BASE}/raw-materials/${id}`)
.then(async (res) => {
if (!res.ok) throw new Error('ไม่พบข้อมูลวัตถุดิบ');
return await res.json();
})
.then((data) => {
setMaterialData(data);
setName(data.name || '');
setUnit(data.unit || 'piece');
setPricePerUnit(Number(data.pricePerUnit) || 0);
})
.catch((err) => {
console.error(err);
setToast({ type: 'error', text: err.message || 'เกิดข้อผิดพลาดในการโหลดข้อมูล' });
})
.finally(() => setLoading(false));
}, [id, isNew]);
const showToast = (type: 'success' | 'error', text: string) => {
setToast({ type, text });
setTimeout(() => setToast(null), 3000);
};
const handleSave = async (e?: FormEvent) => {
if (e) e.preventDefault();
if (!name.trim()) {
showToast('error', 'กรุณาระบุชื่อวัตถุดิบ');
return;
}
if (!unit.trim()) {
showToast('error', 'กรุณาระบุหน่วยนับ');
return;
}
if (pricePerUnit < 0) {
showToast('error', 'ราคาต้นทุนต่อหน่วยต้องไม่ติดลบ');
return;
}
setSaving(true);
try {
const payload = {
name: name.trim(),
unit: unit.trim(),
pricePerUnit: Number(pricePerUnit) || 0,
};
const url = isNew ? `${API_BASE}/raw-materials` : `${API_BASE}/raw-materials/${id}`;
const method = isNew ? 'POST' : 'PUT';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || 'บันทึกข้อมูลไม่สำเร็จ');
}
showToast('success', isNew ? 'สร้างวัตถุดิบใหม่สำเร็จ' : 'บันทึกการแก้ไขสำเร็จ');
if (isNew && data.id) {
setTimeout(() => {
navigate(`/raw-materials/${data.id}`, { replace: true });
}, 800);
}
} catch (err: any) {
showToast('error', err.message || 'เกิดข้อผิดพลาดในการบันทึก');
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบวัตถุดิบ "${name}"?`)) return;
setDeleting(true);
try {
const res = await fetch(`${API_BASE}/raw-materials/${id}`, {
method: 'DELETE',
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || 'ไม่สามารถลบวัตถุดิบนี้ได้');
}
navigate('/raw-materials', { replace: true });
} catch (err: any) {
showToast('error', err.message || 'เกิดข้อผิดพลาดในการลบ');
setDeleting(false);
}
};
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] space-y-4">
<Loader2 className="w-8 h-8 text-cyan-400 animate-spin" />
<p className="text-slate-400 text-sm">กำลังโหลดข้อมูลวัตถุดิบ...</p>
</div>
);
}
const bomItems = materialData?.bomItems || [];
return (
<div className="space-y-8 animate-fade-in max-w-5xl mx-auto pb-16">
{/* Toast Notification */}
{toast && (
<div
className={`fixed bottom-6 right-6 z-50 p-4 rounded-2xl border shadow-2xl flex items-center gap-3 animate-slide-up ${
toast.type === 'success'
? 'bg-emerald-950/90 border-emerald-500/40 text-emerald-200'
: 'bg-rose-950/90 border-rose-500/40 text-rose-200'
}`}
>
{toast.type === 'success' ? (
<CheckCircle2 className="w-5 h-5 text-emerald-400" />
) : (
<AlertCircle className="w-5 h-5 text-rose-400" />
)}
<span className="text-sm font-medium">{toast.text}</span>
</div>
)}
{/* Top Breadcrumb & Navigation */}
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 text-xs text-slate-400">
<Link to="/raw-materials" className="hover:text-white transition-colors flex items-center gap-1">
<PackageOpen className="w-3.5 h-3.5" />
<span>Raw Materials</span>
</Link>
<span>/</span>
<span className="text-cyan-400 font-medium">
{isNew ? 'เพิ่มวัตถุดิบใหม่' : name || `ID #${id}`}
</span>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => navigate('/raw-materials')}
className="p-2.5 rounded-2xl bg-white/5 border border-white/10 hover:bg-white/10 text-slate-300 hover:text-white transition-all shadow-sm"
title="ย้อนกลับไปหน้ารายการ"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl md:text-3xl font-bold text-white flex items-center gap-2.5">
{isNew ? 'เพิ่มวัตถุดิบใหม่' : `แก้ไขวัตถุดิบ`}
{!isNew && (
<span className="text-xs px-2.5 py-1 rounded-full bg-cyan-500/10 text-cyan-300 border border-cyan-500/20 font-mono">
ID: #{id}
</span>
)}
</h1>
<p className="text-xs md:text-sm text-slate-400">
{isNew
? 'สร้างรายการวัตถุดิบสำหรับใช้คำนวณต้นทุนสินค้าและผูกสูตรผลิต (BOM)'
: 'กำหนดข้อมูลราคาต้นทุนและหน่วยนับสำหรับใช้ในสูตรการผลิตสินค้า'}
</p>
</div>
</div>
<div className="flex items-center gap-3">
{!isNew && (
<button
type="button"
onClick={handleDelete}
disabled={deleting || saving}
className="px-4 py-2.5 rounded-xl border border-rose-500/30 text-rose-400 hover:bg-rose-500/10 hover:border-rose-500/50 font-medium text-sm flex items-center gap-2 transition-all disabled:opacity-50"
>
{deleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
<span>ลบวัตถุดิบ</span>
</button>
)}
<button
type="button"
onClick={() => handleSave()}
disabled={saving}
className="btn-primary px-6 py-2.5 flex items-center gap-2 text-sm shadow-lg shadow-cyan-500/10"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
<span>{saving ? 'กำลังบันทึก...' : 'บันทึกข้อมูล'}</span>
</button>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Column: Form Details (2 cols) */}
<div className="lg:col-span-2 space-y-6">
<form onSubmit={handleSave} className="admin-card space-y-6 border-white/10 shadow-2xl p-6 md:p-8">
<div className="flex items-center gap-2 pb-4 border-b border-white/5">
<Sparkles className="w-4 h-4 text-cyan-400" />
<h2 className="text-base font-bold text-white uppercase tracking-wider text-xs">
ข้อมูลวัตถุดิบ (Material Information)
</h2>
</div>
{/* Material Name */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center justify-between">
<span>ชื่อวัตถุดิบ (Material Name) <span className="text-rose-400">*</span></span>
<span className="text-[10px] text-slate-500 font-normal">ห้ามซ้ำกับรายการอื่นในระบบ</span>
</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="เช่น ขวดแก้ว 100ml, น้ำมันหอมระเหย ลาเวนเดอร์, ก้านไม้ไฟเบอร์"
className="admin-input w-full text-sm"
/>
</div>
{/* Unit Selection */}
<div className="space-y-3">
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider">
หน่วยนับ (Unit of Measurement) <span className="text-rose-400">*</span>
</label>
<input
type="text"
required
value={unit}
onChange={(e) => setUnit(e.target.value)}
placeholder="เช่น piece, kg, liter, pack, ml"
className="admin-input w-full text-sm"
/>
{/* Quick Preset Unit Chips */}
<div className="space-y-1.5">
<span className="text-[11px] text-slate-400">เลือกหน่วยที่ใช้บ่อย:</span>
<div className="flex flex-wrap gap-2">
{COMMON_UNITS.map((u) => (
<button
key={u.value}
type="button"
onClick={() => setUnit(u.value)}
className={`text-xs px-2.5 py-1 rounded-lg border transition-all ${
unit === u.value
? 'bg-cyan-500/20 border-cyan-500/40 text-cyan-300 font-bold'
: 'bg-white/5 border-white/5 text-slate-400 hover:text-white hover:bg-white/10'
}`}
>
{u.label}
</button>
))}
</div>
</div>
</div>
{/* Cost Price per Unit */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center justify-between">
<span>ต้นทุนต่อหน่วย (Cost per Unit) <span className="text-rose-400">*</span></span>
<span className="text-[10px] text-slate-500 font-normal">ใช้ในการคำนวณสูตร BOM สินค้า</span>
</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 font-bold text-sm">
฿
</span>
<input
type="number"
step="0.01"
min="0"
required
value={pricePerUnit === 0 ? '' : pricePerUnit}
onChange={(e) => setPricePerUnit(parseFloat(e.target.value) || 0)}
placeholder="0.00"
className="admin-input w-full pl-9 font-mono text-emerald-400 font-bold text-base"
/>
</div>
<p className="text-[11px] text-slate-500">
ระบุราคาต้นทุนเฉลี่ยต่อ 1 {unit || 'หน่วย'} เช่น 18.50 บาท / ขวด
</p>
</div>
</form>
</div>
{/* Right Column: BOM Linked Products & Summary (1 col) */}
<div className="space-y-6">
{/* Summary Card */}
<div className="admin-card border-white/10 shadow-xl p-6 space-y-4">
<div className="flex items-center gap-2 pb-3 border-b border-white/5">
<Layers className="w-4 h-4 text-cyan-400" />
<h3 className="text-xs font-bold text-white uppercase tracking-wider">
สรุปภาพรวม (Overview)
</h3>
</div>
<div className="space-y-3 text-xs">
<div className="flex justify-between py-1.5 border-b border-white/5 text-slate-300">
<span className="text-slate-400">สถานะ:</span>
<span className="font-semibold text-emerald-400">พร้อมใช้งาน</span>
</div>
<div className="flex justify-between py-1.5 border-b border-white/5 text-slate-300">
<span className="text-slate-400">หน่วยนับ:</span>
<span className="font-mono font-bold text-white">{unit || '-'}</span>
</div>
<div className="flex justify-between py-1.5 border-b border-white/5 text-slate-300">
<span className="text-slate-400">ต้นทุน / หน่วย:</span>
<span className="font-mono font-bold text-emerald-400">
฿{Number(pricePerUnit || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })}
</span>
</div>
{!isNew && materialData?.updatedAt && (
<div className="flex justify-between py-1.5 text-slate-300">
<span className="text-slate-400">อัปเดตล่าสุด:</span>
<span className="text-slate-400">
{new Date(materialData.updatedAt).toLocaleDateString('th-TH')}
</span>
</div>
)}
</div>
</div>
{/* BOM Usage Section */}
{!isNew && (
<div className="admin-card border-white/10 shadow-xl p-6 space-y-4">
<div className="flex items-center justify-between pb-3 border-b border-white/5">
<div className="flex items-center gap-2">
<Boxes className="w-4 h-4 text-cyan-400" />
<h3 className="text-xs font-bold text-white uppercase tracking-wider">
สูตรสินค้าที่ใช้วัตถุดิบนี้ ({bomItems.length})
</h3>
</div>
<Link
to="/products/bom"
className="text-[11px] text-cyan-400 hover:text-cyan-300 flex items-center gap-1 transition-colors"
>
<span>จัดการ BOM</span>
<ExternalLink className="w-3 h-3" />
</Link>
</div>
{bomItems.length === 0 ? (
<div className="py-6 text-center space-y-2">
<Boxes className="w-8 h-8 text-slate-700 mx-auto" />
<p className="text-xs text-slate-400 italic">
ยังไม่มีสูตรสินค้า (BOM) ที่ผูกกับวัตถุดิบนี้
</p>
<p className="text-[11px] text-slate-500">
สามารถไปผูกวัตถุดิบเข้ากับ Variant สินค้าได้ที่เมนู "BOM สูตรผลิต"
</p>
</div>
) : (
<div className="space-y-2.5 max-h-[300px] overflow-y-auto pr-1">
{bomItems.map((item: any, idx: number) => {
const product = item.productVariant?.product;
const variant = item.productVariant;
const variantLabel = [variant?.option1, variant?.option2, variant?.option3]
.filter(Boolean)
.join(' / ');
return (
<div
key={item.id || idx}
className="p-3 rounded-xl bg-white/[0.02] border border-white/5 space-y-1 hover:border-white/10 transition-all"
>
<p className="text-xs font-bold text-white line-clamp-1">
{product?.name || `Product #${variant?.productId}`}
</p>
<div className="flex items-center justify-between text-[11px] text-slate-400">
<span>{variantLabel ? `ตัวเลือก: ${variantLabel}` : 'Base Variant'}</span>
<span className="font-mono text-cyan-400 font-bold">
{item.quantity} {unit}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
+336 -168
View File
@@ -1,227 +1,395 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useMemo } from 'react';
import { Plus, Edit3, Trash2, Loader2, PackageOpen } from 'lucide-react' import { useNavigate, Link } from 'react-router-dom';
import { API_BASE, type RawMaterial } from '@homaroma/types' import {
Plus,
Edit3,
Trash2,
PackageOpen,
Search,
FileJson,
Boxes,
Layers,
Sparkles,
Check,
X,
Loader2,
Pencil,
} from 'lucide-react';
import { API_BASE, type RawMaterial } from '@homaroma/types';
import RawMaterialBulkJsonModal from '../components/RawMaterialBulkJsonModal';
export default function RawMaterialsPage() { export default function RawMaterialsPage() {
const [materials, setMaterials] = useState<RawMaterial[]>([]) const navigate = useNavigate();
const [loading, setLoading] = useState(true) const [materials, setMaterials] = useState<RawMaterial[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [isBulkModalOpen, setIsBulkModalOpen] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false) // Inline Price Editing State
const [editingId, setEditingId] = useState<number | null>(null) const [editingId, setEditingId] = useState<number | null>(null);
const [editingPrice, setEditingPrice] = useState<string>('');
const [savingId, setSavingId] = useState<number | null>(null);
const [name, setName] = useState('') const startEditing = (item: RawMaterial) => {
const [unit, setUnit] = useState('') setEditingId(item.id);
const [pricePerUnit, setPricePerUnit] = useState<number>(0) setEditingPrice(String(item.pricePerUnit ?? ''));
const [saving, setSaving] = useState(false) };
const cancelEditing = () => {
setEditingId(null);
setEditingPrice('');
};
const handleSavePrice = async (item: RawMaterial) => {
const val = parseFloat(editingPrice);
if (isNaN(val) || val < 0) {
alert('กรุณาระบุราคาต้นทุนให้ถูกต้อง (ต้องเป็นตัวเลขมากกว่าหรือเท่ากับ 0)');
return;
}
setSavingId(item.id);
try {
const res = await fetch(`${API_BASE}/raw-materials/${item.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pricePerUnit: val,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || 'บันทึกราคาไม่สำเร็จ');
}
setMaterials((prev) =>
prev.map((m) => (m.id === item.id ? { ...m, pricePerUnit: val } : m))
);
setEditingId(null);
setEditingPrice('');
} catch (err: any) {
console.error(err);
alert(err.message || 'เกิดข้อผิดพลาดในการบันทึกราคา');
} finally {
setSavingId(null);
}
};
const fetchMaterials = async () => { const fetchMaterials = async () => {
setLoading(true) setLoading(true);
try { try {
const res = await fetch(`${API_BASE}/raw-materials`) const res = await fetch(`${API_BASE}/raw-materials`);
if (res.ok) { if (res.ok) {
setMaterials(await res.json()) setMaterials(await res.json());
} }
} catch (err) { } catch (err) {
console.error('Failed to fetch raw materials', err) console.error('Failed to fetch raw materials', err);
} finally { } finally {
setLoading(false) setLoading(false);
} }
} };
useEffect(() => { useEffect(() => {
fetchMaterials() fetchMaterials();
}, []) }, []);
const handleOpenModal = (material?: RawMaterial) => { const handleDelete = async (id: number, name: string) => {
if (material) { if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบวัตถุดิบ "${name}"?`)) return;
setEditingId(material.id)
setName(material.name)
setUnit(material.unit)
setPricePerUnit(material.pricePerUnit)
} else {
setEditingId(null)
setName('')
setUnit('')
setPricePerUnit(0)
}
setIsModalOpen(true)
}
const handleSave = async () => {
if (!name || !unit || pricePerUnit <= 0) return alert('Please fill all fields')
setSaving(true)
try { try {
const payload = { name, unit, pricePerUnit } const res = await fetch(`${API_BASE}/raw-materials/${id}`, { method: 'DELETE' });
const url = editingId
? `${API_BASE}/raw-materials/${editingId}`
: `${API_BASE}/raw-materials`
const res = await fetch(url, {
method: editingId ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
if (res.ok) { if (res.ok) {
setIsModalOpen(false) fetchMaterials();
fetchMaterials()
} else { } else {
alert('Failed to save raw material') const data = await res.json();
alert(data.message || 'ไม่สามารถลบวัตถุดิบนี้ได้');
} }
} catch (err) { } catch (err) {
console.error(err) console.error(err);
} finally {
setSaving(false)
} }
} };
const handleDelete = async (id: number) => { // Filtered materials by search query
if (!confirm('Are you sure you want to delete this raw material?')) return const filteredMaterials = useMemo(() => {
if (!searchQuery.trim()) return materials;
const q = searchQuery.toLowerCase().trim();
return materials.filter(
(m) =>
m.name.toLowerCase().includes(q) ||
m.unit.toLowerCase().includes(q) ||
String(m.id).includes(q)
);
}, [materials, searchQuery]);
try { // Aggregate metrics
const res = await fetch(`${API_BASE}/raw-materials/${id}`, { method: 'DELETE' }) const metrics = useMemo(() => {
if (res.ok) { const totalCount = materials.length;
fetchMaterials() const avgCost = totalCount > 0
} ? materials.reduce((sum, m) => sum + (Number(m.pricePerUnit) || 0), 0) / totalCount
} catch (err) { : 0;
console.error(err) const units = new Set(materials.map((m) => m.unit.toLowerCase())).size;
} return { totalCount, avgCost, units };
} }, [materials]);
return ( return (
<div className="space-y-8 animate-fade-in"> <div className="space-y-8 animate-fade-in pb-12">
{/* Page Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-3xl font-bold">Raw Materials (วัตถุดิบ)</h1> <div className="flex items-center gap-3">
<p className="text-slate-500 text-sm">Manage inventory and costs for BOM calculation.</p> <h1 className="text-3xl font-bold text-white">Raw Materials (วัตถุดิบ)</h1>
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-cyan-500/10 text-cyan-400 border border-cyan-500/20">
{materials.length} รายการ
</span>
</div>
<p className="text-slate-400 text-sm">
จัดการรายการวัตถุดิบ หน่วยนับ และต้นทุนสำหรับผูกสูตรผลิตสินค้า (BOM)
</p>
</div>
<div className="flex items-center gap-3">
{/* Bulk JSON Import / Export Button */}
<button
type="button"
onClick={() => setIsBulkModalOpen(true)}
className="px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 font-medium text-sm flex items-center gap-2 transition-all shadow-sm"
>
<FileJson className="w-4 h-4 text-cyan-400" />
<span>Import / Export JSON</span>
</button>
{/* Add Material (Navigates to Full Page Route) */}
<button
type="button"
onClick={() => navigate('/raw-materials/new')}
className="btn-primary flex items-center gap-2 text-sm shadow-lg shadow-cyan-500/10"
>
<Plus className="w-4 h-4" />
<span>เพิ่มวัตถุดิบ</span>
</button>
</div> </div>
<button onClick={() => handleOpenModal()} className="btn-primary flex items-center gap-2">
<Plus className="w-4 h-4" />
Add Material
</button>
</div> </div>
{/* Metrics Row */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="admin-card p-5 border-white/5 bg-night-900/60 flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400 shrink-0">
<PackageOpen className="w-6 h-6" />
</div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">วัตถุดิบทั้งหมด</p>
<p className="text-2xl font-bold text-white font-mono mt-0.5">{metrics.totalCount} <span className="text-xs font-normal text-slate-400">รายการ</span></p>
</div>
</div>
<div className="admin-card p-5 border-white/5 bg-night-900/60 flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400 shrink-0">
<Sparkles className="w-6 h-6" />
</div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">ต้นทุนเฉลี่ย / หน่วย</p>
<p className="text-2xl font-bold text-emerald-400 font-mono mt-0.5">
฿{metrics.avgCost.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
</div>
<div className="admin-card p-5 border-white/5 bg-night-900/60 flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-400 shrink-0">
<Layers className="w-6 h-6" />
</div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">ประเภทหน่วยนับ</p>
<p className="text-2xl font-bold text-white font-mono mt-0.5">{metrics.units} <span className="text-xs font-normal text-slate-400">แบบ</span></p>
</div>
</div>
</div>
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="relative w-full sm:w-80">
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="ค้นหาตามชื่อ หรือ หน่วยนับ..."
className="admin-input w-full pl-10 text-xs"
/>
</div>
<div className="text-xs text-slate-400">
แสดง <strong>{filteredMaterials.length}</strong> จากทั้งหมด {materials.length} รายการ
</div>
</div>
{/* Data Table */}
<div className="admin-card !p-0 overflow-hidden border-white/10 shadow-2xl"> <div className="admin-card !p-0 overflow-hidden border-white/10 shadow-2xl">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left border-collapse"> <table className="w-full text-left border-collapse">
<thead> <thead>
<tr className="bg-white/[0.02] border-b border-white/5"> <tr className="bg-white/[0.02] border-b border-white/5">
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Material Name</th> <th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 w-16">ID</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Unit</th> <th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">ชื่อวัตถุดิบ (Material Name)</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Cost Per Unit</th> <th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">หน่วยนับ (Unit)</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 text-right">Actions</th> <th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">ต้นทุนต่อหน่วย (Cost)</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 text-right">การจัดการ</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/[0.03]"> <tbody className="divide-y divide-white/[0.03]">
{loading ? ( {loading ? (
Array.from({ length: 3 }).map((_, i) => ( Array.from({ length: 4 }).map((_, i) => (
<tr key={i}> <tr key={i}>
<td colSpan={4} className="px-6 py-8"> <td colSpan={5} className="px-6 py-8">
<div className="h-6 w-full bg-white/5 animate-pulse rounded-lg" /> <div className="h-6 w-full bg-white/5 animate-pulse rounded-lg" />
</td> </td>
</tr> </tr>
)) ))
) : materials.length === 0 ? ( ) : filteredMaterials.length === 0 ? (
<tr> <tr>
<td colSpan={4} className="px-6 py-20 text-center space-y-3"> <td colSpan={5} className="px-6 py-20 text-center space-y-3">
<PackageOpen className="w-10 h-10 text-slate-700 mx-auto" /> <PackageOpen className="w-10 h-10 text-slate-700 mx-auto" />
<p className="text-slate-500 font-medium italic">No raw materials found.</p> <p className="text-slate-400 font-medium">
</td> {searchQuery ? 'ไม่พบวัตถุดิบที่ตรงกับคำค้นหา' : 'ยังไม่มีข้อมูลวัตถุดิบในระบบ'}
</tr> </p>
) : materials.map((item) => ( <div className="flex items-center justify-center gap-3 pt-2">
<tr key={item.id} className="hover:bg-white/[0.01] transition-colors"> <button
<td className="px-6 py-4"> type="button"
<p className="text-sm font-bold text-white">{item.name}</p> onClick={() => setIsBulkModalOpen(true)}
</td> className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-xs font-medium border border-white/10 transition-colors"
<td className="px-6 py-4"> >
<span className="inline-block px-2 py-1 rounded-md bg-night-950 border border-white/5 text-slate-400 text-[10px] uppercase font-bold tracking-wider"> นำเข้าผ่าน JSON
{item.unit}
</span>
</td>
<td className="px-6 py-4">
<p className="text-sm font-bold text-emerald-400">฿{item.pricePerUnit.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button onClick={() => handleOpenModal(item)} className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-cyan-glow transition-all">
<Edit3 className="w-4 h-4" />
</button> </button>
<button onClick={() => handleDelete(item.id)} className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-rose-500 transition-all"> <button
<Trash2 className="w-4 h-4" /> type="button"
onClick={() => navigate('/raw-materials/new')}
className="btn-primary text-xs px-4 py-2"
>
เพิ่มวัตถุดิบใหม่
</button> </button>
</div> </div>
</td> </td>
</tr> </tr>
))} ) : (
filteredMaterials.map((item) => (
<tr key={item.id} className="hover:bg-white/[0.015] transition-colors group">
<td className="px-6 py-4 text-xs font-mono text-slate-500">
#{item.id}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400 shrink-0">
<Boxes className="w-4 h-4" />
</div>
<Link
to={`/raw-materials/${item.id}`}
className="text-sm font-bold text-white hover:text-cyan-400 transition-colors"
>
{item.name}
</Link>
</div>
</td>
<td className="px-6 py-4">
<span className="inline-block px-2.5 py-1 rounded-md bg-night-950 border border-white/5 text-slate-300 text-xs font-mono font-medium">
{item.unit}
</span>
</td>
<td className="px-6 py-3">
{editingId === item.id ? (
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
<div className="relative">
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-xs text-slate-500 font-mono font-bold">
฿
</span>
<input
type="number"
step="0.01"
min="0"
autoFocus
value={editingPrice}
onChange={(e) => setEditingPrice(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSavePrice(item);
if (e.key === 'Escape') cancelEditing();
}}
disabled={savingId === item.id}
className="w-28 pl-6 pr-2 py-1 text-sm font-bold font-mono text-emerald-400 bg-black/80 border border-cyan-500/60 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500/40 shadow-inner"
placeholder="0.00"
/>
</div>
<button
type="button"
disabled={savingId === item.id}
onClick={() => handleSavePrice(item)}
className="p-1.5 rounded-lg bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-300 border border-emerald-500/40 transition-all disabled:opacity-50"
title="บันทึกราคา (Enter)"
>
{savingId === item.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Check className="w-3.5 h-3.5" />
)}
</button>
<button
type="button"
disabled={savingId === item.id}
onClick={cancelEditing}
className="p-1.5 rounded-lg bg-white/5 hover:bg-rose-500/20 text-slate-400 hover:text-rose-300 border border-white/10 transition-all disabled:opacity-50"
title="ยกเลิก (Esc)"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
) : (
<div
onClick={() => startEditing(item)}
className="group/price inline-flex items-center gap-2 cursor-pointer px-2.5 py-1 -ml-2.5 rounded-lg hover:bg-white/5 border border-transparent hover:border-white/10 transition-all"
title="คลิกที่นี่เพื่อแก้ไขราคาต่อหน่วย (Inline Edit)"
>
<p className="text-sm font-bold text-emerald-400 font-mono">
฿{Number(item.pricePerUnit).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
<Pencil className="w-3 h-3 text-slate-500 opacity-0 group-hover/price:opacity-100 transition-opacity" />
</div>
)}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
{/* Edit Button navigates to Full Page Route */}
<button
type="button"
onClick={() => navigate(`/raw-materials/${item.id}`)}
className="px-3 py-1.5 rounded-lg bg-white/5 hover:bg-cyan-500/10 text-slate-400 hover:text-cyan-400 border border-white/5 hover:border-cyan-500/20 text-xs font-medium transition-all flex items-center gap-1.5"
title="แก้ไขรายละเอียด"
>
<Edit3 className="w-3.5 h-3.5" />
<span>แก้ไข</span>
</button>
<button
type="button"
onClick={() => handleDelete(item.id, item.name)}
className="p-1.5 rounded-lg hover:bg-rose-500/10 text-slate-500 hover:text-rose-400 transition-all"
title="ลบรายการ"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
{isModalOpen && ( {/* Bulk JSON Import / Export Modal */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <RawMaterialBulkJsonModal
<div className="absolute inset-0 bg-night-950/80 backdrop-blur-sm" onClick={() => !saving && setIsModalOpen(false)} /> isOpen={isBulkModalOpen}
<div className="relative w-full max-w-md bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-zoom-in p-6 space-y-6"> onClose={() => setIsBulkModalOpen(false)}
<h3 className="text-xl font-bold">{editingId ? 'Edit Material' : 'Add New Material'}</h3> materials={materials}
onSuccess={fetchMaterials}
<div className="space-y-4"> />
<div className="space-y-1">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Material Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="admin-input w-full"
placeholder="e.g. Alcohol, Glass Bottle 50ml"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Unit</label>
<input
type="text"
value={unit}
onChange={e => setUnit(e.target.value)}
className="admin-input w-full"
placeholder="e.g. kg, liter, piece"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Price per Unit (฿)</label>
<input
type="number"
value={pricePerUnit || ''}
onChange={e => setPricePerUnit(Number(e.target.value))}
className="admin-input w-full"
placeholder="0.00"
step="0.01"
/>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-white/5">
<button
onClick={() => setIsModalOpen(false)}
disabled={saving}
className="px-4 py-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 font-medium transition-all"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="btn-primary px-6 py-2 flex items-center gap-2"
>
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
{saving ? 'Saving...' : 'Save Material'}
</button>
</div>
</div>
</div>
)}
</div> </div>
) );
} }
+33 -8
View File
@@ -21,6 +21,7 @@ import {
import { API_BASE } from '@homaroma/types'; import { API_BASE } from '@homaroma/types';
import { TIKTOK_SHOPS } from './TiktokOrderPage'; import { TIKTOK_SHOPS } from './TiktokOrderPage';
import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils'; import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils';
import { TiktokFeeBreakdownModal } from '../components/tiktok-order/TiktokFeeBreakdownModal';
function authHeaders() { function authHeaders() {
return { return {
@@ -66,6 +67,7 @@ export default function TiktokOrderDetailPage() {
const [order, setOrder] = useState<any | null>(null); const [order, setOrder] = useState<any | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showFeeModal, setShowFeeModal] = useState(false);
useEffect(() => { useEffect(() => {
if (!orderId) return; if (!orderId) return;
@@ -410,19 +412,35 @@ export default function TiktokOrderDetailPage() {
</span> </span>
</div> </div>
<div className="flex justify-between items-center text-violet-300"> <button
<span className="text-slate-400">{t('tiktokOrderDetail.commissionSummary')}</span> type="button"
<span className="font-mono font-semibold"> onClick={() => setShowFeeModal(true)}
className="w-full flex justify-between items-center text-violet-300 hover:bg-violet-500/10 p-1.5 -mx-1.5 rounded-lg transition-colors cursor-pointer group"
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
>
<span className="text-slate-400 group-hover:text-slate-200 text-xs flex items-center gap-1">
{t('tiktokOrderDetail.commissionSummary')}
<Receipt className="w-3 h-3 text-violet-400/50 group-hover:text-violet-300 transition-colors" />
</span>
<span className="font-mono font-semibold text-xs text-violet-400 group-hover:underline">
{order.commissionFee > 0 ? `-฿${fmt(order.commissionFee)}` : '฿0.00'} {order.commissionFee > 0 ? `-฿${fmt(order.commissionFee)}` : '฿0.00'}
</span> </span>
</div> </button>
<div className="flex justify-between items-center text-cyan-300"> <button
<span className="text-slate-400">{t('tiktokOrderDetail.affiliateSummary')}</span> type="button"
<span className="font-mono font-semibold"> onClick={() => setShowFeeModal(true)}
className="w-full flex justify-between items-center text-cyan-300 hover:bg-cyan-500/10 p-1.5 -mx-1.5 rounded-lg transition-colors cursor-pointer group"
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
>
<span className="text-slate-400 group-hover:text-slate-200 text-xs flex items-center gap-1">
{t('tiktokOrderDetail.affiliateSummary')}
<Receipt className="w-3 h-3 text-cyan-400/50 group-hover:text-cyan-300 transition-colors" />
</span>
<span className="font-mono font-semibold text-xs text-cyan-400 group-hover:underline">
{order.affiliateFee > 0 ? `-฿${fmt(order.affiliateFee)}` : '฿0.00'} {order.affiliateFee > 0 ? `-฿${fmt(order.affiliateFee)}` : '฿0.00'}
</span> </span>
</div> </button>
<div className="pt-3 border-t border-white/10 flex justify-between items-center"> <div className="pt-3 border-t border-white/10 flex justify-between items-center">
<div> <div>
@@ -527,6 +545,13 @@ export default function TiktokOrderDetailPage() {
</div> </div>
</div> </div>
</div> </div>
{showFeeModal && (
<TiktokFeeBreakdownModal
order={order}
onClose={() => setShowFeeModal(false)}
/>
)}
</div> </div>
); );
} }
+61 -13
View File
@@ -7,10 +7,11 @@ import {
ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign, ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign,
Coins, ChevronLeft, ChevronRight, ExternalLink, Store, Trash2, Coins, ChevronLeft, ChevronRight, ExternalLink, Store, Trash2,
FileSpreadsheet, AlertTriangle, CheckCircle2, Files, Search, X, RotateCcw, FileSpreadsheet, AlertTriangle, CheckCircle2, Files, Search, X, RotateCcw,
BadgePercent, Wallet, Receipt BadgePercent, Wallet, Receipt, Info
} from 'lucide-react'; } from 'lucide-react';
import { API_BASE } from '@homaroma/types'; import { API_BASE } from '@homaroma/types';
import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils'; import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils';
import { TiktokFeeBreakdownModal } from '../components/tiktok-order/TiktokFeeBreakdownModal';
// ── Shop Config ─────────────────────────────────────────────────────────────── // ── Shop Config ───────────────────────────────────────────────────────────────
export const TIKTOK_SHOPS = [ export const TIKTOK_SHOPS = [
@@ -164,6 +165,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all');
const [bomFilter, setBomFilter] = useState('all'); const [bomFilter, setBomFilter] = useState('all');
const [selectedFeeOrder, setSelectedFeeOrder] = useState<any | null>(null);
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -396,8 +398,24 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
<th className="px-4 py-3.5 whitespace-nowrap">{t('tiktokOrders.thSkusBom')}</th> <th className="px-4 py-3.5 whitespace-nowrap">{t('tiktokOrders.thSkusBom')}</th>
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thOrderAmount')}</th> <th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thOrderAmount')}</th>
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thBomCost')}</th> <th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thBomCost')}</th>
<th className="px-4 py-3.5 whitespace-nowrap text-right text-violet-400">{t('tiktokOrders.thCommission')}</th> <th
<th className="px-4 py-3.5 whitespace-nowrap text-right text-cyan-400">{t('tiktokOrders.thAffiliate')}</th> className="px-4 py-3.5 whitespace-nowrap text-right text-violet-400 cursor-help"
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
>
<span className="inline-flex items-center gap-1 justify-end">
{t('tiktokOrders.thCommission')}
<Info className="w-3 h-3 text-violet-400/50" />
</span>
</th>
<th
className="px-4 py-3.5 whitespace-nowrap text-right text-cyan-400 cursor-help"
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
>
<span className="inline-flex items-center gap-1 justify-end">
{t('tiktokOrders.thAffiliate')}
<Info className="w-3 h-3 text-cyan-400/50" />
</span>
</th>
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thNetProfit')}</th> <th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thNetProfit')}</th>
<th className="px-4 py-3.5 text-center" /> <th className="px-4 py-3.5 text-center" />
</tr> </tr>
@@ -507,20 +525,42 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
{/* Commission (฿) */} {/* Commission (฿) */}
<td className="px-4 py-3.5 text-right font-mono whitespace-nowrap"> <td className="px-4 py-3.5 text-right font-mono whitespace-nowrap">
{order.commissionFee > 0 ? ( <button
<span className="text-violet-400 font-bold">-฿{fmt(order.commissionFee)}</span> type="button"
) : ( onClick={(e) => {
<span className="text-slate-500 font-mono text-[11px]">-</span> e.stopPropagation();
)} setSelectedFeeOrder(order);
}}
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
className="inline-flex items-center gap-1 group/btn px-2 py-1 rounded-lg hover:bg-violet-500/15 border border-transparent hover:border-violet-500/30 transition-all cursor-pointer"
>
{order.commissionFee > 0 ? (
<span className="text-violet-400 font-bold group-hover/btn:underline">-฿{fmt(order.commissionFee)}</span>
) : (
<span className="text-slate-500 font-mono text-[11px] group-hover/btn:text-slate-300">-</span>
)}
<Receipt className="w-3 h-3 text-violet-400/40 group-hover/btn:text-violet-300 transition-colors" />
</button>
</td> </td>
{/* Affiliate (฿) */} {/* Affiliate (฿) */}
<td className="px-4 py-3.5 text-right font-mono whitespace-nowrap"> <td className="px-4 py-3.5 text-right font-mono whitespace-nowrap">
{order.affiliateFee > 0 ? ( <button
<span className="text-cyan-400 font-bold">-฿{fmt(order.affiliateFee)}</span> type="button"
) : ( onClick={(e) => {
<span className="text-slate-500 font-mono text-[11px]">-</span> e.stopPropagation();
)} setSelectedFeeOrder(order);
}}
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
className="inline-flex items-center gap-1 group/btn px-2 py-1 rounded-lg hover:bg-cyan-500/15 border border-transparent hover:border-cyan-500/30 transition-all cursor-pointer"
>
{order.affiliateFee > 0 ? (
<span className="text-cyan-400 font-bold group-hover/btn:underline">-฿{fmt(order.affiliateFee)}</span>
) : (
<span className="text-slate-500 font-mono text-[11px] group-hover/btn:text-slate-300">-</span>
)}
<Receipt className="w-3 h-3 text-cyan-400/40 group-hover/btn:text-cyan-300 transition-colors" />
</button>
</td> </td>
{/* Profit */} {/* Profit */}
@@ -553,6 +593,14 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Fee Breakdown Modal */}
{selectedFeeOrder && (
<TiktokFeeBreakdownModal
order={selectedFeeOrder}
onClose={() => setSelectedFeeOrder(null)}
/>
)}
</div> </div>
); );
} }
@@ -1,9 +1,11 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { API_BASE, type Menu } from '@homaroma/types' import { API_BASE, type Menu } from '@homaroma/types'
import { Save, Loader2, Plus, Trash2, ChevronUp, ChevronDown, Copy, Check, Download, Upload, Star, Pencil, Sparkles, Code2, X } from 'lucide-react' import { Save, Loader2, Plus, Trash2, ChevronUp, ChevronDown, Copy, Check, Download, Upload, Star, Pencil, Sparkles, Code2, X, LayoutGrid, ListFilter, Layers, Image as ImageIcon } from 'lucide-react'
import { ICON_REGISTRY } from '../../utils/iconRegistry' import { ICON_REGISTRY } from '../../utils/iconRegistry'
import FocalPointPicker from '../../components/page-builder/FocalPointPicker' import FocalPointPicker from '../../components/page-builder/FocalPointPicker'
import MenuJsonGuideModal from '../../components/MenuJsonGuideModal' import MenuJsonGuideModal from '../../components/MenuJsonGuideModal'
import MediaLibraryModal from '../../components/MediaLibraryModal'
const IconSelect = ({ value, onChange }: { value?: string, onChange: (val: string) => void }) => { const IconSelect = ({ value, onChange }: { value?: string, onChange: (val: string) => void }) => {
return ( return (
@@ -30,7 +32,7 @@ type MenuEditorPageProps = {
description: string; description: string;
} }
const FeaturedCardEditorFields = ({ featured, onChange }: any) => { const FeaturedCardEditorFields = ({ featured, onChange, onOpenMediaPicker }: any) => {
const [activeTab, setActiveTab] = useState<'content' | 'media'>('content'); const [activeTab, setActiveTab] = useState<'content' | 'media'>('content');
const [activeLang, setActiveLang] = useState('th'); const [activeLang, setActiveLang] = useState('th');
@@ -54,8 +56,28 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
['ctaLabel', 'CTA label', 'View details'], ['ctaLabel', 'CTA label', 'View details'],
]; ];
const isEnabled = featured.enabled !== false;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between p-3 rounded-xl bg-white/5 border border-white/5">
<div>
<div className="text-xs font-bold text-white">Enable Featured Card</div>
<div className="text-[10px] text-slate-400">เปิดหรือปิดการแสดงการ์ดโปรโมตใน Mega Menu</div>
</div>
<button
type="button"
onClick={() => updateFeatured('enabled', !isEnabled)}
className={`px-3 py-1 text-xs font-bold rounded-lg border transition-colors ${
isEnabled
? 'border-amber-400/40 bg-amber-400/20 text-amber-300'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
{isEnabled ? 'Enabled (เปิด)' : 'Disabled (ปิด)'}
</button>
</div>
<div className="grid grid-cols-2 rounded-xl bg-white/5 p-1"> <div className="grid grid-cols-2 rounded-xl bg-white/5 p-1">
{[ {[
['content', 'Content'], ['content', 'Content'],
@@ -136,22 +158,59 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
</div> </div>
<div className="space-y-4 rounded-xl border border-white/10 bg-white/5 p-4"> <div className="space-y-4 rounded-xl border border-white/10 bg-white/5 p-4">
<div className="aspect-[4/3] overflow-hidden rounded-xl border border-white/10 bg-white/5 flex items-center justify-center"> <div
onClick={() => onOpenMediaPicker && onOpenMediaPicker((url: string) => updateFeatured('imageUrl', url))}
className="aspect-[4/3] relative overflow-hidden rounded-xl border-2 border-dashed border-white/10 hover:border-cyan-glow/50 bg-night-950/40 flex items-center justify-center cursor-pointer group transition-all"
title="คลิกเพื่อเลือกรูปจากคลังภาพ (Media Library)"
>
{featured.imageUrl ? ( {featured.imageUrl ? (
<img src={featured.imageUrl} alt="Featured preview" className="h-full w-full object-cover" /> <>
<img src={featured.imageUrl} alt="Featured preview" className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-300" />
<div className="absolute inset-0 bg-night-950/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1.5 backdrop-blur-xs">
<ImageIcon className="w-6 h-6 text-cyan-glow" />
<span className="text-xs font-bold text-white">เปลี่ยนรูปจากคลังภาพ</span>
</div>
</>
) : ( ) : (
<span className="px-4 text-center text-xs text-slate-600">No featured image</span> <div className="flex flex-col items-center gap-2 text-slate-500 group-hover:text-cyan-glow transition-colors py-4">
<ImageIcon className="w-8 h-8" />
<span className="text-xs font-bold">เลือกรูปจากคลังภาพ (Images Lib)</span>
<span className="text-[10px] text-slate-600">คลิกที่นี่เพื่อเปิดคลังสื่อของระบบ</span>
</div>
)} )}
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[10px] uppercase font-bold text-slate-500">Featured Image URL</label> <div className="flex items-center justify-between">
<input <label className="text-[10px] uppercase font-bold text-slate-500">Featured Image URL</label>
className="admin-input w-full text-xs py-1 px-2" {onOpenMediaPicker && (
value={featured.imageUrl || ''} <button
placeholder="https://cdn.example.com/product.webp" type="button"
onChange={e => updateFeatured('imageUrl', e.target.value || null)} onClick={() => onOpenMediaPicker((url: string) => updateFeatured('imageUrl', url))}
/> className="text-[10px] font-bold text-cyan-glow hover:underline flex items-center gap-1 cursor-pointer"
>
<ImageIcon className="w-3 h-3" /> เลือกจากคลังภาพ
</button>
)}
</div>
<div className="flex gap-2">
<input
className="admin-input w-full text-xs py-1 px-2"
value={featured.imageUrl || ''}
placeholder="https://cdn.example.com/product.webp"
onChange={e => updateFeatured('imageUrl', e.target.value || null)}
/>
{featured.imageUrl && (
<button
type="button"
onClick={() => updateFeatured('imageUrl', null)}
className="px-2.5 py-1 text-xs text-rose-400 hover:bg-rose-500/10 rounded border border-white/10 shrink-0 cursor-pointer"
title="ลบรูปภาพ"
>
Clear
</button>
)}
</div>
</div> </div>
{featured.imageUrl && ( {featured.imageUrl && (
<div className="pt-2"> <div className="pt-2">
@@ -188,7 +247,7 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
); );
} }
const FeaturedCardSidePanel = ({ node, onChange }: any) => { const FeaturedCardSidePanel = ({ node, onChange, onOpenMediaPicker }: any) => {
if (!node) { if (!node) {
return ( return (
<div className="admin-card p-6 space-y-3"> <div className="admin-card p-6 space-y-3">
@@ -223,12 +282,13 @@ const FeaturedCardSidePanel = ({ node, onChange }: any) => {
<FeaturedCardEditorFields <FeaturedCardEditorFields
featured={featured} featured={featured}
onChange={(newFeatured: any) => onChange({ ...node, featured: newFeatured })} onChange={(newFeatured: any) => onChange({ ...node, featured: newFeatured })}
onOpenMediaPicker={onOpenMediaPicker}
/> />
</div> </div>
); );
} }
const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured, isFeaturedSelected }: any) => { const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured, isFeaturedSelected, onOpenMediaPicker }: any) => {
const childrenKey = type === 'root' ? 'columns' : 'links'; const childrenKey = type === 'root' ? 'columns' : 'links';
const labelKey = type === 'column' ? 'title' : 'label'; const labelKey = type === 'column' ? 'title' : 'label';
const hasUrl = type !== 'column'; const hasUrl = type !== 'column';
@@ -300,21 +360,56 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
{/* Image URL field */} {/* Image URL field */}
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{/* Preview thumbnail */} {/* Preview thumbnail */}
<div className="w-14 h-14 shrink-0 rounded-lg overflow-hidden border border-white/10 bg-white/5 flex items-center justify-center"> <div
onClick={() => onOpenMediaPicker && onOpenMediaPicker((url: string) => onChange({ ...node, [imageFieldKey]: url }))}
className="w-14 h-14 shrink-0 rounded-lg overflow-hidden border border-white/10 hover:border-cyan-glow/50 bg-white/5 flex items-center justify-center cursor-pointer group relative transition-colors"
title="คลิกเพื่อเลือกรูปจากคลังภาพ (Media Library)"
>
{imageValue ? ( {imageValue ? (
<img src={imageValue} alt="preview" className="w-full h-full object-cover" /> <>
<img src={imageValue} alt="preview" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-night-950/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<ImageIcon className="w-4 h-4 text-cyan-glow" />
</div>
</>
) : ( ) : (
<span className="text-[9px] text-slate-600 text-center leading-tight px-1">No Image</span> <div className="flex flex-col items-center justify-center text-slate-600 group-hover:text-cyan-glow transition-colors">
<ImageIcon className="w-4 h-4 mb-0.5" />
<span className="text-[8px] text-center leading-tight">Library</span>
</div>
)} )}
</div> </div>
<div className="flex-1 space-y-1"> <div className="flex-1 space-y-1">
<label className="text-[10px] uppercase font-bold text-slate-500">{imageFieldLabel}</label> <div className="flex items-center justify-between">
<input <label className="text-[10px] uppercase font-bold text-slate-500">{imageFieldLabel}</label>
className="admin-input w-full text-xs py-1 px-2" {onOpenMediaPicker && (
value={imageValue} <button
placeholder="https://cdn.example.com/image.webp" type="button"
onChange={e => onChange({ ...node, [imageFieldKey]: e.target.value || null })} onClick={() => onOpenMediaPicker((url: string) => onChange({ ...node, [imageFieldKey]: url }))}
/> className="text-[10px] font-bold text-cyan-glow hover:underline flex items-center gap-1 cursor-pointer"
>
<ImageIcon className="w-3 h-3" /> เลือกจากคลังภาพ
</button>
)}
</div>
<div className="flex gap-2">
<input
className="admin-input w-full text-xs py-1 px-2"
value={imageValue}
placeholder="https://cdn.example.com/image.webp"
onChange={e => onChange({ ...node, [imageFieldKey]: e.target.value || null })}
/>
{imageValue && (
<button
type="button"
onClick={() => onChange({ ...node, [imageFieldKey]: null })}
className="px-2 py-1 text-xs text-rose-400 hover:bg-rose-500/10 rounded border border-white/10 shrink-0 cursor-pointer"
title="ลบรูปภาพ"
>
Clear
</button>
)}
</div>
{type !== 'root' && ( {type !== 'root' && (
<IconSelect <IconSelect
value={node.icon} value={node.icon}
@@ -325,30 +420,193 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
</div> </div>
{type === 'root' && ( {type === 'root' && (
<div className="rounded-xl border border-amber-400/20 bg-amber-400/5 p-4"> <div className="space-y-3">
<div className="flex items-center justify-between gap-4"> {/* Layout Mode Selector */}
<div className="flex min-w-0 items-center gap-3"> <div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-400/10 text-amber-300"> <div className="flex items-center justify-between">
<Star className="w-5 h-5" /> <label className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">
Sub-Menu Layout Preset
</label>
<span className="text-[10px] text-cyan-glow font-medium">
{node.layout === 'classic'
? 'Classic Dropdown'
: node.layout === 'tabbed'
? 'Tabbed Hierarchy'
: node.layout === 'visual-cards'
? 'Visual Cards'
: `Mega Menu (${node.layoutConfig?.columnCount || 2} Cols)`}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<button
type="button"
onClick={() => onChange({ ...node, layout: 'columns' })}
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
(!node.layout || node.layout === 'columns')
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
}`}
>
<LayoutGrid className="w-4 h-4 shrink-0 text-cyan-glow" />
<div className="min-w-0">
<div className="text-xs font-bold leading-tight truncate">Column Grid</div>
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">2-4 คอลัมน์</div>
</div>
</button>
<button
type="button"
onClick={() => onChange({ ...node, layout: 'tabbed' })}
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
node.layout === 'tabbed'
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
}`}
>
<Layers className="w-4 h-4 shrink-0 text-cyan-glow" />
<div className="min-w-0">
<div className="text-xs font-bold leading-tight truncate">Tabbed Catalog</div>
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">แท็บซ้าย + ลิงก์ขวา</div>
</div>
</button>
<button
type="button"
onClick={() => onChange({ ...node, layout: 'visual-cards' })}
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
node.layout === 'visual-cards'
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
}`}
>
<Sparkles className="w-4 h-4 shrink-0 text-cyan-glow" />
<div className="min-w-0">
<div className="text-xs font-bold leading-tight truncate">Visual Cards</div>
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">การ์ดรูปถ่ายหรูหรา</div>
</div>
</button>
<button
type="button"
onClick={() => onChange({ ...node, layout: 'classic' })}
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
node.layout === 'classic'
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
}`}
>
<ListFilter className="w-4 h-4 shrink-0 text-cyan-glow" />
<div className="min-w-0">
<div className="text-xs font-bold leading-tight truncate">Classic Dropdown</div>
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">แนวตั้งมินิมอล</div>
</div>
</button>
</div>
{/* Submenu Layout Modifier / Description */}
{(!node.layout || node.layout === 'columns') && (
<div className="pt-3 border-t border-white/5 space-y-3">
{/* Column Count */}
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-300">จำนวนคอลัมน์ (Columns)</span>
<div className="flex rounded-lg bg-black/40 p-1 border border-white/5">
{[2, 3, 4].map((count) => {
const currentCols = node.layoutConfig?.columnCount || 2;
return (
<button
key={count}
type="button"
onClick={() => onChange({
...node,
layoutConfig: { ...(node.layoutConfig || {}), columnCount: count }
})}
className={`px-3 py-1 text-xs font-bold rounded-md transition-colors ${
currentCols === count
? 'bg-cyan-glow text-night-900'
: 'text-slate-400 hover:text-white'
}`}
>
{count} Cols
</button>
);
})}
</div>
</div>
{/* Toggle Featured Card */}
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-300">แสดง Featured Card</span>
<button
type="button"
onClick={() => {
const isCurrentlyShown = node.layoutConfig?.showFeatured !== false && featured.enabled !== false;
const nextState = !isCurrentlyShown;
onChange({
...node,
layoutConfig: { ...(node.layoutConfig || {}), showFeatured: nextState },
featured: { ...featured, enabled: nextState }
});
}}
className={`flex items-center gap-1.5 px-3 py-1 text-xs font-bold rounded-lg border transition-colors ${
(node.layoutConfig?.showFeatured !== false && featured.enabled !== false)
? 'border-amber-400/40 bg-amber-400/20 text-amber-300'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
{(node.layoutConfig?.showFeatured !== false && featured.enabled !== false) ? 'ON (เปิด)' : 'OFF (ปิด)'}
</button>
</div>
</div> </div>
<div className="min-w-0"> )}
<h3 className="text-xs font-bold uppercase tracking-widest text-amber-200">Featured card</h3>
<p className="truncate text-[11px] text-slate-500"> {node.layout === 'tabbed' && (
{featured.enabled === false ? 'Disabled' : featuredTitle} <div className="pt-3 border-t border-white/5 text-xs text-slate-400 leading-relaxed bg-white/2 p-2.5 rounded-lg">
</p> 💡 <strong className="text-slate-200">Tabbed Hierarchy:</strong> แต่ละ Column จะทำหน้าที่เป็นแท็บหมวดหมู่ฝั่งซ้าย เมื่อเอาเมาส์ Hover แท็บใด รายการ Links ย่อยจะปรากฏฝั่งขวาทันที เหมาะสำหรับร้านค้าที่มีหมวดหมู่แคตตาล็อกลึก (เช่น อะไหล่ยนต์, เครื่องใช้ไฟฟ้า, ไอที)
</div>
)}
{node.layout === 'visual-cards' && (
<div className="pt-3 border-t border-white/5 text-xs text-slate-400 leading-relaxed bg-white/2 p-2.5 rounded-lg">
✨ <strong className="text-slate-200">Visual Image Cards:</strong> แสดงเป็นการ์ดรูปถ่าย 4 ใบแนวนอน โดยดึงรูปภาพจาก Column Icon URL หรือ Hover Image ของลิงก์แรก เหมาะสำหรับสินค้าที่เน้นความงามและดีไซน์ (เช่น ก้านไม้หอม, เทียนหอม, คอลเลกชันแฟชั่น)
</div>
)}
{node.layout === 'classic' && (
<div className="pt-3 border-t border-white/5 text-xs text-slate-400 leading-relaxed bg-white/2 p-2.5 rounded-lg">
📄 <strong className="text-slate-200">Classic Dropdown:</strong> แสดงเป็นกล่องแนวตั้งกะทัดรัดใต้ปุ่มเมนูบน Navbar เหมาะสำหรับเมนูข้อมูลทั่วไปที่มี 3-7 ลิงก์ (เช่น เกี่ยวกับเรา, ศูนย์บริการ, ข่าวสาร)
</div>
)}
</div>
{/* Featured Card editor trigger (only shown when in columns mode and featured card is enabled) */}
{(!node.layout || node.layout === 'columns') && (node.layoutConfig?.showFeatured !== false && featured.enabled !== false) && (
<div className="rounded-xl border border-amber-400/20 bg-amber-400/5 p-4">
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-400/10 text-amber-300">
<Star className="w-5 h-5" />
</div>
<div className="min-w-0">
<h3 className="text-xs font-bold uppercase tracking-widest text-amber-200">Featured card</h3>
<p className="truncate text-[11px] text-slate-500">
{featuredTitle}
</p>
</div>
</div>
<button
type="button"
onClick={onEditFeatured}
className={`flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-xs font-bold transition-colors ${
isFeaturedSelected ? 'bg-amber-300 text-night-900' : 'bg-amber-400/10 text-amber-200 hover:bg-amber-400/20'
}`}
>
<Pencil className="w-3.5 h-3.5" />
{isFeaturedSelected ? 'Editing' : 'Edit'}
</button>
</div> </div>
</div> </div>
<button )}
type="button"
onClick={onEditFeatured}
className={`flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-xs font-bold transition-colors ${
isFeaturedSelected ? 'bg-amber-300 text-night-900' : 'bg-amber-400/10 text-amber-200 hover:bg-amber-400/20'
}`}
>
<Pencil className="w-3.5 h-3.5" />
{isFeaturedSelected ? 'Editing' : 'Edit'}
</button>
</div>
</div> </div>
)} )}
</div> </div>
@@ -383,6 +641,7 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
depth={depth + 1} depth={depth + 1}
onChange={(newChild: any) => handleUpdateChild(i, newChild)} onChange={(newChild: any) => handleUpdateChild(i, newChild)}
onRemove={() => handleRemoveChild(i)} onRemove={() => handleRemoveChild(i)}
onOpenMediaPicker={onOpenMediaPicker}
/> />
))} ))}
</div> </div>
@@ -406,6 +665,13 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
const [pastedJsonText, setPastedJsonText] = useState('') const [pastedJsonText, setPastedJsonText] = useState('')
const [pasteError, setPasteError] = useState<string | null>(null) const [pasteError, setPasteError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
const [mediaSelectCallback, setMediaSelectCallback] = useState<((url: string) => void) | null>(null)
const handleOpenMediaPicker = (onSelectUrl: (url: string) => void) => {
setMediaSelectCallback(() => onSelectUrl)
setIsMediaModalOpen(true)
}
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
@@ -708,6 +974,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
}} }}
onEditFeatured={() => setSelectedFeaturedIndex(idx)} onEditFeatured={() => setSelectedFeaturedIndex(idx)}
isFeaturedSelected={safeFeaturedIndex === idx} isFeaturedSelected={safeFeaturedIndex === idx}
onOpenMediaPicker={handleOpenMediaPicker}
/> />
)) ))
)} )}
@@ -829,6 +1096,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
<FeaturedCardSidePanel <FeaturedCardSidePanel
node={selectedFeaturedNode} node={selectedFeaturedNode}
onChange={handleUpdateFeaturedNode} onChange={handleUpdateFeaturedNode}
onOpenMediaPicker={handleOpenMediaPicker}
/> />
) : ( ) : (
<div className="admin-card p-6 space-y-4"> <div className="admin-card p-6 space-y-4">
@@ -884,7 +1152,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
)} )}
{/* Paste JSON Modal */} {/* Paste JSON Modal */}
{showPasteModal && ( {showPasteModal && createPortal(
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-4 animate-fade-in"> <div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-4 animate-fade-in">
<div className="w-full max-w-2xl bg-night-900 border border-white/10 rounded-2xl shadow-2xl p-6 space-y-4 animate-zoom-in"> <div className="w-full max-w-2xl bg-night-900 border border-white/10 rounded-2xl shadow-2xl p-6 space-y-4 animate-zoom-in">
<div className="flex items-center justify-between border-b border-white/10 pb-3"> <div className="flex items-center justify-between border-b border-white/10 pb-3">
@@ -932,7 +1200,8 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
</button> </button>
</div> </div>
</div> </div>
</div> </div>,
document.body
)} )}
{/* LLM Mega Menu Guide Modal */} {/* LLM Mega Menu Guide Modal */}
@@ -942,6 +1211,23 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
onOpenPasteJson={() => setShowPasteModal(true)} onOpenPasteJson={() => setShowPasteModal(true)}
onOpenImportFile={() => fileInputRef.current?.click()} onOpenImportFile={() => fileInputRef.current?.click()}
/> />
{/* Media Library Modal */}
<MediaLibraryModal
isOpen={isMediaModalOpen}
onClose={() => {
setIsMediaModalOpen(false)
setMediaSelectCallback(null)
}}
onSelect={(url) => {
if (mediaSelectCallback) {
mediaSelectCallback(url)
}
setIsMediaModalOpen(false)
setMediaSelectCallback(null)
}}
mediaType="image"
/>
</div> </div>
) )
} }
+349 -3
View File
@@ -431,6 +431,95 @@ export const productsRoute = new Elysia({ prefix: "/products" })
}, },
}; };
}) })
.get("/bom-status", async ({ query }) => {
const search = query.search?.trim();
const filterStatus = query.status?.trim() || "all";
const limit = query.limit ? parseInt(query.limit) : 50;
const where: any = { isDeleted: false };
if (search) {
where.OR = [
{ name: { contains: search, mode: "insensitive" } },
{ translations: { some: { name: { contains: search, mode: "insensitive" } } } },
{ brand: { name: { contains: search, mode: "insensitive" } } },
{ variants: { some: { sku: { contains: search, mode: "insensitive" } } } },
];
}
const products = await prisma.product.findMany({
where,
select: {
id: true,
name: true,
brand: { select: { id: true, name: true } },
category: { select: { id: true, name: true } },
translations: { select: { langCode: true, name: true } },
variants: {
select: {
id: true,
sku: true,
option1: true,
option2: true,
option3: true,
price: true,
bomItems: {
select: {
id: true,
rawMaterialId: true,
quantity: true,
rawMaterial: { select: { id: true, name: true, unit: true, pricePerUnit: true } },
},
},
},
},
},
take: limit,
orderBy: { id: "asc" },
});
const mapped = products.map((p) => {
const thName = p.translations.find((t) => t.langCode === "th")?.name;
const productName = p.name || thName || p.translations[0]?.name || `Product #${p.id}`;
const totalVariants = p.variants.length;
const configuredVariants = p.variants.filter((v) => v.bomItems && v.bomItems.length > 0).length;
let bomStatus = "MISSING";
if (configuredVariants === totalVariants && totalVariants > 0) {
bomStatus = "CONFIGURED";
} else if (configuredVariants > 0) {
bomStatus = "PARTIAL";
}
return {
id: p.id,
name: productName,
brand: p.brand?.name || null,
category: p.category?.name || null,
totalVariants,
configuredVariants,
bomStatus,
variants: p.variants.map((v) => ({
id: v.id,
sku: v.sku,
options: [v.option1, v.option2, v.option3].filter(Boolean).join(" / "),
price: v.price,
hasBom: v.bomItems.length > 0,
bomItemCount: v.bomItems.length,
totalBomCost: Math.round(v.bomItems.reduce((acc, b) => acc + (b.rawMaterial?.pricePerUnit || 0) * b.quantity, 0) * 100) / 100,
})),
};
});
const filtered = filterStatus === "all"
? mapped
: mapped.filter((p) => p.bomStatus.toLowerCase() === filterStatus.toLowerCase());
return {
success: true,
count: filtered.length,
products: filtered,
};
})
.get("/:id", async ({ params, query, status }) => { .get("/:id", async ({ params, query, status }) => {
const id = parseInt(params.id); const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" }); if (isNaN(id)) return status(400, { message: "Invalid product ID" });
@@ -471,9 +560,9 @@ export const productsRoute = new Elysia({ prefix: "/products" })
if (!product || (!includeDeleted && product.isDeleted)) return status(404, { message: "Product not found" }); if (!product || (!includeDeleted && product.isDeleted)) return status(404, { message: "Product not found" });
if (lang === 'all') { if (lang === 'all') {
const nameObj: any = {}; const nameObj: any = product.name ? { th: product.name } : {};
const descObj: any = {}; const descObj: any = product.description ? { th: product.description } : {};
const detailsObj: any = {}; const detailsObj: any = product.details ? { th: product.details } : {};
product.translations.forEach((t: any) => { product.translations.forEach((t: any) => {
nameObj[t.langCode] = t.name; nameObj[t.langCode] = t.name;
descObj[t.langCode] = t.description; descObj[t.langCode] = t.description;
@@ -499,6 +588,263 @@ export const productsRoute = new Elysia({ prefix: "/products" })
}; };
} }
}) })
.get("/:id/bom", async ({ params, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const [product, rawMaterials] = await Promise.all([
prisma.product.findUnique({
where: { id },
include: {
brand: true,
category: true,
translations: true,
variants: {
include: {
bomItems: {
include: {
rawMaterial: true,
},
},
},
orderBy: { price: "asc" },
},
},
}),
prisma.rawMaterial.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true, unit: true, pricePerUnit: true },
}),
]);
if (!product || product.isDeleted) return status(404, { message: "Product not found" });
const thName = product.translations.find((t) => t.langCode === "th")?.name;
const productName = product.name || thName || product.translations[0]?.name || `Product #${product.id}`;
const variants = product.variants.map((v) => {
const items = (v.bomItems || []).map((b) => {
const rm = b.rawMaterial;
const lineCost = (rm?.pricePerUnit || 0) * b.quantity;
return {
id: b.id,
rawMaterialId: b.rawMaterialId,
name: rm?.name || `Material #${b.rawMaterialId}`,
unit: rm?.unit || "piece",
pricePerUnit: rm?.pricePerUnit || 0,
quantity: b.quantity,
lineCost: Math.round(lineCost * 100) / 100,
};
});
const totalBomCost = items.reduce((acc, i) => acc + i.lineCost, 0);
const retailPrice = Number(v.price) || 0;
const profit = retailPrice - totalBomCost;
const marginPercent = retailPrice > 0 ? (profit / retailPrice) * 100 : 0;
const optionLabel = [v.option1, v.option2, v.option3].filter(Boolean).join(" / ");
return {
id: v.id,
sku: v.sku,
optionLabel: optionLabel || "Standard",
price: retailPrice,
stock: v.stock,
bomItems: items,
totalBomCost: Math.round(totalBomCost * 100) / 100,
estimatedProfit: Math.round(profit * 100) / 100,
marginPercent: Math.round(marginPercent * 10) / 10,
};
});
const totalVariants = variants.length;
const configuredVariants = variants.filter((v) => v.bomItems.length > 0).length;
return {
success: true,
productId: product.id,
productName,
brand: product.brand?.name || null,
category: product.category?.name || null,
bomStatus:
configuredVariants === totalVariants && totalVariants > 0
? "CONFIGURED"
: configuredVariants > 0
? "PARTIAL"
: "MISSING",
summary: {
totalVariants,
configuredVariants,
},
variants,
availableRawMaterials: rawMaterials,
};
})
.put("/:id/bom", async ({ params, body, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const payload = body as any;
const incomingVariants = Array.isArray(payload) ? payload : (payload.variants || []);
if (!Array.isArray(incomingVariants) || incomingVariants.length === 0) {
return status(400, { message: "variants array is required" });
}
const [product, rawMaterials] = await Promise.all([
prisma.product.findUnique({
where: { id },
include: {
brand: true,
translations: true,
variants: true,
},
}),
prisma.rawMaterial.findMany(),
]);
if (!product || product.isDeleted) return status(404, { message: "Product not found" });
const rawMaterialById = new Map<number, (typeof rawMaterials)[0]>();
const rawMaterialByName = new Map<string, (typeof rawMaterials)[0]>();
for (const rm of rawMaterials) {
rawMaterialById.set(rm.id, rm);
rawMaterialByName.set(rm.name.trim().toLowerCase(), rm);
}
const plan: Array<{
targetVariant: (typeof product.variants)[0];
bomItemsToCreate: Array<{ rawMaterialId: number; quantity: number }>;
}> = [];
const unresolvedVariants: any[] = [];
const unresolvedMaterials: any[] = [];
for (const iv of incomingVariants) {
const vId = Number(iv.variantId || iv.id);
const vSku = String(iv.sku || "").trim().toLowerCase();
const vOption = String(iv.option1 || iv.option || iv.name || "").trim().toLowerCase();
let targetVariant = product.variants.find((v) => v.id === vId);
if (!targetVariant && vSku) {
targetVariant = product.variants.find((v) => (v.sku || "").trim().toLowerCase() === vSku);
}
if (!targetVariant && vOption) {
targetVariant = product.variants.find((v) => (v.option1 || "").trim().toLowerCase() === vOption);
}
if (!targetVariant) {
unresolvedVariants.push(iv);
continue;
}
const bomItemsToCreate: Array<{ rawMaterialId: number; quantity: number }> = [];
const items = Array.isArray(iv.bomItems) ? iv.bomItems : [];
for (const item of items) {
const rmId = Number(item.rawMaterialId || item.id);
const rmName = String(item.name || item.rawMaterialName || "").trim().toLowerCase();
const qty = Number(item.quantity) || 0;
if (qty <= 0) continue;
let matchedRm = rawMaterialById.get(rmId);
if (!matchedRm && rmName) {
matchedRm = rawMaterialByName.get(rmName);
if (!matchedRm) {
for (const rm of rawMaterials) {
if (rm.name.toLowerCase().includes(rmName) || rmName.includes(rm.name.toLowerCase())) {
matchedRm = rm;
break;
}
}
}
}
if (!matchedRm) {
unresolvedMaterials.push({ variantSku: targetVariant.sku, item });
continue;
}
bomItemsToCreate.push({
rawMaterialId: matchedRm.id,
quantity: qty,
});
}
plan.push({ targetVariant, bomItemsToCreate });
}
if (plan.length === 0) {
return status(400, {
message: "No matching variants could be resolved from payload",
unresolvedVariants,
});
}
await prisma.$transaction(async (tx) => {
for (const p of plan) {
await tx.bomItem.deleteMany({
where: { productVariantId: p.targetVariant.id },
});
if (p.bomItemsToCreate.length > 0) {
await tx.bomItem.createMany({
data: p.bomItemsToCreate.map((b) => ({
productVariantId: p.targetVariant.id,
rawMaterialId: b.rawMaterialId,
quantity: b.quantity,
})),
});
}
}
});
const updatedVariants = await prisma.productVariant.findMany({
where: { productId: id },
include: {
bomItems: {
include: { rawMaterial: true },
},
},
orderBy: { price: "asc" },
});
const report = updatedVariants.map((v) => {
const items = v.bomItems.map((b) => ({
rawMaterialId: b.rawMaterialId,
name: b.rawMaterial.name,
unit: b.rawMaterial.unit,
pricePerUnit: b.rawMaterial.pricePerUnit,
quantity: b.quantity,
lineCost: Math.round(b.rawMaterial.pricePerUnit * b.quantity * 100) / 100,
}));
const totalBomCost = items.reduce((sum, i) => sum + i.lineCost, 0);
const profit = (v.price || 0) - totalBomCost;
const margin = (v.price || 0) > 0 ? (profit / (v.price || 0)) * 100 : 0;
return {
variantId: v.id,
sku: v.sku,
option: [v.option1, v.option2, v.option3].filter(Boolean).join(" / "),
retailPrice: v.price,
totalBomCost: Math.round(totalBomCost * 100) / 100,
estimatedProfit: Math.round(profit * 100) / 100,
marginPercent: Math.round(margin * 10) / 10,
bomItems: items,
};
});
const thName = product.translations.find((t) => t.langCode === "th")?.name;
const productName = product.name || thName || product.translations[0]?.name || `Product #${product.id}`;
return {
success: true,
message: `Successfully updated BOM for ${plan.length} variants on product #${id} (${productName})`,
productId: id,
productName,
variantsUpdated: plan.length,
variants: report,
unresolvedMaterials: unresolvedMaterials.length > 0 ? unresolvedMaterials : undefined,
};
})
.get("/:id/detail-sections", async ({ params, query, status }) => { .get("/:id/detail-sections", async ({ params, query, status }) => {
const id = parseInt(params.id); const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" }); if (isNaN(id)) return status(400, { message: "Invalid product ID" });
+112 -7
View File
@@ -14,10 +14,105 @@ export const rawMaterialsRoute = new Elysia({ prefix: "/raw-materials" })
const rawMaterial = await prisma.rawMaterial.findUnique({ const rawMaterial = await prisma.rawMaterial.findUnique({
where: { id }, where: { id },
include: {
bomItems: {
include: {
productVariant: {
include: {
product: {
select: {
id: true,
name: true,
translations: {
select: { langCode: true, name: true }
}
}
}
}
}
}
}
}
}); });
if (!rawMaterial) return status(404, { message: "Raw material not found" }); if (!rawMaterial) return status(404, { message: "Raw material not found" });
return rawMaterial;
const result = {
...rawMaterial,
bomItems: rawMaterial.bomItems.map((item) => {
const prod = item.productVariant?.product;
if (!prod) return item;
const thName = prod.translations?.find((t) => t.langCode === "th")?.name;
const fallbackName = prod.translations?.[0]?.name;
const resolvedName = prod.name || thName || fallbackName || `Product #${prod.id}`;
return {
...item,
productVariant: {
...item.productVariant,
product: {
...prod,
name: resolvedName,
}
}
};
})
};
return result;
})
.post("/bulk", async ({ body, status }) => {
try {
const items = Array.isArray(body) ? body : [body];
if (items.length === 0) {
return status(400, { message: "Payload must be a non-empty array" });
}
const results = await prisma.$transaction(async (tx) => {
const records = [];
for (const item of items) {
const name = String(item.name || "").trim();
if (!name) continue;
const unit = String(item.unit || "piece").trim() || "piece";
const rawPrice = item.pricePerUnit;
const pricePerUnit = typeof rawPrice === "string" ? (parseFloat(rawPrice) || 0) : Number(rawPrice) || 0;
const record = await tx.rawMaterial.upsert({
where: { name },
create: {
name,
unit,
pricePerUnit,
},
update: {
unit,
pricePerUnit,
},
});
records.push(record);
}
return records;
});
return status(200, {
success: true,
count: results.length,
items: results,
});
} catch (err: any) {
console.error("Bulk raw material import failed:", err);
return status(500, { message: "Failed to bulk import raw materials", error: err.message });
}
}, {
body: t.Array(
t.Object({
name: t.String(),
unit: t.Optional(t.String()),
pricePerUnit: t.Union([t.Number(), t.String()]),
})
),
detail: { tags: ["raw-materials"] }
}) })
.post("/", async ({ body, status }) => { .post("/", async ({ body, status }) => {
try { try {
@@ -42,20 +137,30 @@ export const rawMaterialsRoute = new Elysia({ prefix: "/raw-materials" })
if (isNaN(id)) return status(400, { message: "Invalid ID" }); if (isNaN(id)) return status(400, { message: "Invalid ID" });
try { try {
const updateData: any = {};
if (body.name !== undefined) updateData.name = String(body.name).trim();
if (body.unit !== undefined) updateData.unit = String(body.unit).trim();
if (body.pricePerUnit !== undefined) {
updateData.pricePerUnit =
typeof body.pricePerUnit === "string"
? parseFloat(body.pricePerUnit) || 0
: Number(body.pricePerUnit) || 0;
}
const rawMaterial = await prisma.rawMaterial.update({ const rawMaterial = await prisma.rawMaterial.update({
where: { id }, where: { id },
data: body, data: updateData,
}); });
return rawMaterial; return rawMaterial;
} catch (err) { } catch (err: any) {
console.error(err); console.error(err);
return status(500, { message: "Failed to update raw material" }); return status(500, { message: "Failed to update raw material", error: err.message });
} }
}, { }, {
body: t.Object({ body: t.Object({
name: t.String(), name: t.Optional(t.String()),
unit: t.String(), unit: t.Optional(t.String()),
pricePerUnit: t.Number(), pricePerUnit: t.Optional(t.Union([t.Number(), t.String()])),
}), }),
detail: { tags: ["raw-materials"] } detail: { tags: ["raw-materials"] }
}) })
+27
View File
@@ -469,6 +469,33 @@ export const toolsRoute = new Elysia({ prefix: "/tools" })
} }
) )
// ── 4.1 POST Bulk Link / Unlink Master Variants ──────────────────────────────
.post(
"/marketplace-products/bulk-map",
async ({ body }: { body: { mappings: Array<{ marketplaceProductId: number; masterVariantId: number | null }> } }) => {
const { mappings } = body;
await prisma.$transaction(
mappings.map((m) =>
prisma.marketplaceProduct.update({
where: { id: m.marketplaceProductId },
data: { masterVariantId: m.masterVariantId },
})
)
);
return { success: true, count: mappings.length };
},
{
body: t.Object({
mappings: t.Array(
t.Object({
marketplaceProductId: t.Number(),
masterVariantId: t.Nullable(t.Number()),
})
),
}),
}
)
// ── 5. POST Bulk Auto Match Unmapped Items ────────────────────────────────── // ── 5. POST Bulk Auto Match Unmapped Items ──────────────────────────────────
.post("/marketplace-products/bulk-auto-match", async () => { .post("/marketplace-products/bulk-auto-match", async () => {
const masterVariants = await prisma.productVariant.findMany({ const masterVariants = await prisma.productVariant.findMany({
+1 -1
View File
@@ -51,7 +51,7 @@ test("settings writes send only supported fields and simple product fields are a
try { try {
client = await connectMcp(`http://localhost:${backend.port}/api`); client = await connectMcp(`http://localhost:${backend.port}/api`);
const tools = (await client.listTools()).tools; const tools = (await client.listTools()).tools;
expect(tools).toHaveLength(113); expect(tools).toHaveLength(116);
expect(tools.find((item) => item.name === "get_footer_layout")?.annotations?.readOnlyHint).toBe(true); expect(tools.find((item) => item.name === "get_footer_layout")?.annotations?.readOnlyHint).toBe(true);
expect(tools.find((item) => item.name === "delete_category_section")?.annotations?.destructiveHint).toBe(true); expect(tools.find((item) => item.name === "delete_category_section")?.annotations?.destructiveHint).toBe(true);
const footerLayout = await client.callTool({ name: "get_footer_layout", arguments: {} }); const footerLayout = await client.callTool({ name: "get_footer_layout", arguments: {} });
+168
View File
@@ -371,6 +371,123 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
required: ["id"] required: ["id"]
} }
}, },
{
name: "list_products_bom_status",
description: "List products and their BOM (Bill of Materials) setup status (whether variants have formulas defined). Useful for finding products needing BOM configuration.",
inputSchema: {
type: "object",
properties: {
search: { type: "string", description: "Search by product name, brand, or SKU" },
status: { type: "string", enum: ["all", "missing", "configured", "partial"], description: "Filter by BOM status. Default is 'all'." },
limit: { type: "number", description: "Maximum products to return. Default 20." }
}
}
},
{
name: "get_product_bom",
description: "Get full BOM formula details, current material costs, gross profit, margin %, and available raw materials for a product. Use this to inspect a product's variants and determine required materials.",
inputSchema: {
type: "object",
properties: {
productId: { type: "number", description: "Product ID" }
},
required: ["productId"]
}
},
{
name: "set_product_bom",
description: "Set or update the BOM (Bill of Materials) formula for a product's variants. Matches variants by variantId or sku, and raw materials by rawMaterialId or name. Replaces the variant's BOM items and recalculates cost/margin.",
inputSchema: {
type: "object",
properties: {
productId: { type: "number", description: "Product ID" },
variants: {
type: "array",
description: "Array of variant BOM formulas to apply",
items: {
type: "object",
properties: {
variantId: { type: "number", description: "Variant ID (optional if sku provided)" },
sku: { type: "string", description: "Variant SKU (optional if variantId provided)" },
bomItems: {
type: "array",
description: "List of raw material ingredients/packaging for this variant",
items: {
type: "object",
properties: {
rawMaterialId: { type: "number", description: "Raw material ID (optional if name provided)" },
name: { type: "string", description: "Raw material name (optional if rawMaterialId provided)" },
quantity: { type: "number", description: "Quantity of raw material used per unit" }
},
required: ["quantity"]
}
}
},
required: ["bomItems"]
}
}
},
required: ["productId", "variants"]
}
},
{
name: "list_marketplace_products",
description: "List marketplace products (TikTok, Shopee, Lazada) extracted from orders. Allows filtering by status ('unmapped', 'mapped', 'all'), channel ('tiktok', 'shopee', 'lazada', 'all'), search text, and pagination. Returns sales metrics and current masterVariant mapping.",
inputSchema: {
type: "object",
properties: {
channel: { type: "string", enum: ["tiktok", "shopee", "lazada", "all"], description: "Marketplace channel. Default is 'all'." },
status: { type: "string", enum: ["unmapped", "mapped", "all"], description: "Mapping status. Default is 'unmapped'." },
search: { type: "string", description: "Search by product name, variation, or SKU" },
page: { type: "number", description: "Page number. Default 1." },
limit: { type: "number", description: "Items per page. Default 50." }
}
}
},
{
name: "get_master_product_variants",
description: "List all master product variants from the core catalog for mapping, including product name, options (size/scent), SKU, barcode, and unit BOM cost.",
inputSchema: {
type: "object",
properties: {
search: { type: "string", description: "Search by product name, variant option, or SKU" }
}
}
},
{
name: "map_marketplace_product",
description: "Link or unlink a single marketplace product (TikTok/Shopee/Lazada) to a master product variant (ProductVariant) to enable BOM cost and profitability calculation. Pass masterVariantId=null to unlink.",
inputSchema: {
type: "object",
properties: {
marketplaceProductId: { type: "number", description: "Marketplace product ID" },
masterVariantId: { type: ["number", "null"], description: "Master variant ID, or null to unlink" }
},
required: ["marketplaceProductId", "masterVariantId"]
}
},
{
name: "bulk_map_marketplace_products",
description: "Link multiple marketplace products to master catalog variants in a single batch operation.",
inputSchema: {
type: "object",
properties: {
mappings: {
type: "array",
description: "Array of mappings to apply",
items: {
type: "object",
properties: {
marketplaceProductId: { type: "number", description: "Marketplace product ID" },
masterVariantId: { type: ["number", "null"], description: "Master variant ID, or null to unlink" }
},
required: ["marketplaceProductId", "masterVariantId"]
}
}
},
required: ["mappings"]
}
},
{ {
name: "update_category_translation", name: "update_category_translation",
description: "Add or update translations for a specific category. Do NOT use this tool for products.", description: "Add or update translations for a specific category. Do NOT use this tool for products.",
@@ -1449,6 +1566,57 @@ async function handleToolRequest(request: CallToolRequest) {
const res = await api.get(`/products/${params.id}${lang}`); const res = await api.get(`/products/${params.id}${lang}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
} }
case "list_products_bom_status": {
const query = new URLSearchParams();
if (params.search) query.set("search", String(params.search));
if (params.status) query.set("status", String(params.status));
query.set("limit", String(params.limit ?? 20));
const res = await api.get(`/products/bom-status?${query.toString()}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "get_product_bom": {
const res = await api.get(`/products/${params.productId}/bom`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "set_product_bom": {
const res = await api.put(`/products/${params.productId}/bom`, { variants: params.variants });
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "list_marketplace_products": {
const query = new URLSearchParams();
if (params.channel) query.set("channel", String(params.channel));
if (params.status) query.set("status", String(params.status));
if (params.search) query.set("search", String(params.search));
if (params.page) query.set("page", String(params.page));
query.set("limit", String(params.limit ?? 50));
const res = await api.get(`/tools/marketplace-products?${query.toString()}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "get_master_product_variants": {
const res = await api.get(`/tools/marketplace-products/master-variants`);
let variants = res.data.variants || [];
if (params.search) {
const words = String(params.search).toLowerCase().split(/\s+/).filter(Boolean);
variants = variants.filter((v: any) => {
const target = `${v.displayName} ${v.sku} ${v.productName} ${v.optionsStr}`.toLowerCase();
return words.every((w: string) => target.includes(w));
});
}
return { content: [{ type: "text", text: JSON.stringify({ variants }, null, 2) }] };
}
case "map_marketplace_product": {
const res = await api.post(`/tools/marketplace-products/map`, {
marketplaceProductId: params.marketplaceProductId,
masterVariantId: params.masterVariantId,
});
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "bulk_map_marketplace_products": {
const res = await api.post(`/tools/marketplace-products/bulk-map`, {
mappings: params.mappings,
});
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "create_product": { case "create_product": {
const res = await api.post(`/products`, params); const res = await api.post(`/products`, params);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
+116
View File
@@ -0,0 +1,116 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { join } from "node:path";
async function main() {
console.log("Connecting to MCP server...");
const transport = new StdioClientTransport({
command: process.execPath,
args: [join(import.meta.dir, "index.ts")],
cwd: import.meta.dir,
env: {
...process.env,
MCP_BACKEND_URL: "http://localhost:3051/api",
},
stderr: "pipe",
});
const client = new Client({ name: "bom-test-client", version: "1.0.0" });
await client.connect(transport);
console.log("Connected to MCP server!");
// 1. List tools and verify BOM tools are registered
const tools = (await client.listTools()).tools;
console.log(`Total MCP tools registered: ${tools.length}`);
const bomTools = ["list_products_bom_status", "get_product_bom", "set_product_bom"];
for (const name of bomTools) {
const found = tools.find((t) => t.name === name);
if (!found) {
throw new Error(`Tool ${name} not found!`);
}
console.log(`✓ Found tool: ${name}`);
}
// 2. Call list_products_bom_status
console.log("\nCalling list_products_bom_status for search='ซากุระ'...");
const statusRes = await client.callTool({
name: "list_products_bom_status",
arguments: { search: "ซากุระ", limit: 5 },
});
console.log("Status result:", (statusRes.content as any)[0].text.slice(0, 300) + "...");
// 3. Call get_product_bom for Product #250
console.log("\nCalling get_product_bom for productId=250...");
const getBomRes = await client.callTool({
name: "get_product_bom",
arguments: { productId: 250 },
});
const bomData = JSON.parse((getBomRes.content as any)[0].text);
console.log(`Product Name: ${bomData.productName}`);
console.log(`Variants count: ${bomData.variants.length}`);
console.log(`Available Raw Materials count: ${bomData.availableRawMaterials.length}`);
// 4. Call set_product_bom for Product #250
console.log("\nCalling set_product_bom for Product #250 with 4 variants...");
const setBomRes = await client.callTool({
name: "set_product_bom",
arguments: {
productId: 250,
variants: [
{
variantId: 3206,
sku: "4-1-30ML",
bomItems: [
{ rawMaterialId: 4, name: "หัวน้ำหอม กลิ่นซากุระ", quantity: 2.4 },
{ rawMaterialId: 91, name: "DPG", quantity: 27.6 },
{ rawMaterialId: 92, name: "ขวดพร้อมฝา Oilozone 30ml", quantity: 1 },
],
},
{
variantId: 3207,
sku: "4-1-60ML",
bomItems: [
{ rawMaterialId: 4, name: "หัวน้ำหอม กลิ่นซากุระ", quantity: 4.8 },
{ rawMaterialId: 91, name: "DPG", quantity: 55.2 },
{ rawMaterialId: 93, name: "ขวดพร้อมฝา Oilozone 60ml", quantity: 1 },
],
},
{
variantId: 3208,
sku: "4-1-85ML",
bomItems: [
{ rawMaterialId: 4, name: "หัวน้ำหอม กลิ่นซากุระ", quantity: 6.8 },
{ rawMaterialId: 91, name: "DPG", quantity: 78.2 },
{ rawMaterialId: 94, name: "ขวดพร้อมฝา Oilozone 85ml", quantity: 1 },
],
},
{
variantId: 3209,
sku: "4-1-120ML",
bomItems: [
{ rawMaterialId: 4, name: "หัวน้ำหอม กลิ่นซากุระ", quantity: 9.6 },
{ rawMaterialId: 91, name: "DPG", quantity: 110.4 },
{ rawMaterialId: 95, name: "ขวดพร้อมฝา Oilozone 120ml", quantity: 1 },
],
},
],
},
});
const setResult = JSON.parse((setBomRes.content as any)[0].text);
console.log("\nSet BOM result:");
console.log(`Success: ${setResult.success}`);
console.log(`Message: ${setResult.message}`);
for (const v of setResult.variants) {
console.log(` - Variant: ${v.option} (SKU: ${v.sku}) | Retail: ฿${v.retailPrice} | BOM Cost: ฿${v.totalBomCost} | Margin: ${v.marginPercent}%`);
}
await client.close();
console.log("\nAll BOM MCP tests passed successfully!");
}
main().catch((err) => {
console.error("Test failed:", err);
process.exit(1);
});
+314 -5
View File
@@ -60,6 +60,7 @@ function MenuImage({ src, alt, placeholder, className, style }: { src?: string |
export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) { export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
const [openId, setOpenId] = useState<string | null>(null); const [openId, setOpenId] = useState<string | null>(null);
const [hoveredLinkImage, setHoveredLinkImage] = useState<string | null>(null); const [hoveredLinkImage, setHoveredLinkImage] = useState<string | null>(null);
const [activeTabByMenu, setActiveTabByMenu] = useState<Record<string, number>>({});
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const location = useLocation(); const location = useLocation();
const { i18n } = useTranslation(); const { i18n } = useTranslation();
@@ -110,10 +111,17 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
const isOpen = openId === uniqueId; const isOpen = openId === uniqueId;
const hasColumns = item.columns && item.columns.length > 0; const hasColumns = item.columns && item.columns.length > 0;
const visibleColumns = item.columns?.slice(0, 2) ?? []; const layout = item.layout || 'columns';
const isClassic = layout === 'classic';
const isTabbed = layout === 'tabbed';
const isVisualCards = layout === 'visual-cards';
const configuredCols = item.layoutConfig?.columnCount || (item.columns?.length ? Math.min(Math.max(item.columns.length, 2), 4) : 2);
const visibleColumns = item.columns?.slice(0, configuredCols) ?? [];
const featuredLink = getFirstImageLink(item.columns); const featuredLink = getFirstImageLink(item.columns);
const featuredConfig = item.featured; const featuredConfig = item.featured;
const showFeatured = featuredConfig?.enabled !== false; const showFeatured = (item.layoutConfig?.showFeatured !== false) && (featuredConfig?.enabled !== false) && !isClassic && !isVisualCards && !isTabbed && (
!!(featuredConfig?.imageUrl || featuredConfig?.title || featuredConfig?.sectionTitle || featuredLink?.imageUrl)
);
const configuredImage = featuredConfig?.imageUrl || null; const configuredImage = featuredConfig?.imageUrl || null;
const featuredImage = hoveredLinkImage ?? configuredImage ?? featuredLink?.imageUrl ?? item.rootImageUrl; const featuredImage = hoveredLinkImage ?? configuredImage ?? featuredLink?.imageUrl ?? item.rootImageUrl;
const focalPoint = featuredConfig?.focalPoint; const focalPoint = featuredConfig?.focalPoint;
@@ -129,6 +137,28 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
const featuredHref = featuredConfig?.url || featuredLink?.url || item.url || '#'; const featuredHref = featuredConfig?.url || featuredLink?.url || item.url || '#';
const featuredCta = getLocalizedText(featuredConfig?.ctaLabel, lang) || 'View details'; const featuredCta = getLocalizedText(featuredConfig?.ctaLabel, lang) || 'View details';
const getMegaContainerClass = (cols: number, hasFeatured: boolean) => {
if (hasFeatured) {
if (cols >= 4) return 'w-[min(100vw-2rem,1260px)]';
if (cols === 3) return 'w-[min(100vw-2rem,1120px)]';
return 'w-[min(100vw-2rem,980px)]';
}
if (cols >= 4) return 'w-[min(100vw-2rem,1180px)]';
if (cols === 3) return 'w-[min(100vw-2rem,960px)]';
return 'w-[min(100vw-2rem,720px)]';
};
const getMegaGridColsClass = (cols: number, hasFeatured: boolean) => {
if (hasFeatured) {
if (cols >= 4) return 'grid-cols-[1fr_1fr_1fr_1fr_260px]';
if (cols === 3) return 'grid-cols-[1fr_1fr_1fr_270px]';
return 'grid-cols-[1fr_1fr_270px]';
}
if (cols >= 4) return 'grid-cols-4';
if (cols === 3) return 'grid-cols-3';
return 'grid-cols-2';
};
return ( return (
<div <div
key={uniqueId} key={uniqueId}
@@ -159,9 +189,288 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
)} )}
</Link> </Link>
{hasColumns && ( {hasColumns && isClassic && (
<div <div
className={`fixed left-1/2 z-50 w-[min(100vw-2rem,980px)] -translate-x-1/2 transition-all duration-200 ${ className={`absolute left-0 top-full z-50 pt-2 transition-all duration-200 ${
isOpen ? 'opacity-100 pointer-events-auto translate-y-0' : 'opacity-0 pointer-events-none -translate-y-1'
}`}
onMouseEnter={() => handleOpen(uniqueId)}
onMouseLeave={handleClose}
>
<div className="min-w-[230px] max-w-xs cursor-default overflow-hidden rounded-xl border border-pop-black/10 bg-pop-bg/95 p-2 shadow-2xl backdrop-blur-xl">
{item.columns?.map((col, colIdx) => (
<div key={colIdx} className={colIdx > 0 ? 'mt-2 border-t border-pop-black/10 pt-2' : ''}>
{getLocalizedText(col.title, lang) && item.columns && item.columns.length > 1 && (
<h5 className="px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-pop-black/50">
{getLocalizedText(col.title, lang)}
</h5>
)}
<ul className="space-y-0.5">
{col.links.map((link, linkIdx) => (
<li key={linkIdx}>
<Link
to={link.url || '#'}
onClick={handleLinkClick}
className="group flex items-center justify-between rounded-lg px-3 py-2 text-xs font-semibold normal-case tracking-normal text-pop-black transition-colors hover:bg-pop-card hover:text-pop-black"
>
<span className="flex items-center gap-2.5">
{link.icon && (
<DynamicIcon
iconName={link.icon}
defaultIcon={Box}
className="h-4 w-4 shrink-0 text-pop-black/70 transition-colors group-hover:text-pop-black"
strokeWidth={1.5}
/>
)}
<span>{getLocalizedText(link.label, lang)}</span>
</span>
{link.links?.length ? (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-pop-black/30 transition-colors group-hover:text-pop-black" />
) : null}
</Link>
{link.links && link.links.length > 0 && (
<ul className="ml-4 space-y-0.5 border-l border-pop-black/10 pl-2 my-1">
{link.links.map((child, childIdx) => (
<li key={childIdx}>
<Link
to={child.url || '#'}
onClick={handleLinkClick}
className="block rounded-md px-2 py-1 text-[11px] font-normal normal-case tracking-normal text-pop-black/60 transition-colors hover:bg-pop-card hover:text-pop-black"
>
{getLocalizedText(child.label, lang)}
</Link>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</div>
))}
</div>
</div>
)}
{hasColumns && isTabbed && (
<div
className={`fixed left-1/2 z-50 w-[min(100vw-2rem,1040px)] -translate-x-1/2 transition-all duration-200 ${
isOpen ? 'opacity-100 pointer-events-auto translate-y-0' : 'opacity-0 pointer-events-none -translate-y-1'
}`}
style={{ top: 'var(--navbar-bottom, 56px)' }}
onMouseEnter={() => handleOpen(uniqueId)}
onMouseLeave={handleClose}
>
<div className="mx-auto h-3 w-20 overflow-hidden">
<div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-pop-black/10 bg-pop-bg/95" />
</div>
<div className="w-full cursor-default overflow-hidden rounded-xl border border-pop-black/10 bg-pop-bg/95 shadow-2xl backdrop-blur-xl">
<div className="flex min-h-[380px] max-h-[500px]">
{/* Left Category Tabs Sidebar */}
<div className="w-64 shrink-0 border-r border-pop-black/10 bg-pop-card/30 p-3 space-y-1 overflow-y-auto">
<div className="px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-pop-black/40">
{lang === 'th' ? 'หมวดหมู่หลัก' : 'Categories'}
</div>
{item.columns?.map((column, colIdx) => {
const activeIdx = activeTabByMenu[uniqueId] ?? 0;
const isSelected = activeIdx === colIdx;
return (
<button
key={colIdx}
type="button"
onMouseEnter={() => {
setActiveTabByMenu(prev => ({ ...prev, [uniqueId]: colIdx }));
setHoveredLinkImage(null);
}}
className={`flex w-full items-center justify-between rounded-lg px-3.5 py-2.5 text-left transition-all ${
isSelected
? 'bg-pop-primary/10 text-pop-primary font-bold shadow-sm'
: 'text-pop-black/80 hover:bg-pop-card hover:text-pop-black font-medium'
}`}
>
<span className="flex items-center gap-2.5 min-w-0">
<DynamicIcon
iconName={column.icon}
defaultIcon={Sparkles}
className={`h-4 w-4 shrink-0 ${isSelected ? 'text-pop-primary' : 'text-pop-black/50'}`}
strokeWidth={1.5}
/>
<span className="truncate text-xs normal-case">{getLocalizedText(column.title, lang)}</span>
</span>
<ChevronRight className={`h-3.5 w-3.5 shrink-0 transition-transform ${isSelected ? 'text-pop-primary translate-x-0.5' : 'text-pop-black/20'}`} />
</button>
);
})}
</div>
{/* Right Content Panel for Selected Tab */}
{(() => {
const activeIdx = activeTabByMenu[uniqueId] ?? 0;
const activeCol = item.columns?.[activeIdx] || item.columns?.[0];
if (!activeCol) return null;
return (
<div className="flex-1 p-6 flex flex-col justify-between overflow-y-auto">
<div>
<div className="flex items-center justify-between border-b border-pop-black/10 pb-3 mb-4">
<div className="flex items-center gap-2.5">
<DynamicIcon
iconName={activeCol.icon}
defaultIcon={Sparkles}
className="h-5 w-5 text-pop-primary"
strokeWidth={1.5}
/>
<h3 className="text-sm font-bold normal-case tracking-normal text-pop-black">
{getLocalizedText(activeCol.title, lang)}
</h3>
</div>
<Link
to={activeCol.links[0]?.url || item.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-1.5 text-xs font-semibold normal-case text-pop-primary hover:underline"
>
<span>{lang === 'th' ? 'ดูรายการทั้งหมด' : 'View all'}</span>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{activeCol.links.map((link, lIdx) => (
<div
key={lIdx}
className="rounded-lg p-2.5 hover:bg-pop-card transition-colors group"
onMouseEnter={() => setHoveredLinkImage(link.imageUrl || null)}
onMouseLeave={() => setHoveredLinkImage(null)}
>
<Link
to={link.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-2.5"
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-pop-black/10 bg-pop-bg text-pop-primary group-hover:border-pop-primary/40">
<DynamicIcon
iconName={link.icon}
defaultIcon={Box}
className="h-3.5 w-3.5"
strokeWidth={1.35}
/>
</span>
<span className="truncate text-xs font-semibold normal-case text-pop-black group-hover:text-pop-primary transition-colors">
{getLocalizedText(link.label, lang)}
</span>
</Link>
{link.links && link.links.length > 0 && (
<div className="ml-9 mt-1.5 flex flex-wrap gap-1.5">
{link.links.slice(0, 3).map((child, cIdx) => (
<Link
key={cIdx}
to={child.url || '#'}
onClick={handleLinkClick}
className="text-[10px] text-pop-black/60 hover:text-pop-primary transition-colors bg-pop-card px-1.5 py-0.5 rounded border border-pop-black/5"
>
{getLocalizedText(child.label, lang)}
</Link>
))}
</div>
)}
</div>
))}
</div>
</div>
<div className="border-t border-pop-black/10 pt-4 mt-6 flex items-center justify-between text-xs text-pop-black/50">
<span>{item.columns?.length || 0} {lang === 'th' ? 'หมวดหมู่ย่อย' : 'categories'}</span>
<Link
to={item.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-1.5 font-semibold text-pop-black hover:text-pop-primary transition-colors"
>
<span>{getLocalizedText(item.label, lang)}</span>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
</div>
);
})()}
</div>
</div>
</div>
)}
{hasColumns && isVisualCards && (
<div
className={`fixed left-1/2 z-50 w-[min(100vw-2rem,1040px)] -translate-x-1/2 transition-all duration-200 ${
isOpen ? 'opacity-100 pointer-events-auto translate-y-0' : 'opacity-0 pointer-events-none -translate-y-1'
}`}
style={{ top: 'var(--navbar-bottom, 56px)' }}
onMouseEnter={() => handleOpen(uniqueId)}
onMouseLeave={handleClose}
>
<div className="mx-auto h-3 w-20 overflow-hidden">
<div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-pop-black/10 bg-pop-bg/95" />
</div>
<div className="w-full cursor-default overflow-hidden rounded-2xl border border-pop-black/10 bg-pop-bg/95 p-6 shadow-2xl backdrop-blur-xl">
<div className="mb-4 flex items-center justify-between">
<h4 className="text-xs font-bold uppercase tracking-wider text-pop-black/40">
{getLocalizedText(item.label, lang)}
</h4>
<Link
to={item.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-1 text-xs font-semibold text-pop-primary hover:underline"
>
<span>{lang === 'th' ? 'ดูทั้งหมด' : 'Explore all'}</span>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{item.columns?.slice(0, 4).map((column, colIdx) => {
const cardImg = column.iconUrl || column.links?.[0]?.imageUrl || configuredImage || item.rootImageUrl;
const cardHref = column.links?.[0]?.url || item.url || '#';
const cardTitle = getLocalizedText(column.title, lang);
const firstSubLink = column.links?.[0];
const subText = firstSubLink ? getLocalizedText(firstSubLink.label, lang) : null;
return (
<Link
key={colIdx}
to={cardHref}
onClick={handleLinkClick}
className="group relative block overflow-hidden rounded-xl border border-pop-black/10 bg-pop-card transition-all hover:border-pop-primary/50 hover:shadow-lg"
>
<div className="relative aspect-[4/5] w-full overflow-hidden bg-pop-card/50">
<MenuImage
src={cardImg}
alt={cardTitle}
placeholder={cardTitle}
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-4 space-y-1">
<span className="block text-sm font-bold text-white normal-case leading-snug drop-shadow-sm">
{cardTitle}
</span>
{subText && (
<span className="flex items-center justify-between text-[11px] font-normal text-white/80 normal-case">
<span className="truncate">{subText}</span>
<ArrowRight className="h-3.5 w-3.5 shrink-0 text-white/80 transition-transform group-hover:translate-x-1" />
</span>
)}
</div>
</div>
</Link>
);
})}
</div>
</div>
</div>
)}
{hasColumns && !isClassic && !isTabbed && !isVisualCards && (
<div
className={`fixed left-1/2 z-50 ${getMegaContainerClass(visibleColumns.length, showFeatured)} -translate-x-1/2 transition-all duration-200 ${
isOpen ? 'opacity-100 pointer-events-auto translate-y-0' : 'opacity-0 pointer-events-none -translate-y-1' isOpen ? 'opacity-100 pointer-events-auto translate-y-0' : 'opacity-0 pointer-events-none -translate-y-1'
}`} }`}
style={{ top: 'var(--navbar-bottom, 56px)' }} style={{ top: 'var(--navbar-bottom, 56px)' }}
@@ -172,7 +481,7 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
<div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-pop-black/10 bg-pop-bg/95" /> <div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-pop-black/10 bg-pop-bg/95" />
</div> </div>
<div className="w-full overflow-hidden rounded-lg border border-pop-black/10 bg-pop-bg/95 shadow-2xl cursor-default backdrop-blur-xl"> <div className="w-full overflow-hidden rounded-lg border border-pop-black/10 bg-pop-bg/95 shadow-2xl cursor-default backdrop-blur-xl">
<div className={`grid gap-0 px-8 py-8 ${showFeatured ? 'grid-cols-[1fr_1fr_270px]' : 'grid-cols-2'}`}> <div className={`grid gap-0 px-8 py-8 ${getMegaGridColsClass(visibleColumns.length, showFeatured)}`}>
{visibleColumns.map((col, idx) => ( {visibleColumns.map((col, idx) => (
<div <div
key={idx} key={idx}
+10
View File
@@ -29,11 +29,21 @@ export interface MenuFeaturedCard {
ctaLabel?: Record<string, string> | string; ctaLabel?: Record<string, string> | string;
} }
export type SubmenuLayoutType = 'columns' | 'classic' | 'tabbed' | 'visual-cards';
export interface SubmenuLayoutConfig {
columnCount?: 2 | 3 | 4;
showFeatured?: boolean;
itemStyle?: 'icon' | 'thumbnail' | 'text-only';
}
export interface MegaMenuPayload { export interface MegaMenuPayload {
id?: string; id?: string;
nodeId?: string; nodeId?: string;
label: Record<string, string> | string; label: Record<string, string> | string;
url?: string; url?: string;
layout?: SubmenuLayoutType;
layoutConfig?: SubmenuLayoutConfig;
columns?: MenuColumn[]; columns?: MenuColumn[];
rootImageUrl?: string | null; // รูปหลักฝั่งซ้ายสุด (1:1) rootImageUrl?: string | null; // รูปหลักฝั่งซ้ายสุด (1:1)
featured?: MenuFeaturedCard; featured?: MenuFeaturedCard;
@@ -84,6 +84,7 @@ function MenuImage({
export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuProps) { export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuProps) {
const [openId, setOpenId] = useState<string | null>(null) const [openId, setOpenId] = useState<string | null>(null)
const [hoveredLinkImage, setHoveredLinkImage] = useState<string | null>(null) const [hoveredLinkImage, setHoveredLinkImage] = useState<string | null>(null)
const [activeTabByMenu, setActiveTabByMenu] = useState<Record<string, number>>({})
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null) const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Automatically close mega menu when route changes // Automatically close mega menu when route changes
@@ -128,10 +129,17 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
const hasColumns = !!item.columns?.length const hasColumns = !!item.columns?.length
const isActive = item.url ? currentPath === item.url : false const isActive = item.url ? currentPath === item.url : false
const visibleColumns = item.columns?.slice(0, 2) ?? [] const layout = item.layout || 'columns'
const isClassic = layout === 'classic'
const isTabbed = layout === 'tabbed'
const isVisualCards = layout === 'visual-cards'
const configuredCols = item.layoutConfig?.columnCount || (item.columns?.length ? Math.min(Math.max(item.columns.length, 2), 4) : 2)
const visibleColumns = item.columns?.slice(0, configuredCols) ?? []
const featuredLink = getFirstImageLink(item.columns) const featuredLink = getFirstImageLink(item.columns)
const featuredConfig = item.featured const featuredConfig = item.featured
const showFeatured = featuredConfig?.enabled !== false const showFeatured = (item.layoutConfig?.showFeatured !== false) && (featuredConfig?.enabled !== false) && !isClassic && !isVisualCards && !isTabbed && (
!!(featuredConfig?.imageUrl || featuredConfig?.title || featuredConfig?.sectionTitle || featuredLink?.imageUrl)
)
const configuredImage = featuredConfig?.imageUrl || null const configuredImage = featuredConfig?.imageUrl || null
const featuredImage = hoveredLinkImage ?? configuredImage ?? featuredLink?.imageUrl ?? item.rootImageUrl const featuredImage = hoveredLinkImage ?? configuredImage ?? featuredLink?.imageUrl ?? item.rootImageUrl
const focalPoint = featuredConfig?.focalPoint const focalPoint = featuredConfig?.focalPoint
@@ -147,6 +155,28 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
const featuredHref = featuredConfig?.url || featuredLink?.url || item.url || '#' const featuredHref = featuredConfig?.url || featuredLink?.url || item.url || '#'
const featuredCta = getLocalizedMenuText(featuredConfig?.ctaLabel, lang) || 'View details' const featuredCta = getLocalizedMenuText(featuredConfig?.ctaLabel, lang) || 'View details'
const getMegaContainerClass = (cols: number, hasFeatured: boolean) => {
if (hasFeatured) {
if (cols >= 4) return 'w-[min(100vw-2rem,1260px)]'
if (cols === 3) return 'w-[min(100vw-2rem,1120px)]'
return 'w-[min(100vw-2rem,980px)]'
}
if (cols >= 4) return 'w-[min(100vw-2rem,1180px)]'
if (cols === 3) return 'w-[min(100vw-2rem,960px)]'
return 'w-[min(100vw-2rem,720px)]'
}
const getMegaGridColsClass = (cols: number, hasFeatured: boolean) => {
if (hasFeatured) {
if (cols >= 4) return 'grid-cols-[1fr_1fr_1fr_1fr_260px]'
if (cols === 3) return 'grid-cols-[1fr_1fr_1fr_270px]'
return 'grid-cols-[1fr_1fr_270px]'
}
if (cols >= 4) return 'grid-cols-4'
if (cols === 3) return 'grid-cols-3'
return 'grid-cols-2'
}
return ( return (
<div <div
key={uniqueId} key={uniqueId}
@@ -176,9 +206,288 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
)} )}
</Link> </Link>
{hasColumns && ( {hasColumns && isClassic && (
<div <div
className={`fixed left-1/2 z-50 w-[min(100vw-2rem,980px)] -translate-x-1/2 transition-all duration-200 ${ className={`absolute left-0 top-full z-50 pt-2 transition-all duration-200 ${
isOpen ? 'pointer-events-auto translate-y-0 opacity-100' : 'pointer-events-none -translate-y-1 opacity-0'
}`}
onMouseEnter={() => handleOpen(uniqueId)}
onMouseLeave={handleClose}
>
<div className="min-w-[230px] max-w-xs cursor-default overflow-hidden rounded-xl border border-line-primary/15 bg-surface-card/95 p-2 shadow-card backdrop-blur-xl">
{item.columns?.map((column, columnIndex) => (
<div key={columnIndex} className={columnIndex > 0 ? 'mt-2 border-t border-line-primary/10 pt-2' : ''}>
{getLocalizedMenuText(column.title, lang) && item.columns && item.columns.length > 1 && (
<h5 className="px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-muted-text">
{getLocalizedMenuText(column.title, lang)}
</h5>
)}
<ul className="space-y-0.5">
{column.links.map((link, linkIndex) => (
<li key={linkIndex}>
<Link
href={link.url || '#'}
onClick={handleLinkClick}
className="group flex items-center justify-between rounded-lg px-3 py-2 text-xs font-semibold normal-case tracking-normal text-text-main transition-colors hover:bg-surface-muted hover:text-brand-primary"
>
<span className="flex items-center gap-2.5">
{link.icon && (
<DynamicIcon
iconName={link.icon}
defaultIcon={Box}
className="h-4 w-4 shrink-0 text-brand-primary/80 transition-colors group-hover:text-brand-primary"
strokeWidth={1.5}
/>
)}
<span>{getLocalizedMenuText(link.label, lang)}</span>
</span>
{link.links?.length ? (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-text-main/30 transition-colors group-hover:text-brand-primary" />
) : null}
</Link>
{link.links && link.links.length > 0 && (
<ul className="ml-4 space-y-0.5 border-l border-line-primary/15 pl-2 my-1">
{link.links.map((child, childIdx) => (
<li key={childIdx}>
<Link
href={child.url || '#'}
onClick={handleLinkClick}
className="block rounded-md px-2 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-text transition-colors hover:bg-surface-muted hover:text-brand-primary"
>
{getLocalizedMenuText(child.label, lang)}
</Link>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</div>
))}
</div>
</div>
)}
{hasColumns && isTabbed && (
<div
className={`fixed left-1/2 z-50 w-[min(100vw-2rem,1040px)] -translate-x-1/2 transition-all duration-200 ${
isOpen ? 'pointer-events-auto translate-y-0 opacity-100' : 'pointer-events-none -translate-y-1 opacity-0'
}`}
style={{ top: 'var(--navbar-bottom, 64px)' }}
onMouseEnter={() => handleOpen(uniqueId)}
onMouseLeave={handleClose}
>
<div className="mx-auto h-3 w-20 overflow-hidden">
<div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-line-primary/15 bg-surface-card/95" />
</div>
<div className="w-full cursor-default overflow-hidden rounded-xl border border-line-primary/15 bg-surface-card/95 shadow-card backdrop-blur-xl">
<div className="flex min-h-[380px] max-h-[500px]">
{/* Left Category Tabs Sidebar */}
<div className="w-64 shrink-0 border-r border-line-primary/15 bg-surface-page/50 p-3 space-y-1 overflow-y-auto">
<div className="px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-muted-text">
{lang === 'th' ? 'หมวดหมู่หลัก' : 'Categories'}
</div>
{item.columns?.map((column, colIdx) => {
const activeIdx = activeTabByMenu[uniqueId] ?? 0
const isSelected = activeIdx === colIdx
return (
<button
key={colIdx}
type="button"
onMouseEnter={() => {
setActiveTabByMenu(prev => ({ ...prev, [uniqueId]: colIdx }))
setHoveredLinkImage(null)
}}
className={`flex w-full items-center justify-between rounded-lg px-3.5 py-2.5 text-left transition-all ${
isSelected
? 'bg-brand-primary/10 text-brand-primary font-bold shadow-sm'
: 'text-text-main/80 hover:bg-surface-muted hover:text-text-main font-medium'
}`}
>
<span className="flex items-center gap-2.5 min-w-0">
<DynamicIcon
iconName={column.icon}
defaultIcon={Sparkles}
className={`h-4 w-4 shrink-0 ${isSelected ? 'text-brand-primary' : 'text-brand-primary/60'}`}
strokeWidth={1.5}
/>
<span className="truncate text-xs normal-case">{getLocalizedMenuText(column.title, lang)}</span>
</span>
<ChevronRight className={`h-3.5 w-3.5 shrink-0 transition-transform ${isSelected ? 'text-brand-primary translate-x-0.5' : 'text-text-main/20'}`} />
</button>
)
})}
</div>
{/* Right Content Panel for Selected Tab */}
{(() => {
const activeIdx = activeTabByMenu[uniqueId] ?? 0
const activeCol = item.columns?.[activeIdx] || item.columns?.[0]
if (!activeCol) return null
return (
<div className="flex-1 p-6 flex flex-col justify-between overflow-y-auto">
<div>
<div className="flex items-center justify-between border-b border-line-primary/10 pb-3 mb-4">
<div className="flex items-center gap-2.5">
<DynamicIcon
iconName={activeCol.icon}
defaultIcon={Sparkles}
className="h-5 w-5 text-brand-primary"
strokeWidth={1.5}
/>
<h3 className="text-sm font-bold normal-case tracking-normal text-text-main">
{getLocalizedMenuText(activeCol.title, lang)}
</h3>
</div>
<Link
href={activeCol.links[0]?.url || item.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-1.5 text-xs font-semibold normal-case text-brand-primary hover:underline"
>
<span>{lang === 'th' ? 'ดูรายการทั้งหมด' : 'View all'}</span>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{activeCol.links.map((link, lIdx) => (
<div
key={lIdx}
className="rounded-lg p-2.5 hover:bg-surface-muted transition-colors group"
onMouseEnter={() => setHoveredLinkImage(link.imageUrl || null)}
onMouseLeave={() => setHoveredLinkImage(null)}
>
<Link
href={link.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-2.5"
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-line-primary/20 bg-surface-page text-brand-primary group-hover:border-brand-primary/40">
<DynamicIcon
iconName={link.icon}
defaultIcon={Box}
className="h-3.5 w-3.5"
strokeWidth={1.35}
/>
</span>
<span className="truncate text-xs font-semibold normal-case text-text-main group-hover:text-brand-primary transition-colors">
{getLocalizedMenuText(link.label, lang)}
</span>
</Link>
{link.links && link.links.length > 0 && (
<div className="ml-9 mt-1.5 flex flex-wrap gap-1.5">
{link.links.slice(0, 3).map((child, cIdx) => (
<Link
key={cIdx}
href={child.url || '#'}
onClick={handleLinkClick}
className="text-[10px] text-muted-text hover:text-brand-primary transition-colors bg-surface-page/60 px-1.5 py-0.5 rounded border border-line-primary/10"
>
{getLocalizedMenuText(child.label, lang)}
</Link>
))}
</div>
)}
</div>
))}
</div>
</div>
<div className="border-t border-line-primary/10 pt-4 mt-6 flex items-center justify-between text-xs text-muted-text">
<span>{item.columns?.length || 0} {lang === 'th' ? 'หมวดหมู่ย่อย' : 'categories'}</span>
<Link
href={item.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-1.5 font-semibold text-text-main hover:text-brand-primary transition-colors"
>
<span>{getLocalizedMenuText(item.label, lang)}</span>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
</div>
)
})()}
</div>
</div>
</div>
)}
{hasColumns && isVisualCards && (
<div
className={`fixed left-1/2 z-50 w-[min(100vw-2rem,1040px)] -translate-x-1/2 transition-all duration-200 ${
isOpen ? 'pointer-events-auto translate-y-0 opacity-100' : 'pointer-events-none -translate-y-1 opacity-0'
}`}
style={{ top: 'var(--navbar-bottom, 64px)' }}
onMouseEnter={() => handleOpen(uniqueId)}
onMouseLeave={handleClose}
>
<div className="mx-auto h-3 w-20 overflow-hidden">
<div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-line-primary/15 bg-surface-card/95" />
</div>
<div className="w-full cursor-default overflow-hidden rounded-2xl border border-line-primary/15 bg-surface-card/95 p-6 shadow-card backdrop-blur-xl">
<div className="mb-4 flex items-center justify-between">
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-text">
{getLocalizedMenuText(item.label, lang)}
</h4>
<Link
href={item.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-1 text-xs font-semibold text-brand-primary hover:underline"
>
<span>{lang === 'th' ? 'ดูทั้งหมด' : 'Explore all'}</span>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{item.columns?.slice(0, 4).map((column, colIdx) => {
const cardImg = column.iconUrl || column.links?.[0]?.imageUrl || configuredImage || item.rootImageUrl
const cardHref = column.links?.[0]?.url || item.url || '#'
const cardTitle = getLocalizedMenuText(column.title, lang)
const firstSubLink = column.links?.[0]
const subText = firstSubLink ? getLocalizedMenuText(firstSubLink.label, lang) : null
return (
<Link
key={colIdx}
href={cardHref}
onClick={handleLinkClick}
className="group relative block overflow-hidden rounded-xl border border-line-primary/20 bg-surface-page transition-all hover:border-brand-primary/50 hover:shadow-lg"
>
<div className="relative aspect-[4/5] w-full overflow-hidden bg-surface-muted">
<MenuImage
src={cardImg}
alt={cardTitle}
placeholder={cardTitle}
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-4 space-y-1">
<span className="block text-sm font-bold text-white normal-case leading-snug drop-shadow-sm">
{cardTitle}
</span>
{subText && (
<span className="flex items-center justify-between text-[11px] font-normal text-white/80 normal-case">
<span className="truncate">{subText}</span>
<ArrowRight className="h-3.5 w-3.5 shrink-0 text-white/80 transition-transform group-hover:translate-x-1" />
</span>
)}
</div>
</div>
</Link>
)
})}
</div>
</div>
</div>
)}
{hasColumns && !isClassic && !isTabbed && !isVisualCards && (
<div
className={`fixed left-1/2 z-50 ${getMegaContainerClass(visibleColumns.length, showFeatured)} -translate-x-1/2 transition-all duration-200 ${
isOpen ? 'pointer-events-auto translate-y-0 opacity-100' : 'pointer-events-none -translate-y-1 opacity-0' isOpen ? 'pointer-events-auto translate-y-0 opacity-100' : 'pointer-events-none -translate-y-1 opacity-0'
}`} }`}
style={{ top: 'var(--navbar-bottom, 64px)' }} style={{ top: 'var(--navbar-bottom, 64px)' }}
@@ -189,7 +498,7 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
<div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-line-primary/15 bg-surface-card/95" /> <div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-line-primary/15 bg-surface-card/95" />
</div> </div>
<div className="w-full cursor-default overflow-hidden rounded-lg border border-line-primary/15 bg-surface-card/95 shadow-card backdrop-blur-xl"> <div className="w-full cursor-default overflow-hidden rounded-lg border border-line-primary/15 bg-surface-card/95 shadow-card backdrop-blur-xl">
<div className={`grid gap-0 px-8 py-8 ${showFeatured ? 'grid-cols-[1fr_1fr_270px]' : 'grid-cols-2'}`}> <div className={`grid gap-0 px-8 py-8 ${getMegaGridColsClass(visibleColumns.length, showFeatured)}`}>
{visibleColumns.map((column, columnIndex) => ( {visibleColumns.map((column, columnIndex) => (
<div <div
key={columnIndex} key={columnIndex}
@@ -26,11 +26,21 @@ export interface MenuFeaturedCard {
ctaLabel?: Record<string, string> | string ctaLabel?: Record<string, string> | string
} }
export type SubmenuLayoutType = 'columns' | 'classic' | 'tabbed' | 'visual-cards'
export interface SubmenuLayoutConfig {
columnCount?: 2 | 3 | 4
showFeatured?: boolean
itemStyle?: 'icon' | 'thumbnail' | 'text-only'
}
export interface MegaMenuPayload { export interface MegaMenuPayload {
id?: string id?: string
nodeId?: string nodeId?: string
label: Record<string, string> | string label: Record<string, string> | string
url?: string url?: string
layout?: SubmenuLayoutType
layoutConfig?: SubmenuLayoutConfig
columns?: MenuColumn[] columns?: MenuColumn[]
rootImageUrl?: string | null // รูปหลักฝั่งซ้ายสุด (1:1) rootImageUrl?: string | null // รูปหลักฝั่งซ้ายสุด (1:1)
featured?: MenuFeaturedCard featured?: MenuFeaturedCard
+34
View File
@@ -13,6 +13,40 @@ See [Platform Overview](PLATFORM_OVERVIEW.md) for terminology.
- `PROJECT_SYSTEM_SUMMARY.md` - `PROJECT_SYSTEM_SUMMARY.md`
- `PROJECT_MANAGEMENT_PERSPECTIVE.md` - `PROJECT_MANAGEMENT_PERSPECTIVE.md`
- `LLM_MCP_GUIDE.md` - `LLM_MCP_GUIDE.md`
- `ARCHITECTURE_DECISIONS.md`
- `CUSTOM_BLOCK_FLOW_GUIDE.md`
- `ADMIN_MCP_SECTION_GUIDE.md`
- `PRISMA7_GUIDE.md`
- `mcp_update.md`
- `implementation_plan.md`
- `frontend-comparison.md`
- `BUSINESS_ANALYTICS_MAPS_PLAN.md`
- `goal.section.md`
- `DEPLOYMENT_GUIDE.md`
- `DDC_DEPLOYMENT_RUNBOOK.md`
- `HUAHIN_DEPLOYMENT_RUNBOOK.md`
- `KITTISAK_DEPLOYMENT_RUNBOOK.md`
- `TENANT_PROVISIONING_POSTMORTEM_CHECKLIST.md`
- `PRODUCT_JSON_IMPORT_SCHEMA.md`
- `LLM_PRODUCT_JSON_GENERATOR_GUIDE.md`
- `LLM_PROMPT_1_WELLNESS_LUXURY.md`
- `LLM_PROMPT_2_BEAUTY_MULTIBRAND.md`
- `LLM_PROMPT_3_FASHION_LIFESTYLE.md`
- `LLM_PROMPT_4_MOTO_PARTS.md`
- `TEST_INFRA.md`
- `docs/notifications/order-confirmation.md`
- `docs/notifications/shipping-tracking.md`
- `docs/policies/privacy-policy.md`
- `docs/policies/return-policy.md`
- `docs/policies/shipping-policy.md`
- `docs/tenants/kittisak.com/README.md`
- `docs/tenants/kittisak.com/brand-identity.md`
- `docs/tenants/kittisak.com/integrations.md`
- `docs/tenants/kittisak.com/user-flows.md`
- `ORIGINAL_REQUEST.md`
- `RESUME_PORTFOLIO_MARKETING_DEV.md`
- `.agents/README.md`
- `.agents/ORIGINAL_REQUEST.md`
These documents now introduce DBK Platform before mentioning HomAroma and explain These documents now introduce DBK Platform before mentioning HomAroma and explain
the platform/tenant boundary. the platform/tenant boundary.
+31 -39
View File
@@ -1,79 +1,71 @@
# สรุปความแตกต่างและเป้าหมายการใช้งาน: `apps/ssr` vs `apps/website` # สรุปความแตกต่างและเป้าหมายการใช้งาน: `apps/spa` vs `apps/website` — DBK Platform
> **Platform architecture note:** Any HomAroma-specific routes, data, or branding cited below are reference-tenant examples. > **Platform architecture note:** DBK Platform integrates two complementary frontend applications. HomAroma is the reference tenant; tenant-specific routes or branding cited below are reference examples. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
เอกสารนี้สรุปความแตกต่างเชิงสถาปัตยกรรม (Architecture) และจุดประสงค์การใช้งานระหว่าง Frontend ทั้งสองระบบในโปรเจกต์ HomAroma เพื่อให้ทีมพัฒนาเข้าใจว่าควรเลือกใช้หรือพัฒนาต่อยอดที่โปรเจกต์ใด เอกสารนี้สรุปความแตกต่างเชิงสถาปัตยกรรม (Architecture) และจุดประสงค์การใช้งานระหว่าง Frontend ทั้งสองระบบใน DBK Platform เพื่อให้ทีมพัฒนาเข้าใจว่าควรเลือกใช้หรือพัฒนาต่อยอดที่โปรเจกต์ใด
## ตารางเปรียบเทียบ (Comparison Table) ## ตารางเปรียบเทียบ (Comparison Table)
| คุณสมบัติ (Feature) | `apps/website` (SPA) | `apps/ssr` (Next.js) | | คุณสมบัติ (Feature) | `apps/spa` (Mobile / Social Commerce) | `apps/website` (Main Public Website) |
| :--- | :--- | :--- | | :--- | :--- | :--- |
| **สถาปัตยกรรมพื้นฐาน** | Single Page Application (SPA) | Server-Side Rendering (SSR) | | **สถาปัตยกรรมพื้นฐาน** | Single Page Application (SPA) | Server-Side Rendering (SSR) |
| **เครื่องมือ / Framework**| React 19 + Vite | Next.js 15 (App Router) + React 19 | | **เครื่องมือ / Framework**| React 19 + Vite + Tailwind CSS | Next.js 15 (App Router) + React 19 + Tailwind CSS |
| **การประมวลผล (Rendering)**| Client-side Rendering (ประมวลผลที่เบราว์เซอร์) | Server-side Rendering (ประมวลผลที่เซิร์ฟเวอร์ก่อนส่งให้ผู้ใช้) | | **การประมวลผล (Rendering)**| Client-side Rendering (ประมวลผลที่เบราว์เซอร์) | Server-side Rendering (ประมวลผลที่เซิร์ฟเวอร์ก่อนส่งให้ผู้ใช้) |
| **เป้าหมายหลัก (Use Case)** | **LINE OA / LIFF App**, Web App ที่เน้นความลื่นไหล | **SEO**, การค้นหาผ่าน Google, หน้าเว็บสาธารณะ | | **เป้าหมายหลัก (Use Case)** | **LINE OA / LIFF App**, Mobile-first commerce, Retention | **SEO**, การค้นหาผ่าน Google, หน้าเว็บแบรนด์สาธารณะ |
| **จุดเด่น** | ทำงานลื่นไหลเหมือนแอปมือถือ (ไม่กระตุกตอนเปลี่ยนหน้า), เหมาะกับ Webview ในแอปพลิเคชัน | ทำ SEO ได้ดีมาก Bot สามารถอ่านข้อมูลสินค้าได้ทันที, โหลดหน้าแรกได้ไว | | **จุดเด่น** | ทำงานลื่นไหลเหมือนแอปมือถือ (ไม่กระตุกตอนเปลี่ยนหน้า), เหมาะกับ Webview ใน LINE | ทำ SEO ได้ดีมาก Bot ของ Search Engine อ่านข้อมูลสินค้าได้ทันที, โหลดหน้าแรกได้ไว |
| **จุดด้อย** | ทำ SEO ได้ยาก (Bot ของ Google อาจจะอ่านข้อมูลไม่ครบเพราะต้องรอ JavaScript ทำงาน) | ต้องใช้ทรัพยากรฝั่ง Server ในการรัน (Node.js Process), ซับซ้อนกว่าในการตั้งค่า | | **จุดด้อย** | ทำ SEO ได้ยาก (Bot ของ Google อาจจะอ่านข้อมูลไม่ครบเพราะต้องรอ JavaScript) | ต้องใช้ทรัพยากรฝั่ง Server ในการรัน (Node.js Process), ซับซ้อนกว่าในการตั้งค่า |
| **รูปแบบการ Deploy** | **Static Files** (HTML/CSS/JS) เสิร์ฟผ่าน Nginx โดยตรง ไม่ต้องมี Node.js รันเป็นพื้นหลัง | **Node.js Server** ต้องใช้ PM2 รัน `next start` และใช้ Nginx ทำ Reverse Proxy | | **รูปแบบการ Deploy** | **Static Files** (HTML/CSS/JS) เสิร์ฟผ่าน Nginx โดยตรง | **Node.js Server** รันด้วย PM2 / Bun และใช้ Nginx ทำ Reverse Proxy |
| **ตำแหน่งไฟล์บน Server**| `/home/ti/projects/MyNginxMain/nginx/html/homaroma.shop/website` | `/home/ti/projects/homaroma-ssr` | | **คำสั่ง Build** | `cd apps/spa && bun run build` (ได้โฟลเดอร์ `dist`) | `cd apps/website && bun run build` (ได้โฟลเดอร์ `.next`) |
| **คำสั่ง Build** | `bun run build` (ได้โฟลเดอร์ `dist`) | `bun run build` (ได้โฟลเดอร์ `.next`) | | **การจัดการ Routing** | `react-router-dom` (ประมวลผลที่ฝั่ง Client ทั้งหมด) | Next.js App Router (File-based routing with Server Components) |
| **Service ที่ทำงาน**| ไม่มี (Nginx จัดการทั้งหมด) | **PM2** ชื่อ process: `homaroma-ssr` รันบนพอร์ต `3001` | | **การจัดการ State** | Zustand | React Context / Zustand / Server State |
| **การจัดการ Routing** | `react-router-dom` (ประมวลผลที่ฝั่ง Client ทั้งหมด) | Next.js App Router (มีระบบ File-based routing) | | **ความเหมาะสมของระบบ Auth**| เหมาะสมกับ **LINE LIFF Login** และ Token-based auth เพราะมี Context แบบ SPA | เหมาะกับ Cookie-based Auth / Server Session |
| **การจัดการ State** | Zustand | React Context / Zustand / Server Components |
| **ความเหมาะสมของระบบ Login**| เหมาะสมกับ **LINE LIFF Login** และ Token-based auth เพราะมี Context แบบ SPA | เหมาะกับ Cookie-based Auth (NextAuth/Auth.js) หรือ Session |
--- ---
## สรุปแนวทางการพัฒนา (Development Guidelines) ## สรุปแนวทางการพัฒนา (Development Guidelines)
### 1. เลือกใช้ `apps/website` เมื่อ: ### 1. เลือกใช้ `apps/spa` เมื่อ:
- คุณต้องการพัฒนาฟีเจอร์ที่ให้ลูกค้าเปิดใช้งานผ่าน **LINE Official Account (LIFF Menu)** - คุณต้องการพัฒนาฟีเจอร์ที่ให้ลูกค้าเปิดใช้งานผ่าน **LINE Official Account (LIFF Menu)** หรือ Social Commerce
- เน้นประสบการณ์ผู้ใช้แบบ **App-like UX** ที่ต้องการความรวดเร็วในการกดเปลี่ยนหน้า เช่น ระบบตะกร้าสินค้าแบบ Popup หรือ Dashboard ส่วนตัวลูกค้า - เน้นประสบการณ์ผู้ใช้แบบ **App-like UX** ที่ต้องการความรวดเร็วในการกดเปลี่ยนหน้า เช่น ระบบตะกร้าสินค้าแบบ Popup หรือ Dashboard ส่วนตัวลูกค้า
- ไม่สนใจเรื่อง SEO (เช่น หน้าจัดการโปรไฟล์ลูกค้าส่วนตัว, หน้า Checkout) - ไม่เน้นเรื่อง Public Search SEO (เช่น หน้าจัดการโปรไฟล์ลูกค้าส่วนตัว, หน้าสะสมแต้ม, หน้า Checkout)
### 2. เลือกใช้ `apps/ssr` เมื่อ: ### 2. เลือกใช้ `apps/website` เมื่อ:
- เป็นหน้าเพจที่ต้องการดึงดูดลูกค้าใหม่ (Acquisition) ผ่าน Search Engine (Google) - เป็นหน้าเพจที่ต้องการดึงดูดลูกค้าใหม่ (Acquisition) ผ่าน Search Engine (Google)
- หน้าสินค้า (Product Details), หมวดหมู่สินค้า (Category), หรือบทความ (Blog) ที่คุณอยากให้แชร์ลิงก์แล้วขึ้นภาพ Preview (Open Graph) ที่สวยงามบน Facebook/LINE - หน้าสินค้า (Product Details), หมวดหมู่สินค้า (Category), หรือบทความ (Blog) ที่ต้องการแชร์ลิงก์แล้วมี Rich Snippets / Open Graph สวยงามบน Facebook/LINE/X
- เป็นหน้าหลัก (Landing Page) ที่เน้นความน่าเชื่อถือและต้องการโหลดให้เร็วที่สุดตั้งแต่ครั้งแรกที่เปิด - เป็นหน้าหลัก (Landing Page) ที่เน้นความน่าเชื่อถือและต้องการโหลดให้เร็วที่สุดตั้งแต่ครั้งแรกที่เปิด (Core Web Vitals)
--- ---
## ข้อมูลเชิงลึกด้านการ Build & Deploy (Server Config) ## ข้อมูลเชิงลึกด้านการ Build & Deploy (Server Config)
อ้างอิงจากการตั้งค่าในสคริปต์ `serverinfo/post-receive` บน Server ปัจจุบัน
### 📱 1. `apps/website` (Single Page Application) ### 📱 1. `apps/spa` (Single Page Application)
ระบบนี้ถูกคอมไพล์เป็นไฟล์ Static (HTML, CSS, JS) ซึ่งไม่มีการทำงานของ Server-side รันอยู่เบื้องหลัง ระบบนี้ถูกคอมไพล์เป็นไฟล์ Static (HTML, CSS, JS) ซึ่งไม่มีการทำงานของ Server-side รันอยู่เบื้องหลัง
- **คำสั่ง Build:** `cd apps/website && bun run build` - **คำสั่ง Build:** `cd apps/spa && bun run build`
- **สิ่งที่ได้จากการ Build:** โฟลเดอร์ `dist/` - **สิ่งที่ได้จากการ Build:** โฟลเดอร์ `dist/`
- **ตำแหน่งไฟล์ปลายทาง (Target Directory):**
`/home/ti/projects/MyNginxMain/nginx/html/homaroma.shop/website`
- **การจัดการ Environment:** ต้องประกาศตัวแปรแวดล้อมก่อนสั่ง Build เสมอ (เช่น `export VITE_LIFF_ID=...`) เพื่อให้ Vite ฝังค่าเข้าไปในไฟล์โค้ด
- **การตั้งค่า Nginx ที่จำเป็น (Routing Configuration):** - **การตั้งค่า Nginx ที่จำเป็น (Routing Configuration):**
เพื่อให้ระบบ React Router ทำงานได้โดยไม่เกิด 404 Error Nginx ต้องถูกตั้งค่าให้โยน Path ที่ไม่รู้จักกลับไปหา `index.html` เสมอ เพื่อให้ระบบ React Router ทำงานได้โดยไม่เกิด 404 Error Nginx ต้องถูกตั้งค่าให้โยน Path ที่ไม่รู้จักกลับไปหา `index.html` เสมอ
```nginx ```nginx
location / { location / {
root /usr/share/nginx/html/homaroma.shop/website; root /usr/share/nginx/html/tenant/spa;
index index.html; index index.html;
try_files $uri $uri/ /index.html; # <--- บังคับให้ SPA จัดการ Route เอง try_files $uri $uri/ /index.html; # <--- บังคับให้ SPA จัดการ Route เอง
} }
``` ```
### 🌍 2. `apps/ssr` (Next.js Application) ### 🌍 2. `apps/website` (Next.js Application)
ระบบนี้ต้องการ Node.js Server (รันผ่าน PM2) เพื่อประมวลผลข้อมูลที่ฝั่งเซิร์ฟเวอร์ทุกครั้งที่มีคนเปิดหน้าเว็บ (หรือประมวลผลไว้ล่วงหน้า) ระบบนี้ต้องการ Server Runtime (Node.js หรือ Bun) เพื่อประมวลผลข้อมูลที่ฝั่งเซิร์ฟเวอร์
- **คำสั่ง Build:** `cd apps/ssr && bun run build` - **คำสั่ง Build:** `cd apps/website && bun run build`
- **สิ่งที่ได้จากการ Build:** โฟลเดอร์ `.next/` และไฟล์ Source Code - **สิ่งที่ได้จากการ Build:** โฟลเดอร์ `.next/`
- **ตำแหน่งไฟล์ปลายทาง (Target Directory):**
`/home/ti/projects/homaroma-ssr`
- **การจัดการ Service (PM2):** - **การจัดการ Service (PM2):**
ต้องสั่งรันให้แอปทำงานอยู่เบื้องหลังเสมอด้วยคำสั่ง: รันแอปเป็น background process เช่น:
```bash ```bash
pm2 start /home/ti/projects/homaroma-ssr/node_modules/.bin/next --name homaroma-ssr -- start -p 3001 pm2 start npm --name "website-ssr" -- start
``` ```
- **การตั้งค่า Nginx ที่จำเป็น (Reverse Proxy):** - **การตั้งค่า Nginx ที่จำเป็น (Reverse Proxy):**
Nginx จะไม่ได้เสิร์ฟไฟล์ตรงๆ เหมือน SPA แต่ทำหน้าที่เป็นประตูส่ง Request ต่อไปให้ PM2 (ที่พอร์ต 3001) Nginx ทำหน้าที่เป็น Reverse Proxy ส่ง Request ต่อไปให้ Next.js:
```nginx ```nginx
location / { location / {
proxy_pass http://127.0.0.1:3001; proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade'; proxy_set_header Connection 'upgrade';
@@ -83,4 +75,4 @@
``` ```
--- ---
*Generated by Antigravity AI on 2026-05-01* *Updated for DBK Platform architecture standards*
+1 -1
View File
@@ -1,6 +1,6 @@
# ระบบ CMS Pages และ Section Block ของ DBK Platform # ระบบ CMS Pages และ Section Block ของ DBK Platform
> **Platform scope:** HomAroma content blocks are reference-tenant examples, not a restriction on tenant verticals. > **Platform scope:** HomAroma content blocks are reference-tenant examples, not a restriction on tenant verticals. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
## การทำงานของระบบ CMS Pages และ Section Block ## การทำงานของระบบ CMS Pages และ Section Block
File diff suppressed because it is too large. Load diff
+3
View File
@@ -143,6 +143,9 @@ export interface RawMaterial {
name: string name: string
unit: string unit: string
pricePerUnit: number pricePerUnit: number
bomItems?: BomItem[]
createdAt?: string
updatedAt?: string
} }
export interface BomItem { export interface BomItem {
+157
View File
@@ -0,0 +1,157 @@
import * as fs from "fs";
import * as path from "path";
const PROD_API = "https://api.homaroma.shop/api";
interface RawMaterialRef {
id: number;
name: string;
pricePerUnit: number;
unit: string;
}
interface VariantPlan {
id: number;
sku: string;
option: string;
price: number;
hasBom: boolean;
totalBomCost: number;
}
interface ProductPlan {
index: number;
productId: number;
productName: string;
scentName: string;
category: string | null;
bomStatus: string;
configuredVariants: number;
totalVariants: number;
variants: VariantPlan[];
matchedFragrance: RawMaterialRef | null;
packagingReady: boolean;
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const BOTTLE_MAP: Record<number, number> = {
30: 10, // ขวดพร้อมฝา Oilozone 30ml
60: 11, // ขวดพร้อมฝา Oilozone 60ml
85: 12, // ขวดพร้อมฝา Oilozone 85ml
120: 9, // ขวดพร้อมฝา Oilozone 120ml
};
const FORMULA_MAP: Record<number, { fragQty: number; dpgQty: number }> = {
30: { fragQty: 2.4, dpgQty: 27.6 },
60: { fragQty: 4.8, dpgQty: 55.2 },
85: { fragQty: 6.8, dpgQty: 78.2 },
120: { fragQty: 9.6, dpgQty: 110.4 },
};
function detectSize(variant: VariantPlan): number {
const opt = (variant.option || "").toLowerCase();
const sku = (variant.sku || "").toLowerCase();
if (opt.includes("120") || sku.includes("120ml")) return 120;
if (opt.includes("85") || sku.includes("85ml")) return 85;
if (opt.includes("60") || sku.includes("60ml")) return 60;
if (opt.includes("30") || sku.includes("30ml")) return 30;
throw new Error(`Cannot detect size for variant ${variant.id} (${variant.sku} / ${variant.option})`);
}
async function main() {
const jsonPath = path.resolve(process.cwd(), "oil_ozone_products.json");
if (!fs.existsSync(jsonPath)) {
throw new Error(`File not found: ${jsonPath}`);
}
const products: ProductPlan[] = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
console.log(`Loaded ${products.length} products from ${jsonPath}`);
let successCount = 0;
let skippedCount = 0;
let failedCount = 0;
for (const product of products) {
console.log(`\n--------------------------------------------------`);
console.log(`[${product.index}/${products.length}] Product #${product.productId}: ${product.productName}`);
if (!product.matchedFragrance) {
console.error(`❌ Skipped: No matching fragrance found for scent '${product.scentName}'`);
failedCount++;
continue;
}
if (product.bomStatus === "CONFIGURED" && product.configuredVariants === product.totalVariants) {
console.log(`⚡ Already configured (${product.configuredVariants}/${product.totalVariants} variants). Skipping.`);
skippedCount++;
continue;
}
// Build variants payload
const variantsPayload = product.variants.map((v) => {
const size = detectSize(v);
const formula = FORMULA_MAP[size];
const bottleId = BOTTLE_MAP[size];
return {
variantId: v.id,
sku: v.sku,
bomItems: [
{
rawMaterialId: product.matchedFragrance!.id,
name: product.matchedFragrance!.name,
quantity: formula.fragQty,
},
{
rawMaterialId: 2, // DPG
name: "DPG",
quantity: formula.dpgQty,
},
{
rawMaterialId: bottleId,
quantity: 1,
},
],
};
});
console.log(`Applying BOM for ${variantsPayload.length} variants with fragrance [ID ${product.matchedFragrance.id}] ${product.matchedFragrance.name}...`);
try {
const res = await fetch(`${PROD_API}/products/${product.productId}/bom`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ variants: variantsPayload }),
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`HTTP ${res.status}: ${errText}`);
}
const resData = (await res.json()) as any;
console.log(`✓ BOM saved successfully for #${product.productId}! Updated variants: ${resData.updatedVariants ?? variantsPayload.length}`);
successCount++;
} catch (err: any) {
console.error(`❌ Failed to update BOM for #${product.productId}: ${err.message}`);
failedCount++;
}
// Rate-limit pause
await sleep(200);
}
console.log(`\n==================================================`);
console.log(`BOM Application Summary:`);
console.log(` - Newly configured: ${successCount}`);
console.log(` - Already configured (skipped): ${skippedCount}`);
console.log(` - Failed: ${failedCount}`);
console.log(`==================================================\n`);
}
main().catch(console.error);
+169
View File
@@ -0,0 +1,169 @@
import * as fs from "fs";
import * as path from "path";
const PROD_API = "https://api.homaroma.shop/api";
interface RawMaterial {
id: number;
name: string;
unit: string;
pricePerUnit: number;
}
interface Variant {
id: number;
sku: string;
options: string;
price: number;
hasBom: boolean;
bomItemCount: number;
totalBomCost: number;
}
interface Product {
id: number;
name: string;
brand: string | null;
category: string | null;
totalVariants: number;
configuredVariants: number;
bomStatus: string;
variants: Variant[];
}
async function main() {
console.log("1. Fetching all products from production...");
const statusRes = await fetch(`${PROD_API}/products/bom-status?limit=250`);
if (!statusRes.ok) throw new Error(`Failed to fetch products: ${statusRes.statusText}`);
const statusData = (await statusRes.json()) as { products: Product[] };
// Filter only Oil Ozone products
const allProducts = statusData.products || [];
const oilOzoneProducts = allProducts.filter(
(p) =>
p.name.includes("Oil Ozone") ||
(p.category && p.category.includes("โอโซน")) ||
p.variants.some((v) => v.sku && v.sku.includes("-1-"))
);
console.log(`Found ${oilOzoneProducts.length} Oil Ozone products.`);
// 2. Fetch all raw materials from production
console.log("2. Fetching available raw materials from production...");
const rmRes = await fetch(`${PROD_API}/raw-materials`);
if (!rmRes.ok) throw new Error(`Failed to fetch raw materials: ${rmRes.statusText}`);
const rawMaterials = (await rmRes.json()) as RawMaterial[];
console.log(`Found ${rawMaterials.length} raw materials on production.`);
// Identify common raw materials
const dpg = rawMaterials.find((r) => r.name.trim().toLowerCase() === "dpg");
const b30 = rawMaterials.find((r) => r.name.includes("Oilozone 30ml") || r.name.includes("ขวดพร้อมฝา Oilozone 30ml"));
const b60 = rawMaterials.find((r) => r.name.includes("Oilozone 60ml") || r.name.includes("ขวดพร้อมฝา Oilozone 60ml"));
const b85 = rawMaterials.find((r) => r.name.includes("Oilozone 85ml") || r.name.includes("ขวดพร้อมฝา Oilozone 85ml"));
const b120 = rawMaterials.find((r) => r.name.includes("Oilozone 120ml") || r.name.includes("ขวดพร้อมฝา Oilozone 120ml"));
// Build match plan for each product
const productPlan = oilOzoneProducts.map((p, idx) => {
// Extract scent name e.g. "ซากุระ Oil Ozone" -> "ซากุระ"
const scentName = p.name.replace(/Oil Ozone/gi, "").trim();
// Look for matching fragrance oil in raw materials
const matchedFragrance = rawMaterials.find((rm) => {
const rmName = rm.name.trim();
return (
rmName === `หัวน้ำหอม กลิ่น${scentName}` ||
rmName === `หัวน้ำหอม ${scentName}` ||
rmName.includes(`กลิ่น${scentName}`) ||
(rmName.includes(scentName) && rmName.includes("หัวน้ำหอม"))
);
});
return {
index: idx + 1,
productId: p.id,
productName: p.name,
scentName,
category: p.category,
bomStatus: p.bomStatus,
configuredVariants: p.configuredVariants,
totalVariants: p.totalVariants,
variants: p.variants.map((v) => ({
id: v.id,
sku: v.sku,
option: v.options,
price: v.price,
hasBom: v.hasBom,
totalBomCost: v.totalBomCost,
marginPercent: v.price > 0 && v.totalBomCost > 0
? Number((((v.price - v.totalBomCost) / v.price) * 100).toFixed(1))
: null,
})),
matchedFragrance: matchedFragrance
? {
id: matchedFragrance.id,
name: matchedFragrance.name,
pricePerUnit: matchedFragrance.pricePerUnit,
unit: matchedFragrance.unit,
}
: null,
packagingReady: !!(dpg && b30 && b60 && b85 && b120),
};
});
// 3. Save JSON to local root folder
const jsonPath = path.resolve(process.cwd(), "oil_ozone_products.json");
fs.writeFileSync(jsonPath, JSON.stringify(productPlan, null, 2), "utf-8");
console.log(`\n✓ Saved JSON data to: ${jsonPath}`);
// 4. Generate Markdown Ledger
let md = `# รายการสินค้ากลุ่ม Oil Ozone สำหรับกำหนดสูตร BOM (Production)\n\n`;
md += `> **เป้าหมาย:** กำหนดสูตร BOM อัตราส่วน หัวน้ำหอม 8% + DPG 92% + ขวดและฝา 4 บาท (ทุกขนาด: 30ml, 60ml, 85ml, 120ml)\n`;
md += `> **อัปเดตล่าสุด:** ${new Date().toLocaleString("th-TH")}\n`;
md += `> **จำนวนสินค้าทั้งหมด:** ${productPlan.length} รายการ\n\n`;
md += `## 📦 สรุปสถานะภาพรวม\n`;
const configured = productPlan.filter((p) => p.bomStatus === "CONFIGURED").length;
const missing = productPlan.filter((p) => p.bomStatus === "MISSING").length;
const partial = productPlan.filter((p) => p.bomStatus === "PARTIAL").length;
const fragranceFound = productPlan.filter((p) => p.matchedFragrance !== null).length;
md += `- **ตั้งค่าเสร็จแล้ว (CONFIGURED):** ${configured} / ${productPlan.length} รายการ (100%)\n`;
md += `- **ยังไม่ได้ตั้งค่า (MISSING):** ${missing} รายการ\n`;
md += `- **ตั้งค่าบางส่วน (PARTIAL):** ${partial} รายการ\n`;
md += `- **พบวัตถุดิบหัวน้ำหอมตรงกลิ่น:** ${fragranceFound} / ${productPlan.length} รายการ\n\n`;
md += `## 📋 ตารางรายละเอียดต้นทุน BOM และอัตรากำไร (Margin)\n\n`;
md += `| # | ID | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุน 30ml (กำไร%) | ต้นทุน 60ml (กำไร%) | ต้นทุน 85ml (กำไร%) | ต้นทุน 120ml (กำไร%) | สถานะ |\n`;
md += `| :-: | :-: | :--- | :--- | :-: | :-: | :-: | :-: | :-: |\n`;
for (const p of productPlan) {
const statusBadge =
p.bomStatus === "CONFIGURED"
? "✅ พร้อมขาย"
: p.bomStatus === "PARTIAL"
? "⚠️ บางส่วน"
: "❌ ขาด BOM";
const fragText = p.matchedFragrance
? `[ID: ${p.matchedFragrance.id}] ${p.matchedFragrance.name} (฿${p.matchedFragrance.pricePerUnit})`
: `<span style="color:red">⚠️ ไม่พบหัวน้ำหอม</span>`;
const v30 = p.variants.find((v) => (v.option || "").includes("30") || (v.sku || "").includes("30ML"));
const v60 = p.variants.find((v) => (v.option || "").includes("60") || (v.sku || "").includes("60ML"));
const v85 = p.variants.find((v) => (v.option || "").includes("85") || (v.sku || "").includes("85ML"));
const v120 = p.variants.find((v) => (v.option || "").includes("120") || (v.sku || "").includes("120ML"));
const formatCost = (v?: (typeof p.variants)[0]) => {
if (!v || !v.hasBom) return "-";
return `฿${v.totalBomCost.toFixed(2)} (${v.marginPercent}%)`;
};
md += `| ${p.index} | **#${p.productId}** | [${p.productName}](https://admin.homaroma.shop/products/${p.productId}/bom) | ${fragText} | ${formatCost(v30)} | ${formatCost(v60)} | ${formatCost(v85)} | ${formatCost(v120)} | ${statusBadge} |\n`;
}
const mdPath = path.resolve(process.cwd(), "OIL_OZONE_BOM_LEDGER.md");
fs.writeFileSync(mdPath, md, "utf-8");
console.log(`✓ Saved Markdown ledger to: ${mdPath}`);
}
main().catch(console.error);
+299
View File
@@ -0,0 +1,299 @@
import * as fs from "fs";
import * as path from "path";
const PROD_API = "https://api.homaroma.shop/api";
interface MasterVariant {
id: number;
sku: string;
barcode: string;
price: number;
stock: number;
optionsStr: string;
productName: string;
categoryName: string;
brandName: string;
bomCost: number;
displayName: string;
}
interface MarketplaceProduct {
id: number;
channel: string;
shop: string;
marketplaceSku: string;
productName: string;
variation: string;
avgPrice: number;
totalSoldQty: number;
totalRevenue: number;
firstSoldAt: string;
lastSoldAt: string;
masterVariantId: number | null;
}
// Scents standard dictionary & aliases
const SCENT_ALIASES: Record<string, string> = {
"เปปเปอร์มินต์": "เปเปอร์มิ้น",
"เปปเปอร์มิ้นท์": "เปเปอร์มิ้น",
"เปปเปอร์มิ้น": "เปเปอร์มิ้น",
"เปเปอร์มินต์": "เปเปอร์มิ้น",
"ตะไคร้บ้าน": "ตะไคร้",
"เฟรช ลาเวนเดอร์": "เฟรชลาเวนเดอร์",
"เฟรชลาเวนเดอร์": "เฟรชลาเวนเดอร์",
"แซนดัลวูด": "แซนด้าวูส",
"แซนด้าวูด": "แซนด้าวูส",
"แซนดัลวู้ด": "แซนด้าวูส",
"กุหลาบ": "กุหลาบแดง",
"มะลิซ้อน": "มะลิ",
"ลูกพีช": "กลิ่นลูกพีช",
"พีช": "กลิ่นลูกพีช",
"องุ่น": "กลิ่นองุ่น",
"น้ำนม": "กลิ่นน้ำนม",
"นม": "กลิ่นน้ำนม",
"มหาสมุทร": "โอเชี่ยน",
"ราตรี": "ดอกราตรี",
"การเวก": "ดอกการะเวก",
"ดอกอัญชัน": "อัญชัน",
"ดอกบัวหลวง": "ดอกบัว",
"ไม้หอม": "แซนด้าวูส",
};
// Size extractor helper
function extractSize(variation: string, productName: string): string | null {
const v = (variation || "").toLowerCase();
const p = (productName || "").toLowerCase();
// Variation has highest priority for size
if (v.includes("120") || v.includes("120ml")) return "120ml";
if (v.includes("85") || v.includes("85ml")) return "85ml";
if (v.includes("60") || v.includes("60ml")) return "60ml";
if (v.includes("30") || v.includes("30ml")) return "30ml";
// Fallback to product name
if (p.includes("120") || p.includes("120ml") || p.includes("ขวดใหญ่")) return "120ml";
if (p.includes("85") || p.includes("85ml")) return "85ml";
if (p.includes("60") || p.includes("60ml")) return "60ml";
if (p.includes("30") || p.includes("30ml") || p.includes("ขวดเล็ก")) return "30ml";
return null;
}
// Scent extractor helper
function extractScent(variation: string, productName: string, masterScents: string[]): string | null {
const cleanVar = (variation || "")
.replace(/\d+\s*ml/gi, "")
.replace(/,/g, "")
.replace(/^กลิ่น\s*/, "")
.trim();
// 1. Check variation aliases first
for (const [alias, canonical] of Object.entries(SCENT_ALIASES)) {
if (cleanVar.includes(alias) || cleanVar === alias) {
return canonical;
}
}
// 2. Check direct master scents in variation
for (const scent of masterScents) {
if (cleanVar.includes(scent) || cleanVar === scent) {
return scent;
}
}
// 3. Fallback to product name aliases
for (const [alias, canonical] of Object.entries(SCENT_ALIASES)) {
if (productName.includes(alias)) {
return canonical;
}
}
// 4. Fallback to direct master scents in product name
for (const scent of masterScents) {
if (productName.includes(`กลิ่น ${scent}`) || productName.includes(`กลิ่น${scent}`) || productName.includes(scent)) {
return scent;
}
}
return null;
}
async function main() {
console.log("1. Fetching master variants from production...");
const mvRes = await fetch(`${PROD_API}/tools/marketplace-products/master-variants`);
const mvData = (await mvRes.json()) as { variants: MasterVariant[] };
const masterVariants = mvData.variants || [];
console.log(`Loaded ${masterVariants.length} master variants.`);
// Filter only Oil Ozone master variants
const ozoneVariants = masterVariants.filter(
(v) => (v.categoryName && v.categoryName.includes("โอโซน")) || v.productName.includes("Oil Ozone")
);
console.log(`Found ${ozoneVariants.length} Oil Ozone master variants.`);
// Collect distinct scents
const masterScents = Array.from(
new Set(ozoneVariants.map((v) => v.productName.replace(/Oil Ozone/gi, "").trim()))
).filter(Boolean);
console.log(`Detected ${masterScents.length} distinct Oil Ozone scents.`);
// 2. Fetch unmapped TikTok products
console.log("\n2. Fetching unmapped TikTok products from production...");
const mpRes = await fetch(`${PROD_API}/tools/marketplace-products?channel=tiktok&status=unmapped&limit=350`);
const mpData = (await mpRes.json()) as { items: MarketplaceProduct[]; pagination: any };
const unmappedItems = mpData.items || [];
console.log(`Found ${unmappedItems.length} unmapped TikTok items (total in DB: ${mpData.pagination?.total}).`);
// 3. Test matching
const matches: Array<{
item: MarketplaceProduct;
matchedVariant: MasterVariant;
scent: string;
size: string;
confidence: "HIGH" | "MEDIUM";
}> = [];
const unmatchable: MarketplaceProduct[] = [];
for (const item of unmappedItems) {
const text = `${item.productName} ${item.variation || ""}`;
const isOilOzone =
text.includes("โอโซน") ||
text.includes("oil ozone") ||
text.includes("ออยโอโซน") ||
text.includes("น้ำมันหอมระเหย") ||
text.includes("น้ำหอมอโรม่า") ||
text.includes("น้ำมันอโรม่า") ||
text.includes("น้ำมันหอมไอน้ำ") ||
item.marketplaceSku.includes("OZO") ||
item.marketplaceSku.includes("OIL_") ||
item.marketplaceSku === "30-ALL";
if (!isOilOzone) {
unmatchable.push(item);
continue;
}
const size = extractSize(item.variation, item.productName);
const scent = extractScent(item.variation, item.productName, masterScents);
if (scent && size) {
// Find matching variant
const matched = ozoneVariants.find((v) => {
const vScent = v.productName.replace(/Oil Ozone/gi, "").trim();
const vSize = (v.optionsStr || "").toLowerCase();
return (
vScent === scent &&
(vSize.includes(size.toLowerCase()) || v.sku.toLowerCase().includes(size.toLowerCase()))
);
});
if (matched) {
matches.push({
item,
matchedVariant: matched,
scent,
size,
confidence: "HIGH",
});
} else {
unmatchable.push(item);
}
} else {
unmatchable.push(item);
}
}
console.log(`\n==================================================`);
console.log(`Matching Simulation Results:`);
console.log(` - Successfully matched: ${matches.length} items (${((matches.length / unmappedItems.length) * 100).toFixed(1)}%)`);
console.log(` - Unmatched / Non-ozone: ${unmatchable.length} items`);
console.log(`==================================================\n`);
console.log("Sample 15 matches:");
matches.slice(0, 15).forEach((m, idx) => {
console.log(
`[${idx + 1}] MP #${m.item.id} (${m.item.marketplaceSku || 'NO_SKU'}) | '${m.item.productName.slice(0, 35)}...' | Var: '${m.item.variation}'`
);
console.log(
` -> MATCHED: [Variant #${m.matchedVariant.id} | SKU: ${m.matchedVariant.sku}] ${m.matchedVariant.displayName} (BOM: ฿${m.matchedVariant.bomCost.toFixed(2)})`
);
});
// Save report to scratch file
const reportPath = path.resolve(process.cwd(), "scratch_tiktok_matches.json");
fs.writeFileSync(
reportPath,
JSON.stringify(
{
matchedCount: matches.length,
unmatchedCount: unmatchable.length,
matches: matches.map((m) => ({
marketplaceProductId: m.item.id,
marketplaceSku: m.item.marketplaceSku,
marketplaceName: m.item.productName,
marketplaceVariation: m.item.variation,
matchedVariantId: m.matchedVariant.id,
matchedVariantSku: m.matchedVariant.sku,
matchedVariantName: m.matchedVariant.displayName,
unitBomCost: m.matchedVariant.bomCost,
soldQty: m.item.totalSoldQty,
revenue: m.item.totalRevenue,
})),
unmatched: unmatchable.map((u) => ({
id: u.id,
sku: u.marketplaceSku,
name: u.productName,
variation: u.variation,
revenue: u.totalRevenue,
})),
},
null,
2
),
"utf-8"
);
console.log(`\nSaved match report to: ${reportPath}`);
// 4. Apply mappings if --apply flag is passed
const shouldApply = process.argv.includes("--apply");
if (!shouldApply) {
console.log(`\n💡 To apply all ${matches.length} mappings to production, run:`);
console.log(` bun scripts/match_and_map_tiktok_products.ts --apply\n`);
return;
}
console.log(`\n🚀 Applying ${matches.length} mappings to production...`);
let applied = 0;
let failed = 0;
const BATCH = 15;
for (let i = 0; i < matches.length; i += BATCH) {
const chunk = matches.slice(i, i + BATCH);
await Promise.all(
chunk.map(async (m) => {
try {
const res = await fetch(`${PROD_API}/tools/marketplace-products/map`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
marketplaceProductId: m.item.id,
masterVariantId: m.matchedVariant.id,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
applied++;
} catch (err: any) {
console.error(`Failed to map MP #${m.item.id}:`, err.message);
failed++;
}
})
);
process.stdout.write(`\rProgress: ${applied}/${matches.length} applied (${failed} failed)...`);
await new Promise((r) => setTimeout(r, 100));
}
console.log(`\n\n🎉 Finished! Successfully mapped: ${applied} | Failed: ${failed}`);
}
main().catch(console.error);
+210
View File
@@ -0,0 +1,210 @@
/**
* Gitea Automation CLI & API Client for DBK Platform / HomAroma
* Usage: bun tools/gitea.ts <command> [args]
*/
const GITEA_URL = process.env.GITEA_URL || 'https://git.kittisak.com';
const GITEA_API = process.env.GITEA_API_URL || `${GITEA_URL}/api/v1`;
const GITEA_TOKEN = process.env.GITEA_TOKEN || '';
const OWNER = process.env.GITEA_OWNER || 'admin';
const REPO = process.env.GITEA_REPO || 'homaroma';
if (!GITEA_TOKEN) {
console.error('Error: GITEA_TOKEN is not set. Please check .env.local');
process.exit(1);
}
const headers = {
'Authorization': `token ${GITEA_TOKEN}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
};
async function api(path: string, options: RequestInit = {}) {
const url = `${GITEA_API}${path.startsWith('/') ? path : `/${path}`}`;
const res = await fetch(url, {
...options,
headers: { ...headers, ...(options.headers || {}) },
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Gitea API Error (${res.status} ${res.statusText}): ${errorText}`);
}
const contentType = res.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return await res.json();
}
return await res.text();
}
async function main() {
const [,, cmd, ...args] = process.argv;
try {
switch (cmd) {
case 'whoami': {
const user = await api('/user');
console.log(`Logged in as: ${user.username} (${user.email}) [Admin: ${user.is_admin}]`);
break;
}
case 'repo': {
const repo = await api(`/repos/${OWNER}/${REPO}`);
console.log(`Repository: ${repo.full_name}`);
console.log(`URL: ${repo.html_url}`);
console.log(`Default Branch: ${repo.default_branch}`);
console.log(`Open Issues: ${repo.open_issues_count} | Open PRs: ${repo.open_pr_counter}`);
break;
}
case 'issues': {
const sub = args[0] || 'list';
if (sub === 'list') {
const state = args[1] || 'open';
const issues = await api(`/repos/${OWNER}/${REPO}/issues?state=${state}`);
if (issues.length === 0) {
console.log(`No ${state} issues found.`);
} else {
console.log(`Found ${issues.length} ${state} issues:`);
for (const iss of issues) {
console.log(` #${iss.number} [${iss.state.toUpperCase()}] ${iss.title} (${iss.html_url})`);
}
}
} else if (sub === 'create') {
const title = args[1];
const body = args[2] || '';
if (!title) {
console.error('Usage: bun tools/gitea.ts issues create <title> [body]');
process.exit(1);
}
const issue = await api(`/repos/${OWNER}/${REPO}/issues`, {
method: 'POST',
body: JSON.stringify({ title, body }),
});
console.log(`Created Issue #${issue.number}: ${issue.title}`);
console.log(`URL: ${issue.html_url}`);
}
break;
}
case 'prs': {
const sub = args[0] || 'list';
if (sub === 'list') {
const state = args[1] || 'open';
const prs = await api(`/repos/${OWNER}/${REPO}/pulls?state=${state}`);
if (prs.length === 0) {
console.log(`No ${state} pull requests found.`);
} else {
console.log(`Found ${prs.length} ${state} pull requests:`);
for (const pr of prs) {
console.log(` #${pr.number} [${pr.state.toUpperCase()}] ${pr.title} (${pr.head.ref} -> ${pr.base.ref})`);
console.log(` URL: ${pr.html_url}`);
}
}
} else if (sub === 'create') {
const [, head, base, title, ...bodyParts] = args;
const body = bodyParts.join(' ') || '';
if (!head || !base || !title) {
console.error('Usage: bun tools/gitea.ts prs create <head_branch> <base_branch> <title> [body]');
process.exit(1);
}
const pr = await api(`/repos/${OWNER}/${REPO}/pulls`, {
method: 'POST',
body: JSON.stringify({ head, base, title, body }),
});
console.log(`Created Pull Request #${pr.number}: ${pr.title}`);
console.log(`URL: ${pr.html_url}`);
} else if (sub === 'merge') {
const index = args[1];
const method = args[2] || 'merge'; // merge, rebase, rebase-merge, squash
if (!index) {
console.error('Usage: bun tools/gitea.ts prs merge <pr_number> [method]');
process.exit(1);
}
await api(`/repos/${OWNER}/${REPO}/pulls/${index}/merge`, {
method: 'POST',
body: JSON.stringify({ Do: method }),
});
console.log(`Merged Pull Request #${index} using method: ${method}`);
}
break;
}
case 'releases': {
const sub = args[0] || 'list';
if (sub === 'list') {
const releases = await api(`/repos/${OWNER}/${REPO}/releases`);
if (releases.length === 0) {
console.log('No releases found.');
} else {
for (const rel of releases) {
console.log(` ${rel.tag_name} - ${rel.name} (${rel.html_url})`);
}
}
} else if (sub === 'create') {
const tag_name = args[1];
const name = args[2] || tag_name;
const body = args[3] || '';
if (!tag_name) {
console.error('Usage: bun tools/gitea.ts releases create <tag_name> [title] [notes]');
process.exit(1);
}
const rel = await api(`/repos/${OWNER}/${REPO}/releases`, {
method: 'POST',
body: JSON.stringify({ tag_name, name, body }),
});
console.log(`Created Release ${rel.tag_name}: ${rel.name}`);
console.log(`URL: ${rel.html_url}`);
}
break;
}
case 'projects': {
const sub = args[0] || 'list';
if (sub === 'list') {
const projects = await api(`/repos/${OWNER}/${REPO}/projects`);
if (projects.length === 0) {
console.log('No projects found.');
} else {
console.log(`Found ${projects.length} projects:`);
for (const p of projects) {
console.log(` #${p.id} ${p.title}`);
}
}
} else if (sub === 'create') {
const title = args[1];
const description = args.slice(2).join(' ') || '';
if (!title) {
console.error('Usage: bun tools/gitea.ts projects create <title> [description]');
process.exit(1);
}
const project = await api(`/repos/${OWNER}/${REPO}/projects`, {
method: 'POST',
body: JSON.stringify({ title, description }),
});
console.log(`Created Project #${project.id}: ${project.title}`);
}
break;
}
default: {
console.log(`Gitea Helper CLI for ${OWNER}/${REPO}`);
console.log('Available commands:');
console.log(' bun tools/gitea.ts whoami');
console.log(' bun tools/gitea.ts repo');
console.log(' bun tools/gitea.ts projects [list|create]');
console.log(' bun tools/gitea.ts issues [list|create]');
console.log(' bun tools/gitea.ts prs [list|create]');
console.log(' bun tools/gitea.ts releases [list|create]');
break;
}
}
} catch (err: any) {
console.error(err.message || err);
process.exit(1);
}
}
main();