Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f49776f9cb | ||
|
|
4fcdd8a816 | ||
|
|
b030c08f68 | ||
|
|
a5584d0d46 | ||
|
|
a0abfbf306 | ||
|
|
d8f6d8139f | ||
|
|
a647c9682d | ||
|
|
b13f37cf7e | ||
|
|
1ce711d960 | ||
|
|
809f420fe4 | ||
|
|
cbd816592b | ||
|
|
408f07e27a | ||
|
|
468b690ac3 | ||
|
|
18c12a6a14 | ||
|
|
ce252dc061 | ||
|
|
8a8438096c | ||
|
|
765cdd1fa9 | ||
|
|
dd79519bf7 | ||
|
|
0b6e5310c0 | ||
|
|
1be4657f3f | ||
|
|
647428961a | ||
|
|
731619af9d | ||
|
|
5ba4afb2de | ||
|
|
2364aa32b0 | ||
|
|
ca79692b3e | ||
|
|
0bf544720b | ||
|
|
64f7ecf729 |
No files matched your search
@@ -1,5 +1,7 @@
|
||||
# Original User Request
|
||||
|
||||
> **Historical context:** This is an unedited record of an earlier development request for the Split-Screen Editorial Carousel feature. The current platform identity is DBK Platform.
|
||||
|
||||
## Initial Request — 2026-06-28T10:50:33Z
|
||||
|
||||
Implement a Split-Screen Editorial Carousel by extending the existing `EDITORIAL_CAROUSEL` schema and updating the backend, admin, spa, and website apps.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent System & Workspaces Directory
|
||||
|
||||
> **Agent Infrastructure & Historical Provenance:** This directory houses both active multi-agent skills and historical subagent run workspaces. The platform identity is **DBK Platform**, with **HomAroma** as the reference tenant.
|
||||
|
||||
---
|
||||
|
||||
## 1. Active Skills (`.agents/skills/`)
|
||||
The `.agents/skills/` directory contains active domain skill definitions loaded on demand by Antigravity and orchestrator agents. Each skill contains a `SKILL.md` specifying role responsibilities, routing logic, and verification rules:
|
||||
|
||||
- **`orchestrator`**: Central project planning, task routing, delegation, and verification.
|
||||
- **`frontend-lead`**: React 19 SPA & Tailwind CSS development.
|
||||
- **`frontend-code-auditor`**: Review gate for bug risks, contracts, and regressions.
|
||||
- **`nextjs-seo-architect`**: Next.js 15 SSR, metadata, canonical URLs, and performance.
|
||||
- **`backend-mcp-lead`**: Elysia API endpoints, server architecture, and MCP tools.
|
||||
- **`database-architect`**: Prisma schema, PostgreSQL migrations, and data safety.
|
||||
- **`cms-architect`**: Dynamic page builder payloads and headless CMS schemas.
|
||||
- **`cms-content-manager`**: Content management and MCP blackbox testing.
|
||||
- **`product-manager`**: Product scope, roadmap, and business impact analysis.
|
||||
- **`ux-ui-designer`**: Design system, interaction models, and responsive layouts.
|
||||
- **`qa-product-auditor`**: User flows, regression checks, and release readiness.
|
||||
- **`brand-strategist`**: SEO copywriting, brand voice, and positioning.
|
||||
- **`theme-compliance-auditor`**: CMS dynamic theme token compliance (avoid hardcoded colors).
|
||||
|
||||
*Note: Changes to `.agents/skills/` directly affect agent behavior and should follow the operational rules defined in [`AGENTS.md`](../AGENTS.md).*
|
||||
|
||||
---
|
||||
|
||||
## 2. Historical Subagent Workspaces
|
||||
Directories matching `auditor_*`, `worker_*`, `explorer_*`, `reviewer_*`, `challenger_*`, `teamwork_preview_*`, `sentinel`, and `sub_orch_*` are historical workspaces and artifact directories generated during previous autonomous multi-agent sprint sessions.
|
||||
|
||||
- **Status:** Historical records preserved for provenance, auditability, and debugging.
|
||||
- **Usage:** Do not edit active platform rules within historical folders. Refer to [`AGENTS.md`](../AGENTS.md) and [`docs/PLATFORM_OVERVIEW.md`](../docs/PLATFORM_OVERVIEW.md) for current standards.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Handle line endings automatically for files detected as text
|
||||
* text=auto
|
||||
|
||||
# Lockfile must use LF across all platforms for Bun consistency
|
||||
bun.lock text eol=lf
|
||||
|
||||
# Shell scripts must use LF
|
||||
*.sh text eol=lf
|
||||
|
||||
# Windows batch scripts must use CRLF
|
||||
*.bat text eol=crlf
|
||||
@@ -1,6 +1,6 @@
|
||||
# DBK Platform Admin Section Guide
|
||||
|
||||
> **Platform scope:** This guide describes shared CMS administration capabilities. Product and content examples are tenant-specific; HomAroma is the reference tenant.
|
||||
> **Platform scope:** This guide describes shared CMS administration capabilities. Product and content examples are tenant-specific; HomAroma is the reference tenant. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||
|
||||
This guide explains the dynamic page blocks available in the Page Builder and what each block contains.
|
||||
|
||||
@@ -138,7 +138,7 @@ This guide explains the dynamic page blocks available in the Page Builder and wh
|
||||
|
||||
### `SCENT_NOTES_BREAKDOWN`
|
||||
|
||||
- Purpose: Fragrance layer breakdown
|
||||
- Purpose: Fragrance layer breakdown (reference tenant vertical block; pattern for multi-tier attribute breakdowns)
|
||||
- Contains:
|
||||
- `title` localized string optional
|
||||
- `description` localized string optional
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Architecture Decisions — DBK Platform
|
||||
|
||||
> **Platform scope:** HomAroma references in this ADR are reference-tenant context unless a decision explicitly names an operational resource.
|
||||
> **Platform scope:** DBK Platform architecture decisions. HomAroma references in this ADR are reference-tenant context unless a decision explicitly names an operational resource. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||
|
||||
> บันทึกการตัดสินใจเชิงสถาปัตยกรรม (ADR) จากการประชุมทีม
|
||||
> อัปเดตล่าสุด: 2026-07-12
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# แผนการพัฒนาและคู่มือสร้างแผนที่ Malaysia & Vietnam Map
|
||||
|
||||
> **Platform capability plan:** Geographic coverage, data availability, and analytics configuration must be enabled and validated per tenant.
|
||||
สำหรับเมนู Business Analytics บนระบบ HomAroma Admin
|
||||
> **Platform capability plan:** Geographic coverage, data availability, and analytics configuration are platform capabilities enabled per tenant. HomAroma is the reference tenant. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||
สำหรับเมนู Business Analytics บนระบบ DBK Platform Admin (Multi-tenant & Multi-region)
|
||||
|
||||
เอกสารนี้รวบรวมรายละเอียด แหล่งข้อมูล Open-source Repositories สำหรับดาวน์โหลดไฟล์ GeoJSON สถาปัตยกรรมทางเทคนิค และแผนขั้นตอนการนำไปพัฒนาต่อยอดให้เหมือนกับ **Omnichannel Sales Map (แผนที่ประเทศไทย)** เดิม
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Custom Block Flow Guide
|
||||
# Custom Block Flow Guide — DBK Platform
|
||||
|
||||
> **Platform scope:** This guide describes shared CMS block workflows. Content examples belong to their named tenant.
|
||||
> **Platform scope:** This guide describes shared CMS block workflows for DBK Platform. Content examples (e.g. `BakeryCategories`) belong to their named tenant. HomAroma is the reference tenant. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||
|
||||
เอกสารนี้อธิบาย Flow การสร้าง Custom Block สำหรับ HomAroma Page Builder โดยใช้ `BakeryCategories` เป็นตัวอย่างจริง เพื่อให้เพิ่ม block ถัดไปได้โดยไม่ต้อง hardcode section type ใหม่ทุกครั้ง
|
||||
เอกสารนี้อธิบาย Flow การสร้าง Custom Block สำหรับ DBK Platform Page Builder โดยใช้ `BakeryCategories` เป็นตัวอย่าง tenant จริง เพื่อให้เพิ่ม block ถัดไปได้โดยไม่ต้อง hardcode section type ใหม่ทุกครั้ง
|
||||
|
||||
## แนวคิดหลัก
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# DDC Deployment Runbook
|
||||
|
||||
> **Tenant operational runbook:** This document records DDC deployment configuration. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding.
|
||||
> **Tenant operational runbook:** This document records DDC deployment configuration for DBK Platform. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||
|
||||
เอกสารนี้เป็น runbook เฉพาะลูกค้า `ddc` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/pkh-*`
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# คู่มือการติดตั้งโปรเจกต์ใหม่ (New Project Deployment Guide)
|
||||
|
||||
> **DBK Platform operational guide:** Substitute tenant-specific domains, paths, credentials, and process names. Any `homaroma` identifier shown elsewhere in the repository is a legacy or reference-tenant identifier unless this guide explicitly selects it.
|
||||
> **DBK Platform operational guide:** Substitute tenant-specific domains, paths, credentials, and process names. Any `homaroma` identifier shown elsewhere in the repository is a legacy or reference-tenant identifier unless this guide explicitly selects it. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||
|
||||
เอกสารนี้รวบรวมขั้นตอนการสร้างสภาพแวดล้อม (Environment) และการตั้งค่าระบบสำหรับลูกค้ารายใหม่ (เช่น โปรเจกต์ `pkh`) โดยอ้างอิงจากโครงสร้างระบบปัจจุบันบนเซิร์ฟเวอร์ `kitizone.com`
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# HuaHin Deployment Runbook
|
||||
|
||||
> **Tenant operational runbook:** This document records HuaHin deployment configuration. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding.
|
||||
> **Tenant operational runbook:** This document records HuaHin deployment configuration for DBK Platform. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||
|
||||
เอกสารนี้เป็น runbook เฉพาะลูกค้า `huahin` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/ddc-*`
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Kittisak Deployment Runbook
|
||||
|
||||
> **Tenant operational runbook:** This document records Kittisak deployment configuration. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding.
|
||||
> **Tenant operational runbook:** This document records Kittisak deployment configuration for DBK Platform. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||
|
||||
เอกสารนี้เป็น runbook เฉพาะลูกค้า `kittisak` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/ddc-*` / `serverinfo/huahin-*`
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# รายการสินค้ากลุ่ม Oil Ozone สำหรับกำหนดสูตร BOM (Production)
|
||||
|
||||
> **เป้าหมาย:** กำหนดสูตร BOM อัตราส่วน หัวน้ำหอม 8% + DPG 92% + ขวดและฝา 4 บาท (ทุกขนาด: 30ml, 60ml, 85ml, 120ml)
|
||||
> **อัปเดตล่าสุด:** 18/10/2569 13:05:32
|
||||
> **จำนวนสินค้าทั้งหมด:** 45 รายการ
|
||||
|
||||
## 📦 สรุปสถานะภาพรวม
|
||||
- **ตั้งค่าเสร็จแล้ว (CONFIGURED):** 45 / 45 รายการ (100%)
|
||||
- **ยังไม่ได้ตั้งค่า (MISSING):** 0 รายการ
|
||||
- **ตั้งค่าบางส่วน (PARTIAL):** 0 รายการ
|
||||
- **พบวัตถุดิบหัวน้ำหอมตรงกลิ่น:** 45 / 45 รายการ
|
||||
|
||||
## 📋 ตารางรายละเอียดต้นทุน BOM และอัตรากำไร (Margin)
|
||||
|
||||
| # | ID | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุน 30ml (กำไร%) | ต้นทุน 60ml (กำไร%) | ต้นทุน 85ml (กำไร%) | ต้นทุน 120ml (กำไร%) | สถานะ |
|
||||
| :-: | :-: | :--- | :--- | :-: | :-: | :-: | :-: | :-: |
|
||||
| 1 | **#1** | [ตะไคร้ Oil Ozone](https://admin.homaroma.shop/products/1/bom) | [ID: 41] หัวน้ำหอม กลิ่นตะไคร้ (฿4.5) | ฿18.94 (72.6%) | ฿33.88 (66.1%) | ฿46.33 (61.4%) | ฿63.76 (57.5%) | ✅ พร้อมขาย |
|
||||
| 2 | **#6** | [ลาเวนเดอร์ Oil Ozone](https://admin.homaroma.shop/products/6/bom) | [ID: 15] หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
|
||||
| 3 | **#11** | [วานิลลา Oil Ozone](https://admin.homaroma.shop/products/11/bom) | [ID: 53] หัวน้ำหอม กลิ่นวานิลลา (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 4 | **#16** | [ซากุระ Oil Ozone](https://admin.homaroma.shop/products/16/bom) | [ID: 27] หัวน้ำหอม กลิ่นซากุระ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
|
||||
| 5 | **#21** | [มะลิ Oil Ozone](https://admin.homaroma.shop/products/21/bom) | [ID: 48] หัวน้ำหอม กลิ่นมะลิ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
|
||||
| 6 | **#26** | [ยูคาลิปตัส Oil Ozone](https://admin.homaroma.shop/products/26/bom) | [ID: 49] หัวน้ำหอม กลิ่นยูคาลิปตัส (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 7 | **#31** | [เฟรชลาเวนเดอร์ Oil Ozone](https://admin.homaroma.shop/products/31/bom) | [ID: 15] หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
|
||||
| 8 | **#36** | [เปเปอร์มิ้น Oil Ozone](https://admin.homaroma.shop/products/36/bom) | [ID: 14] หัวน้ำหอม กลิ่นเปเปอร์มิ้น (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 9 | **#41** | [โอเชี่ยน Oil Ozone](https://admin.homaroma.shop/products/41/bom) | [ID: 21] หัวน้ำหอม กลิ่นโอเชี่ยน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 10 | **#46** | [เซเว่นซี Oil Ozone](https://admin.homaroma.shop/products/46/bom) | [ID: 13] หัวน้ำหอม กลิ่นเซเว่นซี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 11 | **#51** | [ดอกแก้ว Oil Ozone](https://admin.homaroma.shop/products/51/bom) | [ID: 28] หัวน้ำหอม กลิ่นดอกแก้ว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 12 | **#56** | [ดอกโมก Oil Ozone](https://admin.homaroma.shop/products/56/bom) | [ID: 29] หัวน้ำหอม กลิ่นดอกโมก (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 13 | **#61** | [ลีลาวดี Oil Ozone](https://admin.homaroma.shop/products/61/bom) | [ID: 51] หัวน้ำหอม กลิ่นลีลาวดี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 14 | **#66** | [ส้ม Oil Ozone](https://admin.homaroma.shop/products/66/bom) | [ID: 54] หัวน้ำหอม กลิ่นส้ม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 15 | **#71** | [มะนาว Oil Ozone](https://admin.homaroma.shop/products/71/bom) | [ID: 46] หัวน้ำหอม กลิ่นมะนาว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 16 | **#76** | [แคนตาลูป Oil Ozone](https://admin.homaroma.shop/products/76/bom) | [ID: 16] หัวน้ำหอม กลิ่นแคนตาลูป (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 17 | **#81** | [แอปเปิ้ลแดง Oil Ozone](https://admin.homaroma.shop/products/81/bom) | [ID: 19] หัวน้ำหอม กลิ่นแอปเปิ้ลแดง (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 18 | **#86** | [แอปเปิ้ลเขียว Oil Ozone](https://admin.homaroma.shop/products/86/bom) | [ID: 18] หัวน้ำหอม กลิ่นแอปเปิ้ลเขียว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 19 | **#91** | [ดอกลิลลี่ Oil Ozone](https://admin.homaroma.shop/products/91/bom) | [ID: 40] หัวน้ำหอม กลิ่นดอกลิลลี่ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 20 | **#96** | [โรสเมรี่ Oil Ozone](https://admin.homaroma.shop/products/96/bom) | [ID: 20] หัวน้ำหอม กลิ่นโรสเมรี่ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 21 | **#101** | [อัญชัน Oil Ozone](https://admin.homaroma.shop/products/101/bom) | [ID: 57] หัวน้ำหอม กลิ่นอัญชัน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 22 | **#106** | [กุหลาบแดง Oil Ozone](https://admin.homaroma.shop/products/106/bom) | [ID: 26] หัวน้ำหอม กลิ่นกุหลาบแดง (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 23 | **#111** | [หมากฝรั่ง Oil Ozone](https://admin.homaroma.shop/products/111/bom) | [ID: 55] หัวน้ำหอม กลิ่นหมากฝรั่ง (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 24 | **#116** | [ผลไม้รวม Oil Ozone](https://admin.homaroma.shop/products/116/bom) | [ID: 44] หัวน้ำหอม กลิ่นผลไม้รวม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 25 | **#121** | [มะกรูด Oil Ozone](https://admin.homaroma.shop/products/121/bom) | [ID: 45] หัวน้ำหอม กลิ่นมะกรูด (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 26 | **#126** | [แซนด้าวูส Oil Ozone](https://admin.homaroma.shop/products/126/bom) | [ID: 17] หัวน้ำหอม กลิ่นแซนด้าวูส (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 27 | **#131** | [ดอกปีบ Oil Ozone](https://admin.homaroma.shop/products/131/bom) | [ID: 34] หัวน้ำหอม กลิ่นดอกปีบ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 28 | **#136** | [ดอกบัว Oil Ozone](https://admin.homaroma.shop/products/136/bom) | [ID: 33] หัวน้ำหอม กลิ่นดอกบัว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 29 | **#141** | [โอโซน Oil Ozone](https://admin.homaroma.shop/products/141/bom) | [ID: 22] หัวน้ำหอม กลิ่นโอโซน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 30 | **#146** | [ไทยสปา Oil Ozone](https://admin.homaroma.shop/products/146/bom) | [ID: 23] หัวน้ำหอม กลิ่นไทยสปา (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 31 | **#151** | [กล้วย Oil Ozone](https://admin.homaroma.shop/products/151/bom) | [ID: 24] หัวน้ำหอม กลิ่นกล้วย (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 32 | **#156** | [กาแฟ Oil Ozone](https://admin.homaroma.shop/products/156/bom) | [ID: 25] หัวน้ำหอม กลิ่นกาแฟ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 33 | **#161** | [ดอกชมนาด Oil Ozone](https://admin.homaroma.shop/products/161/bom) | [ID: 32] หัวน้ำหอม กลิ่นดอกชมนาด (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 34 | **#166** | [กลิ่นน้ำนม Oil Ozone](https://admin.homaroma.shop/products/166/bom) | [ID: 43] หัวน้ำหอม กลิ่นน้ำนม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 35 | **#171** | [ดอกการะเวก Oil Ozone](https://admin.homaroma.shop/products/171/bom) | [ID: 30] หัวน้ำหอม กลิ่นดอกการะเวก (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 36 | **#176** | [ดอกจำปา Oil Ozone](https://admin.homaroma.shop/products/176/bom) | [ID: 31] หัวน้ำหอม กลิ่นดอกจำปา (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 37 | **#181** | [ดอกพิกุล Oil Ozone](https://admin.homaroma.shop/products/181/bom) | [ID: 35] หัวน้ำหอม กลิ่นดอกพิกุล (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 38 | **#186** | [ดอกพุด Oil Ozone](https://admin.homaroma.shop/products/186/bom) | [ID: 36] หัวน้ำหอม กลิ่นดอกพุด (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 39 | **#191** | [ดอกราตรี Oil Ozone](https://admin.homaroma.shop/products/191/bom) | [ID: 38] หัวน้ำหอม กลิ่นดอกราตรี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 40 | **#196** | [ดอกลำดวน Oil Ozone](https://admin.homaroma.shop/products/196/bom) | [ID: 39] หัวน้ำหอม กลิ่นดอกลำดวน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 41 | **#201** | [ทับทิม Oil Ozone](https://admin.homaroma.shop/products/201/bom) | [ID: 42] หัวน้ำหอม กลิ่นทับทิม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 42 | **#206** | [มะละกอสุก Oil Ozone](https://admin.homaroma.shop/products/206/bom) | [ID: 47] หัวน้ำหอม กลิ่นมะละกอสุก (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 43 | **#211** | [ดอกราชาวดี Oil Ozone](https://admin.homaroma.shop/products/211/bom) | [ID: 37] หัวน้ำหอม กลิ่นดอกราชาวดี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 44 | **#216** | [กลิ่นลูกพีช Oil Ozone](https://admin.homaroma.shop/products/216/bom) | [ID: 52] หัวน้ำหอม กลิ่นลูกพีช (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
| 45 | **#221** | [กลิ่นองุ่น Oil Ozone](https://admin.homaroma.shop/products/221/bom) | [ID: 56] หัวน้ำหอม กลิ่นองุ่น (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||
@@ -1,14 +1,14 @@
|
||||
# Prisma 7 — คู่มือการเชื่อมต่อและทำ Seed (ไม่งงอีกต่อไป)
|
||||
# Prisma 7 — คู่มือการเชื่อมต่อและทำ Seed — DBK Platform
|
||||
|
||||
> **Platform engineering guide:** Any HomAroma seed data in this guide is reference-tenant data. Validate the target tenant database and seed strategy before use. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||
|
||||
> **Platform engineering guide:** Any HomAroma seed data in this guide is reference-tenant data. Validate the target tenant database and seed strategy before use.
|
||||
|
||||
xx
|
||||
> **สำคัญมาก:** Prisma 7 เปลี่ยนวิธีการเชื่อมต่อฐานข้อมูลอย่างพื้นฐาน
|
||||
> ถ้าเคยใช้ Prisma 4, 5, หรือ 6 มาก่อน — ของเก่าพวกนั้น **ใช้ไม่ได้อีกแล้ว**
|
||||
|
||||
---
|
||||
|
||||
## 1. สิ่งที่เปลี่ยนไปจาก Prisma 6fdss → Prisma 7
|
||||
## 1. สิ่งที่เปลี่ยนไปจาก Prisma 5/6 → Prisma 7
|
||||
|
||||
| เรื่อง | Prisma 5/6 (เก่า) | Prisma 7 (ใหม่) |
|
||||
|---|---|---|
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **DBK Platform tenant example:** Schema fields are shared platform contracts. The HomAroma brand, aroma products, categories, and copy below are reference-tenant sample data only.
|
||||
|
||||
ระบบนำเข้าสินค้าของ HomAroma รองรับการ Import ผ่านไฟล์ `.json` หรือวาง Payload JSON Array ได้โดยตรงผ่าน:
|
||||
ระบบนำเข้าสินค้าของ DBK Platform (ตัวอย่างข้อมูลแคตตาล็อกสินค้าอ้างอิงจาก Reference Tenant: HomAroma) รองรับการ Import ผ่านไฟล์ `.json` หรือวาง Payload JSON Array ได้โดยตรงผ่าน:
|
||||
1. **หน้าจอ Admin:** เมนู `/products` -> กดปุ่ม **"Import JSON"**
|
||||
2. **API Endpoint:** `POST /api/products/bulk` หรือ `POST /api/products`
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# สรุปปัญหา ข้อผิดพลาด และ Checklist การสร้าง Tenant / Subdomain ใหม่
|
||||
|
||||
> **Historical operational record:** This postmortem preserves the facts of a 2026 tenant-provisioning incident. Existing HomAroma identifiers are legacy infrastructure references, not DBK Platform branding.
|
||||
> **Historical operational record:** This postmortem preserves the facts of a 2026 tenant-provisioning incident. Existing HomAroma identifiers are legacy infrastructure references, not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||
(Tenant Provisioning Post-Mortem & Standard Operating Procedure)
|
||||
|
||||
บันทึกข้อผิดพลาดจากการสร้าง Showcase Subdomains (`wellness`, `fashion`, `beauty`, `moto`) บน `kittisak.com` เมื่อวันที่ 24 กันยายน 2026 เพื่อใช้เป็น Checklist ป้องกันการทำผิดพลาดซ้ำในอนาคต
|
||||
|
||||
@@ -14,6 +14,7 @@ import PageBuilderPage from './pages/PageBuilderPage'
|
||||
import PageReorderPage from './pages/PageReorderPage'
|
||||
import CmsMockupPage from './pages/CmsMockupPage'
|
||||
import RawMaterialsPage from './pages/RawMaterialsPage'
|
||||
import RawMaterialDetailPage from './pages/RawMaterialDetailPage'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import InstallSeedPage from './pages/InstallSeedPage'
|
||||
import ProtectedRoute from './components/ProtectedRoute'
|
||||
@@ -123,6 +124,8 @@ function App() {
|
||||
<Route path="/promotions" element={<PromotionsPage />} />
|
||||
<Route path="/coupons" element={<CouponsPage />} />
|
||||
<Route path="/raw-materials" element={<RawMaterialsPage />} />
|
||||
<Route path="/raw-materials/new" element={<RawMaterialDetailPage />} />
|
||||
<Route path="/raw-materials/:id" element={<RawMaterialDetailPage />} />
|
||||
<Route path="/settings" element={<StoreSettingsPage />} />
|
||||
<Route path="/settings/brand-intelligence" element={<BrandIntelligencePage />} />
|
||||
<Route path="/brand-intelligence" element={<BrandIntelligencePage />} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API_BASE } from '@homaroma/types';
|
||||
import { X, Upload, Loader2, Image as ImageIcon, Video } from 'lucide-react';
|
||||
@@ -105,7 +106,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect, mediaType
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="bg-night-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-5xl h-[85vh] flex flex-col overflow-hidden animate-fade-in relative">
|
||||
{/* Header */}
|
||||
@@ -196,6 +197,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect, mediaType
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
Sparkles,
|
||||
Copy,
|
||||
@@ -35,6 +36,8 @@ const GUIDE_OVERVIEW_MARKDOWN = `# คู่มือการใช้ AI/LLM
|
||||
### 1. Root Items (เมนูแถวบนสุด - Level 1)
|
||||
- **label**: ออบเจกต์ข้อความรองรับ 8 ภาษา \`{ th, en, ja, zh, vi, ko, lo, it }\`
|
||||
- **url**: ลิงก์ปลายทาง เช่น \`/category/reed-diffuser\` หรือ \`/collections/all\`
|
||||
- **layout**: (ไม่บังคับ) \`'columns'\` (Mega Menu คอลัมน์) | \`'tabbed'\` (Tabbed Catalog แท็บซ้าย) | \`'visual-cards'\` (การ์ดรูปภาพหรูหรา 4 ใบ) | \`'classic'\` (Dropdown แนวตั้งเรียบหรู กะทัดรัด)
|
||||
- **layoutConfig**: (ไม่บังคับ) ออบเจกต์ตั้งค่าเพิ่มเติม \`{ columnCount: 2 | 3 | 4, showFeatured: boolean }\`
|
||||
- **rootImageUrl**: URL รูปภาพหลักฝั่งซ้ายสุดของเมกะเมนู (อัตราส่วน 1:1 จัตุรัส ขนาดแนะนำ 800x800)
|
||||
- **columns**: อาร์เรย์ของคอลัมน์เมนูย่อย (Level 2)
|
||||
- **featured**: การ์ดสินค้า/ไฮไลต์โปรโมตพิเศษที่แสดงฝั่งขวาสุดของเมกะเมนู
|
||||
@@ -304,7 +307,7 @@ export default function MenuJsonGuideModal({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-5 animate-fade-in">
|
||||
<div className="w-full max-w-5xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col max-h-[92vh] overflow-hidden animate-zoom-in">
|
||||
{/* Header */}
|
||||
@@ -491,6 +494,7 @@ export default function MenuJsonGuideModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
X,
|
||||
FileJson,
|
||||
Upload,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Layers,
|
||||
Sparkles
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ProductBomImportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
variants: any[];
|
||||
rawMaterials: any[];
|
||||
onApply: (updatedVariants: any[]) => void;
|
||||
}
|
||||
|
||||
export default function ProductBomImportModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
variants,
|
||||
rawMaterials,
|
||||
onApply,
|
||||
}: ProductBomImportModalProps) {
|
||||
const [jsonInput, setJsonInput] = useState('');
|
||||
|
||||
// Real-time Parser & Matcher
|
||||
const parsedResult = useMemo(() => {
|
||||
if (!jsonInput.trim()) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonInput);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'ข้อมูลต้องเป็น JSON Array [ { ... }, { ... } ]',
|
||||
matchedVariants: [],
|
||||
unmatchedCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (parsed.length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'JSON Array ว่างเปล่า ไม่มีข้อมูลสูตร',
|
||||
matchedVariants: [],
|
||||
unmatchedCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const matched: any[] = [];
|
||||
let unmatchedCount = 0;
|
||||
|
||||
for (const entry of parsed) {
|
||||
// 1. Match Variant by ID or SKU or Option
|
||||
const variant = variants.find((v) => {
|
||||
if (entry.variantId && Number(v.id) === Number(entry.variantId)) return true;
|
||||
if (entry.sku && v.sku && String(v.sku).trim().toLowerCase() === String(entry.sku).trim().toLowerCase()) return true;
|
||||
if (entry.option1 && v.option1 && String(v.option1).trim().toLowerCase() === String(entry.option1).trim().toLowerCase()) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!variant) {
|
||||
unmatchedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Match BOM items to RawMaterials
|
||||
const rawItems = Array.isArray(entry.bomItems) ? entry.bomItems : [];
|
||||
const resolvedBomItems: any[] = [];
|
||||
let totalCost = 0;
|
||||
|
||||
for (const b of rawItems) {
|
||||
const qty = Number(b.quantity) || 0;
|
||||
if (qty <= 0) continue;
|
||||
|
||||
// Find raw material by ID or Name
|
||||
const rm = rawMaterials.find((m) => {
|
||||
if (b.rawMaterialId && Number(m.id) === Number(b.rawMaterialId)) return true;
|
||||
if (b.name && String(m.name).trim().toLowerCase() === String(b.name).trim().toLowerCase()) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (rm) {
|
||||
const lineCost = (Number(rm.pricePerUnit) || 0) * qty;
|
||||
totalCost += lineCost;
|
||||
resolvedBomItems.push({
|
||||
rawMaterialId: rm.id,
|
||||
name: rm.name,
|
||||
unit: rm.unit,
|
||||
quantity: qty,
|
||||
pricePerUnit: rm.pricePerUnit,
|
||||
lineCost,
|
||||
});
|
||||
} else {
|
||||
// Unresolved material ID or name
|
||||
resolvedBomItems.push({
|
||||
rawMaterialId: Number(b.rawMaterialId) || 0,
|
||||
name: b.name || `Material #${b.rawMaterialId}`,
|
||||
unit: 'unit',
|
||||
quantity: qty,
|
||||
pricePerUnit: 0,
|
||||
lineCost: 0,
|
||||
unknown: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const price = Number(variant.price) || 0;
|
||||
const profit = price - totalCost;
|
||||
const margin = price > 0 ? (profit / price) * 100 : 0;
|
||||
|
||||
matched.push({
|
||||
targetVariant: variant,
|
||||
resolvedBomItems,
|
||||
totalCost,
|
||||
price,
|
||||
profit,
|
||||
margin,
|
||||
});
|
||||
}
|
||||
|
||||
if (matched.length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'ไม่พบ Variant ในระบบที่ตรงกับ ID หรือ SKU ใน JSON ที่วางมา',
|
||||
matchedVariants: [],
|
||||
unmatchedCount,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
matchedVariants: matched,
|
||||
unmatchedCount,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `รูปแบบ JSON ไม่ถูกต้อง: ${err.message}`,
|
||||
matchedVariants: [],
|
||||
unmatchedCount: 0,
|
||||
};
|
||||
}
|
||||
}, [jsonInput, variants, rawMaterials]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleApply = () => {
|
||||
if (!parsedResult || !parsedResult.valid) return;
|
||||
|
||||
// Deep copy current variants
|
||||
const newVariants = JSON.parse(JSON.stringify(variants));
|
||||
|
||||
for (const match of parsedResult.matchedVariants) {
|
||||
const idx = newVariants.findIndex((v: any) => v.id === match.targetVariant.id);
|
||||
if (idx !== -1) {
|
||||
// Map to exact BOM structure expected by ProductBomPage
|
||||
newVariants[idx].bomItems = match.resolvedBomItems.map((b: any) => ({
|
||||
rawMaterialId: b.rawMaterialId,
|
||||
quantity: b.quantity,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
onApply(newVariants);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-night-950/80 backdrop-blur-sm transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal Container */}
|
||||
<div className="relative w-full max-w-4xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden max-h-[92vh] animate-zoom-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5 bg-night-950/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400">
|
||||
<FileJson className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold text-white">Import BOM from LLM JSON</h2>
|
||||
<span className="text-xs px-2.5 py-0.5 rounded-full bg-cyan-500/10 text-cyan-300 border border-cyan-500/20 font-bold">
|
||||
Auto-Match & Calculate
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
วาง JSON ที่ได้รับจาก LLM ระบบจะจับคู่ Variant และคำนวณต้นทุนให้ก่อนกรอกลงตาราง
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Body */}
|
||||
<div className="p-6 overflow-y-auto space-y-6 flex-1">
|
||||
{/* Textarea Input */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<label className="font-bold text-slate-300 flex items-center gap-1.5">
|
||||
<Sparkles className="w-3.5 h-3.5 text-cyan-400" />
|
||||
วางข้อความ JSON ที่ได้จาก AI (Ctrl+V):
|
||||
</label>
|
||||
{jsonInput && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setJsonInput('')}
|
||||
className="text-rose-400 hover:text-rose-300 font-medium"
|
||||
>
|
||||
ล้างข้อความ
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
rows={8}
|
||||
placeholder={`[\n {\n "variantId": 3206,\n "sku": "4-1-30ML",\n "bomItems": [\n { "rawMaterialId": 4, "quantity": 2.4 },\n { "rawMaterialId": 91, "quantity": 27.6 }\n ]\n }\n]`}
|
||||
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-4 text-slate-200 placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 transition-all resize-y leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Validation Status Banner */}
|
||||
{parsedResult && (
|
||||
<div
|
||||
className={`p-4 rounded-2xl border text-xs flex items-center justify-between ${
|
||||
parsedResult.valid
|
||||
? 'bg-emerald-950/30 border-emerald-500/30 text-emerald-300'
|
||||
: 'bg-rose-950/30 border-rose-500/30 text-rose-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{parsedResult.valid ? (
|
||||
<CheckCircle2 className="w-5 h-5 shrink-0 text-emerald-400" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5 shrink-0 text-rose-400" />
|
||||
)}
|
||||
<span>
|
||||
{parsedResult.valid ? (
|
||||
<>
|
||||
ตรวจพบสูตร BOM ที่ตรงกับสินค้า <strong>{parsedResult.matchedVariants.length}</strong> ตัวเลือก
|
||||
{parsedResult.unmatchedCount > 0 && ` (ข้ามที่ไม่ตรง ${parsedResult.unmatchedCount} รายการ)`}
|
||||
</>
|
||||
) : (
|
||||
parsedResult.error
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{parsedResult.valid && (
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-md bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-medium">
|
||||
พร้อมนำเข้า
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live Preview Cards */}
|
||||
{parsedResult?.valid && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span className="font-bold text-white flex items-center gap-1.5">
|
||||
<Layers className="w-4 h-4 text-cyan-400" />
|
||||
พรีวิวการจัดสูตรและคำนวณต้นทุน (BOM Preview):
|
||||
</span>
|
||||
<span>จำนวน {parsedResult.matchedVariants.length} Variants</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-h-[280px] overflow-y-auto pr-1">
|
||||
{parsedResult.matchedVariants.map((item: any, idx: number) => {
|
||||
const v = item.targetVariant;
|
||||
const label = [v.option1, v.option2, v.option3].filter(Boolean).join(' / ') || 'Default';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={v.id || idx}
|
||||
className="p-4 rounded-2xl bg-white/[0.02] border border-white/5 hover:border-white/10 transition-all space-y-3"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-white/5 pb-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-6 h-6 rounded-lg bg-cyan-500/10 text-cyan-400 text-xs font-mono font-bold flex items-center justify-center">
|
||||
#{v.id}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-white">{label}</span>
|
||||
{v.sku && (
|
||||
<span className="text-[11px] font-mono text-slate-500 bg-white/5 px-2 py-0.5 rounded-md">
|
||||
SKU: {v.sku}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs font-mono">
|
||||
<span className="text-slate-400">
|
||||
ราคาขาย: <strong className="text-white">฿{item.price.toLocaleString()}</strong>
|
||||
</span>
|
||||
<span className="text-slate-400">
|
||||
ต้นทุน BOM: <strong className="text-amber-400">฿{item.totalCost.toFixed(2)}</strong>
|
||||
</span>
|
||||
<span className="text-slate-400">
|
||||
กำไร:{' '}
|
||||
<strong className={item.profit >= 0 ? 'text-emerald-400' : 'text-rose-400'}>
|
||||
฿{item.profit.toFixed(2)} ({item.margin.toFixed(1)}%)
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ingredients Breakdown */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{item.resolvedBomItems.map((b: any, bIdx: number) => (
|
||||
<div
|
||||
key={bIdx}
|
||||
className={`p-2.5 rounded-xl border text-xs flex items-center justify-between ${
|
||||
b.unknown
|
||||
? 'bg-amber-950/20 border-amber-500/30 text-amber-300'
|
||||
: 'bg-night-950/60 border-white/5 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-0.5 min-w-0 pr-2">
|
||||
<p className="font-medium truncate text-white text-[11px]">
|
||||
{b.name}
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-500">
|
||||
ID: #{b.rawMaterialId}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span className="font-mono font-bold text-cyan-400">
|
||||
{b.quantity} {b.unit}
|
||||
</span>
|
||||
<p className="text-[10px] text-slate-500 font-mono">
|
||||
฿{b.lineCost.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-white/5 bg-night-950/40 flex items-center justify-between">
|
||||
<p className="text-xs text-slate-500">
|
||||
เมื่อกด Apply ข้อมูลจะถูกกรอกลงตารางบนหน้าเว็บทันทีเพื่อให้คุณตรวจสอบก่อนกดบันทึก
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-5 py-2.5 rounded-xl text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
ยกเลิก
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApply}
|
||||
disabled={!parsedResult?.valid}
|
||||
className="btn-primary text-sm px-6 py-2.5 flex items-center gap-2 shadow-lg shadow-cyan-500/20 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span>กรอกลงตาราง ({parsedResult?.matchedVariants?.length || 0} รายการ)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
X,
|
||||
Copy,
|
||||
Check,
|
||||
Bot,
|
||||
Sparkles,
|
||||
FileCode,
|
||||
Package,
|
||||
Layers,
|
||||
Code2
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ProductBomPromptModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
productId: number | string;
|
||||
productName: string;
|
||||
variants: any[];
|
||||
rawMaterials: any[];
|
||||
}
|
||||
|
||||
export default function ProductBomPromptModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
productId,
|
||||
productName,
|
||||
variants,
|
||||
rawMaterials,
|
||||
}: ProductBomPromptModalProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'prompt' | 'context' | 'schema'>('prompt');
|
||||
|
||||
// Generate full prompt text for LLM
|
||||
const generatedPrompt = useMemo(() => {
|
||||
const variantLines = variants.map((v) => {
|
||||
const opts = [v.option1, v.option2, v.option3].filter(Boolean).join(' / ') || 'Base Variant';
|
||||
return ` - Variant ID: ${v.id} | SKU: ${v.sku || '-'} | ตัวเลือก: "${opts}" | ราคาขาย: ฿${Number(v.price || 0).toLocaleString()}`;
|
||||
}).join('\n');
|
||||
|
||||
const rawMaterialLines = rawMaterials.map((rm) => {
|
||||
return ` - [ID: ${rm.id}] "${rm.name}" | หน่วย: ${rm.unit} | ต้นทุน: ฿${Number(rm.pricePerUnit || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })}`;
|
||||
}).join('\n');
|
||||
|
||||
return `คุณคือ Senior Manufacturing & Bill of Materials (BOM) Specialist ของระบบ E-Commerce (DBK Platform)
|
||||
|
||||
[บริบทข้อมูลสินค้า]:
|
||||
- รหัสสินค้า (Product ID): ${productId}
|
||||
- ชื่อสินค้า: "${productName}"
|
||||
|
||||
[รายการตัวเลือกสินค้า (Variants ที่ต้องกำหนดสูตร BOM)]:
|
||||
${variantLines}
|
||||
|
||||
[คลังรายการวัตถุดิบในระบบ (Raw Materials ที่สามารถนำมาผูกสูตรได้)]:
|
||||
${rawMaterialLines}
|
||||
|
||||
[ภารกิจของคุณ]:
|
||||
กรุณาจัดทำสูตรการผลิต (BOM Items) ให้กับแต่ละ Variant ของสินค้า รหัส #${productId} ตามคำสั่ง/สัดส่วนที่ระบุ
|
||||
และส่งคืนผลลัพธ์เป็น **Valid JSON Array** ล้วนๆ ตามข้อกำหนดดังต่อไปนี้เท่านั้น:
|
||||
|
||||
[ข้อกำหนดผลลัพธ์ JSON Format ที่ต้องส่งกลับ]:
|
||||
\`\`\`json
|
||||
[
|
||||
{
|
||||
"variantId": <ตัวเลข ID ของ Variant>,
|
||||
"sku": "<รหัส SKU>",
|
||||
"bomItems": [
|
||||
{
|
||||
"rawMaterialId": <ตัวเลข ID ของวัตถุดิบจากคลังด้านบน>,
|
||||
"name": "<ชื่อวัตถุดิบ>",
|
||||
"quantity": <จำนวนปริมาณตัวเลข (ห้ามเป็น string)>
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
\`\`\`
|
||||
*(คำสั่ง/สูตรเฉพาะ: ระบุสัดส่วนการผสม วัตถุดิบบรรจุภัณฑ์ และปริมาณที่ต้องการต่อ 1 ชิ้น)*`;
|
||||
}, [productId, productName, variants, rawMaterials]);
|
||||
|
||||
// Context JSON
|
||||
const contextJson = useMemo(() => {
|
||||
return JSON.stringify(
|
||||
{
|
||||
product: {
|
||||
id: Number(productId),
|
||||
name: productName,
|
||||
variantsCount: variants.length,
|
||||
variants: variants.map((v) => ({
|
||||
id: v.id,
|
||||
sku: v.sku,
|
||||
option1: v.option1,
|
||||
option2: v.option2,
|
||||
option3: v.option3,
|
||||
price: v.price,
|
||||
currentBomCount: v.bomItems?.length || 0,
|
||||
})),
|
||||
},
|
||||
rawMaterialsCount: rawMaterials.length,
|
||||
rawMaterials: rawMaterials.map((rm) => ({
|
||||
id: rm.id,
|
||||
name: rm.name,
|
||||
unit: rm.unit,
|
||||
pricePerUnit: rm.pricePerUnit,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}, [productId, productName, variants, rawMaterials]);
|
||||
|
||||
// Target schema sample
|
||||
const schemaSample = useMemo(() => {
|
||||
const firstVariant = variants[0];
|
||||
const sampleItem = rawMaterials[0];
|
||||
return JSON.stringify(
|
||||
[
|
||||
{
|
||||
variantId: firstVariant ? firstVariant.id : 1,
|
||||
sku: firstVariant?.sku || 'SAMPLE-SKU',
|
||||
bomItems: [
|
||||
{
|
||||
rawMaterialId: sampleItem ? sampleItem.id : 1,
|
||||
name: sampleItem?.name || 'ตัวอย่างวัตถุดิบ',
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
null,
|
||||
2
|
||||
);
|
||||
}, [variants, rawMaterials]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleCopy = async (textToCopy: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(textToCopy);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-night-950/80 backdrop-blur-sm transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal Container */}
|
||||
<div className="relative w-full max-w-4xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden max-h-[92vh] animate-zoom-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5 bg-night-950/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-2xl bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-400">
|
||||
<Bot className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold text-white">LLM Prompt Generator for BOM</h2>
|
||||
<span className="text-xs px-2.5 py-0.5 rounded-full bg-purple-500/10 text-purple-300 border border-purple-500/20 font-mono font-bold">
|
||||
Product #{productId}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
สร้างชุดคำสั่ง Prompt พร้อมรหัสสินค้า, Variants และรายการวัตถุดิบทั้งหมดสำหรับส่งให้ LLM
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats Bar */}
|
||||
<div className="grid grid-cols-3 gap-4 px-6 py-3 bg-night-950/30 border-b border-white/5 text-xs">
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<Package className="w-4 h-4 text-cyan-400" />
|
||||
<span>สินค้า: <strong className="text-white">{productName}</strong></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<Layers className="w-4 h-4 text-purple-400" />
|
||||
<span>Variants: <strong className="text-white">{variants.length} ตัวเลือก</strong></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<Code2 className="w-4 h-4 text-emerald-400" />
|
||||
<span>วัตถุดิบในคลัง: <strong className="text-white">{rawMaterials.length} รายการ</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex border-b border-white/5 px-6 pt-2 bg-night-950/20">
|
||||
<button
|
||||
onClick={() => setActiveTab('prompt')}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
|
||||
activeTab === 'prompt'
|
||||
? 'border-purple-500 text-purple-400'
|
||||
: 'border-transparent text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span>ชุดคำสั่ง Prompt สำหรับ LLM</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('context')}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
|
||||
activeTab === 'context'
|
||||
? 'border-purple-500 text-purple-400'
|
||||
: 'border-transparent text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<FileCode className="w-4 h-4" />
|
||||
<span>ข้อมูลดิบ (Context JSON)</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('schema')}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
|
||||
activeTab === 'schema'
|
||||
? 'border-purple-500 text-purple-400'
|
||||
: 'border-transparent text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Code2 className="w-4 h-4" />
|
||||
<span>Target Output Format</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Body */}
|
||||
<div className="p-6 overflow-y-auto space-y-4 flex-1">
|
||||
{activeTab === 'prompt' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-400">
|
||||
คัดลอกข้อความด้านล่างนี้ไปวางใน <strong>ChatGPT, Claude หรือ Gemini</strong>:
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy(generatedPrompt)}
|
||||
className="px-4 py-2 rounded-xl bg-purple-500/10 hover:bg-purple-500/20 text-purple-300 border border-purple-500/30 text-xs font-bold flex items-center gap-2 transition-all shadow-sm"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
|
||||
<span>{copied ? 'คัดลอก Prompt สำเร็จ!' : 'คัดลอก Prompt ทั้งหมด'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
readOnly
|
||||
value={generatedPrompt}
|
||||
rows={16}
|
||||
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-5 text-slate-200 focus:outline-none resize-y leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'context' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-400">
|
||||
โครงสร้างข้อมูล Product Master, Variants และ Raw Materials ในรูปแบบ JSON:
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy(contextJson)}
|
||||
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 border border-white/10 text-xs font-bold flex items-center gap-2 transition-all"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
|
||||
<span>{copied ? 'คัดลอกแล้ว' : 'คัดลอก Context JSON'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
readOnly
|
||||
value={contextJson}
|
||||
rows={16}
|
||||
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-5 text-slate-300 focus:outline-none resize-y leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'schema' && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-2xl bg-cyan-950/20 border border-cyan-500/20 text-xs text-slate-300 space-y-1">
|
||||
<p className="font-bold text-cyan-300">💡 โครงสร้างผลลัพธ์ที่ออกแบบไว้สำหรับรองรับการ Copy วาง (Paste to Import):</p>
|
||||
<p className="text-slate-400">
|
||||
เมื่อ LLM ได้รับข้อมูลและส่งกลับมาในรูปแบบนี้ คุณจะสามารถนำข้อความ JSON มาวางเพื่อบันทึก BOM เข้าสู่ Variant ทั้งหมดได้ทันที
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
readOnly
|
||||
value={schemaSample}
|
||||
rows={12}
|
||||
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-5 text-emerald-400 focus:outline-none resize-y leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-white/5 bg-night-950/40 flex items-center justify-between">
|
||||
<p className="text-xs text-slate-500">
|
||||
ระบบจัดเตรียมข้อมูลอัตโนมัติจากฐานข้อมูลจริงของสินค้า #{productId}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-5 py-2.5 rounded-xl text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
ปิดหน้าต่าง
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy(generatedPrompt)}
|
||||
className="btn-primary text-sm px-6 py-2.5 flex items-center gap-2 shadow-lg shadow-purple-500/20 bg-purple-600 hover:bg-purple-500 text-white"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
<span>{copied ? 'คัดลอกสำเร็จแล้ว' : 'คัดลอก Prompt ไปใช้งาน'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
Sparkles,
|
||||
Copy,
|
||||
@@ -613,7 +614,7 @@ export default function ProductJsonGuideModal({ isOpen, onClose, onOpenImport }:
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-5 animate-fade-in">
|
||||
<div className="w-full max-w-5xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col max-h-[92vh] overflow-hidden animate-zoom-in">
|
||||
|
||||
@@ -792,6 +793,7 @@ export default function ProductJsonGuideModal({ isOpen, onClose, onOpenImport }:
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import { useState, useMemo, useRef, type ChangeEvent } from 'react';
|
||||
import {
|
||||
X,
|
||||
Copy,
|
||||
Check,
|
||||
Download,
|
||||
Upload,
|
||||
FileJson,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { API_BASE, type RawMaterial } from '@homaroma/types';
|
||||
|
||||
interface RawMaterialBulkJsonModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
materials: RawMaterial[];
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const SAMPLE_TEMPLATE = [
|
||||
{
|
||||
name: "ขวดแก้วสีชา 100ml (Amber Glass Bottle)",
|
||||
unit: "piece",
|
||||
pricePerUnit: 18.50
|
||||
},
|
||||
{
|
||||
name: "ก้านไม้ไฟเบอร์สีดำ 6 ก้าน (Black Fiber Reeds)",
|
||||
unit: "pack",
|
||||
pricePerUnit: 7.00
|
||||
},
|
||||
{
|
||||
name: "น้ำมันหอมระเหย ลาเวนเดอร์บริสุทธิ์ (Pure Lavender Oil)",
|
||||
unit: "kg",
|
||||
pricePerUnit: 2400.00
|
||||
},
|
||||
{
|
||||
name: "เบสกระจายความหอม MMB (Diffuser Base)",
|
||||
unit: "liter",
|
||||
pricePerUnit: 350.00
|
||||
}
|
||||
];
|
||||
|
||||
export default function RawMaterialBulkJsonModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
materials,
|
||||
onSuccess,
|
||||
}: RawMaterialBulkJsonModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<'import' | 'export'>('import');
|
||||
const [copiedTemplate, setCopiedTemplate] = useState(false);
|
||||
const [copiedExport, setCopiedExport] = useState(false);
|
||||
const [importJsonText, setImportJsonText] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [resultMessage, setResultMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Parse & validate input text in real time
|
||||
const parsedPreview = useMemo(() => {
|
||||
if (!importJsonText.trim()) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(importJsonText);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return { valid: false, error: 'ข้อมูลต้องเป็น JSON Array [ { ... }, { ... } ]', count: 0, items: [] };
|
||||
}
|
||||
if (parsed.length === 0) {
|
||||
return { valid: false, error: 'Array ว่างเปล่า ไม่มีรายการวัตถุดิบ', count: 0, items: [] };
|
||||
}
|
||||
|
||||
const invalidItem = parsed.find((item: any) => !item || typeof item !== 'object' || !item.name);
|
||||
if (invalidItem) {
|
||||
return { valid: false, error: 'พบรายการที่ไม่มีฟิลด์ "name" หรือข้อมูลไม่สมบูรณ์', count: 0, items: [] };
|
||||
}
|
||||
|
||||
return { valid: true, error: null, count: parsed.length, items: parsed };
|
||||
} catch (err: any) {
|
||||
return { valid: false, error: `รูปแบบ JSON ไม่ถูกต้อง: ${err.message}`, count: 0, items: [] };
|
||||
}
|
||||
}, [importJsonText]);
|
||||
|
||||
// Export JSON string
|
||||
const exportJsonString = useMemo(() => {
|
||||
const cleanList = materials.map((m) => ({
|
||||
name: m.name,
|
||||
unit: m.unit,
|
||||
pricePerUnit: m.pricePerUnit,
|
||||
}));
|
||||
return JSON.stringify(cleanList, null, 2);
|
||||
}, [materials]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleCopyTemplate = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(SAMPLE_TEMPLATE, null, 2));
|
||||
setCopiedTemplate(true);
|
||||
setTimeout(() => setCopiedTemplate(false), 2000);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyExport = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(exportJsonString);
|
||||
setCopiedExport(true);
|
||||
setTimeout(() => setCopiedExport(false), 2000);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadExport = () => {
|
||||
const blob = new Blob([exportJsonString], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `raw-materials-${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const content = event.target?.result as string;
|
||||
setImportJsonText(content);
|
||||
setResultMessage(null);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleImportSubmit = async () => {
|
||||
if (!parsedPreview || !parsedPreview.valid) return;
|
||||
setSubmitting(true);
|
||||
setResultMessage(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/raw-materials/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(parsedPreview.items),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.message || 'นำเข้าข้อมูลไม่สำเร็จ');
|
||||
}
|
||||
|
||||
setResultMessage({
|
||||
type: 'success',
|
||||
text: `นำเข้าและปรับปรุงข้อมูล (Upsert) สำเร็จ ${data.count || parsedPreview.count} รายการ`,
|
||||
});
|
||||
setImportJsonText('');
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
onClose();
|
||||
}, 1200);
|
||||
} catch (err: any) {
|
||||
setResultMessage({
|
||||
type: 'error',
|
||||
text: err.message || 'เกิดข้อผิดพลาดในการเชื่อมต่อเซิร์ฟเวอร์',
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-night-950/80 backdrop-blur-sm transition-opacity"
|
||||
onClick={() => !submitting && onClose()}
|
||||
/>
|
||||
|
||||
{/* Modal Container */}
|
||||
<div className="relative w-full max-w-3xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden max-h-[90vh] animate-zoom-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5 bg-night-950/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400">
|
||||
<FileJson className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
Raw Materials JSON Tool
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-cyan-500/10 text-cyan-300 border border-cyan-500/20">
|
||||
Bulk Upsert
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400">
|
||||
นำเข้าและส่งออกข้อมูลวัตถุดิบ รองรับการอัปเดตข้อมูลเดิมอัตโนมัติเมื่อชื่อซ้ำ
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex border-b border-white/5 px-6 pt-2 bg-night-950/20">
|
||||
<button
|
||||
onClick={() => { setActiveTab('import'); setResultMessage(null); }}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
|
||||
activeTab === 'import'
|
||||
? 'border-cyan-500 text-cyan-400'
|
||||
: 'border-transparent text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span>นำเข้า JSON (Bulk Import)</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveTab('export'); setResultMessage(null); }}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
|
||||
activeTab === 'export'
|
||||
? 'border-cyan-500 text-cyan-400'
|
||||
: 'border-transparent text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>ส่งออก JSON (Export {materials.length} รายการ)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Body */}
|
||||
<div className="p-6 overflow-y-auto space-y-6 flex-1">
|
||||
{activeTab === 'import' ? (
|
||||
<div className="space-y-5">
|
||||
{/* Guidance Notice */}
|
||||
<div className="p-4 rounded-2xl bg-cyan-950/20 border border-cyan-500/20 text-xs text-cyan-200/90 flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 font-bold text-cyan-300">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
กลไกอัจฉริยะ (Upsert Automation)
|
||||
</div>
|
||||
<p className="text-slate-300 leading-relaxed">
|
||||
วาง JSON Array รายการวัตถุดิบที่ต้องการ หากพบ <strong>name</strong> ที่มีอยู่แล้วในระบบ
|
||||
ระบบจะ <strong>Update</strong> หน่วยนับและราคาต้นทุนให้ทันที หากยังไม่มีจะสร้างรายการใหม่
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyTemplate}
|
||||
className="shrink-0 px-3 py-1.5 rounded-lg bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 font-medium flex items-center gap-1.5 transition-all text-xs"
|
||||
>
|
||||
{copiedTemplate ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
<span>{copiedTemplate ? 'คัดลอกแล้ว' : 'คัดลอก Template'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* JSON Input Area */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<label className="font-bold text-slate-300">วาง JSON Payload:</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileUpload}
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="text-cyan-400 hover:text-cyan-300 flex items-center gap-1 font-medium transition-colors"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
<span>อัปโหลดไฟล์ .json</span>
|
||||
</button>
|
||||
{importJsonText && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setImportJsonText(''); setResultMessage(null); }}
|
||||
className="text-rose-400 hover:text-rose-300 ml-2 font-medium"
|
||||
>
|
||||
ล้างข้อมูล
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={importJsonText}
|
||||
onChange={(e) => {
|
||||
setImportJsonText(e.target.value);
|
||||
setResultMessage(null);
|
||||
}}
|
||||
rows={9}
|
||||
placeholder={`[\n {\n "name": "ขวดแก้ว 100ml",\n "unit": "piece",\n "pricePerUnit": 18.5\n }\n]`}
|
||||
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-4 text-slate-200 placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 transition-all resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Live Preview / Validation Status */}
|
||||
{parsedPreview && (
|
||||
<div
|
||||
className={`p-3.5 rounded-xl border text-xs flex items-center gap-2.5 ${
|
||||
parsedPreview.valid
|
||||
? 'bg-emerald-950/30 border-emerald-500/30 text-emerald-300'
|
||||
: 'bg-rose-950/30 border-rose-500/30 text-rose-300'
|
||||
}`}
|
||||
>
|
||||
{parsedPreview.valid ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||
<span>
|
||||
JSON ถูกต้อง — ตรวจพบวัตถุดิบทั้งหมด <strong>{parsedPreview.count}</strong> รายการ พร้อมนำเข้าสู่ระบบ
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{parsedPreview.error}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Server Result Message */}
|
||||
{resultMessage && (
|
||||
<div
|
||||
className={`p-3.5 rounded-xl border text-xs flex items-center gap-2.5 ${
|
||||
resultMessage.type === 'success'
|
||||
? 'bg-emerald-950/40 border-emerald-500/40 text-emerald-300'
|
||||
: 'bg-rose-950/40 border-rose-500/40 text-rose-300'
|
||||
}`}
|
||||
>
|
||||
{resultMessage.type === 'success' ? (
|
||||
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
)}
|
||||
<span>{resultMessage.text}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span>วัตถุดิบทั้งหมดในระบบ ({materials.length} รายการ):</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyExport}
|
||||
className="px-3 py-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 font-medium flex items-center gap-1.5 transition-all"
|
||||
>
|
||||
{copiedExport ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
<span>{copiedExport ? 'คัดลอกแล้ว' : 'คัดลอก JSON'}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadExport}
|
||||
className="btn-primary text-xs px-3 py-1.5 flex items-center gap-1.5"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>ดาวน์โหลด .json</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
readOnly
|
||||
value={exportJsonString}
|
||||
rows={12}
|
||||
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-4 text-slate-300 focus:outline-none resize-y"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-white/5 bg-night-950/40 flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
className="px-5 py-2.5 rounded-xl text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
ยกเลิก
|
||||
</button>
|
||||
|
||||
{activeTab === 'import' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImportSubmit}
|
||||
disabled={submitting || !parsedPreview?.valid}
|
||||
className="btn-primary text-sm px-6 py-2.5 flex items-center gap-2 disabled:opacity-40 disabled:cursor-not-allowed shadow-lg shadow-cyan-500/10"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>กำลังบันทึกข้อมูล...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span>ยืนยันการนำเข้า ({parsedPreview?.count || 0} รายการ)</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
X,
|
||||
@@ -142,6 +143,60 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
|
||||
<div className="relative z-20 w-4 h-4 rounded-full bg-black/50 text-white flex items-center justify-center text-[8px]">›</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
// 2.96 VDO_FRAME_SCRUBBER
|
||||
{
|
||||
type: 'VDO_FRAME_SCRUBBER',
|
||||
category: 'hero',
|
||||
title: 'VDO FRAME SCRUBBER (Scrollytelling Video)',
|
||||
description: 'Cinema-quality video frame sequence (432 frames) with scroll scrub, direct drag, range slider, and play/pause at 24fps without memory leaks.',
|
||||
badge: 'Video Frames · 4K',
|
||||
renderPreview: () => (
|
||||
<div className="relative w-full aspect-[21/9] rounded-xl overflow-hidden bg-[#0e0e0d] flex items-center justify-between p-3 border border-[#d5ff63]/30">
|
||||
<div className="space-y-1 max-w-[65%] z-10">
|
||||
<span className="text-[6px] uppercase font-mono font-bold text-[#d5ff63] tracking-widest flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#d5ff63] animate-ping" /> FRAME SCRUBBER
|
||||
</span>
|
||||
<h5 className="text-[10px] font-black text-white leading-tight">
|
||||
FROM VIDEO TO INTERACTIVE
|
||||
</h5>
|
||||
<div className="flex items-center gap-2 text-[6px] text-slate-400 font-mono">
|
||||
<span className="px-1.5 py-0.5 rounded bg-white/10 text-white">432 FRAMES</span>
|
||||
<span>00:18.0s @ 24FPS</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-2xl bg-black/80 border border-[#d5ff63]/40 flex items-center justify-center text-[#d5ff63] shadow-xl shrink-0">
|
||||
<Play className="w-5 h-5 fill-current ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
// 2.95 INTERACTIVE_360_SHOWCASE
|
||||
{
|
||||
type: 'INTERACTIVE_360_SHOWCASE',
|
||||
category: 'hero',
|
||||
title: 'INTERACTIVE 360° TURNTABLE SHOWCASE',
|
||||
description: 'Ultra-fast 2.5D Sprite Sheet 360° turntable, scroll-driven rotation, interactive drag-to-spin, and seamless macro detail zoom transition.',
|
||||
badge: '3D Web · Hot',
|
||||
renderPreview: () => (
|
||||
<div className="relative w-full aspect-[21/9] rounded-xl overflow-hidden bg-[#0f0f0e] flex items-center justify-between p-3 border border-[#d5ff63]/30">
|
||||
<div className="space-y-1 max-w-[60%] z-10">
|
||||
<span className="text-[6px] uppercase font-mono font-bold text-[#d5ff63] tracking-widest">
|
||||
360° TURNTABLE + MACRO ZOOM
|
||||
</span>
|
||||
<h5 className="text-[10px] font-black text-white leading-tight">
|
||||
PRECISION TO EVERY ANGLE
|
||||
</h5>
|
||||
<div className="flex items-center gap-1 text-[6px] text-slate-400 font-mono">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#d5ff63]" />
|
||||
<span>16-Angle Turntable Scrub</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-14 h-14 rounded-full bg-black/60 border border-[#d5ff63]/40 flex items-center justify-center p-1 shadow-2xl shrink-0">
|
||||
<span className="text-[9px] font-bold font-mono text-[#d5ff63]">360°</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
// 2.9 APPLE_SHOWCASE
|
||||
{
|
||||
@@ -798,7 +853,7 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 sm:p-6 animate-fade-in">
|
||||
<div className="bg-night-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-6xl flex flex-col h-[90vh] max-h-[850px] overflow-hidden text-slate-200">
|
||||
{/* Modal Header */}
|
||||
@@ -924,6 +979,7 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
X,
|
||||
Upload,
|
||||
Film,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Trash2,
|
||||
FolderOpen,
|
||||
} from 'lucide-react'
|
||||
import { API_BASE } from '@homaroma/types'
|
||||
|
||||
export interface FrameSequenceItem {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
folderPath: string
|
||||
pattern: string
|
||||
padding: number
|
||||
totalFrames: number
|
||||
fps: number
|
||||
durationSeconds: number
|
||||
posterUrl: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSelectSequence: (seq: FrameSequenceItem) => void
|
||||
selectedSlug?: string
|
||||
}
|
||||
|
||||
export default function FrameSequenceModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelectSequence,
|
||||
selectedSlug,
|
||||
}: Props) {
|
||||
const [sequences, setSequences] = useState<FrameSequenceItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
|
||||
// Upload Form State
|
||||
const [showUploadForm, setShowUploadForm] = useState(false)
|
||||
const [uploadName, setUploadName] = useState('')
|
||||
const [uploadFps, setUploadFps] = useState('24')
|
||||
const [zipFile, setZipFile] = useState<File | null>(null)
|
||||
|
||||
const fetchSequences = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/frame-sequences`)
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
setSequences(data.sequences || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load frame sequences', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchSequences()
|
||||
setShowUploadForm(false)
|
||||
setUploadError(null)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleUploadZip = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!zipFile) {
|
||||
setUploadError('กรุณาเลือกไฟล์ .ZIP ที่มีรูปเฟรม')
|
||||
return
|
||||
}
|
||||
|
||||
setIsUploading(true)
|
||||
setUploadError(null)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', zipFile)
|
||||
if (uploadName) formData.append('name', uploadName)
|
||||
formData.append('fps', uploadFps)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/frame-sequences/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok || data.error) {
|
||||
throw new Error(data.error || 'Upload failed')
|
||||
}
|
||||
|
||||
await fetchSequences()
|
||||
setShowUploadForm(false)
|
||||
setZipFile(null)
|
||||
setUploadName('')
|
||||
|
||||
if (data.sequence) {
|
||||
onSelectSequence(data.sequence)
|
||||
onClose()
|
||||
}
|
||||
} catch (err: any) {
|
||||
setUploadError(err.message || 'เกิดข้อผิดพลาดในการอัปโหลด ZIP')
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (slug: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบชุดเฟรม "${slug}"?`)) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/frame-sequences/${slug}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (res.ok) {
|
||||
setSequences((prev) => prev.filter((s) => s.slug !== slug))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete sequence', err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 animate-fade-in">
|
||||
<div className="bg-[#121211] border border-white/10 rounded-3xl shadow-2xl w-full max-w-4xl flex flex-col max-h-[85vh] overflow-hidden text-slate-200">
|
||||
{/* Modal Header */}
|
||||
<div className="p-6 border-b border-white/5 flex items-center justify-between shrink-0 bg-black/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-2xl bg-[#d5ff63]/10 border border-[#d5ff63]/30 flex items-center justify-center text-[#d5ff63]">
|
||||
<Film className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white flex items-center gap-2">
|
||||
<span>คลังชุดวิดีโอเฟรม (Frame Sequence Library)</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[#d5ff63]/20 text-[#d5ff63] font-mono">
|
||||
แยกจาก Media ปกติ
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
เลือกชุดเฟรมภาพแบบ 360° / Scrollytelling โดยไม่ปะปนกับคลังภาพสินค้าทั่วไป
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUploadForm(!showUploadForm)}
|
||||
className="px-4 py-2 rounded-xl bg-white/10 hover:bg-white/20 text-xs font-bold text-white flex items-center gap-2 transition-all cursor-pointer"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5 text-[#d5ff63]" />
|
||||
<span>{showUploadForm ? 'ดูรายการที่มีอยู่' : 'อัปโหลด ZIP ชุดใหม่'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-all cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-6 flex-1 overflow-y-auto space-y-6">
|
||||
{/* Upload Form Accordion */}
|
||||
{showUploadForm && (
|
||||
<form
|
||||
onSubmit={handleUploadZip}
|
||||
className="p-5 rounded-2xl bg-black/60 border border-[#d5ff63]/30 space-y-4 animate-fade-in"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-[#d5ff63] uppercase tracking-wider">
|
||||
<Upload className="w-4 h-4" />
|
||||
<span>อัปโหลดชุดเฟรมภาพด้วยไฟล์ .ZIP</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
บีบอัดโฟลเดอร์ภาพ เช่น <code className="text-slate-200">frame-001.webp</code> ถึง{' '}
|
||||
<code className="text-slate-200">frame-432.webp</code> เป็นไฟล์ .zip แล้วอัปโหลด ระบบจะคลายไฟล์ให้อัตโนมัติ
|
||||
</p>
|
||||
|
||||
{uploadError && (
|
||||
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/30 text-xs text-red-400">
|
||||
{uploadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-[10px] font-bold uppercase text-slate-400 block mb-1">
|
||||
ชื่อชุดวิดีโอ (Name)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={uploadName}
|
||||
onChange={(e) => setUploadName(e.target.value)}
|
||||
placeholder="เช่น Chrome Sprocket 18s Spin"
|
||||
className="admin-input w-full text-xs"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] font-bold uppercase text-slate-400 block mb-1">
|
||||
ความเร็วเฟรมต่อวินาที (FPS)
|
||||
</label>
|
||||
<select
|
||||
value={uploadFps}
|
||||
onChange={(e) => setUploadFps(e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
>
|
||||
<option value="24">24 FPS (Cinematic Video - ค่ามาตรฐาน)</option>
|
||||
<option value="30">30 FPS (Standard Video)</option>
|
||||
<option value="60">60 FPS (Ultra Smooth 3D)</option>
|
||||
<option value="12">12 FPS (Stop Motion)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] font-bold uppercase text-slate-400 block mb-1">
|
||||
ไฟล์ .ZIP ที่มีรูปเฟรม
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={(e) => setZipFile(e.target.files?.[0] || null)}
|
||||
className="admin-input w-full text-xs"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUploadForm(false)}
|
||||
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-xs text-slate-300"
|
||||
>
|
||||
ยกเลิก
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isUploading || !zipFile}
|
||||
className="px-6 py-2 rounded-xl bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e] disabled:opacity-50 flex items-center gap-2 cursor-pointer shadow-lg"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
<span>กำลังแตกไฟล์และจัดเก็บ...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
<span>เริ่มอัปโหลดและประมวลผล ZIP</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Sequence Cards Grid */}
|
||||
{loading ? (
|
||||
<div className="py-20 flex flex-col items-center justify-center gap-3 text-slate-500">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[#d5ff63]" />
|
||||
<span className="text-xs">กำลังโหลดชุดวิดีโอเฟรม...</span>
|
||||
</div>
|
||||
) : sequences.length === 0 ? (
|
||||
<div className="py-20 text-center space-y-3">
|
||||
<FolderOpen className="w-12 h-12 text-slate-600 mx-auto" />
|
||||
<div className="text-sm font-bold text-slate-300">ยังไม่มีชุด Frame Sequence ในระบบ</div>
|
||||
<p className="text-xs text-slate-500 max-w-sm mx-auto">
|
||||
คุณสามารถกดปุ่ม "อัปโหลด ZIP ชุดใหม่" ด้านบนเพื่อนำชุดเฟรมภาพเข้ามาใช้งานได้ทันที
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{sequences.map((seq) => {
|
||||
const isSelected = selectedSlug === seq.slug
|
||||
return (
|
||||
<div
|
||||
key={seq.id}
|
||||
onClick={() => {
|
||||
onSelectSequence(seq)
|
||||
onClose()
|
||||
}}
|
||||
className={`relative group rounded-2xl bg-black/40 border transition-all p-3 space-y-3 cursor-pointer ${
|
||||
isSelected
|
||||
? 'border-[#d5ff63] ring-1 ring-[#d5ff63] shadow-lg shadow-[#d5ff63]/10'
|
||||
: 'border-white/10 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
{/* Poster Thumbnail */}
|
||||
<div className="relative aspect-video rounded-xl bg-slate-900 overflow-hidden border border-white/5">
|
||||
<img
|
||||
src={seq.posterUrl}
|
||||
alt={seq.name}
|
||||
className="w-full h-full object-contain p-2"
|
||||
/>
|
||||
<div className="absolute bottom-2 left-2 px-2 py-0.5 rounded bg-black/80 backdrop-blur-md text-[9px] font-mono text-white">
|
||||
{seq.totalFrames} FRAMES • {seq.durationSeconds}s
|
||||
</div>
|
||||
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 w-6 h-6 rounded-full bg-[#d5ff63] text-black flex items-center justify-center shadow-lg">
|
||||
<CheckCircle className="w-4 h-4 fill-black text-[#d5ff63]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-bold text-white group-hover:text-[#d5ff63] transition-colors truncate">
|
||||
{seq.name}
|
||||
</h4>
|
||||
<p className="text-[10px] font-mono text-slate-500 truncate">
|
||||
{seq.folderPath}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Bar */}
|
||||
<div className="flex items-center justify-between pt-1 border-t border-white/5 text-[10px]">
|
||||
<span className="font-mono text-slate-400">
|
||||
{seq.fps} FPS
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleDelete(seq.slug, e)}
|
||||
title="ลบชุดนี้"
|
||||
className="p-1 rounded hover:bg-red-500/20 text-slate-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1 rounded-lg bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e]"
|
||||
>
|
||||
เลือกชุดนี้
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Sparkles,
|
||||
Upload,
|
||||
Package,
|
||||
ExternalLink,
|
||||
RotateCcw,
|
||||
Layers,
|
||||
} from 'lucide-react'
|
||||
import { API_BASE } from '@homaroma/types'
|
||||
|
||||
interface SpecItem {
|
||||
number?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
payload: Record<string, unknown>
|
||||
updatePayloadValue: (field: string, value: unknown) => void
|
||||
onBrowseMedia?: (onSelect: (url: string) => void) => void
|
||||
}
|
||||
|
||||
const DEFAULT_SPECS: SpecItem[] = [
|
||||
{
|
||||
number: '01',
|
||||
title: 'โครเมียมขัดเงาระดับพรีเมียม',
|
||||
subtitle: 'POLISHED CHROME FINISH',
|
||||
},
|
||||
{
|
||||
number: '02',
|
||||
title: 'เจาะรูกลึงลดน้ำหนักตามหลักอากาศพลศาสตร์',
|
||||
subtitle: 'LIGHTWEIGHT PRECISION CUTOUTS',
|
||||
},
|
||||
{
|
||||
number: '03',
|
||||
title: 'โปรไฟล์ฟันเฟือง 428H แม่นยำระดับไมครอน',
|
||||
subtitle: '428H PRECISION TOOTH PROFILE',
|
||||
},
|
||||
]
|
||||
|
||||
export default function Interactive360ShowcaseForm({
|
||||
payload,
|
||||
updatePayloadValue,
|
||||
onBrowseMedia,
|
||||
}: Props) {
|
||||
const [productsList, setProductsList] = useState<any[]>([])
|
||||
const [loadingProducts, setLoadingProducts] = useState(false)
|
||||
|
||||
const spriteSheetUrl = (payload.spriteSheetUrl as string) || '/images/3d/sprocket-views.png'
|
||||
const detailImageUrl = (payload.detailImageUrl as string) || '/images/3d/sprocket-detail.png'
|
||||
const eyebrow = (payload.eyebrow as string) || ''
|
||||
const title = (payload.title as string) || ''
|
||||
const subtitle = (payload.subtitle as string) || ''
|
||||
const detailKicker = (payload.detailKicker as string) || ''
|
||||
const detailTitle = (payload.detailTitle as string) || ''
|
||||
const detailCaption = (payload.detailCaption as string) || ''
|
||||
const productName = (payload.productName as string) || ''
|
||||
const productBadge = (payload.productBadge as string) || ''
|
||||
const accentColor = (payload.accentColor as string) || '#d5ff63'
|
||||
const theme = (payload.theme as string) || 'dark'
|
||||
const endCtaTitle = (payload.endCtaTitle as string) || ''
|
||||
const endCtaButtonText = (payload.endCtaButtonText as string) || ''
|
||||
const endCtaLink = (payload.endCtaLink as string) || ''
|
||||
const selectedProductId = payload.productId ? String(payload.productId) : ''
|
||||
|
||||
const rawSpecs = Array.isArray(payload.specs) ? (payload.specs as SpecItem[]) : DEFAULT_SPECS
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
const fetchCatalog = async () => {
|
||||
setLoadingProducts(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/products?limit=100`)
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
const items = data.data || data.products || (Array.isArray(data) ? data : [])
|
||||
if (isMounted) setProductsList(items)
|
||||
} catch (err) {
|
||||
console.error('Failed to load products in Interactive360ShowcaseForm', err)
|
||||
} finally {
|
||||
if (isMounted) setLoadingProducts(false)
|
||||
}
|
||||
}
|
||||
fetchCatalog()
|
||||
return () => { isMounted = false }
|
||||
}, [])
|
||||
|
||||
// Auto-seed defaults if brand new block
|
||||
useEffect(() => {
|
||||
if (!payload.spriteSheetUrl) updatePayloadValue('spriteSheetUrl', '/images/3d/sprocket-views.png')
|
||||
if (!payload.detailImageUrl) updatePayloadValue('detailImageUrl', '/images/3d/sprocket-detail.png')
|
||||
if (!payload.eyebrow) updatePayloadValue('eyebrow', 'ENGINEERED TO MOVE')
|
||||
if (!payload.title) updatePayloadValue('title', 'เหล็กหนึ่งชิ้น ที่เล่าได้ทุกองศา')
|
||||
if (!payload.subtitle) updatePayloadValue('subtitle', 'สเตอร์จักรยานยนต์โครเมียม ดีไซน์น้ำหนักเบา ให้ทุกการหมุนมีจังหวะที่แม่นยำ พร้อมสัมผัสรายละเอียดระดับ Ultra-Macro')
|
||||
if (!payload.detailKicker) updatePayloadValue('detailKicker', 'FINISHED TO THE EDGE')
|
||||
if (!payload.detailTitle) updatePayloadValue('detailTitle', 'ฟันแน่น แข็งแรง ผิวเรียบทุกขอบ')
|
||||
if (!payload.detailCaption) updatePayloadValue('detailCaption', 'ผิวชุบโครเมียมขัดเงา สะท้อนแสงเงางาม ทนทานต่อการเสียดสี และป้องกันสนิมในทุกสภาพการใช้งาน')
|
||||
if (!payload.productName) updatePayloadValue('productName', 'Chrome Sprocket 428H')
|
||||
if (!payload.productBadge) updatePayloadValue('productBadge', 'MONO / 01')
|
||||
if (!payload.endCtaTitle) updatePayloadValue('endCtaTitle', 'พร้อมยกระดับสมรรถนะการขับขี่?')
|
||||
if (!payload.endCtaButtonText) updatePayloadValue('endCtaButtonText', 'สั่งซื้อสินค้าตอนนี้')
|
||||
if (!payload.specs) updatePayloadValue('specs', DEFAULT_SPECS)
|
||||
if (!payload.theme) updatePayloadValue('theme', 'dark')
|
||||
if (!payload.accentColor) updatePayloadValue('accentColor', '#d5ff63')
|
||||
}, [])
|
||||
|
||||
const handleSelectProduct = (productIdStr: string) => {
|
||||
if (!productIdStr) {
|
||||
updatePayloadValue('productId', null)
|
||||
return
|
||||
}
|
||||
const pid = Number(productIdStr)
|
||||
updatePayloadValue('productId', pid)
|
||||
|
||||
const prod = productsList.find((p) => p.id === pid)
|
||||
if (prod) {
|
||||
const prodName = typeof prod.name === 'string' ? prod.name : prod.name?.th || prod.name?.en || ''
|
||||
if (prodName) updatePayloadValue('productName', prodName)
|
||||
const slug = prod.slug || `product-${pid}`
|
||||
updatePayloadValue('endCtaLink', `/products/${slug}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyPreset = () => {
|
||||
updatePayloadValue('spriteSheetUrl', '/images/3d/sprocket-views.png')
|
||||
updatePayloadValue('detailImageUrl', '/images/3d/sprocket-detail.png')
|
||||
updatePayloadValue('eyebrow', 'ENGINEERED TO MOVE')
|
||||
updatePayloadValue('title', 'เหล็กหนึ่งชิ้น ที่เล่าได้ทุกองศา')
|
||||
updatePayloadValue('subtitle', 'สเตอร์จักรยานยนต์โครเมียม ดีไซน์น้ำหนักเบา ให้ทุกการหมุนมีจังหวะที่แม่นยำ พร้อมสัมผัสรายละเอียดระดับ Ultra-Macro')
|
||||
updatePayloadValue('detailKicker', 'FINISHED TO THE EDGE')
|
||||
updatePayloadValue('detailTitle', 'ฟันแน่น แข็งแรง ผิวเรียบทุกขอบ')
|
||||
updatePayloadValue('detailCaption', 'ผิวชุบโครเมียมขัดเงา สะท้อนแสงเงางาม ทนทานต่อการเสียดสี และป้องกันสนิมในทุกสภาพการใช้งาน')
|
||||
updatePayloadValue('productName', 'Chrome Sprocket 428H')
|
||||
updatePayloadValue('productBadge', 'MONO / 01')
|
||||
updatePayloadValue('endCtaTitle', 'พร้อมยกระดับสมรรถนะการขับขี่?')
|
||||
updatePayloadValue('endCtaButtonText', 'สั่งซื้อสินค้าตอนนี้')
|
||||
updatePayloadValue('specs', DEFAULT_SPECS)
|
||||
updatePayloadValue('theme', 'dark')
|
||||
updatePayloadValue('accentColor', '#d5ff63')
|
||||
}
|
||||
|
||||
const handleUpdateSpec = (idx: number, field: keyof SpecItem, val: string) => {
|
||||
const updated = [...rawSpecs]
|
||||
updated[idx] = { ...updated[idx], [field]: val }
|
||||
updatePayloadValue('specs', updated)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 text-slate-200">
|
||||
{/* ── Preset & Binding Banner ── */}
|
||||
<div className="p-4 rounded-2xl bg-gradient-to-r from-lime-500/10 via-slate-900/40 to-transparent border border-lime-500/30 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-lime-400 font-bold text-xs uppercase tracking-wider">
|
||||
<Sparkles className="w-4 h-4 text-lime-400" />
|
||||
<span>3D Interactive Turntable & Macro Zoom Showcase</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
แสดงผลสเตอร์/ชิ้นส่วน 360 องศาด้วย Sprite Sheet + Scroll Scrollytelling + ซูม Macro Detail อัตโนมัติ
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApplyPreset}
|
||||
className="px-4 py-2 rounded-xl bg-lime-400 text-slate-950 font-bold text-xs hover:bg-lime-300 transition-all flex items-center gap-1.5 shrink-0 shadow-lg cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<span>รีเซ็ต / โหลด Mockup สเตอร์โครเมียม</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── 1-Click Product Linking ── */}
|
||||
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-slate-200 uppercase tracking-wider">
|
||||
<Package className="w-4 h-4 text-lime-400" />
|
||||
<span>ผูกกับสินค้าในระบบ (1-Click Bind Product)</span>
|
||||
</div>
|
||||
{selectedProductId && (
|
||||
<span className="text-[10px] text-lime-400 font-mono">
|
||||
Product ID: #{selectedProductId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<select
|
||||
value={selectedProductId}
|
||||
onChange={(e) => handleSelectProduct(e.target.value)}
|
||||
disabled={loadingProducts}
|
||||
className="admin-input w-full text-xs"
|
||||
>
|
||||
<option value="">-- ไม่ระบุ (ใช้เป็นหน้า Brand/Product Story อิสระ) --</option>
|
||||
{productsList.map((p) => {
|
||||
const nameStr = typeof p.name === 'string' ? p.name : p.name?.th || p.name?.en || 'Unnamed'
|
||||
return (
|
||||
<option key={p.id} value={p.id}>
|
||||
#{p.id} - {nameStr}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
เมื่อเลือกสินค้า ระบบจะดึงชื่อและตั้งค่าลิงก์ปุ่มสั่งซื้ออัตโนมัติ
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={endCtaLink}
|
||||
onChange={(e) => updatePayloadValue('endCtaLink', e.target.value)}
|
||||
placeholder="ลิงก์ปลายทางเมื่อกดสั่งซื้อ เช่น /products/chain-sprocket-p1"
|
||||
className="admin-input flex-1 text-xs"
|
||||
/>
|
||||
{endCtaLink && (
|
||||
<a
|
||||
href={endCtaLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="p-2 rounded-lg bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Visual Media Uploads (Sprite Sheet + Macro Detail) ── */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Sprite Sheet 16-Angles */}
|
||||
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-3">
|
||||
<label className="text-xs font-bold text-slate-200 uppercase tracking-wider block">
|
||||
1. Sprite Sheet 360° (ภาพรวม 16 มุมมอง 4x4 Grid)
|
||||
</label>
|
||||
{spriteSheetUrl ? (
|
||||
<div className="relative aspect-video rounded-xl bg-black border border-white/10 overflow-hidden group">
|
||||
<img
|
||||
src={spriteSheetUrl}
|
||||
alt="360 Sprite Sheet"
|
||||
className="w-full h-full object-contain p-2"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 flex items-center justify-center gap-2 transition-opacity">
|
||||
{onBrowseMedia && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onBrowseMedia((url) => updatePayloadValue('spriteSheetUrl', url))}
|
||||
className="px-3 py-1.5 rounded-lg bg-lime-400 text-black text-xs font-bold cursor-pointer"
|
||||
>
|
||||
เปลี่ยนรูป
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => onBrowseMedia && onBrowseMedia((url) => updatePayloadValue('spriteSheetUrl', url))}
|
||||
className="aspect-video rounded-xl border border-dashed border-white/20 bg-black/40 flex flex-col items-center justify-center gap-2 cursor-pointer hover:border-lime-400 transition-colors"
|
||||
>
|
||||
<Upload className="w-6 h-6 text-slate-500" />
|
||||
<span className="text-xs text-slate-400">คลิกเพื่อเลือกภาพ Sprite Sheet</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={spriteSheetUrl}
|
||||
onChange={(e) => updatePayloadValue('spriteSheetUrl', e.target.value)}
|
||||
placeholder="/images/3d/sprocket-views.png"
|
||||
className="admin-input w-full text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Macro Detail Shot */}
|
||||
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-3">
|
||||
<label className="text-xs font-bold text-slate-200 uppercase tracking-wider block">
|
||||
2. Macro Detail Shot (ภาพซูมระยะประชิดระดับ 4K)
|
||||
</label>
|
||||
{detailImageUrl ? (
|
||||
<div className="relative aspect-video rounded-xl bg-black border border-white/10 overflow-hidden group">
|
||||
<img
|
||||
src={detailImageUrl}
|
||||
alt="Macro Detail"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 flex items-center justify-center gap-2 transition-opacity">
|
||||
{onBrowseMedia && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onBrowseMedia((url) => updatePayloadValue('detailImageUrl', url))}
|
||||
className="px-3 py-1.5 rounded-lg bg-lime-400 text-black text-xs font-bold cursor-pointer"
|
||||
>
|
||||
เปลี่ยนรูป
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => onBrowseMedia && onBrowseMedia((url) => updatePayloadValue('detailImageUrl', url))}
|
||||
className="aspect-video rounded-xl border border-dashed border-white/20 bg-black/40 flex flex-col items-center justify-center gap-2 cursor-pointer hover:border-lime-400 transition-colors"
|
||||
>
|
||||
<Upload className="w-6 h-6 text-slate-500" />
|
||||
<span className="text-xs text-slate-400">คลิกเพื่อเลือกภาพ Macro Detail</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={detailImageUrl}
|
||||
onChange={(e) => updatePayloadValue('detailImageUrl', e.target.value)}
|
||||
placeholder="/images/3d/sprocket-detail.png"
|
||||
className="admin-input w-full text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Headline & Intro Texts ── */}
|
||||
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-4">
|
||||
<div className="text-xs font-bold text-slate-200 uppercase tracking-wider">
|
||||
ข้อความหัวเรื่อง (Header & Intro)
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
หมวด/ป้ายกำกับ (Eyebrow Tag)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={eyebrow}
|
||||
onChange={(e) => updatePayloadValue('eyebrow', e.target.value)}
|
||||
className="admin-input w-full text-xs font-mono"
|
||||
placeholder="ENGINEERED TO MOVE"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
ชื่อสินค้า (Product Name)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={productName}
|
||||
onChange={(e) => updatePayloadValue('productName', e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
placeholder="Chrome Sprocket 428H"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
ซีรีส์สินค้า (Badge / Series)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={productBadge}
|
||||
onChange={(e) => updatePayloadValue('productBadge', e.target.value)}
|
||||
className="admin-input w-full text-xs font-mono"
|
||||
placeholder="MONO / 01"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
หัวเรื่องหลัก (Main Title)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => updatePayloadValue('title', e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
placeholder="เหล็กหนึ่งชิ้น ที่เล่าได้ทุกองศา"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
คำบรรยายเกริ่นนำ (Intro Subtitle)
|
||||
</label>
|
||||
<textarea
|
||||
value={subtitle}
|
||||
onChange={(e) => updatePayloadValue('subtitle', e.target.value)}
|
||||
rows={2}
|
||||
className="admin-input w-full text-xs"
|
||||
placeholder="สเตอร์จักรยานยนต์โครเมียม ดีไซน์น้ำหนักเบา ให้ทุกการหมุนมีจังหวะที่แม่นยำ"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Macro Zoom Detail Overlay Texts ── */}
|
||||
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-4">
|
||||
<div className="text-xs font-bold text-lime-400 uppercase tracking-wider flex items-center gap-2">
|
||||
<Layers className="w-4 h-4" />
|
||||
<span>ข้อความส่วนซูม Macro Detail (แสดงเมื่อเลื่อนจอลงถึงช่วงท้าย)</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
Kicker Tag
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={detailKicker}
|
||||
onChange={(e) => updatePayloadValue('detailKicker', e.target.value)}
|
||||
className="admin-input w-full text-xs font-mono"
|
||||
placeholder="FINISHED TO THE EDGE"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
Headline ตอนซูม
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={detailTitle}
|
||||
onChange={(e) => updatePayloadValue('detailTitle', e.target.value)}
|
||||
className="admin-input w-full text-xs font-bold"
|
||||
placeholder="ฟันแน่น แข็งแรง ผิวเรียบทุกขอบ"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
คำอธิบายรายละเอียด
|
||||
</label>
|
||||
<textarea
|
||||
value={detailCaption}
|
||||
onChange={(e) => updatePayloadValue('detailCaption', e.target.value)}
|
||||
rows={2}
|
||||
className="admin-input w-full text-xs"
|
||||
placeholder="ผิวชุบโครเมียมขัดเงา สะท้อนแสงเงางาม ทนทานต่อการเสียดสี และป้องกันสนิมในทุกสภาพการใช้งาน"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 3-Spec Highlights Grid ── */}
|
||||
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-4">
|
||||
<div className="text-xs font-bold text-slate-200 uppercase tracking-wider">
|
||||
จุดเด่นด้านสมรรถนะ 3 หัวข้อ (Technical Pillars)
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{rawSpecs.map((spec, idx) => (
|
||||
<div key={idx} className="p-3 rounded-xl bg-black/40 border border-white/10 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-mono font-bold text-lime-400">
|
||||
#{idx + 1}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={spec.number || `0${idx + 1}`}
|
||||
onChange={(e) => handleUpdateSpec(idx, 'number', e.target.value)}
|
||||
className="admin-input w-16 text-center text-xs font-mono"
|
||||
placeholder={`0${idx + 1}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={spec.title}
|
||||
onChange={(e) => handleUpdateSpec(idx, 'title', e.target.value)}
|
||||
className="admin-input w-full text-xs font-bold"
|
||||
placeholder="หัวข้อสเปก"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={spec.subtitle || ''}
|
||||
onChange={(e) => handleUpdateSpec(idx, 'subtitle', e.target.value)}
|
||||
className="admin-input w-full text-xs font-mono text-slate-400"
|
||||
placeholder="SUBTITLE EN"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Theme & CTA Config ── */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 rounded-2xl bg-slate-900/60 border border-white/5">
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
สี Accent Highlight
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={accentColor}
|
||||
onChange={(e) => updatePayloadValue('accentColor', e.target.value)}
|
||||
className="w-8 h-8 rounded border-0 cursor-pointer bg-transparent"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={accentColor}
|
||||
onChange={(e) => updatePayloadValue('accentColor', e.target.value)}
|
||||
className="admin-input flex-1 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
หัวข้อแบนเนอร์สั่งซื้อ (CTA Headline)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={endCtaTitle}
|
||||
onChange={(e) => updatePayloadValue('endCtaTitle', e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
placeholder="พร้อมยกระดับสมรรถนะการขับขี่?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
ข้อความปุ่มสั่งซื้อ (CTA Button)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={endCtaButtonText}
|
||||
onChange={(e) => updatePayloadValue('endCtaButtonText', e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
placeholder="สั่งซื้อสินค้าตอนนี้"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
โทนพื้นหลัง (Theme)
|
||||
</label>
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => updatePayloadValue('theme', e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
>
|
||||
<option value="dark">Dark Theater (#0f0f0e - แนะนำ)</option>
|
||||
<option value="light">Light Studio (#f8f9fa)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, Search, Loader2 } from 'lucide-react';
|
||||
import { API_BASE } from '@homaroma/types';
|
||||
import { getAdminDisplayString } from '../../utils/localization';
|
||||
@@ -63,7 +64,7 @@ export default function ProductPickerModal({
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-night-900/80 backdrop-blur-sm" onClick={onClose}></div>
|
||||
<div className="relative bg-slate-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden flex flex-col h-[80vh]">
|
||||
@@ -160,6 +161,7 @@ export default function ProductPickerModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Film,
|
||||
Package,
|
||||
ExternalLink,
|
||||
RotateCcw,
|
||||
CheckCircle,
|
||||
} from 'lucide-react'
|
||||
import { API_BASE } from '@homaroma/types'
|
||||
import FrameSequenceModal, { type FrameSequenceItem } from './FrameSequenceModal'
|
||||
|
||||
interface Props {
|
||||
payload: Record<string, unknown>
|
||||
updatePayloadValue: (field: string, value: unknown) => void
|
||||
}
|
||||
|
||||
export default function VdoFrameScrubberForm({
|
||||
payload,
|
||||
updatePayloadValue,
|
||||
}: Props) {
|
||||
const [productsList, setProductsList] = useState<any[]>([])
|
||||
const [loadingProducts, setLoadingProducts] = useState(false)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
const folderPath = (payload.folderPath as string) || '/frame-sequences/chrome-sprocket-428h'
|
||||
const pattern = (payload.pattern as string) || 'frame-{index}.webp'
|
||||
const totalFrames = Number(payload.totalFrames) || 432
|
||||
const fps = Number(payload.fps) || 24
|
||||
const durationSeconds = Number(payload.durationSeconds) || 18
|
||||
|
||||
const eyebrow = (payload.eyebrow as string) || ''
|
||||
const title = (payload.title as string) || ''
|
||||
const subtitle = (payload.subtitle as string) || ''
|
||||
const instruction = (payload.instruction as string) || ''
|
||||
const productName = (payload.productName as string) || ''
|
||||
const productBadge = (payload.productBadge as string) || ''
|
||||
const accentColor = (payload.accentColor as string) || '#d5ff63'
|
||||
const theme = (payload.theme as string) || 'dark'
|
||||
const endCtaTitle = (payload.endCtaTitle as string) || ''
|
||||
const endCtaButtonText = (payload.endCtaButtonText as string) || ''
|
||||
const endCtaLink = (payload.endCtaLink as string) || ''
|
||||
const selectedProductId = payload.productId ? String(payload.productId) : ''
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
const fetchCatalog = async () => {
|
||||
setLoadingProducts(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/products?limit=100`)
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
const items = data.data || data.products || (Array.isArray(data) ? data : [])
|
||||
if (isMounted) setProductsList(items)
|
||||
} catch (err) {
|
||||
console.error('Failed to load products in VdoFrameScrubberForm', err)
|
||||
} finally {
|
||||
if (isMounted) setLoadingProducts(false)
|
||||
}
|
||||
}
|
||||
fetchCatalog()
|
||||
return () => { isMounted = false }
|
||||
}, [])
|
||||
|
||||
// Auto-seed defaults on brand new block
|
||||
useEffect(() => {
|
||||
if (!payload.folderPath) updatePayloadValue('folderPath', '/frame-sequences/chrome-sprocket-428h')
|
||||
if (!payload.pattern) updatePayloadValue('pattern', 'frame-{index}.webp')
|
||||
if (!payload.padding) updatePayloadValue('padding', 3)
|
||||
if (!payload.totalFrames) updatePayloadValue('totalFrames', 432)
|
||||
if (!payload.fps) updatePayloadValue('fps', 24)
|
||||
if (!payload.durationSeconds) updatePayloadValue('durationSeconds', 18)
|
||||
if (!payload.eyebrow) updatePayloadValue('eyebrow', 'FROM VIDEO TO INTERACTIVE')
|
||||
if (!payload.title) updatePayloadValue('title', 'หนึ่งวิดีโอ หมุนดูได้ทีละเฟรม')
|
||||
if (!payload.subtitle) updatePayloadValue('subtitle', 'รวมวิดีโอ 18 วินาทีเป็น 432 เฟรม ลากภาพหรือเลื่อนหน้าเว็บเพื่อหมุนต่อ แล้วซูมดูฟันสเตอร์')
|
||||
if (!payload.instruction) updatePayloadValue('instruction', 'ลากซ้าย–ขวา หรือเลื่อนหน้าเว็บ')
|
||||
if (!payload.productName) updatePayloadValue('productName', 'SPROCKET / FRAME SCRUBBER')
|
||||
if (!payload.productBadge) updatePayloadValue('productBadge', '18 SEC / 432 EXTRACTED FRAMES')
|
||||
if (!payload.endCtaTitle) updatePayloadValue('endCtaTitle', 'ทุกมุม อยู่ในมือคุณ พร้อมยกระดับการขับขี่')
|
||||
if (!payload.endCtaButtonText) updatePayloadValue('endCtaButtonText', 'สั่งซื้อสินค้าตอนนี้')
|
||||
if (!payload.theme) updatePayloadValue('theme', 'dark')
|
||||
if (!payload.accentColor) updatePayloadValue('accentColor', '#d5ff63')
|
||||
}, [])
|
||||
|
||||
const handleSelectProduct = (productIdStr: string) => {
|
||||
if (!productIdStr) {
|
||||
updatePayloadValue('productId', null)
|
||||
return
|
||||
}
|
||||
const pid = Number(productIdStr)
|
||||
updatePayloadValue('productId', pid)
|
||||
|
||||
const prod = productsList.find((p) => p.id === pid)
|
||||
if (prod) {
|
||||
const prodName = typeof prod.name === 'string' ? prod.name : prod.name?.th || prod.name?.en || ''
|
||||
if (prodName) updatePayloadValue('productName', prodName)
|
||||
const slug = prod.slug || `product-${pid}`
|
||||
updatePayloadValue('endCtaLink', `/products/${slug}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyPreset = () => {
|
||||
updatePayloadValue('folderPath', '/frame-sequences/chrome-sprocket-428h')
|
||||
updatePayloadValue('pattern', 'frame-{index}.webp')
|
||||
updatePayloadValue('padding', 3)
|
||||
updatePayloadValue('totalFrames', 432)
|
||||
updatePayloadValue('fps', 24)
|
||||
updatePayloadValue('durationSeconds', 18)
|
||||
updatePayloadValue('eyebrow', 'FROM VIDEO TO INTERACTIVE')
|
||||
updatePayloadValue('title', 'หนึ่งวิดีโอ หมุนดูได้ทีละเฟรม')
|
||||
updatePayloadValue('subtitle', 'รวมวิดีโอ 18 วินาทีเป็น 432 เฟรม ลากภาพหรือเลื่อนหน้าเว็บเพื่อหมุนต่อ แล้วซูมดูฟันสเตอร์')
|
||||
updatePayloadValue('instruction', 'ลากซ้าย–ขวา หรือเลื่อนหน้าเว็บ')
|
||||
updatePayloadValue('productName', 'SPROCKET / FRAME SCRUBBER')
|
||||
updatePayloadValue('productBadge', '18 SEC / 432 EXTRACTED FRAMES')
|
||||
updatePayloadValue('endCtaTitle', 'ทุกมุม อยู่ในมือคุณ พร้อมยกระดับการขับขี่')
|
||||
updatePayloadValue('endCtaButtonText', 'สั่งซื้อสินค้าตอนนี้')
|
||||
updatePayloadValue('theme', 'dark')
|
||||
updatePayloadValue('accentColor', '#d5ff63')
|
||||
}
|
||||
|
||||
const handleSequenceSelected = (seq: FrameSequenceItem) => {
|
||||
updatePayloadValue('folderPath', seq.folderPath)
|
||||
updatePayloadValue('pattern', seq.pattern || 'frame-{index}.webp')
|
||||
updatePayloadValue('padding', seq.padding || 3)
|
||||
updatePayloadValue('totalFrames', seq.totalFrames)
|
||||
updatePayloadValue('fps', seq.fps || 24)
|
||||
updatePayloadValue('durationSeconds', seq.durationSeconds || parseFloat((seq.totalFrames / 24).toFixed(1)))
|
||||
updatePayloadValue('productBadge', `${seq.durationSeconds}S / ${seq.totalFrames} FRAMES`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 text-slate-200">
|
||||
{/* ── Preset Banner ── */}
|
||||
<div className="p-4 rounded-2xl bg-gradient-to-r from-[#d5ff63]/10 via-slate-900/40 to-transparent border border-[#d5ff63]/30 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-[#d5ff63] font-bold text-xs uppercase tracking-wider">
|
||||
<Film className="w-4 h-4 text-[#d5ff63]" />
|
||||
<span>VDO Frame-by-Frame Interactive Scrubber</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
แสดงผลวิดีโอแบบ Frame Sequence ควบคุมเฟรมตามการเลื่อนหน้าเว็บ (Scroll) และการลาก (Drag Scrub)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApplyPreset}
|
||||
className="px-4 py-2 rounded-xl bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e] transition-all flex items-center gap-1.5 shrink-0 shadow-lg cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<span>รีเซ็ต / โหลดตัวอย่าง Chrome Sprocket (432 เฟรม)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Sequence Collection Selector (Isolated Library) ── */}
|
||||
<div className="p-5 rounded-2xl bg-slate-900/70 border border-white/5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-white uppercase tracking-wider">
|
||||
<Film className="w-4 h-4 text-[#d5ff63]" />
|
||||
<span>ชุดวิดีโอเฟรมที่เลือกใช้งาน (Frame Sequence Collection)</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="px-4 py-1.5 rounded-xl bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e] transition-all flex items-center gap-1.5 cursor-pointer shadow-md"
|
||||
>
|
||||
<Film className="w-3.5 h-3.5" />
|
||||
<span>เลือก / อัปโหลดชุด ZIP ใหม่</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Selected Sequence Card */}
|
||||
<div className="p-4 rounded-xl bg-black/50 border border-white/10 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-20 aspect-video rounded-lg bg-slate-800 overflow-hidden border border-white/10 shrink-0">
|
||||
<img
|
||||
src={`${folderPath}/frame-001.webp`}
|
||||
alt="Sequence Poster"
|
||||
className="w-full h-full object-contain p-1"
|
||||
onError={(e) => {
|
||||
// Fallback if not yet loaded
|
||||
(e.target as HTMLImageElement).src = '/images/3d/sprocket-detail.png'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-white">
|
||||
{folderPath.split('/').pop()?.replace(/[-_]/g, ' ').toUpperCase() || 'FRAME SEQUENCE'}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-400 text-[9px] font-mono flex items-center gap-1">
|
||||
<CheckCircle className="w-2.5 h-2.5" /> พร้อมใช้งาน
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-400 font-mono">
|
||||
<span>{totalFrames} FRAMES</span>
|
||||
<span>•</span>
|
||||
<span>{fps} FPS</span>
|
||||
<span>•</span>
|
||||
<span>{durationSeconds} วินาที</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 font-mono">
|
||||
โฟลเดอร์: {folderPath} (รูปแบบ: {pattern})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="text-xs text-[#d5ff63] hover:underline font-mono"
|
||||
>
|
||||
เปลี่ยนชุด →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 1-Click Product Linking ── */}
|
||||
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-slate-200 uppercase tracking-wider">
|
||||
<Package className="w-4 h-4 text-[#d5ff63]" />
|
||||
<span>ผูกกับสินค้าในระบบ (1-Click Bind Product)</span>
|
||||
</div>
|
||||
{selectedProductId && (
|
||||
<span className="text-[10px] text-[#d5ff63] font-mono">
|
||||
Product ID: #{selectedProductId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<select
|
||||
value={selectedProductId}
|
||||
onChange={(e) => handleSelectProduct(e.target.value)}
|
||||
disabled={loadingProducts}
|
||||
className="admin-input w-full text-xs"
|
||||
>
|
||||
<option value="">-- ไม่ระบุ (ใช้เป็นหน้า Brand/Product Story อิสระ) --</option>
|
||||
{productsList.map((p) => {
|
||||
const nameStr = typeof p.name === 'string' ? p.name : p.name?.th || p.name?.en || 'Unnamed'
|
||||
return (
|
||||
<option key={p.id} value={p.id}>
|
||||
#{p.id} - {nameStr}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={endCtaLink}
|
||||
onChange={(e) => updatePayloadValue('endCtaLink', e.target.value)}
|
||||
placeholder="ลิงก์ปลายทางเมื่อกดสั่งซื้อ เช่น /products/chain-sprocket-p1"
|
||||
className="admin-input flex-1 text-xs"
|
||||
/>
|
||||
{endCtaLink && (
|
||||
<a
|
||||
href={endCtaLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="p-2 rounded-lg bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Headline & Header Texts ── */}
|
||||
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-4">
|
||||
<div className="text-xs font-bold text-slate-200 uppercase tracking-wider">
|
||||
ข้อความหัวเรื่อง (Header & Story Intro)
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
หมวด/ป้ายกำกับ (Eyebrow Tag)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={eyebrow}
|
||||
onChange={(e) => updatePayloadValue('eyebrow', e.target.value)}
|
||||
className="admin-input w-full text-xs font-mono"
|
||||
placeholder="FROM VIDEO TO INTERACTIVE"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
ป้ายซีรีส์ (Badge / Tag)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={productBadge}
|
||||
onChange={(e) => updatePayloadValue('productBadge', e.target.value)}
|
||||
className="admin-input w-full text-xs font-mono"
|
||||
placeholder="18 SEC / 432 FRAMES"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
ชื่อสินค้า (Product Name)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={productName}
|
||||
onChange={(e) => updatePayloadValue('productName', e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
placeholder="SPROCKET / FRAME SCRUBBER"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
คำแนะนำการเลื่อน (Instruction Cue)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={instruction}
|
||||
onChange={(e) => updatePayloadValue('instruction', e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
placeholder="ลากซ้าย–ขวา หรือเลื่อนหน้าเว็บ"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
หัวเรื่องหลัก (Main Title)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => updatePayloadValue('title', e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
placeholder="หนึ่งวิดีโอ หมุนดูได้ทีละเฟรม"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
คำบรรยายเกริ่นนำ (Intro Subtitle)
|
||||
</label>
|
||||
<textarea
|
||||
value={subtitle}
|
||||
onChange={(e) => updatePayloadValue('subtitle', e.target.value)}
|
||||
rows={2}
|
||||
className="admin-input w-full text-xs"
|
||||
placeholder="รวมวิดีโอ 18 วินาทีเป็น 432 เฟรม ลากภาพหรือเลื่อนหน้าเว็บเพื่อหมุนต่อ แล้วซูมดูฟันสเตอร์"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Theme, Accent, and Conversion CTA ── */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 p-4 rounded-2xl bg-slate-900/60 border border-white/5">
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
สี Accent Highlight
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={accentColor}
|
||||
onChange={(e) => updatePayloadValue('accentColor', e.target.value)}
|
||||
className="w-8 h-8 rounded border-0 cursor-pointer bg-transparent"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={accentColor}
|
||||
onChange={(e) => updatePayloadValue('accentColor', e.target.value)}
|
||||
className="admin-input flex-1 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
โทนพื้นหลัง (Theme)
|
||||
</label>
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => updatePayloadValue('theme', e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
>
|
||||
<option value="dark">Dark Theater (#0e0e0d - แนะนำ)</option>
|
||||
<option value="light">Light Studio (#f8f9fa)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
หัวข้อแบนเนอร์สั่งซื้อ (CTA Headline)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={endCtaTitle}
|
||||
onChange={(e) => updatePayloadValue('endCtaTitle', e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
placeholder="ทุกมุม อยู่ในมือคุณ"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||
ข้อความปุ่มสั่งซื้อ (CTA Button)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={endCtaButtonText}
|
||||
onChange={(e) => updatePayloadValue('endCtaButtonText', e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
placeholder="สั่งซื้อสินค้าตอนนี้"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal for Frame Sequence Collection */}
|
||||
<FrameSequenceModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSelectSequence={handleSequenceSelected}
|
||||
selectedSlug={folderPath.split('/').pop()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
X,
|
||||
Receipt,
|
||||
DollarSign,
|
||||
Wallet,
|
||||
BadgePercent,
|
||||
Coins,
|
||||
Package,
|
||||
Truck,
|
||||
ExternalLink,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Copy,
|
||||
Check,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
import { TIKTOK_SHOPS } from '../../pages/TiktokOrderPage';
|
||||
|
||||
function fmt(val: number | null | undefined) {
|
||||
if (val == null) return '0.00';
|
||||
return new Intl.NumberFormat('th-TH', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(val);
|
||||
}
|
||||
|
||||
interface TiktokFeeBreakdownModalProps {
|
||||
order: any;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TiktokFeeBreakdownModal({ order, onClose }: TiktokFeeBreakdownModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Keyboard navigation & body scroll lock
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// Prevent background page from scrolling
|
||||
const originalOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = originalOverflow;
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
if (!order || typeof document === 'undefined') return null;
|
||||
|
||||
const settlement = order.settlement;
|
||||
const isSettled = order.isSettled || !!settlement;
|
||||
const shopCfg = TIKTOK_SHOPS.find((s) => s.id === order.shop) || TIKTOK_SHOPS[0];
|
||||
|
||||
// Financial figures (Settlement fees are usually stored as negative numbers from TikTok reports)
|
||||
const grossRevenue = settlement?.grossRevenue ?? settlement?.subtotalAfterSellerDiscount ?? order.orderAmount ?? 0;
|
||||
const sellerDiscount = Math.abs(settlement?.sellerDiscount ?? 0);
|
||||
const subtotalBeforeDiscount = settlement?.subtotalBeforeDiscount ?? (grossRevenue + sellerDiscount);
|
||||
|
||||
const commissionFee = Math.abs(settlement?.commissionFee ?? order.commissionFee ?? 0);
|
||||
const affiliateCommission = Math.abs(settlement?.affiliateCommission ?? order.affiliateFee ?? 0);
|
||||
const affiliatePartnerFee = Math.abs(settlement?.affiliatePartnerFee ?? 0);
|
||||
const totalAffiliate = affiliateCommission + affiliatePartnerFee;
|
||||
const orderFee = Math.abs(settlement?.orderFee ?? 0);
|
||||
const growthSupportFee = Math.abs(settlement?.growthSupportFee ?? 0);
|
||||
const shippingFeeActual = Math.abs(settlement?.shippingFeeActual ?? 0);
|
||||
const platformShippingDiscount = Math.abs(settlement?.platformShippingDiscount ?? 0);
|
||||
const netShippingToSeller = Math.max(0, shippingFeeActual - platformShippingDiscount);
|
||||
|
||||
// Total deductions
|
||||
const rawTotalFee = settlement?.totalFee != null
|
||||
? Math.abs(settlement.totalFee)
|
||||
: (commissionFee + totalAffiliate + orderFee + growthSupportFee + netShippingToSeller);
|
||||
|
||||
// Net payout
|
||||
const netSettlement = settlement?.settlementAmount ?? order.netSettlement ?? (grossRevenue - commissionFee - totalAffiliate);
|
||||
|
||||
// BOM & Net Profit
|
||||
const totalBomCost = order.orderTotalBomCost || 0;
|
||||
const hasMissingBom = order.hasIncompleteBom || (order.missingBomCount ?? 0) > 0;
|
||||
const realNetProfit = netSettlement - totalBomCost;
|
||||
const marginOnSales = grossRevenue > 0 ? (realNetProfit / grossRevenue) * 100 : 0;
|
||||
const markupOnCost = totalBomCost > 0 ? (realNetProfit / totalBomCost) * 100 : 0;
|
||||
const realNetMargin = marginOnSales;
|
||||
|
||||
const handleCopyOrderId = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(order.orderId);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-3 sm:p-5 md:p-6 bg-black/80 backdrop-blur-md animate-fade-in"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-slate-900 border border-white/15 rounded-3xl w-full max-w-5xl xl:max-w-6xl max-h-[92vh] flex flex-col shadow-2xl shadow-black/90 overflow-hidden text-white"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/[0.02]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-violet-500/20 to-cyan-500/20 border border-violet-500/30 flex items-center justify-center shadow-lg shadow-violet-500/10 shrink-0">
|
||||
<Receipt className="w-5 h-5 text-violet-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-bold text-white text-base">
|
||||
{t('tiktokOrders.feeModalTitle', 'TikTok Fee & Settlement Breakdown')}
|
||||
</h3>
|
||||
{isSettled ? (
|
||||
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 inline-flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
{t('tiktokOrders.settlementCompleteBadge', 'โอนเงินแล้ว')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/15 text-amber-400 border border-amber-500/30 inline-flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{t('tiktokOrders.settlementPendingBadge', 'รอ Settlement')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400 mt-0.5 flex-wrap">
|
||||
<span className="font-mono text-rose-400 font-bold">#{order.orderId}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyOrderId}
|
||||
className="text-slate-400 hover:text-white p-0.5 rounded transition-colors inline-flex items-center gap-1 text-[11px]"
|
||||
title={t('tiktokOrders.copyOrderId', 'คัดลอก Order ID')}
|
||||
>
|
||||
{copied ? <Check className="w-3 h-3 text-emerald-400" /> : <Copy className="w-3 h-3" />}
|
||||
{copied && <span className="text-[10px] text-emerald-400 font-sans">{t('tiktokOrders.copied', 'คัดลอกแล้ว')}</span>}
|
||||
</button>
|
||||
<span>•</span>
|
||||
<span className={`font-semibold ${shopCfg.color}`}>{shopCfg.label}</span>
|
||||
{order.buyerUsername && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-slate-300">ผู้ซื้อ: {order.buyerUsername}</span>
|
||||
</>
|
||||
)}
|
||||
{order.createdTime && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-slate-400 font-mono">{order.createdTime.slice(0, 16)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-all ml-2"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="p-6 space-y-5 overflow-y-auto">
|
||||
{/* Pending Settlement Notice if not settled */}
|
||||
{!settlement && (
|
||||
<div className="p-3.5 rounded-2xl bg-amber-500/10 border border-amber-500/30 flex items-start gap-3 text-amber-300 text-xs">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-bold text-amber-200">
|
||||
{t('tiktokOrders.settlementPendingAlert', 'คำสั่งซื้อนี้ยังไม่มีข้อมูลใบแจ้งยอด Settlement')}
|
||||
</p>
|
||||
<p className="text-amber-300/80 leading-relaxed text-[11px]">
|
||||
{t(
|
||||
'tiktokOrders.settlementPendingDesc',
|
||||
'ค่าคอมมิชชั่นและตัวเลขด้านล่างเป็นการคำนวณเบื้องต้นจากข้อมูล Order จนกว่าจะมีการ Import ไฟล์ Settlement XLSX จาก TikTok Seller Center'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 5 Financial Summary Cards (Compact Banner) */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2.5">
|
||||
{/* 1. Revenue */}
|
||||
<div className="p-3.5 rounded-2xl bg-white/5 border border-white/10 flex flex-col justify-between min-h-[96px]">
|
||||
<div className="flex items-center justify-between text-slate-400 text-[11px]">
|
||||
<span className="truncate">{t('tiktokOrders.orderRevenueLabel', 'ยอดขายสินค้า')}</span>
|
||||
<DollarSign className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
</div>
|
||||
<p className="text-lg font-bold font-mono text-white mt-1">฿{fmt(grossRevenue)}</p>
|
||||
<span className="text-[10px] text-slate-500 truncate" title={sellerDiscount > 0 ? `ลด ฿${fmt(sellerDiscount)} จาก ฿${fmt(subtotalBeforeDiscount)}` : 'รวมส่วนลดร้านค้าแล้ว'}>
|
||||
{sellerDiscount > 0 ? `ลด ฿${fmt(sellerDiscount)} จาก ฿${fmt(subtotalBeforeDiscount)}` : 'รวมส่วนลดแล้ว'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 2. TikTok Deductions */}
|
||||
<div className="p-3.5 rounded-2xl bg-rose-500/10 border border-rose-500/20 flex flex-col justify-between min-h-[96px]">
|
||||
<div className="flex items-center justify-between text-rose-300 text-[11px]">
|
||||
<span className="truncate">{t('tiktokOrders.tiktokDeductionsLabel', 'TikTok หักรวม')}</span>
|
||||
<BadgePercent className="w-3.5 h-3.5 text-rose-400 shrink-0" />
|
||||
</div>
|
||||
<p className="text-lg font-bold font-mono text-rose-400 mt-1">-฿{fmt(rawTotalFee)}</p>
|
||||
<span className="text-[10px] text-rose-400/80 truncate">
|
||||
{grossRevenue > 0 ? `${((rawTotalFee / grossRevenue) * 100).toFixed(1)}% ของยอดขาย` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 3. Net Payout */}
|
||||
<div className="p-3.5 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex flex-col justify-between min-h-[96px]">
|
||||
<div className="flex items-center justify-between text-cyan-300 text-[11px]">
|
||||
<span className="truncate">{t('tiktokOrders.netPayoutLabel', 'ยอดโอนสุทธิ')}</span>
|
||||
<Wallet className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
|
||||
</div>
|
||||
<p className="text-lg font-bold font-mono text-cyan-400 mt-1">฿{fmt(netSettlement)}</p>
|
||||
<span className="text-[10px] text-cyan-400/80 truncate">
|
||||
{settlement?.orderSettledTime ? `โอนเมื่อ ${settlement.orderSettledTime.slice(0, 10)}` : 'รอสรุปยอดโอน'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 4. Product Cost (BOM) */}
|
||||
<div className="p-3.5 rounded-2xl bg-amber-500/10 border border-amber-500/20 flex flex-col justify-between min-h-[96px]">
|
||||
<div className="flex items-center justify-between text-amber-300 text-[11px]">
|
||||
<span className="truncate">{t('tiktokOrders.cardBomCost', 'ต้นทุนสินค้า (BOM)')}</span>
|
||||
<Coins className="w-3.5 h-3.5 text-amber-400 shrink-0" />
|
||||
</div>
|
||||
<p className="text-lg font-bold font-mono text-amber-400 mt-1">฿{fmt(totalBomCost)}</p>
|
||||
<span className="text-[10px] text-amber-400/80 truncate">
|
||||
{hasMissingBom
|
||||
? '⚠️ มีสินค้ายังไม่ใส่ BOM'
|
||||
: grossRevenue > 0
|
||||
? `${((totalBomCost / grossRevenue) * 100).toFixed(1)}% ของยอดขาย`
|
||||
: 'BOM ครบ 100%'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 5. Net Profit (with Margin % on sales and Markup % on cost) */}
|
||||
<div className={`p-3.5 rounded-2xl border flex flex-col justify-between min-h-[96px] ${
|
||||
realNetProfit >= 0 ? 'bg-emerald-500/10 border-emerald-500/25' : 'bg-rose-500/10 border-rose-500/25'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<span className={`truncate font-semibold ${realNetProfit >= 0 ? 'text-emerald-300' : 'text-rose-300'}`}>
|
||||
{t('tiktokOrders.cardNetProfit', 'กำไรสุทธิ')}
|
||||
</span>
|
||||
<TrendingUp className={`w-3.5 h-3.5 shrink-0 ${realNetProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`} />
|
||||
</div>
|
||||
<p className={`text-lg font-bold font-mono mt-1 ${realNetProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||
฿{fmt(realNetProfit)}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-mono leading-tight flex-wrap">
|
||||
<span className={realNetProfit >= 0 ? 'text-emerald-300 font-semibold' : 'text-rose-300 font-semibold'}>
|
||||
กำไร/ขาย {marginOnSales.toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-slate-500">•</span>
|
||||
<span className={realNetProfit >= 0 ? 'text-emerald-300 font-semibold' : 'text-rose-300 font-semibold'}>
|
||||
กำไร/ทุน {markupOnCost >= 0 ? '+' : ''}{markupOnCost.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2-Column Wide Landscape Grid Layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5 items-start">
|
||||
{/* Left Column (7 Cols): TikTok Platform Fees Breakdown */}
|
||||
<div className="lg:col-span-7 p-4 sm:p-5 rounded-2xl bg-black/30 border border-white/10 space-y-3.5">
|
||||
<div className="flex items-center justify-between pb-2.5 border-b border-white/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<BadgePercent className="w-4 h-4 text-violet-400" />
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-300">
|
||||
{t('tiktokOrders.feeBreakdownTitle', 'Breakdown รายการหักค่าใช้จ่ายของ TikTok')}
|
||||
</h4>
|
||||
</div>
|
||||
<span className="text-xs font-mono font-bold text-rose-400">-฿{fmt(rawTotalFee)}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5 text-xs">
|
||||
{/* Commission Fee */}
|
||||
<div className="flex items-center justify-between p-3 rounded-xl bg-violet-500/10 border border-violet-500/20">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-violet-400 shrink-0" />
|
||||
<div>
|
||||
<p className="font-bold text-violet-200">
|
||||
{t('tiktokOrders.tiktokCommissionFee', 'ค่าคอมมิชชั่น TikTok Shop (Commission Fee)')}
|
||||
</p>
|
||||
<p className="text-[10px] text-violet-300/70">
|
||||
{t('tiktokOrders.tiktokCommissionDesc', 'หักตามอัตราหมวดหมู่สินค้าของแพลตฟอร์ม')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-mono font-bold text-violet-400 text-sm">
|
||||
{commissionFee > 0 ? `-฿${fmt(commissionFee)}` : '฿0.00'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Affiliate Creator Commission */}
|
||||
<div className="flex items-center justify-between p-3 rounded-xl bg-cyan-500/10 border border-cyan-500/20">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-cyan-400 shrink-0" />
|
||||
<div>
|
||||
<p className="font-bold text-cyan-200">
|
||||
{t('tiktokOrders.affiliateCommissionFee', 'ค่าคอมมิชชั่น Creator แอฟฟิลิเอต (Affiliate)')}
|
||||
</p>
|
||||
<p className="text-[10px] text-cyan-300/70">
|
||||
{t('tiktokOrders.affiliateCommissionDesc', 'จ่ายให้ครีเอเตอร์/นายหน้าที่แปะลิงก์หรือไลฟ์')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-mono font-bold text-cyan-400 text-sm">
|
||||
{affiliateCommission > 0 ? `-฿${fmt(affiliateCommission)}` : '฿0.00'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Affiliate Partner Fee (TAP) */}
|
||||
{(affiliatePartnerFee > 0 || settlement) && (
|
||||
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/5 text-slate-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-cyan-400/60" />
|
||||
<span>{t('tiktokOrders.affiliatePartnerFee', 'ค่าคอมมิชชั่น Partner แอฟฟิลิเอต (TAP)')}</span>
|
||||
</div>
|
||||
<span className="font-mono text-cyan-300 font-semibold">
|
||||
{affiliatePartnerFee > 0 ? `-฿${fmt(affiliatePartnerFee)}` : '฿0.00'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order / Transaction Fee */}
|
||||
{(orderFee > 0 || settlement) && (
|
||||
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/5 text-slate-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
||||
<span>{t('tiktokOrders.orderFee', 'ค่าธรรมเนียมคำสั่งซื้อ/ธุรกรรม (Order Fee)')}</span>
|
||||
</div>
|
||||
<span className="font-mono text-amber-400 font-semibold">
|
||||
{orderFee > 0 ? `-฿${fmt(orderFee)}` : '฿0.00'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Growth Support Fee */}
|
||||
{growthSupportFee > 0 && (
|
||||
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/5 text-slate-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-400" />
|
||||
<span>{t('tiktokOrders.growthSupportFee', 'ค่าธรรมเนียมสนับสนุนการเติบโต (Growth Support)')}</span>
|
||||
</div>
|
||||
<span className="font-mono text-blue-400 font-semibold">
|
||||
-฿{fmt(growthSupportFee)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shipping Breakdown */}
|
||||
{(shippingFeeActual > 0 || platformShippingDiscount > 0) && (
|
||||
<div className="p-3 rounded-xl bg-white/[0.02] border border-white/5 space-y-1">
|
||||
<div className="flex items-center justify-between text-slate-300 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<Truck className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>{t('tiktokOrders.shippingBreakdown', 'ค่าจัดส่งขนส่ง')}</span>
|
||||
</div>
|
||||
<span className="font-mono font-semibold text-slate-200">
|
||||
{netShippingToSeller > 0 ? `-฿${fmt(netShippingToSeller)}` : t('tiktokOrders.shippingFreeForSeller', 'ร้านค้าไม่เสียค่าส่ง')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] pl-5 text-slate-400">
|
||||
<span>
|
||||
{t('tiktokOrders.shippingBreakdownDetail', {
|
||||
actual: fmt(shippingFeeActual),
|
||||
subsidy: fmt(platformShippingDiscount),
|
||||
defaultValue: `ค่าส่งจริง ฿${fmt(shippingFeeActual)} | TikTok อุดหนุน -฿${fmt(platformShippingDiscount)}`
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column (5 Cols): BOM Profitability & Order Items */}
|
||||
<div className="lg:col-span-5 space-y-4">
|
||||
{/* BOM Profitability Analysis Card */}
|
||||
<div
|
||||
className={`p-4 sm:p-5 rounded-2xl border space-y-3 ${
|
||||
hasMissingBom ? 'bg-rose-500/10 border-rose-500/25' : 'bg-emerald-500/10 border-emerald-500/25'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between pb-2 border-b border-white/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Coins className={`w-4 h-4 ${hasMissingBom ? 'text-rose-400' : 'text-emerald-400'}`} />
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-200">
|
||||
{t('tiktokOrders.bomProfitAnalysis', 'วิเคราะห์กำไรสุทธิจริงหลังหักต้นทุน BOM')}
|
||||
</h4>
|
||||
</div>
|
||||
{hasMissingBom ? (
|
||||
<span className="text-[10px] font-bold text-rose-300 px-2 py-0.5 rounded-full bg-rose-500/20 border border-rose-500/30">
|
||||
{t('tiktokOrders.missingBomCount', { count: order.missingBomCount })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] font-bold text-emerald-300 px-2 py-0.5 rounded-full bg-emerald-500/20 border border-emerald-500/30">
|
||||
{t('tiktokOrders.bomComplete100', 'BOM ครบ 100%')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex justify-between items-center text-slate-300">
|
||||
<span>{t('tiktokOrders.netPayoutLabel', 'ยอดเงินที่ TikTok โอนให้')}</span>
|
||||
<span className="font-mono font-bold text-white">฿{fmt(netSettlement)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center text-amber-300">
|
||||
<span>{t('tiktokOrders.totalBomCostLabel', 'หัก ต้นทุนสินค้าตามสูตร BOM')}</span>
|
||||
<span className="font-mono font-bold">
|
||||
{totalBomCost > 0 ? `-฿${fmt(totalBomCost)}` : '฿0.00'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pt-2.5 border-t border-white/10 flex justify-between items-center">
|
||||
<div>
|
||||
<p className="font-bold text-white text-sm">
|
||||
{t('tiktokOrders.realNetProfitLabel', 'กำไรสุทธิแท้จริง (Real Net Profit)')}
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-400">
|
||||
{t('tiktokOrders.realNetMarginLabel', 'คิดเป็นอัตรากำไร')}:{' '}
|
||||
<span className="font-mono font-bold text-slate-200">{realNetMargin.toFixed(1)}%</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`text-2xl font-bold font-mono ${realNetProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||
฿{fmt(realNetProfit)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Items Snippet */}
|
||||
{order.items && order.items.length > 0 && (
|
||||
<div className="p-4 rounded-2xl bg-black/20 border border-white/10 space-y-2.5">
|
||||
<div className="flex items-center justify-between text-xs font-bold text-slate-400 pb-1 border-b border-white/5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Package className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span>
|
||||
{t('tiktokOrders.orderItemsTitle', 'รายการสินค้าในออเดอร์นี้')} ({order.items.length})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-white/5 max-h-48 overflow-y-auto pr-1">
|
||||
{order.items.map((item: any, idx: number) => (
|
||||
<div key={idx} className="py-2 flex items-center justify-between gap-3 text-xs">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-slate-200 font-medium truncate">{item.productName}</p>
|
||||
<p className="text-[11px] text-slate-400 font-mono">
|
||||
{item.variation ? `[${item.variation}] ` : ''}จำนวน: {item.quantity} ชิ้น
|
||||
{item.masterSku ? ` • Master: ${item.masterSku}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0 font-mono">
|
||||
<p className="text-white font-bold">
|
||||
฿{fmt(item.subtotalAfterDiscount || item.unitOriginalPrice * (item.quantity || 1))}
|
||||
</p>
|
||||
<p className="text-[10px] text-amber-400">BOM: ฿{fmt(item.totalBomCost || 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-white/10 bg-white/[0.02]">
|
||||
<Link
|
||||
to={`/tiktok-order/${encodeURIComponent(order.orderId)}`}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-rose-500/15 hover:bg-rose-500/25 border border-rose-500/30 text-rose-300 font-bold text-xs transition-colors"
|
||||
>
|
||||
<span>{t('tiktokOrders.viewFullOrderDetail', 'เปิดดูคำสั่งซื้อฉบับเต็ม')}</span>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 rounded-xl bg-white/10 hover:bg-white/15 text-white font-bold text-xs transition-colors"
|
||||
>
|
||||
{t('tiktokOrders.closeModal', 'ปิด')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -116,18 +116,18 @@
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@keyframes zoomIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.4s ease-out forwards;
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
.animate-zoom-in {
|
||||
animation: zoomIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
animation: zoomIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
@@ -454,6 +454,41 @@
|
||||
"thAffiliate": "Affiliate (฿)",
|
||||
"thNetProfit": "Net Profit",
|
||||
"thActions": "Actions",
|
||||
"viewFeeBreakdown": "Click to view TikTok fee breakdown",
|
||||
"feeModalTitle": "TikTok Fee & Settlement Breakdown",
|
||||
"feeModalSub": "Detailed breakdown of TikTok Shop commission, affiliate fees, and net payout",
|
||||
"settlementPendingAlert": "This order does not have a TikTok Settlement statement yet",
|
||||
"settlementPendingDesc": "Commissions and fees below are preliminary estimates from order data until a Settlement XLSX file is imported from TikTok Seller Center.",
|
||||
"settlementCompleteBadge": "Settled & Transferred",
|
||||
"settlementPendingBadge": "Pending Settlement",
|
||||
"orderRevenueLabel": "Product Revenue",
|
||||
"tiktokDeductionsLabel": "Total TikTok Deductions",
|
||||
"netPayoutLabel": "Net Payout",
|
||||
"cardBomCost": "Product Cost (BOM)",
|
||||
"cardNetProfit": "Net Profit",
|
||||
"profitOnSales": "Profit/Sales",
|
||||
"profitOnCost": "Profit/Cost",
|
||||
"feeBreakdownTitle": "TikTok Platform Fee Deductions Breakdown",
|
||||
"tiktokCommissionFee": "TikTok Shop Commission Fee",
|
||||
"tiktokCommissionDesc": "Platform category commission rate deduction",
|
||||
"affiliateCommissionFee": "Creator Affiliate Commission Fee",
|
||||
"affiliateCommissionDesc": "Commission paid to creators/affiliates",
|
||||
"affiliatePartnerFee": "Affiliate Partner Fee (TAP)",
|
||||
"orderFee": "Order / Transaction Fee",
|
||||
"growthSupportFee": "Growth Support Fee",
|
||||
"shippingBreakdown": "Shipping & Logistics Fees",
|
||||
"shippingBreakdownDetail": "Actual Shipping ฿{{actual}} | Platform Subsidy -฿{{subsidy}}",
|
||||
"shippingFreeForSeller": "Seller shipping covered",
|
||||
"sellerDiscountLabel": "Seller Discount",
|
||||
"bomProfitAnalysis": "Real Net Profit Analysis (After BOM Cost)",
|
||||
"totalBomCostLabel": "Deduct: BOM Material Cost",
|
||||
"realNetProfitLabel": "Real Net Profit",
|
||||
"realNetMarginLabel": "Net Margin",
|
||||
"orderItemsTitle": "Items in this Order",
|
||||
"viewFullOrderDetail": "View Full Order Detail",
|
||||
"closeModal": "Close",
|
||||
"copyOrderId": "Copy Order ID",
|
||||
"copied": "Copied",
|
||||
"processingData": "Processing data...",
|
||||
"noOrders": "No data available",
|
||||
"settledTransferred": "Settled ฿{{amount}}",
|
||||
|
||||
@@ -450,6 +450,41 @@
|
||||
"thAffiliate": "Affiliate (฿)",
|
||||
"thNetProfit": "純利益",
|
||||
"thActions": "操作",
|
||||
"viewFeeBreakdown": "クリックしてTikTok手数料明細を表示",
|
||||
"feeModalTitle": "TikTok 手数料・精算内訳 (Breakdown)",
|
||||
"feeModalSub": "TikTok Shop手数料、アフィリエイト報酬、純利益の詳細内訳",
|
||||
"settlementPendingAlert": "この注文にはまだTikTok精算明細データがありません",
|
||||
"settlementPendingDesc": "以下の手数料と数値は注文データに基づく概算です。TikTokセラーセンターから精算XLSXがインポートされると確定値に更新されます。",
|
||||
"settlementCompleteBadge": "振込完了 (Settled)",
|
||||
"settlementPendingBadge": "精算待ち",
|
||||
"orderRevenueLabel": "商品売上高",
|
||||
"tiktokDeductionsLabel": "TikTok 控除合計",
|
||||
"netPayoutLabel": "振込受取額 (Net Payout)",
|
||||
"cardBomCost": "商品原価 (BOM)",
|
||||
"cardNetProfit": "純利益",
|
||||
"profitOnSales": "売上利益率",
|
||||
"profitOnCost": "原価利益率",
|
||||
"feeBreakdownTitle": "TikTok プラットフォーム控除の内訳",
|
||||
"tiktokCommissionFee": "TikTok Shop プラットフォーム手数料 (Commission)",
|
||||
"tiktokCommissionDesc": "カテゴリ別プラットフォーム手数料率による控除",
|
||||
"affiliateCommissionFee": "クリエイターアフィリエイト報酬 (Affiliate)",
|
||||
"affiliateCommissionDesc": "紹介・ライブ配信クリエイターへの支払報酬",
|
||||
"affiliatePartnerFee": "パートナー手数料 (TAP)",
|
||||
"orderFee": "注文・決済処理手数料 (Order Fee)",
|
||||
"growthSupportFee": "成長支援手数料 (Growth Support)",
|
||||
"shippingBreakdown": "配送・物流手数料内訳",
|
||||
"shippingBreakdownDetail": "実送料 ฿{{actual}} | プラットフォーム補助 -฿{{subsidy}}",
|
||||
"shippingFreeForSeller": "ショップ負担なし",
|
||||
"sellerDiscountLabel": "ショップ割引",
|
||||
"bomProfitAnalysis": "BOM原価控除後の実質純利益分析",
|
||||
"totalBomCostLabel": "控除:BOM原料および包材原価",
|
||||
"realNetProfitLabel": "実質純利益 (Real Net Profit)",
|
||||
"realNetMarginLabel": "純利益率",
|
||||
"orderItemsTitle": "注文内商品一覧",
|
||||
"viewFullOrderDetail": "注文詳細画面を開く",
|
||||
"closeModal": "閉じる",
|
||||
"copyOrderId": "注文番号をコピー",
|
||||
"copied": "コピー完了",
|
||||
"processingData": "データを処理中...",
|
||||
"noOrders": "データがありません",
|
||||
"settledTransferred": "振込済 ฿{{amount}}",
|
||||
|
||||
@@ -454,6 +454,41 @@
|
||||
"thAffiliate": "Affiliate (฿)",
|
||||
"thNetProfit": "กำไรสุทธิ",
|
||||
"thActions": "การกระทำ",
|
||||
"viewFeeBreakdown": "คลิกดู Breakdown ค่าธรรมเนียม TikTok",
|
||||
"feeModalTitle": "TikTok Fee & Settlement Breakdown",
|
||||
"feeModalSub": "รายละเอียดค่าธรรมเนียม TikTok Shop, Affiliate และกำไรสุทธิ",
|
||||
"settlementPendingAlert": "คำสั่งซื้อนี้ยังไม่มีข้อมูลใบแจ้งยอด Settlement",
|
||||
"settlementPendingDesc": "ค่าคอมมิชชั่นและตัวเลขด้านล่างเป็นการคำนวณเบื้องต้นจากข้อมูล Order จนกว่าจะมีการ Import ไฟล์ Settlement XLSX จาก TikTok Seller Center",
|
||||
"settlementCompleteBadge": "โอนเงินแล้ว",
|
||||
"settlementPendingBadge": "รอ Settlement",
|
||||
"orderRevenueLabel": "ยอดขายสินค้า",
|
||||
"tiktokDeductionsLabel": "TikTok หักรวม",
|
||||
"netPayoutLabel": "ยอดโอนสุทธิ",
|
||||
"cardBomCost": "ต้นทุนสินค้า (BOM)",
|
||||
"cardNetProfit": "กำไรสุทธิ",
|
||||
"profitOnSales": "กำไร/ยอดขาย",
|
||||
"profitOnCost": "กำไร/ทุน",
|
||||
"feeBreakdownTitle": "Breakdown รายการหักค่าใช้จ่ายของ TikTok",
|
||||
"tiktokCommissionFee": "ค่าคอมมิชชั่น TikTok Shop (Commission Fee)",
|
||||
"tiktokCommissionDesc": "หักตามอัตราหมวดหมู่สินค้าของแพลตฟอร์ม",
|
||||
"affiliateCommissionFee": "ค่าคอมมิชชั่น Creator แอฟฟิลิเอต (Affiliate)",
|
||||
"affiliateCommissionDesc": "จ่ายให้ครีเอเตอร์/นายหน้าที่แปะลิงก์หรือไลฟ์",
|
||||
"affiliatePartnerFee": "ค่าคอมมิชชั่น Partner แอฟฟิลิเอต (TAP)",
|
||||
"orderFee": "ค่าธรรมเนียมคำสั่งซื้อ/ธุรกรรม (Order Fee)",
|
||||
"growthSupportFee": "ค่าธรรมเนียมสนับสนุนการเติบโต (Growth Support)",
|
||||
"shippingBreakdown": "ค่าจัดส่งขนส่ง",
|
||||
"shippingBreakdownDetail": "ค่าส่งจริง ฿{{actual}} | TikTok อุดหนุน -฿{{subsidy}}",
|
||||
"shippingFreeForSeller": "ร้านค้าไม่เสียค่าส่ง",
|
||||
"sellerDiscountLabel": "ส่วนลดจากร้านค้า",
|
||||
"bomProfitAnalysis": "วิเคราะห์กำไรสุทธิจริงหลังหักต้นทุน BOM",
|
||||
"totalBomCostLabel": "หัก ต้นทุนสินค้าตามสูตร BOM",
|
||||
"realNetProfitLabel": "กำไรสุทธิแท้จริง (Real Net Profit)",
|
||||
"realNetMarginLabel": "คิดเป็นอัตรากำไร",
|
||||
"orderItemsTitle": "รายการสินค้าในออเดอร์นี้",
|
||||
"viewFullOrderDetail": "เปิดดูคำสั่งซื้อฉบับเต็ม",
|
||||
"closeModal": "ปิด",
|
||||
"copyOrderId": "คัดลอก Order ID",
|
||||
"copied": "คัดลอกแล้ว",
|
||||
"processingData": "กำลังประมวลผลข้อมูล...",
|
||||
"noOrders": "ยังไม่มีข้อมูล",
|
||||
"settledTransferred": "โอนแล้ว ฿{{amount}}",
|
||||
|
||||
@@ -450,6 +450,41 @@
|
||||
"thAffiliate": "Affiliate (฿)",
|
||||
"thNetProfit": "净利润",
|
||||
"thActions": "操作",
|
||||
"viewFeeBreakdown": "点击查看 TikTok 费用明细",
|
||||
"feeModalTitle": "TikTok 费用与结算明细",
|
||||
"feeModalSub": "TikTok Shop 佣金、达人佣金与净利润详细拆解",
|
||||
"settlementPendingAlert": "该订单暂无 TikTok 结算对账单数据",
|
||||
"settlementPendingDesc": "以下佣金与费用为基于订单数据的预估,待从 TikTok 卖家中心导入结算 XLSX 文件后更新。",
|
||||
"settlementCompleteBadge": "已完成打款",
|
||||
"settlementPendingBadge": "等待结算",
|
||||
"orderRevenueLabel": "商品销售额",
|
||||
"tiktokDeductionsLabel": "TikTok 扣款合计",
|
||||
"netPayoutLabel": "最终打款金额",
|
||||
"cardBomCost": "商品成本 (BOM)",
|
||||
"cardNetProfit": "净利润",
|
||||
"profitOnSales": "利润率(销售)",
|
||||
"profitOnCost": "利润率(成本)",
|
||||
"feeBreakdownTitle": "TikTok 平台扣款明细",
|
||||
"tiktokCommissionFee": "TikTok Shop 平台佣金 (Commission Fee)",
|
||||
"tiktokCommissionDesc": "按平台品类佣金率扣除",
|
||||
"affiliateCommissionFee": "达人/分销佣金 (Affiliate Commission)",
|
||||
"affiliateCommissionDesc": "支付给带货达人的佣金",
|
||||
"affiliatePartnerFee": "机构/合作伙伴佣金 (TAP)",
|
||||
"orderFee": "订单/交易手续费 (Order Fee)",
|
||||
"growthSupportFee": "成长支持费 (Growth Support Fee)",
|
||||
"shippingBreakdown": "物流运费明细",
|
||||
"shippingBreakdownDetail": "实际运费 ฿{{actual}} | 平台补贴 -฿{{subsidy}}",
|
||||
"shippingFreeForSeller": "商家免运费",
|
||||
"sellerDiscountLabel": "商家折扣",
|
||||
"bomProfitAnalysis": "扣除 BOM 成本后真实净利润分析",
|
||||
"totalBomCostLabel": "扣除:BOM 原料及包装成本",
|
||||
"realNetProfitLabel": "真实净利润 (Real Net Profit)",
|
||||
"realNetMarginLabel": "净利润率",
|
||||
"orderItemsTitle": "订单内商品列表",
|
||||
"viewFullOrderDetail": "查看订单完整详情",
|
||||
"closeModal": "关闭",
|
||||
"copyOrderId": "复制订单号",
|
||||
"copied": "已复制",
|
||||
"processingData": "正在处理数据...",
|
||||
"noOrders": "暂无数据",
|
||||
"settledTransferred": "已打款 ฿{{amount}}",
|
||||
|
||||
@@ -33,6 +33,8 @@ import { getAuthHeaders } from '../utils/auth';
|
||||
|
||||
import CinematicStoryScrollForm from '../components/page-builder/CinematicStoryScrollForm';
|
||||
import AppleShowcaseForm from '../components/page-builder/AppleShowcaseForm';
|
||||
import Interactive360ShowcaseForm from '../components/page-builder/Interactive360ShowcaseForm';
|
||||
import VdoFrameScrubberForm from '../components/page-builder/VdoFrameScrubberForm';
|
||||
import HeroForm from '../components/page-builder/HeroForm';
|
||||
import AnnouncementBarForm from '../components/page-builder/AnnouncementBarForm';
|
||||
import NewsletterForm from '../components/page-builder/NewsletterForm';
|
||||
@@ -1469,6 +1471,8 @@ export default function PageBuilderPage() {
|
||||
onChange={(e) => handleTypeChange(e.target.value)}
|
||||
className="admin-input w-full bg-slate-900 border-white/10"
|
||||
>
|
||||
<option value="VDO_FRAME_SCRUBBER">🎬 VDO FRAME SCRUBBER (432 Frames Video Scrub)</option>
|
||||
<option value="INTERACTIVE_360_SHOWCASE">⚡ INTERACTIVE 360° TURNTABLE (3D Web Sprocket)</option>
|
||||
<option value="CINEMATIC_STORY_SCROLL">✨ CINEMATIC STORY SCROLL (Tobaki Scrollytelling)</option>
|
||||
<option value="APPLE_SHOWCASE">✨ APPLE KEYNOTE SHOWCASE (Apple Style Tour)</option>
|
||||
<option value="HERO">HERO (Campaign Intro)</option>
|
||||
@@ -1593,6 +1597,24 @@ export default function PageBuilderPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingSection.type?.toUpperCase() === 'VDO_FRAME_SCRUBBER' && (
|
||||
<VdoFrameScrubberForm
|
||||
payload={editingSection.payload || {}}
|
||||
updatePayloadValue={updatePayloadValue}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingSection.type?.toUpperCase() === 'INTERACTIVE_360_SHOWCASE' && (
|
||||
<Interactive360ShowcaseForm
|
||||
payload={editingSection.payload || {}}
|
||||
updatePayloadValue={updatePayloadValue}
|
||||
onBrowseMedia={(cb) => {
|
||||
setCustomMediaCallback(() => cb);
|
||||
setIsMediaModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingSection.type?.toUpperCase() === 'CINEMATIC_STORY_SCROLL' && (
|
||||
<CinematicStoryScrollForm
|
||||
payload={editingSection.payload || {}}
|
||||
|
||||
@@ -11,9 +11,13 @@ import {
|
||||
ArrowLeft,
|
||||
Coins,
|
||||
Layers,
|
||||
Calculator
|
||||
Calculator,
|
||||
Bot,
|
||||
FileJson
|
||||
} from 'lucide-react'
|
||||
import { API_BASE, type Product } from '@homaroma/types'
|
||||
import ProductBomPromptModal from '../components/ProductBomPromptModal'
|
||||
import ProductBomImportModal from '../components/ProductBomImportModal'
|
||||
|
||||
function fmtMoney(val: number | null | undefined): string {
|
||||
if (val == null || isNaN(val)) return '฿0.00'
|
||||
@@ -38,6 +42,14 @@ export default function ProductBomPage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [isPromptModalOpen, setIsPromptModalOpen] = useState(false)
|
||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false)
|
||||
|
||||
const handleApplyBomJson = (updatedVariants: any[]) => {
|
||||
setVariants(updatedVariants)
|
||||
setSuccess(true)
|
||||
setTimeout(() => setSuccess(false), 3500)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
@@ -143,9 +155,14 @@ export default function ProductBomPage() {
|
||||
}
|
||||
|
||||
const productName =
|
||||
typeof product.name === 'string'
|
||||
? product.name
|
||||
: (product.name as any)?.th || (product.name as any)?.en || 'Unknown Product'
|
||||
(typeof product.name === 'string' && product.name.trim())
|
||||
? product.name.trim()
|
||||
: (product.name as any)?.th ||
|
||||
(product.name as any)?.en ||
|
||||
(typeof product.name === 'object' && Object.values(product.name || {}).find((v) => typeof v === 'string' && (v as string).trim()) as string) ||
|
||||
((product as any).brand?.name
|
||||
? `${(product as any).brand?.name} ${(product as any).category?.name || ''}`.trim()
|
||||
: `Product #${product.id}`)
|
||||
|
||||
// Summary Metrics Across All Variants
|
||||
const totalVariantsCount = variants.length
|
||||
@@ -177,6 +194,24 @@ export default function ProductBomPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPromptModalOpen(true)}
|
||||
className="px-4 py-2.5 rounded-xl bg-purple-500/10 hover:bg-purple-500/20 text-purple-300 border border-purple-500/30 text-sm font-bold flex items-center gap-2 transition-all shadow-sm"
|
||||
title="สร้าง Prompt สำหรับส่งให้ LLM คิดและตอบกลับเป็นสูตร BOM"
|
||||
>
|
||||
<Bot className="w-4 h-4 text-purple-400" />
|
||||
<span>Get Prompt</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsImportModalOpen(true)}
|
||||
className="px-4 py-2.5 rounded-xl bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 text-sm font-bold flex items-center gap-2 transition-all shadow-sm"
|
||||
title="วาง JSON จาก LLM เพื่อกรอกสูตร BOM ลงในตารางอัตโนมัติ"
|
||||
>
|
||||
<FileJson className="w-4 h-4 text-cyan-400" />
|
||||
<span>Import BOM JSON</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/products')}
|
||||
className="px-5 py-2.5 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 text-sm font-bold text-slate-300 transition-all"
|
||||
@@ -534,6 +569,25 @@ export default function ProductBomPage() {
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* LLM Prompt Generator Modal */}
|
||||
<ProductBomPromptModal
|
||||
isOpen={isPromptModalOpen}
|
||||
onClose={() => setIsPromptModalOpen(false)}
|
||||
productId={id || ''}
|
||||
productName={productName}
|
||||
variants={variants}
|
||||
rawMaterials={rawMaterials}
|
||||
/>
|
||||
|
||||
{/* LLM JSON Import Modal */}
|
||||
<ProductBomImportModal
|
||||
isOpen={isImportModalOpen}
|
||||
onClose={() => setIsImportModalOpen(false)}
|
||||
variants={variants}
|
||||
rawMaterials={rawMaterials}
|
||||
onApply={handleApplyBomJson}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import { useState, useEffect, type FormEvent } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Trash2,
|
||||
Loader2,
|
||||
PackageOpen,
|
||||
Boxes,
|
||||
ExternalLink,
|
||||
Layers,
|
||||
Sparkles,
|
||||
CheckCircle2,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { API_BASE } from '@homaroma/types';
|
||||
|
||||
const COMMON_UNITS = [
|
||||
{ label: 'ชิ้น (piece)', value: 'piece' },
|
||||
{ label: 'กก. (kg)', value: 'kg' },
|
||||
{ label: 'กรัม (g)', value: 'g' },
|
||||
{ label: 'ลิตร (liter)', value: 'liter' },
|
||||
{ label: 'มล. (ml)', value: 'ml' },
|
||||
{ label: 'แพ็ค (pack)', value: 'pack' },
|
||||
{ label: 'กล่อง (box)', value: 'box' },
|
||||
{ label: 'หลอด (tube)', value: 'tube' },
|
||||
];
|
||||
|
||||
export default function RawMaterialDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isNew = !id || id === 'new';
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [toast, setToast] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
// Form Fields
|
||||
const [name, setName] = useState('');
|
||||
const [unit, setUnit] = useState('piece');
|
||||
const [pricePerUnit, setPricePerUnit] = useState<number>(0);
|
||||
const [materialData, setMaterialData] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew) return;
|
||||
|
||||
setLoading(true);
|
||||
fetch(`${API_BASE}/raw-materials/${id}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error('ไม่พบข้อมูลวัตถุดิบ');
|
||||
return await res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setMaterialData(data);
|
||||
setName(data.name || '');
|
||||
setUnit(data.unit || 'piece');
|
||||
setPricePerUnit(Number(data.pricePerUnit) || 0);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setToast({ type: 'error', text: err.message || 'เกิดข้อผิดพลาดในการโหลดข้อมูล' });
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, isNew]);
|
||||
|
||||
const showToast = (type: 'success' | 'error', text: string) => {
|
||||
setToast({ type, text });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
const handleSave = async (e?: FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
showToast('error', 'กรุณาระบุชื่อวัตถุดิบ');
|
||||
return;
|
||||
}
|
||||
if (!unit.trim()) {
|
||||
showToast('error', 'กรุณาระบุหน่วยนับ');
|
||||
return;
|
||||
}
|
||||
if (pricePerUnit < 0) {
|
||||
showToast('error', 'ราคาต้นทุนต่อหน่วยต้องไม่ติดลบ');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
unit: unit.trim(),
|
||||
pricePerUnit: Number(pricePerUnit) || 0,
|
||||
};
|
||||
|
||||
const url = isNew ? `${API_BASE}/raw-materials` : `${API_BASE}/raw-materials/${id}`;
|
||||
const method = isNew ? 'POST' : 'PUT';
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.message || 'บันทึกข้อมูลไม่สำเร็จ');
|
||||
}
|
||||
|
||||
showToast('success', isNew ? 'สร้างวัตถุดิบใหม่สำเร็จ' : 'บันทึกการแก้ไขสำเร็จ');
|
||||
|
||||
if (isNew && data.id) {
|
||||
setTimeout(() => {
|
||||
navigate(`/raw-materials/${data.id}`, { replace: true });
|
||||
}, 800);
|
||||
}
|
||||
} catch (err: any) {
|
||||
showToast('error', err.message || 'เกิดข้อผิดพลาดในการบันทึก');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบวัตถุดิบ "${name}"?`)) return;
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/raw-materials/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message || 'ไม่สามารถลบวัตถุดิบนี้ได้');
|
||||
}
|
||||
|
||||
navigate('/raw-materials', { replace: true });
|
||||
} catch (err: any) {
|
||||
showToast('error', err.message || 'เกิดข้อผิดพลาดในการลบ');
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] space-y-4">
|
||||
<Loader2 className="w-8 h-8 text-cyan-400 animate-spin" />
|
||||
<p className="text-slate-400 text-sm">กำลังโหลดข้อมูลวัตถุดิบ...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const bomItems = materialData?.bomItems || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in max-w-5xl mx-auto pb-16">
|
||||
{/* Toast Notification */}
|
||||
{toast && (
|
||||
<div
|
||||
className={`fixed bottom-6 right-6 z-50 p-4 rounded-2xl border shadow-2xl flex items-center gap-3 animate-slide-up ${
|
||||
toast.type === 'success'
|
||||
? 'bg-emerald-950/90 border-emerald-500/40 text-emerald-200'
|
||||
: 'bg-rose-950/90 border-rose-500/40 text-rose-200'
|
||||
}`}
|
||||
>
|
||||
{toast.type === 'success' ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-400" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5 text-rose-400" />
|
||||
)}
|
||||
<span className="text-sm font-medium">{toast.text}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Breadcrumb & Navigation */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<Link to="/raw-materials" className="hover:text-white transition-colors flex items-center gap-1">
|
||||
<PackageOpen className="w-3.5 h-3.5" />
|
||||
<span>Raw Materials</span>
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-cyan-400 font-medium">
|
||||
{isNew ? 'เพิ่มวัตถุดิบใหม่' : name || `ID #${id}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/raw-materials')}
|
||||
className="p-2.5 rounded-2xl bg-white/5 border border-white/10 hover:bg-white/10 text-slate-300 hover:text-white transition-all shadow-sm"
|
||||
title="ย้อนกลับไปหน้ารายการ"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-white flex items-center gap-2.5">
|
||||
{isNew ? 'เพิ่มวัตถุดิบใหม่' : `แก้ไขวัตถุดิบ`}
|
||||
{!isNew && (
|
||||
<span className="text-xs px-2.5 py-1 rounded-full bg-cyan-500/10 text-cyan-300 border border-cyan-500/20 font-mono">
|
||||
ID: #{id}
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-xs md:text-sm text-slate-400">
|
||||
{isNew
|
||||
? 'สร้างรายการวัตถุดิบสำหรับใช้คำนวณต้นทุนสินค้าและผูกสูตรผลิต (BOM)'
|
||||
: 'กำหนดข้อมูลราคาต้นทุนและหน่วยนับสำหรับใช้ในสูตรการผลิตสินค้า'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{!isNew && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting || saving}
|
||||
className="px-4 py-2.5 rounded-xl border border-rose-500/30 text-rose-400 hover:bg-rose-500/10 hover:border-rose-500/50 font-medium text-sm flex items-center gap-2 transition-all disabled:opacity-50"
|
||||
>
|
||||
{deleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||
<span>ลบวัตถุดิบ</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSave()}
|
||||
disabled={saving}
|
||||
className="btn-primary px-6 py-2.5 flex items-center gap-2 text-sm shadow-lg shadow-cyan-500/10"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
<span>{saving ? 'กำลังบันทึก...' : 'บันทึกข้อมูล'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Left Column: Form Details (2 cols) */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<form onSubmit={handleSave} className="admin-card space-y-6 border-white/10 shadow-2xl p-6 md:p-8">
|
||||
<div className="flex items-center gap-2 pb-4 border-b border-white/5">
|
||||
<Sparkles className="w-4 h-4 text-cyan-400" />
|
||||
<h2 className="text-base font-bold text-white uppercase tracking-wider text-xs">
|
||||
ข้อมูลวัตถุดิบ (Material Information)
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Material Name */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center justify-between">
|
||||
<span>ชื่อวัตถุดิบ (Material Name) <span className="text-rose-400">*</span></span>
|
||||
<span className="text-[10px] text-slate-500 font-normal">ห้ามซ้ำกับรายการอื่นในระบบ</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="เช่น ขวดแก้ว 100ml, น้ำมันหอมระเหย ลาเวนเดอร์, ก้านไม้ไฟเบอร์"
|
||||
className="admin-input w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Unit Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider">
|
||||
หน่วยนับ (Unit of Measurement) <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
placeholder="เช่น piece, kg, liter, pack, ml"
|
||||
className="admin-input w-full text-sm"
|
||||
/>
|
||||
|
||||
{/* Quick Preset Unit Chips */}
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[11px] text-slate-400">เลือกหน่วยที่ใช้บ่อย:</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COMMON_UNITS.map((u) => (
|
||||
<button
|
||||
key={u.value}
|
||||
type="button"
|
||||
onClick={() => setUnit(u.value)}
|
||||
className={`text-xs px-2.5 py-1 rounded-lg border transition-all ${
|
||||
unit === u.value
|
||||
? 'bg-cyan-500/20 border-cyan-500/40 text-cyan-300 font-bold'
|
||||
: 'bg-white/5 border-white/5 text-slate-400 hover:text-white hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{u.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost Price per Unit */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center justify-between">
|
||||
<span>ต้นทุนต่อหน่วย (Cost per Unit) <span className="text-rose-400">*</span></span>
|
||||
<span className="text-[10px] text-slate-500 font-normal">ใช้ในการคำนวณสูตร BOM สินค้า</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 font-bold text-sm">
|
||||
฿
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
required
|
||||
value={pricePerUnit === 0 ? '' : pricePerUnit}
|
||||
onChange={(e) => setPricePerUnit(parseFloat(e.target.value) || 0)}
|
||||
placeholder="0.00"
|
||||
className="admin-input w-full pl-9 font-mono text-emerald-400 font-bold text-base"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
ระบุราคาต้นทุนเฉลี่ยต่อ 1 {unit || 'หน่วย'} เช่น 18.50 บาท / ขวด
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Right Column: BOM Linked Products & Summary (1 col) */}
|
||||
<div className="space-y-6">
|
||||
{/* Summary Card */}
|
||||
<div className="admin-card border-white/10 shadow-xl p-6 space-y-4">
|
||||
<div className="flex items-center gap-2 pb-3 border-b border-white/5">
|
||||
<Layers className="w-4 h-4 text-cyan-400" />
|
||||
<h3 className="text-xs font-bold text-white uppercase tracking-wider">
|
||||
สรุปภาพรวม (Overview)
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-xs">
|
||||
<div className="flex justify-between py-1.5 border-b border-white/5 text-slate-300">
|
||||
<span className="text-slate-400">สถานะ:</span>
|
||||
<span className="font-semibold text-emerald-400">พร้อมใช้งาน</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-1.5 border-b border-white/5 text-slate-300">
|
||||
<span className="text-slate-400">หน่วยนับ:</span>
|
||||
<span className="font-mono font-bold text-white">{unit || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-1.5 border-b border-white/5 text-slate-300">
|
||||
<span className="text-slate-400">ต้นทุน / หน่วย:</span>
|
||||
<span className="font-mono font-bold text-emerald-400">
|
||||
฿{Number(pricePerUnit || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
{!isNew && materialData?.updatedAt && (
|
||||
<div className="flex justify-between py-1.5 text-slate-300">
|
||||
<span className="text-slate-400">อัปเดตล่าสุด:</span>
|
||||
<span className="text-slate-400">
|
||||
{new Date(materialData.updatedAt).toLocaleDateString('th-TH')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BOM Usage Section */}
|
||||
{!isNew && (
|
||||
<div className="admin-card border-white/10 shadow-xl p-6 space-y-4">
|
||||
<div className="flex items-center justify-between pb-3 border-b border-white/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Boxes className="w-4 h-4 text-cyan-400" />
|
||||
<h3 className="text-xs font-bold text-white uppercase tracking-wider">
|
||||
สูตรสินค้าที่ใช้วัตถุดิบนี้ ({bomItems.length})
|
||||
</h3>
|
||||
</div>
|
||||
<Link
|
||||
to="/products/bom"
|
||||
className="text-[11px] text-cyan-400 hover:text-cyan-300 flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<span>จัดการ BOM</span>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{bomItems.length === 0 ? (
|
||||
<div className="py-6 text-center space-y-2">
|
||||
<Boxes className="w-8 h-8 text-slate-700 mx-auto" />
|
||||
<p className="text-xs text-slate-400 italic">
|
||||
ยังไม่มีสูตรสินค้า (BOM) ที่ผูกกับวัตถุดิบนี้
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
สามารถไปผูกวัตถุดิบเข้ากับ Variant สินค้าได้ที่เมนู "BOM สูตรผลิต"
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2.5 max-h-[300px] overflow-y-auto pr-1">
|
||||
{bomItems.map((item: any, idx: number) => {
|
||||
const product = item.productVariant?.product;
|
||||
const variant = item.productVariant;
|
||||
const variantLabel = [variant?.option1, variant?.option2, variant?.option3]
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id || idx}
|
||||
className="p-3 rounded-xl bg-white/[0.02] border border-white/5 space-y-1 hover:border-white/10 transition-all"
|
||||
>
|
||||
<p className="text-xs font-bold text-white line-clamp-1">
|
||||
{product?.name || `Product #${variant?.productId}`}
|
||||
</p>
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-400">
|
||||
<span>{variantLabel ? `ตัวเลือก: ${variantLabel}` : 'Base Variant'}</span>
|
||||
<span className="font-mono text-cyan-400 font-bold">
|
||||
{item.quantity} {unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,227 +1,395 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Edit3, Trash2, Loader2, PackageOpen } from 'lucide-react'
|
||||
import { API_BASE, type RawMaterial } from '@homaroma/types'
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import {
|
||||
Plus,
|
||||
Edit3,
|
||||
Trash2,
|
||||
PackageOpen,
|
||||
Search,
|
||||
FileJson,
|
||||
Boxes,
|
||||
Layers,
|
||||
Sparkles,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
Pencil,
|
||||
} from 'lucide-react';
|
||||
import { API_BASE, type RawMaterial } from '@homaroma/types';
|
||||
import RawMaterialBulkJsonModal from '../components/RawMaterialBulkJsonModal';
|
||||
|
||||
export default function RawMaterialsPage() {
|
||||
const [materials, setMaterials] = useState<RawMaterial[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const navigate = useNavigate();
|
||||
const [materials, setMaterials] = useState<RawMaterial[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isBulkModalOpen, setIsBulkModalOpen] = useState(false);
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
// Inline Price Editing State
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editingPrice, setEditingPrice] = useState<string>('');
|
||||
const [savingId, setSavingId] = useState<number | null>(null);
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [unit, setUnit] = useState('')
|
||||
const [pricePerUnit, setPricePerUnit] = useState<number>(0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const startEditing = (item: RawMaterial) => {
|
||||
setEditingId(item.id);
|
||||
setEditingPrice(String(item.pricePerUnit ?? ''));
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditingId(null);
|
||||
setEditingPrice('');
|
||||
};
|
||||
|
||||
const handleSavePrice = async (item: RawMaterial) => {
|
||||
const val = parseFloat(editingPrice);
|
||||
if (isNaN(val) || val < 0) {
|
||||
alert('กรุณาระบุราคาต้นทุนให้ถูกต้อง (ต้องเป็นตัวเลขมากกว่าหรือเท่ากับ 0)');
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingId(item.id);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/raw-materials/${item.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
pricePerUnit: val,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message || 'บันทึกราคาไม่สำเร็จ');
|
||||
}
|
||||
|
||||
setMaterials((prev) =>
|
||||
prev.map((m) => (m.id === item.id ? { ...m, pricePerUnit: val } : m))
|
||||
);
|
||||
setEditingId(null);
|
||||
setEditingPrice('');
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
alert(err.message || 'เกิดข้อผิดพลาดในการบันทึกราคา');
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMaterials = async () => {
|
||||
setLoading(true)
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/raw-materials`)
|
||||
const res = await fetch(`${API_BASE}/raw-materials`);
|
||||
if (res.ok) {
|
||||
setMaterials(await res.json())
|
||||
setMaterials(await res.json());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch raw materials', err)
|
||||
console.error('Failed to fetch raw materials', err);
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchMaterials()
|
||||
}, [])
|
||||
fetchMaterials();
|
||||
}, []);
|
||||
|
||||
const handleOpenModal = (material?: RawMaterial) => {
|
||||
if (material) {
|
||||
setEditingId(material.id)
|
||||
setName(material.name)
|
||||
setUnit(material.unit)
|
||||
setPricePerUnit(material.pricePerUnit)
|
||||
} else {
|
||||
setEditingId(null)
|
||||
setName('')
|
||||
setUnit('')
|
||||
setPricePerUnit(0)
|
||||
}
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name || !unit || pricePerUnit <= 0) return alert('Please fill all fields')
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = { name, unit, pricePerUnit }
|
||||
const url = editingId
|
||||
? `${API_BASE}/raw-materials/${editingId}`
|
||||
: `${API_BASE}/raw-materials`
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: editingId ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setIsModalOpen(false)
|
||||
fetchMaterials()
|
||||
} else {
|
||||
alert('Failed to save raw material')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Are you sure you want to delete this raw material?')) return
|
||||
const handleDelete = async (id: number, name: string) => {
|
||||
if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบวัตถุดิบ "${name}"?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/raw-materials/${id}`, { method: 'DELETE' })
|
||||
const res = await fetch(`${API_BASE}/raw-materials/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
fetchMaterials()
|
||||
fetchMaterials();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert(data.message || 'ไม่สามารถลบวัตถุดิบนี้ได้');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
// Filtered materials by search query
|
||||
const filteredMaterials = useMemo(() => {
|
||||
if (!searchQuery.trim()) return materials;
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
return materials.filter(
|
||||
(m) =>
|
||||
m.name.toLowerCase().includes(q) ||
|
||||
m.unit.toLowerCase().includes(q) ||
|
||||
String(m.id).includes(q)
|
||||
);
|
||||
}, [materials, searchQuery]);
|
||||
|
||||
// Aggregate metrics
|
||||
const metrics = useMemo(() => {
|
||||
const totalCount = materials.length;
|
||||
const avgCost = totalCount > 0
|
||||
? materials.reduce((sum, m) => sum + (Number(m.pricePerUnit) || 0), 0) / totalCount
|
||||
: 0;
|
||||
const units = new Set(materials.map((m) => m.unit.toLowerCase())).size;
|
||||
return { totalCount, avgCost, units };
|
||||
}, [materials]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
<div className="space-y-8 animate-fade-in pb-12">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold">Raw Materials (วัตถุดิบ)</h1>
|
||||
<p className="text-slate-500 text-sm">Manage inventory and costs for BOM calculation.</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold text-white">Raw Materials (วัตถุดิบ)</h1>
|
||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-cyan-500/10 text-cyan-400 border border-cyan-500/20">
|
||||
{materials.length} รายการ
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={() => handleOpenModal()} className="btn-primary flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Material
|
||||
</button>
|
||||
<p className="text-slate-400 text-sm">
|
||||
จัดการรายการวัตถุดิบ หน่วยนับ และต้นทุนสำหรับผูกสูตรผลิตสินค้า (BOM)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Bulk JSON Import / Export Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsBulkModalOpen(true)}
|
||||
className="px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 font-medium text-sm flex items-center gap-2 transition-all shadow-sm"
|
||||
>
|
||||
<FileJson className="w-4 h-4 text-cyan-400" />
|
||||
<span>Import / Export JSON</span>
|
||||
</button>
|
||||
|
||||
{/* Add Material (Navigates to Full Page Route) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/raw-materials/new')}
|
||||
className="btn-primary flex items-center gap-2 text-sm shadow-lg shadow-cyan-500/10"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>เพิ่มวัตถุดิบ</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Row */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="admin-card p-5 border-white/5 bg-night-900/60 flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400 shrink-0">
|
||||
<PackageOpen className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">วัตถุดิบทั้งหมด</p>
|
||||
<p className="text-2xl font-bold text-white font-mono mt-0.5">{metrics.totalCount} <span className="text-xs font-normal text-slate-400">รายการ</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card p-5 border-white/5 bg-night-900/60 flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400 shrink-0">
|
||||
<Sparkles className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">ต้นทุนเฉลี่ย / หน่วย</p>
|
||||
<p className="text-2xl font-bold text-emerald-400 font-mono mt-0.5">
|
||||
฿{metrics.avgCost.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card p-5 border-white/5 bg-night-900/60 flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-400 shrink-0">
|
||||
<Layers className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">ประเภทหน่วยนับ</p>
|
||||
<p className="text-2xl font-bold text-white font-mono mt-0.5">{metrics.units} <span className="text-xs font-normal text-slate-400">แบบ</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="ค้นหาตามชื่อ หรือ หน่วยนับ..."
|
||||
className="admin-input w-full pl-10 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-400">
|
||||
แสดง <strong>{filteredMaterials.length}</strong> จากทั้งหมด {materials.length} รายการ
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
<div className="admin-card !p-0 overflow-hidden border-white/10 shadow-2xl">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-white/[0.02] border-b border-white/5">
|
||||
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Material Name</th>
|
||||
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Unit</th>
|
||||
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Cost Per Unit</th>
|
||||
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 text-right">Actions</th>
|
||||
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 w-16">ID</th>
|
||||
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">ชื่อวัตถุดิบ (Material Name)</th>
|
||||
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">หน่วยนับ (Unit)</th>
|
||||
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">ต้นทุนต่อหน่วย (Cost)</th>
|
||||
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 text-right">การจัดการ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.03]">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td colSpan={4} className="px-6 py-8">
|
||||
<td colSpan={5} className="px-6 py-8">
|
||||
<div className="h-6 w-full bg-white/5 animate-pulse rounded-lg" />
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : materials.length === 0 ? (
|
||||
) : filteredMaterials.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-20 text-center space-y-3">
|
||||
<td colSpan={5} className="px-6 py-20 text-center space-y-3">
|
||||
<PackageOpen className="w-10 h-10 text-slate-700 mx-auto" />
|
||||
<p className="text-slate-500 font-medium italic">No raw materials found.</p>
|
||||
<p className="text-slate-400 font-medium">
|
||||
{searchQuery ? 'ไม่พบวัตถุดิบที่ตรงกับคำค้นหา' : 'ยังไม่มีข้อมูลวัตถุดิบในระบบ'}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsBulkModalOpen(true)}
|
||||
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-xs font-medium border border-white/10 transition-colors"
|
||||
>
|
||||
นำเข้าผ่าน JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/raw-materials/new')}
|
||||
className="btn-primary text-xs px-4 py-2"
|
||||
>
|
||||
เพิ่มวัตถุดิบใหม่
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : materials.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-white/[0.01] transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<p className="text-sm font-bold text-white">{item.name}</p>
|
||||
) : (
|
||||
filteredMaterials.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-white/[0.015] transition-colors group">
|
||||
<td className="px-6 py-4 text-xs font-mono text-slate-500">
|
||||
#{item.id}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="inline-block px-2 py-1 rounded-md bg-night-950 border border-white/5 text-slate-400 text-[10px] uppercase font-bold tracking-wider">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400 shrink-0">
|
||||
<Boxes className="w-4 h-4" />
|
||||
</div>
|
||||
<Link
|
||||
to={`/raw-materials/${item.id}`}
|
||||
className="text-sm font-bold text-white hover:text-cyan-400 transition-colors"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="inline-block px-2.5 py-1 rounded-md bg-night-950 border border-white/5 text-slate-300 text-xs font-mono font-medium">
|
||||
{item.unit}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<p className="text-sm font-bold text-emerald-400">฿{item.pricePerUnit.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
|
||||
<td className="px-6 py-3">
|
||||
{editingId === item.id ? (
|
||||
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="relative">
|
||||
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-xs text-slate-500 font-mono font-bold">
|
||||
฿
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
autoFocus
|
||||
value={editingPrice}
|
||||
onChange={(e) => setEditingPrice(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSavePrice(item);
|
||||
if (e.key === 'Escape') cancelEditing();
|
||||
}}
|
||||
disabled={savingId === item.id}
|
||||
className="w-28 pl-6 pr-2 py-1 text-sm font-bold font-mono text-emerald-400 bg-black/80 border border-cyan-500/60 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500/40 shadow-inner"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={savingId === item.id}
|
||||
onClick={() => handleSavePrice(item)}
|
||||
className="p-1.5 rounded-lg bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-300 border border-emerald-500/40 transition-all disabled:opacity-50"
|
||||
title="บันทึกราคา (Enter)"
|
||||
>
|
||||
{savingId === item.id ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={savingId === item.id}
|
||||
onClick={cancelEditing}
|
||||
className="p-1.5 rounded-lg bg-white/5 hover:bg-rose-500/20 text-slate-400 hover:text-rose-300 border border-white/10 transition-all disabled:opacity-50"
|
||||
title="ยกเลิก (Esc)"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => startEditing(item)}
|
||||
className="group/price inline-flex items-center gap-2 cursor-pointer px-2.5 py-1 -ml-2.5 rounded-lg hover:bg-white/5 border border-transparent hover:border-white/10 transition-all"
|
||||
title="คลิกที่นี่เพื่อแก้ไขราคาต่อหน่วย (Inline Edit)"
|
||||
>
|
||||
<p className="text-sm font-bold text-emerald-400 font-mono">
|
||||
฿{Number(item.pricePerUnit).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</p>
|
||||
<Pencil className="w-3 h-3 text-slate-500 opacity-0 group-hover/price:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button onClick={() => handleOpenModal(item)} className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-cyan-glow transition-all">
|
||||
<Edit3 className="w-4 h-4" />
|
||||
{/* Edit Button navigates to Full Page Route */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/raw-materials/${item.id}`)}
|
||||
className="px-3 py-1.5 rounded-lg bg-white/5 hover:bg-cyan-500/10 text-slate-400 hover:text-cyan-400 border border-white/5 hover:border-cyan-500/20 text-xs font-medium transition-all flex items-center gap-1.5"
|
||||
title="แก้ไขรายละเอียด"
|
||||
>
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
<span>แก้ไข</span>
|
||||
</button>
|
||||
<button onClick={() => handleDelete(item.id)} className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-rose-500 transition-all">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(item.id, item.name)}
|
||||
className="p-1.5 rounded-lg hover:bg-rose-500/10 text-slate-500 hover:text-rose-400 transition-all"
|
||||
title="ลบรายการ"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-night-950/80 backdrop-blur-sm" onClick={() => !saving && setIsModalOpen(false)} />
|
||||
<div className="relative w-full max-w-md bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-zoom-in p-6 space-y-6">
|
||||
<h3 className="text-xl font-bold">{editingId ? 'Edit Material' : 'Add New Material'}</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Material Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. Alcohol, Glass Bottle 50ml"
|
||||
{/* Bulk JSON Import / Export Modal */}
|
||||
<RawMaterialBulkJsonModal
|
||||
isOpen={isBulkModalOpen}
|
||||
onClose={() => setIsBulkModalOpen(false)}
|
||||
materials={materials}
|
||||
onSuccess={fetchMaterials}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Unit</label>
|
||||
<input
|
||||
type="text"
|
||||
value={unit}
|
||||
onChange={e => setUnit(e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. kg, liter, piece"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Price per Unit (฿)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={pricePerUnit || ''}
|
||||
onChange={e => setPricePerUnit(Number(e.target.value))}
|
||||
className="admin-input w-full"
|
||||
placeholder="0.00"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-white/5">
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 font-medium transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn-primary px-6 py-2 flex items-center gap-2"
|
||||
>
|
||||
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{saving ? 'Saving...' : 'Save Material'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { API_BASE } from '@homaroma/types';
|
||||
import { TIKTOK_SHOPS } from './TiktokOrderPage';
|
||||
import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils';
|
||||
import { TiktokFeeBreakdownModal } from '../components/tiktok-order/TiktokFeeBreakdownModal';
|
||||
|
||||
function authHeaders() {
|
||||
return {
|
||||
@@ -66,6 +67,7 @@ export default function TiktokOrderDetailPage() {
|
||||
const [order, setOrder] = useState<any | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showFeeModal, setShowFeeModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderId) return;
|
||||
@@ -410,19 +412,35 @@ export default function TiktokOrderDetailPage() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center text-violet-300">
|
||||
<span className="text-slate-400">{t('tiktokOrderDetail.commissionSummary')}</span>
|
||||
<span className="font-mono font-semibold">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFeeModal(true)}
|
||||
className="w-full flex justify-between items-center text-violet-300 hover:bg-violet-500/10 p-1.5 -mx-1.5 rounded-lg transition-colors cursor-pointer group"
|
||||
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||
>
|
||||
<span className="text-slate-400 group-hover:text-slate-200 text-xs flex items-center gap-1">
|
||||
{t('tiktokOrderDetail.commissionSummary')}
|
||||
<Receipt className="w-3 h-3 text-violet-400/50 group-hover:text-violet-300 transition-colors" />
|
||||
</span>
|
||||
<span className="font-mono font-semibold text-xs text-violet-400 group-hover:underline">
|
||||
{order.commissionFee > 0 ? `-฿${fmt(order.commissionFee)}` : '฿0.00'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="flex justify-between items-center text-cyan-300">
|
||||
<span className="text-slate-400">{t('tiktokOrderDetail.affiliateSummary')}</span>
|
||||
<span className="font-mono font-semibold">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFeeModal(true)}
|
||||
className="w-full flex justify-between items-center text-cyan-300 hover:bg-cyan-500/10 p-1.5 -mx-1.5 rounded-lg transition-colors cursor-pointer group"
|
||||
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||
>
|
||||
<span className="text-slate-400 group-hover:text-slate-200 text-xs flex items-center gap-1">
|
||||
{t('tiktokOrderDetail.affiliateSummary')}
|
||||
<Receipt className="w-3 h-3 text-cyan-400/50 group-hover:text-cyan-300 transition-colors" />
|
||||
</span>
|
||||
<span className="font-mono font-semibold text-xs text-cyan-400 group-hover:underline">
|
||||
{order.affiliateFee > 0 ? `-฿${fmt(order.affiliateFee)}` : '฿0.00'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="pt-3 border-t border-white/10 flex justify-between items-center">
|
||||
<div>
|
||||
@@ -527,6 +545,13 @@ export default function TiktokOrderDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFeeModal && (
|
||||
<TiktokFeeBreakdownModal
|
||||
order={order}
|
||||
onClose={() => setShowFeeModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,11 @@ import {
|
||||
ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign,
|
||||
Coins, ChevronLeft, ChevronRight, ExternalLink, Store, Trash2,
|
||||
FileSpreadsheet, AlertTriangle, CheckCircle2, Files, Search, X, RotateCcw,
|
||||
BadgePercent, Wallet, Receipt
|
||||
BadgePercent, Wallet, Receipt, Info
|
||||
} from 'lucide-react';
|
||||
import { API_BASE } from '@homaroma/types';
|
||||
import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils';
|
||||
import { TiktokFeeBreakdownModal } from '../components/tiktok-order/TiktokFeeBreakdownModal';
|
||||
|
||||
// ── Shop Config ───────────────────────────────────────────────────────────────
|
||||
export const TIKTOK_SHOPS = [
|
||||
@@ -164,6 +165,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [bomFilter, setBomFilter] = useState('all');
|
||||
const [selectedFeeOrder, setSelectedFeeOrder] = useState<any | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -396,8 +398,24 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
|
||||
<th className="px-4 py-3.5 whitespace-nowrap">{t('tiktokOrders.thSkusBom')}</th>
|
||||
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thOrderAmount')}</th>
|
||||
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thBomCost')}</th>
|
||||
<th className="px-4 py-3.5 whitespace-nowrap text-right text-violet-400">{t('tiktokOrders.thCommission')}</th>
|
||||
<th className="px-4 py-3.5 whitespace-nowrap text-right text-cyan-400">{t('tiktokOrders.thAffiliate')}</th>
|
||||
<th
|
||||
className="px-4 py-3.5 whitespace-nowrap text-right text-violet-400 cursor-help"
|
||||
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1 justify-end">
|
||||
{t('tiktokOrders.thCommission')}
|
||||
<Info className="w-3 h-3 text-violet-400/50" />
|
||||
</span>
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3.5 whitespace-nowrap text-right text-cyan-400 cursor-help"
|
||||
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1 justify-end">
|
||||
{t('tiktokOrders.thAffiliate')}
|
||||
<Info className="w-3 h-3 text-cyan-400/50" />
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thNetProfit')}</th>
|
||||
<th className="px-4 py-3.5 text-center" />
|
||||
</tr>
|
||||
@@ -507,20 +525,42 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
|
||||
|
||||
{/* Commission (฿) */}
|
||||
<td className="px-4 py-3.5 text-right font-mono whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedFeeOrder(order);
|
||||
}}
|
||||
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||
className="inline-flex items-center gap-1 group/btn px-2 py-1 rounded-lg hover:bg-violet-500/15 border border-transparent hover:border-violet-500/30 transition-all cursor-pointer"
|
||||
>
|
||||
{order.commissionFee > 0 ? (
|
||||
<span className="text-violet-400 font-bold">-฿{fmt(order.commissionFee)}</span>
|
||||
<span className="text-violet-400 font-bold group-hover/btn:underline">-฿{fmt(order.commissionFee)}</span>
|
||||
) : (
|
||||
<span className="text-slate-500 font-mono text-[11px]">-</span>
|
||||
<span className="text-slate-500 font-mono text-[11px] group-hover/btn:text-slate-300">-</span>
|
||||
)}
|
||||
<Receipt className="w-3 h-3 text-violet-400/40 group-hover/btn:text-violet-300 transition-colors" />
|
||||
</button>
|
||||
</td>
|
||||
|
||||
{/* Affiliate (฿) */}
|
||||
<td className="px-4 py-3.5 text-right font-mono whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedFeeOrder(order);
|
||||
}}
|
||||
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||
className="inline-flex items-center gap-1 group/btn px-2 py-1 rounded-lg hover:bg-cyan-500/15 border border-transparent hover:border-cyan-500/30 transition-all cursor-pointer"
|
||||
>
|
||||
{order.affiliateFee > 0 ? (
|
||||
<span className="text-cyan-400 font-bold">-฿{fmt(order.affiliateFee)}</span>
|
||||
<span className="text-cyan-400 font-bold group-hover/btn:underline">-฿{fmt(order.affiliateFee)}</span>
|
||||
) : (
|
||||
<span className="text-slate-500 font-mono text-[11px]">-</span>
|
||||
<span className="text-slate-500 font-mono text-[11px] group-hover/btn:text-slate-300">-</span>
|
||||
)}
|
||||
<Receipt className="w-3 h-3 text-cyan-400/40 group-hover/btn:text-cyan-300 transition-colors" />
|
||||
</button>
|
||||
</td>
|
||||
|
||||
{/* Profit */}
|
||||
@@ -553,6 +593,14 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Fee Breakdown Modal */}
|
||||
{selectedFeeOrder && (
|
||||
<TiktokFeeBreakdownModal
|
||||
order={selectedFeeOrder}
|
||||
onClose={() => setSelectedFeeOrder(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { API_BASE, type Menu } from '@homaroma/types'
|
||||
import { Save, Loader2, Plus, Trash2, ChevronUp, ChevronDown, Copy, Check, Download, Upload, Star, Pencil, Sparkles, Code2, X } from 'lucide-react'
|
||||
import { Save, Loader2, Plus, Trash2, ChevronUp, ChevronDown, Copy, Check, Download, Upload, Star, Pencil, Sparkles, Code2, X, LayoutGrid, ListFilter, Layers, Image as ImageIcon } from 'lucide-react'
|
||||
import { ICON_REGISTRY } from '../../utils/iconRegistry'
|
||||
import FocalPointPicker from '../../components/page-builder/FocalPointPicker'
|
||||
import MenuJsonGuideModal from '../../components/MenuJsonGuideModal'
|
||||
import MediaLibraryModal from '../../components/MediaLibraryModal'
|
||||
|
||||
const IconSelect = ({ value, onChange }: { value?: string, onChange: (val: string) => void }) => {
|
||||
return (
|
||||
@@ -30,7 +32,7 @@ type MenuEditorPageProps = {
|
||||
description: string;
|
||||
}
|
||||
|
||||
const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
|
||||
const FeaturedCardEditorFields = ({ featured, onChange, onOpenMediaPicker }: any) => {
|
||||
const [activeTab, setActiveTab] = useState<'content' | 'media'>('content');
|
||||
const [activeLang, setActiveLang] = useState('th');
|
||||
|
||||
@@ -54,8 +56,28 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
|
||||
['ctaLabel', 'CTA label', 'View details'],
|
||||
];
|
||||
|
||||
const isEnabled = featured.enabled !== false;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 rounded-xl bg-white/5 border border-white/5">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-white">Enable Featured Card</div>
|
||||
<div className="text-[10px] text-slate-400">เปิดหรือปิดการแสดงการ์ดโปรโมตใน Mega Menu</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateFeatured('enabled', !isEnabled)}
|
||||
className={`px-3 py-1 text-xs font-bold rounded-lg border transition-colors ${
|
||||
isEnabled
|
||||
? 'border-amber-400/40 bg-amber-400/20 text-amber-300'
|
||||
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
{isEnabled ? 'Enabled (เปิด)' : 'Disabled (ปิด)'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 rounded-xl bg-white/5 p-1">
|
||||
{[
|
||||
['content', 'Content'],
|
||||
@@ -136,22 +158,59 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="aspect-[4/3] overflow-hidden rounded-xl border border-white/10 bg-white/5 flex items-center justify-center">
|
||||
<div
|
||||
onClick={() => onOpenMediaPicker && onOpenMediaPicker((url: string) => updateFeatured('imageUrl', url))}
|
||||
className="aspect-[4/3] relative overflow-hidden rounded-xl border-2 border-dashed border-white/10 hover:border-cyan-glow/50 bg-night-950/40 flex items-center justify-center cursor-pointer group transition-all"
|
||||
title="คลิกเพื่อเลือกรูปจากคลังภาพ (Media Library)"
|
||||
>
|
||||
{featured.imageUrl ? (
|
||||
<img src={featured.imageUrl} alt="Featured preview" className="h-full w-full object-cover" />
|
||||
<>
|
||||
<img src={featured.imageUrl} alt="Featured preview" className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-300" />
|
||||
<div className="absolute inset-0 bg-night-950/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1.5 backdrop-blur-xs">
|
||||
<ImageIcon className="w-6 h-6 text-cyan-glow" />
|
||||
<span className="text-xs font-bold text-white">เปลี่ยนรูปจากคลังภาพ</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="px-4 text-center text-xs text-slate-600">No featured image</span>
|
||||
<div className="flex flex-col items-center gap-2 text-slate-500 group-hover:text-cyan-glow transition-colors py-4">
|
||||
<ImageIcon className="w-8 h-8" />
|
||||
<span className="text-xs font-bold">เลือกรูปจากคลังภาพ (Images Lib)</span>
|
||||
<span className="text-[10px] text-slate-600">คลิกที่นี่เพื่อเปิดคลังสื่อของระบบ</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500">Featured Image URL</label>
|
||||
{onOpenMediaPicker && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenMediaPicker((url: string) => updateFeatured('imageUrl', url))}
|
||||
className="text-[10px] font-bold text-cyan-glow hover:underline flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
<ImageIcon className="w-3 h-3" /> เลือกจากคลังภาพ
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="admin-input w-full text-xs py-1 px-2"
|
||||
value={featured.imageUrl || ''}
|
||||
placeholder="https://cdn.example.com/product.webp"
|
||||
onChange={e => updateFeatured('imageUrl', e.target.value || null)}
|
||||
/>
|
||||
{featured.imageUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateFeatured('imageUrl', null)}
|
||||
className="px-2.5 py-1 text-xs text-rose-400 hover:bg-rose-500/10 rounded border border-white/10 shrink-0 cursor-pointer"
|
||||
title="ลบรูปภาพ"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{featured.imageUrl && (
|
||||
<div className="pt-2">
|
||||
@@ -188,7 +247,7 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
|
||||
);
|
||||
}
|
||||
|
||||
const FeaturedCardSidePanel = ({ node, onChange }: any) => {
|
||||
const FeaturedCardSidePanel = ({ node, onChange, onOpenMediaPicker }: any) => {
|
||||
if (!node) {
|
||||
return (
|
||||
<div className="admin-card p-6 space-y-3">
|
||||
@@ -223,12 +282,13 @@ const FeaturedCardSidePanel = ({ node, onChange }: any) => {
|
||||
<FeaturedCardEditorFields
|
||||
featured={featured}
|
||||
onChange={(newFeatured: any) => onChange({ ...node, featured: newFeatured })}
|
||||
onOpenMediaPicker={onOpenMediaPicker}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured, isFeaturedSelected }: any) => {
|
||||
const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured, isFeaturedSelected, onOpenMediaPicker }: any) => {
|
||||
const childrenKey = type === 'root' ? 'columns' : 'links';
|
||||
const labelKey = type === 'column' ? 'title' : 'label';
|
||||
const hasUrl = type !== 'column';
|
||||
@@ -300,21 +360,56 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
|
||||
{/* Image URL field */}
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Preview thumbnail */}
|
||||
<div className="w-14 h-14 shrink-0 rounded-lg overflow-hidden border border-white/10 bg-white/5 flex items-center justify-center">
|
||||
<div
|
||||
onClick={() => onOpenMediaPicker && onOpenMediaPicker((url: string) => onChange({ ...node, [imageFieldKey]: url }))}
|
||||
className="w-14 h-14 shrink-0 rounded-lg overflow-hidden border border-white/10 hover:border-cyan-glow/50 bg-white/5 flex items-center justify-center cursor-pointer group relative transition-colors"
|
||||
title="คลิกเพื่อเลือกรูปจากคลังภาพ (Media Library)"
|
||||
>
|
||||
{imageValue ? (
|
||||
<>
|
||||
<img src={imageValue} alt="preview" className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-night-950/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<ImageIcon className="w-4 h-4 text-cyan-glow" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[9px] text-slate-600 text-center leading-tight px-1">No Image</span>
|
||||
<div className="flex flex-col items-center justify-center text-slate-600 group-hover:text-cyan-glow transition-colors">
|
||||
<ImageIcon className="w-4 h-4 mb-0.5" />
|
||||
<span className="text-[8px] text-center leading-tight">Library</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500">{imageFieldLabel}</label>
|
||||
{onOpenMediaPicker && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenMediaPicker((url: string) => onChange({ ...node, [imageFieldKey]: url }))}
|
||||
className="text-[10px] font-bold text-cyan-glow hover:underline flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
<ImageIcon className="w-3 h-3" /> เลือกจากคลังภาพ
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="admin-input w-full text-xs py-1 px-2"
|
||||
value={imageValue}
|
||||
placeholder="https://cdn.example.com/image.webp"
|
||||
onChange={e => onChange({ ...node, [imageFieldKey]: e.target.value || null })}
|
||||
/>
|
||||
{imageValue && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ ...node, [imageFieldKey]: null })}
|
||||
className="px-2 py-1 text-xs text-rose-400 hover:bg-rose-500/10 rounded border border-white/10 shrink-0 cursor-pointer"
|
||||
title="ลบรูปภาพ"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{type !== 'root' && (
|
||||
<IconSelect
|
||||
value={node.icon}
|
||||
@@ -325,6 +420,167 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
|
||||
</div>
|
||||
|
||||
{type === 'root' && (
|
||||
<div className="space-y-3">
|
||||
{/* Layout Mode Selector */}
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">
|
||||
Sub-Menu Layout Preset
|
||||
</label>
|
||||
<span className="text-[10px] text-cyan-glow font-medium">
|
||||
{node.layout === 'classic'
|
||||
? 'Classic Dropdown'
|
||||
: node.layout === 'tabbed'
|
||||
? 'Tabbed Hierarchy'
|
||||
: node.layout === 'visual-cards'
|
||||
? 'Visual Cards'
|
||||
: `Mega Menu (${node.layoutConfig?.columnCount || 2} Cols)`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ ...node, layout: 'columns' })}
|
||||
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
|
||||
(!node.layout || node.layout === 'columns')
|
||||
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
|
||||
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4 shrink-0 text-cyan-glow" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-bold leading-tight truncate">Column Grid</div>
|
||||
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">2-4 คอลัมน์</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ ...node, layout: 'tabbed' })}
|
||||
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
|
||||
node.layout === 'tabbed'
|
||||
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
|
||||
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<Layers className="w-4 h-4 shrink-0 text-cyan-glow" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-bold leading-tight truncate">Tabbed Catalog</div>
|
||||
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">แท็บซ้าย + ลิงก์ขวา</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ ...node, layout: 'visual-cards' })}
|
||||
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
|
||||
node.layout === 'visual-cards'
|
||||
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
|
||||
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-4 h-4 shrink-0 text-cyan-glow" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-bold leading-tight truncate">Visual Cards</div>
|
||||
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">การ์ดรูปถ่ายหรูหรา</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ ...node, layout: 'classic' })}
|
||||
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
|
||||
node.layout === 'classic'
|
||||
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
|
||||
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<ListFilter className="w-4 h-4 shrink-0 text-cyan-glow" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-bold leading-tight truncate">Classic Dropdown</div>
|
||||
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">แนวตั้งมินิมอล</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Submenu Layout Modifier / Description */}
|
||||
{(!node.layout || node.layout === 'columns') && (
|
||||
<div className="pt-3 border-t border-white/5 space-y-3">
|
||||
{/* Column Count */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-slate-300">จำนวนคอลัมน์ (Columns)</span>
|
||||
<div className="flex rounded-lg bg-black/40 p-1 border border-white/5">
|
||||
{[2, 3, 4].map((count) => {
|
||||
const currentCols = node.layoutConfig?.columnCount || 2;
|
||||
return (
|
||||
<button
|
||||
key={count}
|
||||
type="button"
|
||||
onClick={() => onChange({
|
||||
...node,
|
||||
layoutConfig: { ...(node.layoutConfig || {}), columnCount: count }
|
||||
})}
|
||||
className={`px-3 py-1 text-xs font-bold rounded-md transition-colors ${
|
||||
currentCols === count
|
||||
? 'bg-cyan-glow text-night-900'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{count} Cols
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle Featured Card */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-slate-300">แสดง Featured Card</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const isCurrentlyShown = node.layoutConfig?.showFeatured !== false && featured.enabled !== false;
|
||||
const nextState = !isCurrentlyShown;
|
||||
onChange({
|
||||
...node,
|
||||
layoutConfig: { ...(node.layoutConfig || {}), showFeatured: nextState },
|
||||
featured: { ...featured, enabled: nextState }
|
||||
});
|
||||
}}
|
||||
className={`flex items-center gap-1.5 px-3 py-1 text-xs font-bold rounded-lg border transition-colors ${
|
||||
(node.layoutConfig?.showFeatured !== false && featured.enabled !== false)
|
||||
? 'border-amber-400/40 bg-amber-400/20 text-amber-300'
|
||||
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
{(node.layoutConfig?.showFeatured !== false && featured.enabled !== false) ? 'ON (เปิด)' : 'OFF (ปิด)'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.layout === 'tabbed' && (
|
||||
<div className="pt-3 border-t border-white/5 text-xs text-slate-400 leading-relaxed bg-white/2 p-2.5 rounded-lg">
|
||||
💡 <strong className="text-slate-200">Tabbed Hierarchy:</strong> แต่ละ Column จะทำหน้าที่เป็นแท็บหมวดหมู่ฝั่งซ้าย เมื่อเอาเมาส์ Hover แท็บใด รายการ Links ย่อยจะปรากฏฝั่งขวาทันที เหมาะสำหรับร้านค้าที่มีหมวดหมู่แคตตาล็อกลึก (เช่น อะไหล่ยนต์, เครื่องใช้ไฟฟ้า, ไอที)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.layout === 'visual-cards' && (
|
||||
<div className="pt-3 border-t border-white/5 text-xs text-slate-400 leading-relaxed bg-white/2 p-2.5 rounded-lg">
|
||||
✨ <strong className="text-slate-200">Visual Image Cards:</strong> แสดงเป็นการ์ดรูปถ่าย 4 ใบแนวนอน โดยดึงรูปภาพจาก Column Icon URL หรือ Hover Image ของลิงก์แรก เหมาะสำหรับสินค้าที่เน้นความงามและดีไซน์ (เช่น ก้านไม้หอม, เทียนหอม, คอลเลกชันแฟชั่น)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.layout === 'classic' && (
|
||||
<div className="pt-3 border-t border-white/5 text-xs text-slate-400 leading-relaxed bg-white/2 p-2.5 rounded-lg">
|
||||
📄 <strong className="text-slate-200">Classic Dropdown:</strong> แสดงเป็นกล่องแนวตั้งกะทัดรัดใต้ปุ่มเมนูบน Navbar เหมาะสำหรับเมนูข้อมูลทั่วไปที่มี 3-7 ลิงก์ (เช่น เกี่ยวกับเรา, ศูนย์บริการ, ข่าวสาร)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Featured Card editor trigger (only shown when in columns mode and featured card is enabled) */}
|
||||
{(!node.layout || node.layout === 'columns') && (node.layoutConfig?.showFeatured !== false && featured.enabled !== false) && (
|
||||
<div className="rounded-xl border border-amber-400/20 bg-amber-400/5 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
@@ -334,7 +590,7 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-amber-200">Featured card</h3>
|
||||
<p className="truncate text-[11px] text-slate-500">
|
||||
{featured.enabled === false ? 'Disabled' : featuredTitle}
|
||||
{featuredTitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -352,6 +608,8 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasUrl && (
|
||||
<div className="w-48 space-y-1">
|
||||
@@ -383,6 +641,7 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
|
||||
depth={depth + 1}
|
||||
onChange={(newChild: any) => handleUpdateChild(i, newChild)}
|
||||
onRemove={() => handleRemoveChild(i)}
|
||||
onOpenMediaPicker={onOpenMediaPicker}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -406,6 +665,13 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
|
||||
const [pastedJsonText, setPastedJsonText] = useState('')
|
||||
const [pasteError, setPasteError] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
|
||||
const [mediaSelectCallback, setMediaSelectCallback] = useState<((url: string) => void) | null>(null)
|
||||
|
||||
const handleOpenMediaPicker = (onSelectUrl: (url: string) => void) => {
|
||||
setMediaSelectCallback(() => onSelectUrl)
|
||||
setIsMediaModalOpen(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
@@ -708,6 +974,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
|
||||
}}
|
||||
onEditFeatured={() => setSelectedFeaturedIndex(idx)}
|
||||
isFeaturedSelected={safeFeaturedIndex === idx}
|
||||
onOpenMediaPicker={handleOpenMediaPicker}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -829,6 +1096,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
|
||||
<FeaturedCardSidePanel
|
||||
node={selectedFeaturedNode}
|
||||
onChange={handleUpdateFeaturedNode}
|
||||
onOpenMediaPicker={handleOpenMediaPicker}
|
||||
/>
|
||||
) : (
|
||||
<div className="admin-card p-6 space-y-4">
|
||||
@@ -884,7 +1152,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
|
||||
)}
|
||||
|
||||
{/* Paste JSON Modal */}
|
||||
{showPasteModal && (
|
||||
{showPasteModal && createPortal(
|
||||
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-4 animate-fade-in">
|
||||
<div className="w-full max-w-2xl bg-night-900 border border-white/10 rounded-2xl shadow-2xl p-6 space-y-4 animate-zoom-in">
|
||||
<div className="flex items-center justify-between border-b border-white/10 pb-3">
|
||||
@@ -932,7 +1200,8 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* LLM Mega Menu Guide Modal */}
|
||||
@@ -942,6 +1211,23 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
|
||||
onOpenPasteJson={() => setShowPasteModal(true)}
|
||||
onOpenImportFile={() => fileInputRef.current?.click()}
|
||||
/>
|
||||
|
||||
{/* Media Library Modal */}
|
||||
<MediaLibraryModal
|
||||
isOpen={isMediaModalOpen}
|
||||
onClose={() => {
|
||||
setIsMediaModalOpen(false)
|
||||
setMediaSelectCallback(null)
|
||||
}}
|
||||
onSelect={(url) => {
|
||||
if (mediaSelectCallback) {
|
||||
mediaSelectCallback(url)
|
||||
}
|
||||
setIsMediaModalOpen(false)
|
||||
setMediaSelectCallback(null)
|
||||
}}
|
||||
mediaType="image"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |