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
|
# Original User Request
|
||||||
|
|
||||||
|
> **Historical context:** This is an unedited record of an earlier development request for the Split-Screen Editorial Carousel feature. The current platform identity is DBK Platform.
|
||||||
|
|
||||||
## Initial Request — 2026-06-28T10:50:33Z
|
## Initial Request — 2026-06-28T10:50:33Z
|
||||||
|
|
||||||
Implement a Split-Screen Editorial Carousel by extending the existing `EDITORIAL_CAROUSEL` schema and updating the backend, admin, spa, and website apps.
|
Implement a Split-Screen Editorial Carousel by extending the existing `EDITORIAL_CAROUSEL` schema and updating the backend, admin, spa, and website apps.
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Agent System & Workspaces Directory
|
||||||
|
|
||||||
|
> **Agent Infrastructure & Historical Provenance:** This directory houses both active multi-agent skills and historical subagent run workspaces. The platform identity is **DBK Platform**, with **HomAroma** as the reference tenant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Active Skills (`.agents/skills/`)
|
||||||
|
The `.agents/skills/` directory contains active domain skill definitions loaded on demand by Antigravity and orchestrator agents. Each skill contains a `SKILL.md` specifying role responsibilities, routing logic, and verification rules:
|
||||||
|
|
||||||
|
- **`orchestrator`**: Central project planning, task routing, delegation, and verification.
|
||||||
|
- **`frontend-lead`**: React 19 SPA & Tailwind CSS development.
|
||||||
|
- **`frontend-code-auditor`**: Review gate for bug risks, contracts, and regressions.
|
||||||
|
- **`nextjs-seo-architect`**: Next.js 15 SSR, metadata, canonical URLs, and performance.
|
||||||
|
- **`backend-mcp-lead`**: Elysia API endpoints, server architecture, and MCP tools.
|
||||||
|
- **`database-architect`**: Prisma schema, PostgreSQL migrations, and data safety.
|
||||||
|
- **`cms-architect`**: Dynamic page builder payloads and headless CMS schemas.
|
||||||
|
- **`cms-content-manager`**: Content management and MCP blackbox testing.
|
||||||
|
- **`product-manager`**: Product scope, roadmap, and business impact analysis.
|
||||||
|
- **`ux-ui-designer`**: Design system, interaction models, and responsive layouts.
|
||||||
|
- **`qa-product-auditor`**: User flows, regression checks, and release readiness.
|
||||||
|
- **`brand-strategist`**: SEO copywriting, brand voice, and positioning.
|
||||||
|
- **`theme-compliance-auditor`**: CMS dynamic theme token compliance (avoid hardcoded colors).
|
||||||
|
|
||||||
|
*Note: Changes to `.agents/skills/` directly affect agent behavior and should follow the operational rules defined in [`AGENTS.md`](../AGENTS.md).*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Historical Subagent Workspaces
|
||||||
|
Directories matching `auditor_*`, `worker_*`, `explorer_*`, `reviewer_*`, `challenger_*`, `teamwork_preview_*`, `sentinel`, and `sub_orch_*` are historical workspaces and artifact directories generated during previous autonomous multi-agent sprint sessions.
|
||||||
|
|
||||||
|
- **Status:** Historical records preserved for provenance, auditability, and debugging.
|
||||||
|
- **Usage:** Do not edit active platform rules within historical folders. Refer to [`AGENTS.md`](../AGENTS.md) and [`docs/PLATFORM_OVERVIEW.md`](../docs/PLATFORM_OVERVIEW.md) for current standards.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Handle line endings automatically for files detected as text
|
||||||
|
* text=auto
|
||||||
|
|
||||||
|
# Lockfile must use LF across all platforms for Bun consistency
|
||||||
|
bun.lock text eol=lf
|
||||||
|
|
||||||
|
# Shell scripts must use LF
|
||||||
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
# Windows batch scripts must use CRLF
|
||||||
|
*.bat text eol=crlf
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# DBK Platform Admin Section Guide
|
# DBK Platform Admin Section Guide
|
||||||
|
|
||||||
> **Platform scope:** This guide describes shared CMS administration capabilities. Product and content examples are tenant-specific; HomAroma is the reference tenant.
|
> **Platform scope:** This guide describes shared CMS administration capabilities. Product and content examples are tenant-specific; HomAroma is the reference tenant. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||||
|
|
||||||
This guide explains the dynamic page blocks available in the Page Builder and what each block contains.
|
This guide explains the dynamic page blocks available in the Page Builder and what each block contains.
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ This guide explains the dynamic page blocks available in the Page Builder and wh
|
|||||||
|
|
||||||
### `SCENT_NOTES_BREAKDOWN`
|
### `SCENT_NOTES_BREAKDOWN`
|
||||||
|
|
||||||
- Purpose: Fragrance layer breakdown
|
- Purpose: Fragrance layer breakdown (reference tenant vertical block; pattern for multi-tier attribute breakdowns)
|
||||||
- Contains:
|
- Contains:
|
||||||
- `title` localized string optional
|
- `title` localized string optional
|
||||||
- `description` localized string optional
|
- `description` localized string optional
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Architecture Decisions — DBK Platform
|
# Architecture Decisions — DBK Platform
|
||||||
|
|
||||||
> **Platform scope:** HomAroma references in this ADR are reference-tenant context unless a decision explicitly names an operational resource.
|
> **Platform scope:** DBK Platform architecture decisions. HomAroma references in this ADR are reference-tenant context unless a decision explicitly names an operational resource. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||||
|
|
||||||
> บันทึกการตัดสินใจเชิงสถาปัตยกรรม (ADR) จากการประชุมทีม
|
> บันทึกการตัดสินใจเชิงสถาปัตยกรรม (ADR) จากการประชุมทีม
|
||||||
> อัปเดตล่าสุด: 2026-07-12
|
> อัปเดตล่าสุด: 2026-07-12
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# แผนการพัฒนาและคู่มือสร้างแผนที่ Malaysia & Vietnam Map
|
# แผนการพัฒนาและคู่มือสร้างแผนที่ Malaysia & Vietnam Map
|
||||||
|
|
||||||
> **Platform capability plan:** Geographic coverage, data availability, and analytics configuration must be enabled and validated per tenant.
|
> **Platform capability plan:** Geographic coverage, data availability, and analytics configuration are platform capabilities enabled per tenant. HomAroma is the reference tenant. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||||
สำหรับเมนู Business Analytics บนระบบ HomAroma Admin
|
สำหรับเมนู Business Analytics บนระบบ DBK Platform Admin (Multi-tenant & Multi-region)
|
||||||
|
|
||||||
เอกสารนี้รวบรวมรายละเอียด แหล่งข้อมูล Open-source Repositories สำหรับดาวน์โหลดไฟล์ GeoJSON สถาปัตยกรรมทางเทคนิค และแผนขั้นตอนการนำไปพัฒนาต่อยอดให้เหมือนกับ **Omnichannel Sales Map (แผนที่ประเทศไทย)** เดิม
|
เอกสารนี้รวบรวมรายละเอียด แหล่งข้อมูล Open-source Repositories สำหรับดาวน์โหลดไฟล์ GeoJSON สถาปัตยกรรมทางเทคนิค และแผนขั้นตอนการนำไปพัฒนาต่อยอดให้เหมือนกับ **Omnichannel Sales Map (แผนที่ประเทศไทย)** เดิม
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Custom Block Flow Guide
|
# Custom Block Flow Guide — DBK Platform
|
||||||
|
|
||||||
> **Platform scope:** This guide describes shared CMS block workflows. Content examples belong to their named tenant.
|
> **Platform scope:** This guide describes shared CMS block workflows for DBK Platform. Content examples (e.g. `BakeryCategories`) belong to their named tenant. HomAroma is the reference tenant. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||||
|
|
||||||
เอกสารนี้อธิบาย Flow การสร้าง Custom Block สำหรับ HomAroma Page Builder โดยใช้ `BakeryCategories` เป็นตัวอย่างจริง เพื่อให้เพิ่ม block ถัดไปได้โดยไม่ต้อง hardcode section type ใหม่ทุกครั้ง
|
เอกสารนี้อธิบาย Flow การสร้าง Custom Block สำหรับ DBK Platform Page Builder โดยใช้ `BakeryCategories` เป็นตัวอย่าง tenant จริง เพื่อให้เพิ่ม block ถัดไปได้โดยไม่ต้อง hardcode section type ใหม่ทุกครั้ง
|
||||||
|
|
||||||
## แนวคิดหลัก
|
## แนวคิดหลัก
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# DDC Deployment Runbook
|
# DDC Deployment Runbook
|
||||||
|
|
||||||
> **Tenant operational runbook:** This document records DDC deployment configuration. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding.
|
> **Tenant operational runbook:** This document records DDC deployment configuration for DBK Platform. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||||
|
|
||||||
เอกสารนี้เป็น runbook เฉพาะลูกค้า `ddc` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/pkh-*`
|
เอกสารนี้เป็น runbook เฉพาะลูกค้า `ddc` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/pkh-*`
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# คู่มือการติดตั้งโปรเจกต์ใหม่ (New Project Deployment Guide)
|
# คู่มือการติดตั้งโปรเจกต์ใหม่ (New Project Deployment Guide)
|
||||||
|
|
||||||
> **DBK Platform operational guide:** Substitute tenant-specific domains, paths, credentials, and process names. Any `homaroma` identifier shown elsewhere in the repository is a legacy or reference-tenant identifier unless this guide explicitly selects it.
|
> **DBK Platform operational guide:** Substitute tenant-specific domains, paths, credentials, and process names. Any `homaroma` identifier shown elsewhere in the repository is a legacy or reference-tenant identifier unless this guide explicitly selects it. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||||
|
|
||||||
เอกสารนี้รวบรวมขั้นตอนการสร้างสภาพแวดล้อม (Environment) และการตั้งค่าระบบสำหรับลูกค้ารายใหม่ (เช่น โปรเจกต์ `pkh`) โดยอ้างอิงจากโครงสร้างระบบปัจจุบันบนเซิร์ฟเวอร์ `kitizone.com`
|
เอกสารนี้รวบรวมขั้นตอนการสร้างสภาพแวดล้อม (Environment) และการตั้งค่าระบบสำหรับลูกค้ารายใหม่ (เช่น โปรเจกต์ `pkh`) โดยอ้างอิงจากโครงสร้างระบบปัจจุบันบนเซิร์ฟเวอร์ `kitizone.com`
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# HuaHin Deployment Runbook
|
# HuaHin Deployment Runbook
|
||||||
|
|
||||||
> **Tenant operational runbook:** This document records HuaHin deployment configuration. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding.
|
> **Tenant operational runbook:** This document records HuaHin deployment configuration for DBK Platform. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||||
|
|
||||||
เอกสารนี้เป็น runbook เฉพาะลูกค้า `huahin` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/ddc-*`
|
เอกสารนี้เป็น runbook เฉพาะลูกค้า `huahin` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/ddc-*`
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Kittisak Deployment Runbook
|
# Kittisak Deployment Runbook
|
||||||
|
|
||||||
> **Tenant operational runbook:** This document records Kittisak deployment configuration. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding.
|
> **Tenant operational runbook:** This document records Kittisak deployment configuration for DBK Platform. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||||
|
|
||||||
เอกสารนี้เป็น runbook เฉพาะลูกค้า `kittisak` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/ddc-*` / `serverinfo/huahin-*`
|
เอกสารนี้เป็น runbook เฉพาะลูกค้า `kittisak` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/ddc-*` / `serverinfo/huahin-*`
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# รายการสินค้ากลุ่ม Oil Ozone สำหรับกำหนดสูตร BOM (Production)
|
||||||
|
|
||||||
|
> **เป้าหมาย:** กำหนดสูตร BOM อัตราส่วน หัวน้ำหอม 8% + DPG 92% + ขวดและฝา 4 บาท (ทุกขนาด: 30ml, 60ml, 85ml, 120ml)
|
||||||
|
> **อัปเดตล่าสุด:** 18/10/2569 13:05:32
|
||||||
|
> **จำนวนสินค้าทั้งหมด:** 45 รายการ
|
||||||
|
|
||||||
|
## 📦 สรุปสถานะภาพรวม
|
||||||
|
- **ตั้งค่าเสร็จแล้ว (CONFIGURED):** 45 / 45 รายการ (100%)
|
||||||
|
- **ยังไม่ได้ตั้งค่า (MISSING):** 0 รายการ
|
||||||
|
- **ตั้งค่าบางส่วน (PARTIAL):** 0 รายการ
|
||||||
|
- **พบวัตถุดิบหัวน้ำหอมตรงกลิ่น:** 45 / 45 รายการ
|
||||||
|
|
||||||
|
## 📋 ตารางรายละเอียดต้นทุน BOM และอัตรากำไร (Margin)
|
||||||
|
|
||||||
|
| # | ID | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุน 30ml (กำไร%) | ต้นทุน 60ml (กำไร%) | ต้นทุน 85ml (กำไร%) | ต้นทุน 120ml (กำไร%) | สถานะ |
|
||||||
|
| :-: | :-: | :--- | :--- | :-: | :-: | :-: | :-: | :-: |
|
||||||
|
| 1 | **#1** | [ตะไคร้ Oil Ozone](https://admin.homaroma.shop/products/1/bom) | [ID: 41] หัวน้ำหอม กลิ่นตะไคร้ (฿4.5) | ฿18.94 (72.6%) | ฿33.88 (66.1%) | ฿46.33 (61.4%) | ฿63.76 (57.5%) | ✅ พร้อมขาย |
|
||||||
|
| 2 | **#6** | [ลาเวนเดอร์ Oil Ozone](https://admin.homaroma.shop/products/6/bom) | [ID: 15] หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
|
||||||
|
| 3 | **#11** | [วานิลลา Oil Ozone](https://admin.homaroma.shop/products/11/bom) | [ID: 53] หัวน้ำหอม กลิ่นวานิลลา (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 4 | **#16** | [ซากุระ Oil Ozone](https://admin.homaroma.shop/products/16/bom) | [ID: 27] หัวน้ำหอม กลิ่นซากุระ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
|
||||||
|
| 5 | **#21** | [มะลิ Oil Ozone](https://admin.homaroma.shop/products/21/bom) | [ID: 48] หัวน้ำหอม กลิ่นมะลิ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
|
||||||
|
| 6 | **#26** | [ยูคาลิปตัส Oil Ozone](https://admin.homaroma.shop/products/26/bom) | [ID: 49] หัวน้ำหอม กลิ่นยูคาลิปตัส (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 7 | **#31** | [เฟรชลาเวนเดอร์ Oil Ozone](https://admin.homaroma.shop/products/31/bom) | [ID: 15] หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
|
||||||
|
| 8 | **#36** | [เปเปอร์มิ้น Oil Ozone](https://admin.homaroma.shop/products/36/bom) | [ID: 14] หัวน้ำหอม กลิ่นเปเปอร์มิ้น (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 9 | **#41** | [โอเชี่ยน Oil Ozone](https://admin.homaroma.shop/products/41/bom) | [ID: 21] หัวน้ำหอม กลิ่นโอเชี่ยน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 10 | **#46** | [เซเว่นซี Oil Ozone](https://admin.homaroma.shop/products/46/bom) | [ID: 13] หัวน้ำหอม กลิ่นเซเว่นซี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 11 | **#51** | [ดอกแก้ว Oil Ozone](https://admin.homaroma.shop/products/51/bom) | [ID: 28] หัวน้ำหอม กลิ่นดอกแก้ว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 12 | **#56** | [ดอกโมก Oil Ozone](https://admin.homaroma.shop/products/56/bom) | [ID: 29] หัวน้ำหอม กลิ่นดอกโมก (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 13 | **#61** | [ลีลาวดี Oil Ozone](https://admin.homaroma.shop/products/61/bom) | [ID: 51] หัวน้ำหอม กลิ่นลีลาวดี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 14 | **#66** | [ส้ม Oil Ozone](https://admin.homaroma.shop/products/66/bom) | [ID: 54] หัวน้ำหอม กลิ่นส้ม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 15 | **#71** | [มะนาว Oil Ozone](https://admin.homaroma.shop/products/71/bom) | [ID: 46] หัวน้ำหอม กลิ่นมะนาว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 16 | **#76** | [แคนตาลูป Oil Ozone](https://admin.homaroma.shop/products/76/bom) | [ID: 16] หัวน้ำหอม กลิ่นแคนตาลูป (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 17 | **#81** | [แอปเปิ้ลแดง Oil Ozone](https://admin.homaroma.shop/products/81/bom) | [ID: 19] หัวน้ำหอม กลิ่นแอปเปิ้ลแดง (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 18 | **#86** | [แอปเปิ้ลเขียว Oil Ozone](https://admin.homaroma.shop/products/86/bom) | [ID: 18] หัวน้ำหอม กลิ่นแอปเปิ้ลเขียว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 19 | **#91** | [ดอกลิลลี่ Oil Ozone](https://admin.homaroma.shop/products/91/bom) | [ID: 40] หัวน้ำหอม กลิ่นดอกลิลลี่ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 20 | **#96** | [โรสเมรี่ Oil Ozone](https://admin.homaroma.shop/products/96/bom) | [ID: 20] หัวน้ำหอม กลิ่นโรสเมรี่ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 21 | **#101** | [อัญชัน Oil Ozone](https://admin.homaroma.shop/products/101/bom) | [ID: 57] หัวน้ำหอม กลิ่นอัญชัน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 22 | **#106** | [กุหลาบแดง Oil Ozone](https://admin.homaroma.shop/products/106/bom) | [ID: 26] หัวน้ำหอม กลิ่นกุหลาบแดง (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 23 | **#111** | [หมากฝรั่ง Oil Ozone](https://admin.homaroma.shop/products/111/bom) | [ID: 55] หัวน้ำหอม กลิ่นหมากฝรั่ง (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 24 | **#116** | [ผลไม้รวม Oil Ozone](https://admin.homaroma.shop/products/116/bom) | [ID: 44] หัวน้ำหอม กลิ่นผลไม้รวม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 25 | **#121** | [มะกรูด Oil Ozone](https://admin.homaroma.shop/products/121/bom) | [ID: 45] หัวน้ำหอม กลิ่นมะกรูด (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 26 | **#126** | [แซนด้าวูส Oil Ozone](https://admin.homaroma.shop/products/126/bom) | [ID: 17] หัวน้ำหอม กลิ่นแซนด้าวูส (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 27 | **#131** | [ดอกปีบ Oil Ozone](https://admin.homaroma.shop/products/131/bom) | [ID: 34] หัวน้ำหอม กลิ่นดอกปีบ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 28 | **#136** | [ดอกบัว Oil Ozone](https://admin.homaroma.shop/products/136/bom) | [ID: 33] หัวน้ำหอม กลิ่นดอกบัว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 29 | **#141** | [โอโซน Oil Ozone](https://admin.homaroma.shop/products/141/bom) | [ID: 22] หัวน้ำหอม กลิ่นโอโซน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 30 | **#146** | [ไทยสปา Oil Ozone](https://admin.homaroma.shop/products/146/bom) | [ID: 23] หัวน้ำหอม กลิ่นไทยสปา (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 31 | **#151** | [กล้วย Oil Ozone](https://admin.homaroma.shop/products/151/bom) | [ID: 24] หัวน้ำหอม กลิ่นกล้วย (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 32 | **#156** | [กาแฟ Oil Ozone](https://admin.homaroma.shop/products/156/bom) | [ID: 25] หัวน้ำหอม กลิ่นกาแฟ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 33 | **#161** | [ดอกชมนาด Oil Ozone](https://admin.homaroma.shop/products/161/bom) | [ID: 32] หัวน้ำหอม กลิ่นดอกชมนาด (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 34 | **#166** | [กลิ่นน้ำนม Oil Ozone](https://admin.homaroma.shop/products/166/bom) | [ID: 43] หัวน้ำหอม กลิ่นน้ำนม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 35 | **#171** | [ดอกการะเวก Oil Ozone](https://admin.homaroma.shop/products/171/bom) | [ID: 30] หัวน้ำหอม กลิ่นดอกการะเวก (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 36 | **#176** | [ดอกจำปา Oil Ozone](https://admin.homaroma.shop/products/176/bom) | [ID: 31] หัวน้ำหอม กลิ่นดอกจำปา (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 37 | **#181** | [ดอกพิกุล Oil Ozone](https://admin.homaroma.shop/products/181/bom) | [ID: 35] หัวน้ำหอม กลิ่นดอกพิกุล (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 38 | **#186** | [ดอกพุด Oil Ozone](https://admin.homaroma.shop/products/186/bom) | [ID: 36] หัวน้ำหอม กลิ่นดอกพุด (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 39 | **#191** | [ดอกราตรี Oil Ozone](https://admin.homaroma.shop/products/191/bom) | [ID: 38] หัวน้ำหอม กลิ่นดอกราตรี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 40 | **#196** | [ดอกลำดวน Oil Ozone](https://admin.homaroma.shop/products/196/bom) | [ID: 39] หัวน้ำหอม กลิ่นดอกลำดวน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 41 | **#201** | [ทับทิม Oil Ozone](https://admin.homaroma.shop/products/201/bom) | [ID: 42] หัวน้ำหอม กลิ่นทับทิม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 42 | **#206** | [มะละกอสุก Oil Ozone](https://admin.homaroma.shop/products/206/bom) | [ID: 47] หัวน้ำหอม กลิ่นมะละกอสุก (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 43 | **#211** | [ดอกราชาวดี Oil Ozone](https://admin.homaroma.shop/products/211/bom) | [ID: 37] หัวน้ำหอม กลิ่นดอกราชาวดี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 44 | **#216** | [กลิ่นลูกพีช Oil Ozone](https://admin.homaroma.shop/products/216/bom) | [ID: 52] หัวน้ำหอม กลิ่นลูกพีช (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
|
| 45 | **#221** | [กลิ่นองุ่น Oil Ozone](https://admin.homaroma.shop/products/221/bom) | [ID: 56] หัวน้ำหอม กลิ่นองุ่น (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
# Prisma 7 — คู่มือการเชื่อมต่อและทำ Seed (ไม่งงอีกต่อไป)
|
# Prisma 7 — คู่มือการเชื่อมต่อและทำ Seed — DBK Platform
|
||||||
|
|
||||||
|
> **Platform engineering guide:** Any HomAroma seed data in this guide is reference-tenant data. Validate the target tenant database and seed strategy before use. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||||
|
|
||||||
> **Platform engineering guide:** Any HomAroma seed data in this guide is reference-tenant data. Validate the target tenant database and seed strategy before use.
|
|
||||||
|
|
||||||
xx
|
|
||||||
> **สำคัญมาก:** Prisma 7 เปลี่ยนวิธีการเชื่อมต่อฐานข้อมูลอย่างพื้นฐาน
|
> **สำคัญมาก:** Prisma 7 เปลี่ยนวิธีการเชื่อมต่อฐานข้อมูลอย่างพื้นฐาน
|
||||||
> ถ้าเคยใช้ Prisma 4, 5, หรือ 6 มาก่อน — ของเก่าพวกนั้น **ใช้ไม่ได้อีกแล้ว**
|
> ถ้าเคยใช้ Prisma 4, 5, หรือ 6 มาก่อน — ของเก่าพวกนั้น **ใช้ไม่ได้อีกแล้ว**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. สิ่งที่เปลี่ยนไปจาก Prisma 6fdss → Prisma 7
|
## 1. สิ่งที่เปลี่ยนไปจาก Prisma 5/6 → Prisma 7
|
||||||
|
|
||||||
| เรื่อง | Prisma 5/6 (เก่า) | Prisma 7 (ใหม่) |
|
| เรื่อง | Prisma 5/6 (เก่า) | Prisma 7 (ใหม่) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **DBK Platform tenant example:** Schema fields are shared platform contracts. The HomAroma brand, aroma products, categories, and copy below are reference-tenant sample data only.
|
> **DBK Platform tenant example:** Schema fields are shared platform contracts. The HomAroma brand, aroma products, categories, and copy below are reference-tenant sample data only.
|
||||||
|
|
||||||
ระบบนำเข้าสินค้าของ HomAroma รองรับการ Import ผ่านไฟล์ `.json` หรือวาง Payload JSON Array ได้โดยตรงผ่าน:
|
ระบบนำเข้าสินค้าของ DBK Platform (ตัวอย่างข้อมูลแคตตาล็อกสินค้าอ้างอิงจาก Reference Tenant: HomAroma) รองรับการ Import ผ่านไฟล์ `.json` หรือวาง Payload JSON Array ได้โดยตรงผ่าน:
|
||||||
1. **หน้าจอ Admin:** เมนู `/products` -> กดปุ่ม **"Import JSON"**
|
1. **หน้าจอ Admin:** เมนู `/products` -> กดปุ่ม **"Import JSON"**
|
||||||
2. **API Endpoint:** `POST /api/products/bulk` หรือ `POST /api/products`
|
2. **API Endpoint:** `POST /api/products/bulk` หรือ `POST /api/products`
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# สรุปปัญหา ข้อผิดพลาด และ Checklist การสร้าง Tenant / Subdomain ใหม่
|
# สรุปปัญหา ข้อผิดพลาด และ Checklist การสร้าง Tenant / Subdomain ใหม่
|
||||||
|
|
||||||
> **Historical operational record:** This postmortem preserves the facts of a 2026 tenant-provisioning incident. Existing HomAroma identifiers are legacy infrastructure references, not DBK Platform branding.
|
> **Historical operational record:** This postmortem preserves the facts of a 2026 tenant-provisioning incident. Existing HomAroma identifiers are legacy infrastructure references, not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
|
||||||
(Tenant Provisioning Post-Mortem & Standard Operating Procedure)
|
(Tenant Provisioning Post-Mortem & Standard Operating Procedure)
|
||||||
|
|
||||||
บันทึกข้อผิดพลาดจากการสร้าง Showcase Subdomains (`wellness`, `fashion`, `beauty`, `moto`) บน `kittisak.com` เมื่อวันที่ 24 กันยายน 2026 เพื่อใช้เป็น Checklist ป้องกันการทำผิดพลาดซ้ำในอนาคต
|
บันทึกข้อผิดพลาดจากการสร้าง Showcase Subdomains (`wellness`, `fashion`, `beauty`, `moto`) บน `kittisak.com` เมื่อวันที่ 24 กันยายน 2026 เพื่อใช้เป็น Checklist ป้องกันการทำผิดพลาดซ้ำในอนาคต
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import PageBuilderPage from './pages/PageBuilderPage'
|
|||||||
import PageReorderPage from './pages/PageReorderPage'
|
import PageReorderPage from './pages/PageReorderPage'
|
||||||
import CmsMockupPage from './pages/CmsMockupPage'
|
import CmsMockupPage from './pages/CmsMockupPage'
|
||||||
import RawMaterialsPage from './pages/RawMaterialsPage'
|
import RawMaterialsPage from './pages/RawMaterialsPage'
|
||||||
|
import RawMaterialDetailPage from './pages/RawMaterialDetailPage'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import InstallSeedPage from './pages/InstallSeedPage'
|
import InstallSeedPage from './pages/InstallSeedPage'
|
||||||
import ProtectedRoute from './components/ProtectedRoute'
|
import ProtectedRoute from './components/ProtectedRoute'
|
||||||
@@ -123,6 +124,8 @@ function App() {
|
|||||||
<Route path="/promotions" element={<PromotionsPage />} />
|
<Route path="/promotions" element={<PromotionsPage />} />
|
||||||
<Route path="/coupons" element={<CouponsPage />} />
|
<Route path="/coupons" element={<CouponsPage />} />
|
||||||
<Route path="/raw-materials" element={<RawMaterialsPage />} />
|
<Route path="/raw-materials" element={<RawMaterialsPage />} />
|
||||||
|
<Route path="/raw-materials/new" element={<RawMaterialDetailPage />} />
|
||||||
|
<Route path="/raw-materials/:id" element={<RawMaterialDetailPage />} />
|
||||||
<Route path="/settings" element={<StoreSettingsPage />} />
|
<Route path="/settings" element={<StoreSettingsPage />} />
|
||||||
<Route path="/settings/brand-intelligence" element={<BrandIntelligencePage />} />
|
<Route path="/settings/brand-intelligence" element={<BrandIntelligencePage />} />
|
||||||
<Route path="/brand-intelligence" element={<BrandIntelligencePage />} />
|
<Route path="/brand-intelligence" element={<BrandIntelligencePage />} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { API_BASE } from '@homaroma/types';
|
import { API_BASE } from '@homaroma/types';
|
||||||
import { X, Upload, Loader2, Image as ImageIcon, Video } from 'lucide-react';
|
import { X, Upload, Loader2, Image as ImageIcon, Video } from 'lucide-react';
|
||||||
@@ -105,7 +106,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect, mediaType
|
|||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||||
<div className="bg-night-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-5xl h-[85vh] flex flex-col overflow-hidden animate-fade-in relative">
|
<div className="bg-night-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-5xl h-[85vh] flex flex-col overflow-hidden animate-fade-in relative">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -196,6 +197,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect, mediaType
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import {
|
import {
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Copy,
|
Copy,
|
||||||
@@ -35,6 +36,8 @@ const GUIDE_OVERVIEW_MARKDOWN = `# คู่มือการใช้ AI/LLM
|
|||||||
### 1. Root Items (เมนูแถวบนสุด - Level 1)
|
### 1. Root Items (เมนูแถวบนสุด - Level 1)
|
||||||
- **label**: ออบเจกต์ข้อความรองรับ 8 ภาษา \`{ th, en, ja, zh, vi, ko, lo, it }\`
|
- **label**: ออบเจกต์ข้อความรองรับ 8 ภาษา \`{ th, en, ja, zh, vi, ko, lo, it }\`
|
||||||
- **url**: ลิงก์ปลายทาง เช่น \`/category/reed-diffuser\` หรือ \`/collections/all\`
|
- **url**: ลิงก์ปลายทาง เช่น \`/category/reed-diffuser\` หรือ \`/collections/all\`
|
||||||
|
- **layout**: (ไม่บังคับ) \`'columns'\` (Mega Menu คอลัมน์) | \`'tabbed'\` (Tabbed Catalog แท็บซ้าย) | \`'visual-cards'\` (การ์ดรูปภาพหรูหรา 4 ใบ) | \`'classic'\` (Dropdown แนวตั้งเรียบหรู กะทัดรัด)
|
||||||
|
- **layoutConfig**: (ไม่บังคับ) ออบเจกต์ตั้งค่าเพิ่มเติม \`{ columnCount: 2 | 3 | 4, showFeatured: boolean }\`
|
||||||
- **rootImageUrl**: URL รูปภาพหลักฝั่งซ้ายสุดของเมกะเมนู (อัตราส่วน 1:1 จัตุรัส ขนาดแนะนำ 800x800)
|
- **rootImageUrl**: URL รูปภาพหลักฝั่งซ้ายสุดของเมกะเมนู (อัตราส่วน 1:1 จัตุรัส ขนาดแนะนำ 800x800)
|
||||||
- **columns**: อาร์เรย์ของคอลัมน์เมนูย่อย (Level 2)
|
- **columns**: อาร์เรย์ของคอลัมน์เมนูย่อย (Level 2)
|
||||||
- **featured**: การ์ดสินค้า/ไฮไลต์โปรโมตพิเศษที่แสดงฝั่งขวาสุดของเมกะเมนู
|
- **featured**: การ์ดสินค้า/ไฮไลต์โปรโมตพิเศษที่แสดงฝั่งขวาสุดของเมกะเมนู
|
||||||
@@ -304,7 +307,7 @@ export default function MenuJsonGuideModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-5 animate-fade-in">
|
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-5 animate-fade-in">
|
||||||
<div className="w-full max-w-5xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col max-h-[92vh] overflow-hidden animate-zoom-in">
|
<div className="w-full max-w-5xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col max-h-[92vh] overflow-hidden animate-zoom-in">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -491,6 +494,7 @@ export default function MenuJsonGuideModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
FileJson,
|
||||||
|
Upload,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
Layers,
|
||||||
|
Sparkles
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface ProductBomImportModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
variants: any[];
|
||||||
|
rawMaterials: any[];
|
||||||
|
onApply: (updatedVariants: any[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductBomImportModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
variants,
|
||||||
|
rawMaterials,
|
||||||
|
onApply,
|
||||||
|
}: ProductBomImportModalProps) {
|
||||||
|
const [jsonInput, setJsonInput] = useState('');
|
||||||
|
|
||||||
|
// Real-time Parser & Matcher
|
||||||
|
const parsedResult = useMemo(() => {
|
||||||
|
if (!jsonInput.trim()) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonInput);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'ข้อมูลต้องเป็น JSON Array [ { ... }, { ... } ]',
|
||||||
|
matchedVariants: [],
|
||||||
|
unmatchedCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.length === 0) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'JSON Array ว่างเปล่า ไม่มีข้อมูลสูตร',
|
||||||
|
matchedVariants: [],
|
||||||
|
unmatchedCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched: any[] = [];
|
||||||
|
let unmatchedCount = 0;
|
||||||
|
|
||||||
|
for (const entry of parsed) {
|
||||||
|
// 1. Match Variant by ID or SKU or Option
|
||||||
|
const variant = variants.find((v) => {
|
||||||
|
if (entry.variantId && Number(v.id) === Number(entry.variantId)) return true;
|
||||||
|
if (entry.sku && v.sku && String(v.sku).trim().toLowerCase() === String(entry.sku).trim().toLowerCase()) return true;
|
||||||
|
if (entry.option1 && v.option1 && String(v.option1).trim().toLowerCase() === String(entry.option1).trim().toLowerCase()) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!variant) {
|
||||||
|
unmatchedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Match BOM items to RawMaterials
|
||||||
|
const rawItems = Array.isArray(entry.bomItems) ? entry.bomItems : [];
|
||||||
|
const resolvedBomItems: any[] = [];
|
||||||
|
let totalCost = 0;
|
||||||
|
|
||||||
|
for (const b of rawItems) {
|
||||||
|
const qty = Number(b.quantity) || 0;
|
||||||
|
if (qty <= 0) continue;
|
||||||
|
|
||||||
|
// Find raw material by ID or Name
|
||||||
|
const rm = rawMaterials.find((m) => {
|
||||||
|
if (b.rawMaterialId && Number(m.id) === Number(b.rawMaterialId)) return true;
|
||||||
|
if (b.name && String(m.name).trim().toLowerCase() === String(b.name).trim().toLowerCase()) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rm) {
|
||||||
|
const lineCost = (Number(rm.pricePerUnit) || 0) * qty;
|
||||||
|
totalCost += lineCost;
|
||||||
|
resolvedBomItems.push({
|
||||||
|
rawMaterialId: rm.id,
|
||||||
|
name: rm.name,
|
||||||
|
unit: rm.unit,
|
||||||
|
quantity: qty,
|
||||||
|
pricePerUnit: rm.pricePerUnit,
|
||||||
|
lineCost,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Unresolved material ID or name
|
||||||
|
resolvedBomItems.push({
|
||||||
|
rawMaterialId: Number(b.rawMaterialId) || 0,
|
||||||
|
name: b.name || `Material #${b.rawMaterialId}`,
|
||||||
|
unit: 'unit',
|
||||||
|
quantity: qty,
|
||||||
|
pricePerUnit: 0,
|
||||||
|
lineCost: 0,
|
||||||
|
unknown: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const price = Number(variant.price) || 0;
|
||||||
|
const profit = price - totalCost;
|
||||||
|
const margin = price > 0 ? (profit / price) * 100 : 0;
|
||||||
|
|
||||||
|
matched.push({
|
||||||
|
targetVariant: variant,
|
||||||
|
resolvedBomItems,
|
||||||
|
totalCost,
|
||||||
|
price,
|
||||||
|
profit,
|
||||||
|
margin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matched.length === 0) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'ไม่พบ Variant ในระบบที่ตรงกับ ID หรือ SKU ใน JSON ที่วางมา',
|
||||||
|
matchedVariants: [],
|
||||||
|
unmatchedCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
error: null,
|
||||||
|
matchedVariants: matched,
|
||||||
|
unmatchedCount,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `รูปแบบ JSON ไม่ถูกต้อง: ${err.message}`,
|
||||||
|
matchedVariants: [],
|
||||||
|
unmatchedCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [jsonInput, variants, rawMaterials]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
if (!parsedResult || !parsedResult.valid) return;
|
||||||
|
|
||||||
|
// Deep copy current variants
|
||||||
|
const newVariants = JSON.parse(JSON.stringify(variants));
|
||||||
|
|
||||||
|
for (const match of parsedResult.matchedVariants) {
|
||||||
|
const idx = newVariants.findIndex((v: any) => v.id === match.targetVariant.id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
// Map to exact BOM structure expected by ProductBomPage
|
||||||
|
newVariants[idx].bomItems = match.resolvedBomItems.map((b: any) => ({
|
||||||
|
rawMaterialId: b.rawMaterialId,
|
||||||
|
quantity: b.quantity,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onApply(newVariants);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-night-950/80 backdrop-blur-sm transition-opacity"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Container */}
|
||||||
|
<div className="relative w-full max-w-4xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden max-h-[92vh] animate-zoom-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5 bg-night-950/40">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400">
|
||||||
|
<FileJson className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-xl font-bold text-white">Import BOM from LLM JSON</h2>
|
||||||
|
<span className="text-xs px-2.5 py-0.5 rounded-full bg-cyan-500/10 text-cyan-300 border border-cyan-500/20 font-bold">
|
||||||
|
Auto-Match & Calculate
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
วาง JSON ที่ได้รับจาก LLM ระบบจะจับคู่ Variant และคำนวณต้นทุนให้ก่อนกรอกลงตาราง
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Body */}
|
||||||
|
<div className="p-6 overflow-y-auto space-y-6 flex-1">
|
||||||
|
{/* Textarea Input */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<label className="font-bold text-slate-300 flex items-center gap-1.5">
|
||||||
|
<Sparkles className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
วางข้อความ JSON ที่ได้จาก AI (Ctrl+V):
|
||||||
|
</label>
|
||||||
|
{jsonInput && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setJsonInput('')}
|
||||||
|
className="text-rose-400 hover:text-rose-300 font-medium"
|
||||||
|
>
|
||||||
|
ล้างข้อความ
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={jsonInput}
|
||||||
|
onChange={(e) => setJsonInput(e.target.value)}
|
||||||
|
rows={8}
|
||||||
|
placeholder={`[\n {\n "variantId": 3206,\n "sku": "4-1-30ML",\n "bomItems": [\n { "rawMaterialId": 4, "quantity": 2.4 },\n { "rawMaterialId": 91, "quantity": 27.6 }\n ]\n }\n]`}
|
||||||
|
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-4 text-slate-200 placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 transition-all resize-y leading-relaxed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Validation Status Banner */}
|
||||||
|
{parsedResult && (
|
||||||
|
<div
|
||||||
|
className={`p-4 rounded-2xl border text-xs flex items-center justify-between ${
|
||||||
|
parsedResult.valid
|
||||||
|
? 'bg-emerald-950/30 border-emerald-500/30 text-emerald-300'
|
||||||
|
: 'bg-rose-950/30 border-rose-500/30 text-rose-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
{parsedResult.valid ? (
|
||||||
|
<CheckCircle2 className="w-5 h-5 shrink-0 text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-5 h-5 shrink-0 text-rose-400" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{parsedResult.valid ? (
|
||||||
|
<>
|
||||||
|
ตรวจพบสูตร BOM ที่ตรงกับสินค้า <strong>{parsedResult.matchedVariants.length}</strong> ตัวเลือก
|
||||||
|
{parsedResult.unmatchedCount > 0 && ` (ข้ามที่ไม่ตรง ${parsedResult.unmatchedCount} รายการ)`}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
parsedResult.error
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{parsedResult.valid && (
|
||||||
|
<span className="text-[11px] px-2 py-0.5 rounded-md bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-medium">
|
||||||
|
พร้อมนำเข้า
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Live Preview Cards */}
|
||||||
|
{parsedResult?.valid && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span className="font-bold text-white flex items-center gap-1.5">
|
||||||
|
<Layers className="w-4 h-4 text-cyan-400" />
|
||||||
|
พรีวิวการจัดสูตรและคำนวณต้นทุน (BOM Preview):
|
||||||
|
</span>
|
||||||
|
<span>จำนวน {parsedResult.matchedVariants.length} Variants</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 max-h-[280px] overflow-y-auto pr-1">
|
||||||
|
{parsedResult.matchedVariants.map((item: any, idx: number) => {
|
||||||
|
const v = item.targetVariant;
|
||||||
|
const label = [v.option1, v.option2, v.option3].filter(Boolean).join(' / ') || 'Default';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={v.id || idx}
|
||||||
|
className="p-4 rounded-2xl bg-white/[0.02] border border-white/5 hover:border-white/10 transition-all space-y-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-white/5 pb-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-6 h-6 rounded-lg bg-cyan-500/10 text-cyan-400 text-xs font-mono font-bold flex items-center justify-center">
|
||||||
|
#{v.id}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold text-white">{label}</span>
|
||||||
|
{v.sku && (
|
||||||
|
<span className="text-[11px] font-mono text-slate-500 bg-white/5 px-2 py-0.5 rounded-md">
|
||||||
|
SKU: {v.sku}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-xs font-mono">
|
||||||
|
<span className="text-slate-400">
|
||||||
|
ราคาขาย: <strong className="text-white">฿{item.price.toLocaleString()}</strong>
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-400">
|
||||||
|
ต้นทุน BOM: <strong className="text-amber-400">฿{item.totalCost.toFixed(2)}</strong>
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-400">
|
||||||
|
กำไร:{' '}
|
||||||
|
<strong className={item.profit >= 0 ? 'text-emerald-400' : 'text-rose-400'}>
|
||||||
|
฿{item.profit.toFixed(2)} ({item.margin.toFixed(1)}%)
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ingredients Breakdown */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
|
{item.resolvedBomItems.map((b: any, bIdx: number) => (
|
||||||
|
<div
|
||||||
|
key={bIdx}
|
||||||
|
className={`p-2.5 rounded-xl border text-xs flex items-center justify-between ${
|
||||||
|
b.unknown
|
||||||
|
? 'bg-amber-950/20 border-amber-500/30 text-amber-300'
|
||||||
|
: 'bg-night-950/60 border-white/5 text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-0.5 min-w-0 pr-2">
|
||||||
|
<p className="font-medium truncate text-white text-[11px]">
|
||||||
|
{b.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-500">
|
||||||
|
ID: #{b.rawMaterialId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<span className="font-mono font-bold text-cyan-400">
|
||||||
|
{b.quantity} {b.unit}
|
||||||
|
</span>
|
||||||
|
<p className="text-[10px] text-slate-500 font-mono">
|
||||||
|
฿{b.lineCost.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-6 py-4 border-t border-white/5 bg-night-950/40 flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
เมื่อกด Apply ข้อมูลจะถูกกรอกลงตารางบนหน้าเว็บทันทีเพื่อให้คุณตรวจสอบก่อนกดบันทึก
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-5 py-2.5 rounded-xl text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||||
|
>
|
||||||
|
ยกเลิก
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={!parsedResult?.valid}
|
||||||
|
className="btn-primary text-sm px-6 py-2.5 flex items-center gap-2 shadow-lg shadow-cyan-500/20 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
<span>กรอกลงตาราง ({parsedResult?.matchedVariants?.length || 0} รายการ)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Bot,
|
||||||
|
Sparkles,
|
||||||
|
FileCode,
|
||||||
|
Package,
|
||||||
|
Layers,
|
||||||
|
Code2
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface ProductBomPromptModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
productId: number | string;
|
||||||
|
productName: string;
|
||||||
|
variants: any[];
|
||||||
|
rawMaterials: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductBomPromptModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
productId,
|
||||||
|
productName,
|
||||||
|
variants,
|
||||||
|
rawMaterials,
|
||||||
|
}: ProductBomPromptModalProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<'prompt' | 'context' | 'schema'>('prompt');
|
||||||
|
|
||||||
|
// Generate full prompt text for LLM
|
||||||
|
const generatedPrompt = useMemo(() => {
|
||||||
|
const variantLines = variants.map((v) => {
|
||||||
|
const opts = [v.option1, v.option2, v.option3].filter(Boolean).join(' / ') || 'Base Variant';
|
||||||
|
return ` - Variant ID: ${v.id} | SKU: ${v.sku || '-'} | ตัวเลือก: "${opts}" | ราคาขาย: ฿${Number(v.price || 0).toLocaleString()}`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const rawMaterialLines = rawMaterials.map((rm) => {
|
||||||
|
return ` - [ID: ${rm.id}] "${rm.name}" | หน่วย: ${rm.unit} | ต้นทุน: ฿${Number(rm.pricePerUnit || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })}`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
return `คุณคือ Senior Manufacturing & Bill of Materials (BOM) Specialist ของระบบ E-Commerce (DBK Platform)
|
||||||
|
|
||||||
|
[บริบทข้อมูลสินค้า]:
|
||||||
|
- รหัสสินค้า (Product ID): ${productId}
|
||||||
|
- ชื่อสินค้า: "${productName}"
|
||||||
|
|
||||||
|
[รายการตัวเลือกสินค้า (Variants ที่ต้องกำหนดสูตร BOM)]:
|
||||||
|
${variantLines}
|
||||||
|
|
||||||
|
[คลังรายการวัตถุดิบในระบบ (Raw Materials ที่สามารถนำมาผูกสูตรได้)]:
|
||||||
|
${rawMaterialLines}
|
||||||
|
|
||||||
|
[ภารกิจของคุณ]:
|
||||||
|
กรุณาจัดทำสูตรการผลิต (BOM Items) ให้กับแต่ละ Variant ของสินค้า รหัส #${productId} ตามคำสั่ง/สัดส่วนที่ระบุ
|
||||||
|
และส่งคืนผลลัพธ์เป็น **Valid JSON Array** ล้วนๆ ตามข้อกำหนดดังต่อไปนี้เท่านั้น:
|
||||||
|
|
||||||
|
[ข้อกำหนดผลลัพธ์ JSON Format ที่ต้องส่งกลับ]:
|
||||||
|
\`\`\`json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"variantId": <ตัวเลข ID ของ Variant>,
|
||||||
|
"sku": "<รหัส SKU>",
|
||||||
|
"bomItems": [
|
||||||
|
{
|
||||||
|
"rawMaterialId": <ตัวเลข ID ของวัตถุดิบจากคลังด้านบน>,
|
||||||
|
"name": "<ชื่อวัตถุดิบ>",
|
||||||
|
"quantity": <จำนวนปริมาณตัวเลข (ห้ามเป็น string)>
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
\`\`\`
|
||||||
|
*(คำสั่ง/สูตรเฉพาะ: ระบุสัดส่วนการผสม วัตถุดิบบรรจุภัณฑ์ และปริมาณที่ต้องการต่อ 1 ชิ้น)*`;
|
||||||
|
}, [productId, productName, variants, rawMaterials]);
|
||||||
|
|
||||||
|
// Context JSON
|
||||||
|
const contextJson = useMemo(() => {
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
product: {
|
||||||
|
id: Number(productId),
|
||||||
|
name: productName,
|
||||||
|
variantsCount: variants.length,
|
||||||
|
variants: variants.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
sku: v.sku,
|
||||||
|
option1: v.option1,
|
||||||
|
option2: v.option2,
|
||||||
|
option3: v.option3,
|
||||||
|
price: v.price,
|
||||||
|
currentBomCount: v.bomItems?.length || 0,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
rawMaterialsCount: rawMaterials.length,
|
||||||
|
rawMaterials: rawMaterials.map((rm) => ({
|
||||||
|
id: rm.id,
|
||||||
|
name: rm.name,
|
||||||
|
unit: rm.unit,
|
||||||
|
pricePerUnit: rm.pricePerUnit,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}, [productId, productName, variants, rawMaterials]);
|
||||||
|
|
||||||
|
// Target schema sample
|
||||||
|
const schemaSample = useMemo(() => {
|
||||||
|
const firstVariant = variants[0];
|
||||||
|
const sampleItem = rawMaterials[0];
|
||||||
|
return JSON.stringify(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
variantId: firstVariant ? firstVariant.id : 1,
|
||||||
|
sku: firstVariant?.sku || 'SAMPLE-SKU',
|
||||||
|
bomItems: [
|
||||||
|
{
|
||||||
|
rawMaterialId: sampleItem ? sampleItem.id : 1,
|
||||||
|
name: sampleItem?.name || 'ตัวอย่างวัตถุดิบ',
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}, [variants, rawMaterials]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleCopy = async (textToCopy: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(textToCopy);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-night-950/80 backdrop-blur-sm transition-opacity"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Container */}
|
||||||
|
<div className="relative w-full max-w-4xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden max-h-[92vh] animate-zoom-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5 bg-night-950/40">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-400">
|
||||||
|
<Bot className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-xl font-bold text-white">LLM Prompt Generator for BOM</h2>
|
||||||
|
<span className="text-xs px-2.5 py-0.5 rounded-full bg-purple-500/10 text-purple-300 border border-purple-500/20 font-mono font-bold">
|
||||||
|
Product #{productId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
สร้างชุดคำสั่ง Prompt พร้อมรหัสสินค้า, Variants และรายการวัตถุดิบทั้งหมดสำหรับส่งให้ LLM
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Stats Bar */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 px-6 py-3 bg-night-950/30 border-b border-white/5 text-xs">
|
||||||
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
|
<Package className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span>สินค้า: <strong className="text-white">{productName}</strong></span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
|
<Layers className="w-4 h-4 text-purple-400" />
|
||||||
|
<span>Variants: <strong className="text-white">{variants.length} ตัวเลือก</strong></span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
|
<Code2 className="w-4 h-4 text-emerald-400" />
|
||||||
|
<span>วัตถุดิบในคลัง: <strong className="text-white">{rawMaterials.length} รายการ</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Tabs */}
|
||||||
|
<div className="flex border-b border-white/5 px-6 pt-2 bg-night-950/20">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('prompt')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
|
||||||
|
activeTab === 'prompt'
|
||||||
|
? 'border-purple-500 text-purple-400'
|
||||||
|
: 'border-transparent text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
<span>ชุดคำสั่ง Prompt สำหรับ LLM</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('context')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
|
||||||
|
activeTab === 'context'
|
||||||
|
? 'border-purple-500 text-purple-400'
|
||||||
|
: 'border-transparent text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FileCode className="w-4 h-4" />
|
||||||
|
<span>ข้อมูลดิบ (Context JSON)</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('schema')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
|
||||||
|
activeTab === 'schema'
|
||||||
|
? 'border-purple-500 text-purple-400'
|
||||||
|
: 'border-transparent text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Code2 className="w-4 h-4" />
|
||||||
|
<span>Target Output Format</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Body */}
|
||||||
|
<div className="p-6 overflow-y-auto space-y-4 flex-1">
|
||||||
|
{activeTab === 'prompt' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
คัดลอกข้อความด้านล่างนี้ไปวางใน <strong>ChatGPT, Claude หรือ Gemini</strong>:
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopy(generatedPrompt)}
|
||||||
|
className="px-4 py-2 rounded-xl bg-purple-500/10 hover:bg-purple-500/20 text-purple-300 border border-purple-500/30 text-xs font-bold flex items-center gap-2 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
|
||||||
|
<span>{copied ? 'คัดลอก Prompt สำเร็จ!' : 'คัดลอก Prompt ทั้งหมด'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={generatedPrompt}
|
||||||
|
rows={16}
|
||||||
|
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-5 text-slate-200 focus:outline-none resize-y leading-relaxed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'context' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
โครงสร้างข้อมูล Product Master, Variants และ Raw Materials ในรูปแบบ JSON:
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopy(contextJson)}
|
||||||
|
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 border border-white/10 text-xs font-bold flex items-center gap-2 transition-all"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
|
||||||
|
<span>{copied ? 'คัดลอกแล้ว' : 'คัดลอก Context JSON'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={contextJson}
|
||||||
|
rows={16}
|
||||||
|
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-5 text-slate-300 focus:outline-none resize-y leading-relaxed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'schema' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 rounded-2xl bg-cyan-950/20 border border-cyan-500/20 text-xs text-slate-300 space-y-1">
|
||||||
|
<p className="font-bold text-cyan-300">💡 โครงสร้างผลลัพธ์ที่ออกแบบไว้สำหรับรองรับการ Copy วาง (Paste to Import):</p>
|
||||||
|
<p className="text-slate-400">
|
||||||
|
เมื่อ LLM ได้รับข้อมูลและส่งกลับมาในรูปแบบนี้ คุณจะสามารถนำข้อความ JSON มาวางเพื่อบันทึก BOM เข้าสู่ Variant ทั้งหมดได้ทันที
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={schemaSample}
|
||||||
|
rows={12}
|
||||||
|
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-5 text-emerald-400 focus:outline-none resize-y leading-relaxed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-6 py-4 border-t border-white/5 bg-night-950/40 flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
ระบบจัดเตรียมข้อมูลอัตโนมัติจากฐานข้อมูลจริงของสินค้า #{productId}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-5 py-2.5 rounded-xl text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||||
|
>
|
||||||
|
ปิดหน้าต่าง
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopy(generatedPrompt)}
|
||||||
|
className="btn-primary text-sm px-6 py-2.5 flex items-center gap-2 shadow-lg shadow-purple-500/20 bg-purple-600 hover:bg-purple-500 text-white"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||||
|
<span>{copied ? 'คัดลอกสำเร็จแล้ว' : 'คัดลอก Prompt ไปใช้งาน'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import {
|
import {
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Copy,
|
Copy,
|
||||||
@@ -613,7 +614,7 @@ export default function ProductJsonGuideModal({ isOpen, onClose, onOpenImport }:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-5 animate-fade-in">
|
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-5 animate-fade-in">
|
||||||
<div className="w-full max-w-5xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col max-h-[92vh] overflow-hidden animate-zoom-in">
|
<div className="w-full max-w-5xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col max-h-[92vh] overflow-hidden animate-zoom-in">
|
||||||
|
|
||||||
@@ -792,6 +793,7 @@ export default function ProductJsonGuideModal({ isOpen, onClose, onOpenImport }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
import { useState, useMemo, useRef, type ChangeEvent } from 'react';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
FileJson,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Loader2,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { API_BASE, type RawMaterial } from '@homaroma/types';
|
||||||
|
|
||||||
|
interface RawMaterialBulkJsonModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
materials: RawMaterial[];
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_TEMPLATE = [
|
||||||
|
{
|
||||||
|
name: "ขวดแก้วสีชา 100ml (Amber Glass Bottle)",
|
||||||
|
unit: "piece",
|
||||||
|
pricePerUnit: 18.50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ก้านไม้ไฟเบอร์สีดำ 6 ก้าน (Black Fiber Reeds)",
|
||||||
|
unit: "pack",
|
||||||
|
pricePerUnit: 7.00
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "น้ำมันหอมระเหย ลาเวนเดอร์บริสุทธิ์ (Pure Lavender Oil)",
|
||||||
|
unit: "kg",
|
||||||
|
pricePerUnit: 2400.00
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "เบสกระจายความหอม MMB (Diffuser Base)",
|
||||||
|
unit: "liter",
|
||||||
|
pricePerUnit: 350.00
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function RawMaterialBulkJsonModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
materials,
|
||||||
|
onSuccess,
|
||||||
|
}: RawMaterialBulkJsonModalProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<'import' | 'export'>('import');
|
||||||
|
const [copiedTemplate, setCopiedTemplate] = useState(false);
|
||||||
|
const [copiedExport, setCopiedExport] = useState(false);
|
||||||
|
const [importJsonText, setImportJsonText] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [resultMessage, setResultMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
// Parse & validate input text in real time
|
||||||
|
const parsedPreview = useMemo(() => {
|
||||||
|
if (!importJsonText.trim()) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(importJsonText);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return { valid: false, error: 'ข้อมูลต้องเป็น JSON Array [ { ... }, { ... } ]', count: 0, items: [] };
|
||||||
|
}
|
||||||
|
if (parsed.length === 0) {
|
||||||
|
return { valid: false, error: 'Array ว่างเปล่า ไม่มีรายการวัตถุดิบ', count: 0, items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidItem = parsed.find((item: any) => !item || typeof item !== 'object' || !item.name);
|
||||||
|
if (invalidItem) {
|
||||||
|
return { valid: false, error: 'พบรายการที่ไม่มีฟิลด์ "name" หรือข้อมูลไม่สมบูรณ์', count: 0, items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, error: null, count: parsed.length, items: parsed };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { valid: false, error: `รูปแบบ JSON ไม่ถูกต้อง: ${err.message}`, count: 0, items: [] };
|
||||||
|
}
|
||||||
|
}, [importJsonText]);
|
||||||
|
|
||||||
|
// Export JSON string
|
||||||
|
const exportJsonString = useMemo(() => {
|
||||||
|
const cleanList = materials.map((m) => ({
|
||||||
|
name: m.name,
|
||||||
|
unit: m.unit,
|
||||||
|
pricePerUnit: m.pricePerUnit,
|
||||||
|
}));
|
||||||
|
return JSON.stringify(cleanList, null, 2);
|
||||||
|
}, [materials]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleCopyTemplate = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(JSON.stringify(SAMPLE_TEMPLATE, null, 2));
|
||||||
|
setCopiedTemplate(true);
|
||||||
|
setTimeout(() => setCopiedTemplate(false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyExport = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(exportJsonString);
|
||||||
|
setCopiedExport(true);
|
||||||
|
setTimeout(() => setCopiedExport(false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadExport = () => {
|
||||||
|
const blob = new Blob([exportJsonString], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `raw-materials-${new Date().toISOString().split('T')[0]}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const content = event.target?.result as string;
|
||||||
|
setImportJsonText(content);
|
||||||
|
setResultMessage(null);
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportSubmit = async () => {
|
||||||
|
if (!parsedPreview || !parsedPreview.valid) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setResultMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/raw-materials/bulk`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(parsedPreview.items),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.message || 'นำเข้าข้อมูลไม่สำเร็จ');
|
||||||
|
}
|
||||||
|
|
||||||
|
setResultMessage({
|
||||||
|
type: 'success',
|
||||||
|
text: `นำเข้าและปรับปรุงข้อมูล (Upsert) สำเร็จ ${data.count || parsedPreview.count} รายการ`,
|
||||||
|
});
|
||||||
|
setImportJsonText('');
|
||||||
|
setTimeout(() => {
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
}, 1200);
|
||||||
|
} catch (err: any) {
|
||||||
|
setResultMessage({
|
||||||
|
type: 'error',
|
||||||
|
text: err.message || 'เกิดข้อผิดพลาดในการเชื่อมต่อเซิร์ฟเวอร์',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-night-950/80 backdrop-blur-sm transition-opacity"
|
||||||
|
onClick={() => !submitting && onClose()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Container */}
|
||||||
|
<div className="relative w-full max-w-3xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden max-h-[90vh] animate-zoom-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5 bg-night-950/40">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400">
|
||||||
|
<FileJson className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
Raw Materials JSON Tool
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-cyan-500/10 text-cyan-300 border border-cyan-500/20">
|
||||||
|
Bulk Upsert
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
นำเข้าและส่งออกข้อมูลวัตถุดิบ รองรับการอัปเดตข้อมูลเดิมอัตโนมัติเมื่อชื่อซ้ำ
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={submitting}
|
||||||
|
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Tabs */}
|
||||||
|
<div className="flex border-b border-white/5 px-6 pt-2 bg-night-950/20">
|
||||||
|
<button
|
||||||
|
onClick={() => { setActiveTab('import'); setResultMessage(null); }}
|
||||||
|
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
|
||||||
|
activeTab === 'import'
|
||||||
|
? 'border-cyan-500 text-cyan-400'
|
||||||
|
: 'border-transparent text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
<span>นำเข้า JSON (Bulk Import)</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setActiveTab('export'); setResultMessage(null); }}
|
||||||
|
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
|
||||||
|
activeTab === 'export'
|
||||||
|
? 'border-cyan-500 text-cyan-400'
|
||||||
|
: 'border-transparent text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
<span>ส่งออก JSON (Export {materials.length} รายการ)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Body */}
|
||||||
|
<div className="p-6 overflow-y-auto space-y-6 flex-1">
|
||||||
|
{activeTab === 'import' ? (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Guidance Notice */}
|
||||||
|
<div className="p-4 rounded-2xl bg-cyan-950/20 border border-cyan-500/20 text-xs text-cyan-200/90 flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 font-bold text-cyan-300">
|
||||||
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
|
กลไกอัจฉริยะ (Upsert Automation)
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-300 leading-relaxed">
|
||||||
|
วาง JSON Array รายการวัตถุดิบที่ต้องการ หากพบ <strong>name</strong> ที่มีอยู่แล้วในระบบ
|
||||||
|
ระบบจะ <strong>Update</strong> หน่วยนับและราคาต้นทุนให้ทันที หากยังไม่มีจะสร้างรายการใหม่
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyTemplate}
|
||||||
|
className="shrink-0 px-3 py-1.5 rounded-lg bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 font-medium flex items-center gap-1.5 transition-all text-xs"
|
||||||
|
>
|
||||||
|
{copiedTemplate ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||||
|
<span>{copiedTemplate ? 'คัดลอกแล้ว' : 'คัดลอก Template'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* JSON Input Area */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<label className="font-bold text-slate-300">วาง JSON Payload:</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
accept=".json"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="text-cyan-400 hover:text-cyan-300 flex items-center gap-1 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
<span>อัปโหลดไฟล์ .json</span>
|
||||||
|
</button>
|
||||||
|
{importJsonText && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setImportJsonText(''); setResultMessage(null); }}
|
||||||
|
className="text-rose-400 hover:text-rose-300 ml-2 font-medium"
|
||||||
|
>
|
||||||
|
ล้างข้อมูล
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={importJsonText}
|
||||||
|
onChange={(e) => {
|
||||||
|
setImportJsonText(e.target.value);
|
||||||
|
setResultMessage(null);
|
||||||
|
}}
|
||||||
|
rows={9}
|
||||||
|
placeholder={`[\n {\n "name": "ขวดแก้ว 100ml",\n "unit": "piece",\n "pricePerUnit": 18.5\n }\n]`}
|
||||||
|
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-4 text-slate-200 placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 transition-all resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live Preview / Validation Status */}
|
||||||
|
{parsedPreview && (
|
||||||
|
<div
|
||||||
|
className={`p-3.5 rounded-xl border text-xs flex items-center gap-2.5 ${
|
||||||
|
parsedPreview.valid
|
||||||
|
? 'bg-emerald-950/30 border-emerald-500/30 text-emerald-300'
|
||||||
|
: 'bg-rose-950/30 border-rose-500/30 text-rose-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{parsedPreview.valid ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
JSON ถูกต้อง — ตรวจพบวัตถุดิบทั้งหมด <strong>{parsedPreview.count}</strong> รายการ พร้อมนำเข้าสู่ระบบ
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||||
|
<span>{parsedPreview.error}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Server Result Message */}
|
||||||
|
{resultMessage && (
|
||||||
|
<div
|
||||||
|
className={`p-3.5 rounded-xl border text-xs flex items-center gap-2.5 ${
|
||||||
|
resultMessage.type === 'success'
|
||||||
|
? 'bg-emerald-950/40 border-emerald-500/40 text-emerald-300'
|
||||||
|
: 'bg-rose-950/40 border-rose-500/40 text-rose-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{resultMessage.type === 'success' ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span>{resultMessage.text}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>วัตถุดิบทั้งหมดในระบบ ({materials.length} รายการ):</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyExport}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 font-medium flex items-center gap-1.5 transition-all"
|
||||||
|
>
|
||||||
|
{copiedExport ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||||
|
<span>{copiedExport ? 'คัดลอกแล้ว' : 'คัดลอก JSON'}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDownloadExport}
|
||||||
|
className="btn-primary text-xs px-3 py-1.5 flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
<span>ดาวน์โหลด .json</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={exportJsonString}
|
||||||
|
rows={12}
|
||||||
|
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-4 text-slate-300 focus:outline-none resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-6 py-4 border-t border-white/5 bg-night-950/40 flex items-center justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-5 py-2.5 rounded-xl text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||||
|
>
|
||||||
|
ยกเลิก
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{activeTab === 'import' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleImportSubmit}
|
||||||
|
disabled={submitting || !parsedPreview?.valid}
|
||||||
|
className="btn-primary text-sm px-6 py-2.5 flex items-center gap-2 disabled:opacity-40 disabled:cursor-not-allowed shadow-lg shadow-cyan-500/10"
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span>กำลังบันทึกข้อมูล...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
<span>ยืนยันการนำเข้า ({parsedPreview?.count || 0} รายการ)</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
@@ -143,7 +144,61 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
// 2.9 APPLE_SHOWCASE
|
// 2.96 VDO_FRAME_SCRUBBER
|
||||||
|
{
|
||||||
|
type: 'VDO_FRAME_SCRUBBER',
|
||||||
|
category: 'hero',
|
||||||
|
title: 'VDO FRAME SCRUBBER (Scrollytelling Video)',
|
||||||
|
description: 'Cinema-quality video frame sequence (432 frames) with scroll scrub, direct drag, range slider, and play/pause at 24fps without memory leaks.',
|
||||||
|
badge: 'Video Frames · 4K',
|
||||||
|
renderPreview: () => (
|
||||||
|
<div className="relative w-full aspect-[21/9] rounded-xl overflow-hidden bg-[#0e0e0d] flex items-center justify-between p-3 border border-[#d5ff63]/30">
|
||||||
|
<div className="space-y-1 max-w-[65%] z-10">
|
||||||
|
<span className="text-[6px] uppercase font-mono font-bold text-[#d5ff63] tracking-widest flex items-center gap-1">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-[#d5ff63] animate-ping" /> FRAME SCRUBBER
|
||||||
|
</span>
|
||||||
|
<h5 className="text-[10px] font-black text-white leading-tight">
|
||||||
|
FROM VIDEO TO INTERACTIVE
|
||||||
|
</h5>
|
||||||
|
<div className="flex items-center gap-2 text-[6px] text-slate-400 font-mono">
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-white/10 text-white">432 FRAMES</span>
|
||||||
|
<span>00:18.0s @ 24FPS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-black/80 border border-[#d5ff63]/40 flex items-center justify-center text-[#d5ff63] shadow-xl shrink-0">
|
||||||
|
<Play className="w-5 h-5 fill-current ml-0.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
// 2.95 INTERACTIVE_360_SHOWCASE
|
||||||
|
{
|
||||||
|
type: 'INTERACTIVE_360_SHOWCASE',
|
||||||
|
category: 'hero',
|
||||||
|
title: 'INTERACTIVE 360° TURNTABLE SHOWCASE',
|
||||||
|
description: 'Ultra-fast 2.5D Sprite Sheet 360° turntable, scroll-driven rotation, interactive drag-to-spin, and seamless macro detail zoom transition.',
|
||||||
|
badge: '3D Web · Hot',
|
||||||
|
renderPreview: () => (
|
||||||
|
<div className="relative w-full aspect-[21/9] rounded-xl overflow-hidden bg-[#0f0f0e] flex items-center justify-between p-3 border border-[#d5ff63]/30">
|
||||||
|
<div className="space-y-1 max-w-[60%] z-10">
|
||||||
|
<span className="text-[6px] uppercase font-mono font-bold text-[#d5ff63] tracking-widest">
|
||||||
|
360° TURNTABLE + MACRO ZOOM
|
||||||
|
</span>
|
||||||
|
<h5 className="text-[10px] font-black text-white leading-tight">
|
||||||
|
PRECISION TO EVERY ANGLE
|
||||||
|
</h5>
|
||||||
|
<div className="flex items-center gap-1 text-[6px] text-slate-400 font-mono">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-[#d5ff63]" />
|
||||||
|
<span>16-Angle Turntable Scrub</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-14 h-14 rounded-full bg-black/60 border border-[#d5ff63]/40 flex items-center justify-center p-1 shadow-2xl shrink-0">
|
||||||
|
<span className="text-[9px] font-bold font-mono text-[#d5ff63]">360°</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
// 2.9 APPLE_SHOWCASE
|
||||||
{
|
{
|
||||||
type: 'APPLE_SHOWCASE',
|
type: 'APPLE_SHOWCASE',
|
||||||
category: 'hero',
|
category: 'hero',
|
||||||
@@ -798,7 +853,7 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
|
|||||||
return matchesCategory && matchesSearch;
|
return matchesCategory && matchesSearch;
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 sm:p-6 animate-fade-in">
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 sm:p-6 animate-fade-in">
|
||||||
<div className="bg-night-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-6xl flex flex-col h-[90vh] max-h-[850px] overflow-hidden text-slate-200">
|
<div className="bg-night-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-6xl flex flex-col h-[90vh] max-h-[850px] overflow-hidden text-slate-200">
|
||||||
{/* Modal Header */}
|
{/* Modal Header */}
|
||||||
@@ -924,6 +979,7 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Upload,
|
||||||
|
Film,
|
||||||
|
CheckCircle,
|
||||||
|
Loader2,
|
||||||
|
Trash2,
|
||||||
|
FolderOpen,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { API_BASE } from '@homaroma/types'
|
||||||
|
|
||||||
|
export interface FrameSequenceItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
folderPath: string
|
||||||
|
pattern: string
|
||||||
|
padding: number
|
||||||
|
totalFrames: number
|
||||||
|
fps: number
|
||||||
|
durationSeconds: number
|
||||||
|
posterUrl: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSelectSequence: (seq: FrameSequenceItem) => void
|
||||||
|
selectedSlug?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FrameSequenceModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSelectSequence,
|
||||||
|
selectedSlug,
|
||||||
|
}: Props) {
|
||||||
|
const [sequences, setSequences] = useState<FrameSequenceItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Upload Form State
|
||||||
|
const [showUploadForm, setShowUploadForm] = useState(false)
|
||||||
|
const [uploadName, setUploadName] = useState('')
|
||||||
|
const [uploadFps, setUploadFps] = useState('24')
|
||||||
|
const [zipFile, setZipFile] = useState<File | null>(null)
|
||||||
|
|
||||||
|
const fetchSequences = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/frame-sequences`)
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
setSequences(data.sequences || [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load frame sequences', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
fetchSequences()
|
||||||
|
setShowUploadForm(false)
|
||||||
|
setUploadError(null)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const handleUploadZip = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!zipFile) {
|
||||||
|
setUploadError('กรุณาเลือกไฟล์ .ZIP ที่มีรูปเฟรม')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploading(true)
|
||||||
|
setUploadError(null)
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', zipFile)
|
||||||
|
if (uploadName) formData.append('name', uploadName)
|
||||||
|
formData.append('fps', uploadFps)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/frame-sequences/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok || data.error) {
|
||||||
|
throw new Error(data.error || 'Upload failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchSequences()
|
||||||
|
setShowUploadForm(false)
|
||||||
|
setZipFile(null)
|
||||||
|
setUploadName('')
|
||||||
|
|
||||||
|
if (data.sequence) {
|
||||||
|
onSelectSequence(data.sequence)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setUploadError(err.message || 'เกิดข้อผิดพลาดในการอัปโหลด ZIP')
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (slug: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบชุดเฟรม "${slug}"?`)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/frame-sequences/${slug}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setSequences((prev) => prev.filter((s) => s.slug !== slug))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete sequence', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 animate-fade-in">
|
||||||
|
<div className="bg-[#121211] border border-white/10 rounded-3xl shadow-2xl w-full max-w-4xl flex flex-col max-h-[85vh] overflow-hidden text-slate-200">
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="p-6 border-b border-white/5 flex items-center justify-between shrink-0 bg-black/40">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-[#d5ff63]/10 border border-[#d5ff63]/30 flex items-center justify-center text-[#d5ff63]">
|
||||||
|
<Film className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-white flex items-center gap-2">
|
||||||
|
<span>คลังชุดวิดีโอเฟรม (Frame Sequence Library)</span>
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[#d5ff63]/20 text-[#d5ff63] font-mono">
|
||||||
|
แยกจาก Media ปกติ
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
เลือกชุดเฟรมภาพแบบ 360° / Scrollytelling โดยไม่ปะปนกับคลังภาพสินค้าทั่วไป
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowUploadForm(!showUploadForm)}
|
||||||
|
className="px-4 py-2 rounded-xl bg-white/10 hover:bg-white/20 text-xs font-bold text-white flex items-center gap-2 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5 text-[#d5ff63]" />
|
||||||
|
<span>{showUploadForm ? 'ดูรายการที่มีอยู่' : 'อัปโหลด ZIP ชุดใหม่'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Body */}
|
||||||
|
<div className="p-6 flex-1 overflow-y-auto space-y-6">
|
||||||
|
{/* Upload Form Accordion */}
|
||||||
|
{showUploadForm && (
|
||||||
|
<form
|
||||||
|
onSubmit={handleUploadZip}
|
||||||
|
className="p-5 rounded-2xl bg-black/60 border border-[#d5ff63]/30 space-y-4 animate-fade-in"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-xs font-bold text-[#d5ff63] uppercase tracking-wider">
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
<span>อัปโหลดชุดเฟรมภาพด้วยไฟล์ .ZIP</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
บีบอัดโฟลเดอร์ภาพ เช่น <code className="text-slate-200">frame-001.webp</code> ถึง{' '}
|
||||||
|
<code className="text-slate-200">frame-432.webp</code> เป็นไฟล์ .zip แล้วอัปโหลด ระบบจะคลายไฟล์ให้อัตโนมัติ
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{uploadError && (
|
||||||
|
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/30 text-xs text-red-400">
|
||||||
|
{uploadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-bold uppercase text-slate-400 block mb-1">
|
||||||
|
ชื่อชุดวิดีโอ (Name)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={uploadName}
|
||||||
|
onChange={(e) => setUploadName(e.target.value)}
|
||||||
|
placeholder="เช่น Chrome Sprocket 18s Spin"
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-bold uppercase text-slate-400 block mb-1">
|
||||||
|
ความเร็วเฟรมต่อวินาที (FPS)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={uploadFps}
|
||||||
|
onChange={(e) => setUploadFps(e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
>
|
||||||
|
<option value="24">24 FPS (Cinematic Video - ค่ามาตรฐาน)</option>
|
||||||
|
<option value="30">30 FPS (Standard Video)</option>
|
||||||
|
<option value="60">60 FPS (Ultra Smooth 3D)</option>
|
||||||
|
<option value="12">12 FPS (Stop Motion)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-bold uppercase text-slate-400 block mb-1">
|
||||||
|
ไฟล์ .ZIP ที่มีรูปเฟรม
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".zip"
|
||||||
|
onChange={(e) => setZipFile(e.target.files?.[0] || null)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowUploadForm(false)}
|
||||||
|
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-xs text-slate-300"
|
||||||
|
>
|
||||||
|
ยกเลิก
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isUploading || !zipFile}
|
||||||
|
className="px-6 py-2 rounded-xl bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e] disabled:opacity-50 flex items-center gap-2 cursor-pointer shadow-lg"
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
<span>กำลังแตกไฟล์และจัดเก็บ...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
<span>เริ่มอัปโหลดและประมวลผล ZIP</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sequence Cards Grid */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-20 flex flex-col items-center justify-center gap-3 text-slate-500">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-[#d5ff63]" />
|
||||||
|
<span className="text-xs">กำลังโหลดชุดวิดีโอเฟรม...</span>
|
||||||
|
</div>
|
||||||
|
) : sequences.length === 0 ? (
|
||||||
|
<div className="py-20 text-center space-y-3">
|
||||||
|
<FolderOpen className="w-12 h-12 text-slate-600 mx-auto" />
|
||||||
|
<div className="text-sm font-bold text-slate-300">ยังไม่มีชุด Frame Sequence ในระบบ</div>
|
||||||
|
<p className="text-xs text-slate-500 max-w-sm mx-auto">
|
||||||
|
คุณสามารถกดปุ่ม "อัปโหลด ZIP ชุดใหม่" ด้านบนเพื่อนำชุดเฟรมภาพเข้ามาใช้งานได้ทันที
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{sequences.map((seq) => {
|
||||||
|
const isSelected = selectedSlug === seq.slug
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={seq.id}
|
||||||
|
onClick={() => {
|
||||||
|
onSelectSequence(seq)
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
className={`relative group rounded-2xl bg-black/40 border transition-all p-3 space-y-3 cursor-pointer ${
|
||||||
|
isSelected
|
||||||
|
? 'border-[#d5ff63] ring-1 ring-[#d5ff63] shadow-lg shadow-[#d5ff63]/10'
|
||||||
|
: 'border-white/10 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Poster Thumbnail */}
|
||||||
|
<div className="relative aspect-video rounded-xl bg-slate-900 overflow-hidden border border-white/5">
|
||||||
|
<img
|
||||||
|
src={seq.posterUrl}
|
||||||
|
alt={seq.name}
|
||||||
|
className="w-full h-full object-contain p-2"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-2 left-2 px-2 py-0.5 rounded bg-black/80 backdrop-blur-md text-[9px] font-mono text-white">
|
||||||
|
{seq.totalFrames} FRAMES • {seq.durationSeconds}s
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute top-2 right-2 w-6 h-6 rounded-full bg-[#d5ff63] text-black flex items-center justify-center shadow-lg">
|
||||||
|
<CheckCircle className="w-4 h-4 fill-black text-[#d5ff63]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h4 className="text-sm font-bold text-white group-hover:text-[#d5ff63] transition-colors truncate">
|
||||||
|
{seq.name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-[10px] font-mono text-slate-500 truncate">
|
||||||
|
{seq.folderPath}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Bar */}
|
||||||
|
<div className="flex items-center justify-between pt-1 border-t border-white/5 text-[10px]">
|
||||||
|
<span className="font-mono text-slate-400">
|
||||||
|
{seq.fps} FPS
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => handleDelete(seq.slug, e)}
|
||||||
|
title="ลบชุดนี้"
|
||||||
|
className="p-1 rounded hover:bg-red-500/20 text-slate-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-3 py-1 rounded-lg bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e]"
|
||||||
|
>
|
||||||
|
เลือกชุดนี้
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Sparkles,
|
||||||
|
Upload,
|
||||||
|
Package,
|
||||||
|
ExternalLink,
|
||||||
|
RotateCcw,
|
||||||
|
Layers,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { API_BASE } from '@homaroma/types'
|
||||||
|
|
||||||
|
interface SpecItem {
|
||||||
|
number?: string
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
updatePayloadValue: (field: string, value: unknown) => void
|
||||||
|
onBrowseMedia?: (onSelect: (url: string) => void) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SPECS: SpecItem[] = [
|
||||||
|
{
|
||||||
|
number: '01',
|
||||||
|
title: 'โครเมียมขัดเงาระดับพรีเมียม',
|
||||||
|
subtitle: 'POLISHED CHROME FINISH',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: '02',
|
||||||
|
title: 'เจาะรูกลึงลดน้ำหนักตามหลักอากาศพลศาสตร์',
|
||||||
|
subtitle: 'LIGHTWEIGHT PRECISION CUTOUTS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: '03',
|
||||||
|
title: 'โปรไฟล์ฟันเฟือง 428H แม่นยำระดับไมครอน',
|
||||||
|
subtitle: '428H PRECISION TOOTH PROFILE',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Interactive360ShowcaseForm({
|
||||||
|
payload,
|
||||||
|
updatePayloadValue,
|
||||||
|
onBrowseMedia,
|
||||||
|
}: Props) {
|
||||||
|
const [productsList, setProductsList] = useState<any[]>([])
|
||||||
|
const [loadingProducts, setLoadingProducts] = useState(false)
|
||||||
|
|
||||||
|
const spriteSheetUrl = (payload.spriteSheetUrl as string) || '/images/3d/sprocket-views.png'
|
||||||
|
const detailImageUrl = (payload.detailImageUrl as string) || '/images/3d/sprocket-detail.png'
|
||||||
|
const eyebrow = (payload.eyebrow as string) || ''
|
||||||
|
const title = (payload.title as string) || ''
|
||||||
|
const subtitle = (payload.subtitle as string) || ''
|
||||||
|
const detailKicker = (payload.detailKicker as string) || ''
|
||||||
|
const detailTitle = (payload.detailTitle as string) || ''
|
||||||
|
const detailCaption = (payload.detailCaption as string) || ''
|
||||||
|
const productName = (payload.productName as string) || ''
|
||||||
|
const productBadge = (payload.productBadge as string) || ''
|
||||||
|
const accentColor = (payload.accentColor as string) || '#d5ff63'
|
||||||
|
const theme = (payload.theme as string) || 'dark'
|
||||||
|
const endCtaTitle = (payload.endCtaTitle as string) || ''
|
||||||
|
const endCtaButtonText = (payload.endCtaButtonText as string) || ''
|
||||||
|
const endCtaLink = (payload.endCtaLink as string) || ''
|
||||||
|
const selectedProductId = payload.productId ? String(payload.productId) : ''
|
||||||
|
|
||||||
|
const rawSpecs = Array.isArray(payload.specs) ? (payload.specs as SpecItem[]) : DEFAULT_SPECS
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true
|
||||||
|
const fetchCatalog = async () => {
|
||||||
|
setLoadingProducts(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/products?limit=100`)
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
const items = data.data || data.products || (Array.isArray(data) ? data : [])
|
||||||
|
if (isMounted) setProductsList(items)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load products in Interactive360ShowcaseForm', err)
|
||||||
|
} finally {
|
||||||
|
if (isMounted) setLoadingProducts(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchCatalog()
|
||||||
|
return () => { isMounted = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Auto-seed defaults if brand new block
|
||||||
|
useEffect(() => {
|
||||||
|
if (!payload.spriteSheetUrl) updatePayloadValue('spriteSheetUrl', '/images/3d/sprocket-views.png')
|
||||||
|
if (!payload.detailImageUrl) updatePayloadValue('detailImageUrl', '/images/3d/sprocket-detail.png')
|
||||||
|
if (!payload.eyebrow) updatePayloadValue('eyebrow', 'ENGINEERED TO MOVE')
|
||||||
|
if (!payload.title) updatePayloadValue('title', 'เหล็กหนึ่งชิ้น ที่เล่าได้ทุกองศา')
|
||||||
|
if (!payload.subtitle) updatePayloadValue('subtitle', 'สเตอร์จักรยานยนต์โครเมียม ดีไซน์น้ำหนักเบา ให้ทุกการหมุนมีจังหวะที่แม่นยำ พร้อมสัมผัสรายละเอียดระดับ Ultra-Macro')
|
||||||
|
if (!payload.detailKicker) updatePayloadValue('detailKicker', 'FINISHED TO THE EDGE')
|
||||||
|
if (!payload.detailTitle) updatePayloadValue('detailTitle', 'ฟันแน่น แข็งแรง ผิวเรียบทุกขอบ')
|
||||||
|
if (!payload.detailCaption) updatePayloadValue('detailCaption', 'ผิวชุบโครเมียมขัดเงา สะท้อนแสงเงางาม ทนทานต่อการเสียดสี และป้องกันสนิมในทุกสภาพการใช้งาน')
|
||||||
|
if (!payload.productName) updatePayloadValue('productName', 'Chrome Sprocket 428H')
|
||||||
|
if (!payload.productBadge) updatePayloadValue('productBadge', 'MONO / 01')
|
||||||
|
if (!payload.endCtaTitle) updatePayloadValue('endCtaTitle', 'พร้อมยกระดับสมรรถนะการขับขี่?')
|
||||||
|
if (!payload.endCtaButtonText) updatePayloadValue('endCtaButtonText', 'สั่งซื้อสินค้าตอนนี้')
|
||||||
|
if (!payload.specs) updatePayloadValue('specs', DEFAULT_SPECS)
|
||||||
|
if (!payload.theme) updatePayloadValue('theme', 'dark')
|
||||||
|
if (!payload.accentColor) updatePayloadValue('accentColor', '#d5ff63')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSelectProduct = (productIdStr: string) => {
|
||||||
|
if (!productIdStr) {
|
||||||
|
updatePayloadValue('productId', null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const pid = Number(productIdStr)
|
||||||
|
updatePayloadValue('productId', pid)
|
||||||
|
|
||||||
|
const prod = productsList.find((p) => p.id === pid)
|
||||||
|
if (prod) {
|
||||||
|
const prodName = typeof prod.name === 'string' ? prod.name : prod.name?.th || prod.name?.en || ''
|
||||||
|
if (prodName) updatePayloadValue('productName', prodName)
|
||||||
|
const slug = prod.slug || `product-${pid}`
|
||||||
|
updatePayloadValue('endCtaLink', `/products/${slug}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyPreset = () => {
|
||||||
|
updatePayloadValue('spriteSheetUrl', '/images/3d/sprocket-views.png')
|
||||||
|
updatePayloadValue('detailImageUrl', '/images/3d/sprocket-detail.png')
|
||||||
|
updatePayloadValue('eyebrow', 'ENGINEERED TO MOVE')
|
||||||
|
updatePayloadValue('title', 'เหล็กหนึ่งชิ้น ที่เล่าได้ทุกองศา')
|
||||||
|
updatePayloadValue('subtitle', 'สเตอร์จักรยานยนต์โครเมียม ดีไซน์น้ำหนักเบา ให้ทุกการหมุนมีจังหวะที่แม่นยำ พร้อมสัมผัสรายละเอียดระดับ Ultra-Macro')
|
||||||
|
updatePayloadValue('detailKicker', 'FINISHED TO THE EDGE')
|
||||||
|
updatePayloadValue('detailTitle', 'ฟันแน่น แข็งแรง ผิวเรียบทุกขอบ')
|
||||||
|
updatePayloadValue('detailCaption', 'ผิวชุบโครเมียมขัดเงา สะท้อนแสงเงางาม ทนทานต่อการเสียดสี และป้องกันสนิมในทุกสภาพการใช้งาน')
|
||||||
|
updatePayloadValue('productName', 'Chrome Sprocket 428H')
|
||||||
|
updatePayloadValue('productBadge', 'MONO / 01')
|
||||||
|
updatePayloadValue('endCtaTitle', 'พร้อมยกระดับสมรรถนะการขับขี่?')
|
||||||
|
updatePayloadValue('endCtaButtonText', 'สั่งซื้อสินค้าตอนนี้')
|
||||||
|
updatePayloadValue('specs', DEFAULT_SPECS)
|
||||||
|
updatePayloadValue('theme', 'dark')
|
||||||
|
updatePayloadValue('accentColor', '#d5ff63')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateSpec = (idx: number, field: keyof SpecItem, val: string) => {
|
||||||
|
const updated = [...rawSpecs]
|
||||||
|
updated[idx] = { ...updated[idx], [field]: val }
|
||||||
|
updatePayloadValue('specs', updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 text-slate-200">
|
||||||
|
{/* ── Preset & Binding Banner ── */}
|
||||||
|
<div className="p-4 rounded-2xl bg-gradient-to-r from-lime-500/10 via-slate-900/40 to-transparent border border-lime-500/30 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-lime-400 font-bold text-xs uppercase tracking-wider">
|
||||||
|
<Sparkles className="w-4 h-4 text-lime-400" />
|
||||||
|
<span>3D Interactive Turntable & Macro Zoom Showcase</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
แสดงผลสเตอร์/ชิ้นส่วน 360 องศาด้วย Sprite Sheet + Scroll Scrollytelling + ซูม Macro Detail อัตโนมัติ
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApplyPreset}
|
||||||
|
className="px-4 py-2 rounded-xl bg-lime-400 text-slate-950 font-bold text-xs hover:bg-lime-300 transition-all flex items-center gap-1.5 shrink-0 shadow-lg cursor-pointer"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
<span>รีเซ็ต / โหลด Mockup สเตอร์โครเมียม</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 1-Click Product Linking ── */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-bold text-slate-200 uppercase tracking-wider">
|
||||||
|
<Package className="w-4 h-4 text-lime-400" />
|
||||||
|
<span>ผูกกับสินค้าในระบบ (1-Click Bind Product)</span>
|
||||||
|
</div>
|
||||||
|
{selectedProductId && (
|
||||||
|
<span className="text-[10px] text-lime-400 font-mono">
|
||||||
|
Product ID: #{selectedProductId}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={selectedProductId}
|
||||||
|
onChange={(e) => handleSelectProduct(e.target.value)}
|
||||||
|
disabled={loadingProducts}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
>
|
||||||
|
<option value="">-- ไม่ระบุ (ใช้เป็นหน้า Brand/Product Story อิสระ) --</option>
|
||||||
|
{productsList.map((p) => {
|
||||||
|
const nameStr = typeof p.name === 'string' ? p.name : p.name?.th || p.name?.en || 'Unnamed'
|
||||||
|
return (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
#{p.id} - {nameStr}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-1">
|
||||||
|
เมื่อเลือกสินค้า ระบบจะดึงชื่อและตั้งค่าลิงก์ปุ่มสั่งซื้ออัตโนมัติ
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={endCtaLink}
|
||||||
|
onChange={(e) => updatePayloadValue('endCtaLink', e.target.value)}
|
||||||
|
placeholder="ลิงก์ปลายทางเมื่อกดสั่งซื้อ เช่น /products/chain-sprocket-p1"
|
||||||
|
className="admin-input flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
{endCtaLink && (
|
||||||
|
<a
|
||||||
|
href={endCtaLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="p-2 rounded-lg bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Visual Media Uploads (Sprite Sheet + Macro Detail) ── */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Sprite Sheet 16-Angles */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-3">
|
||||||
|
<label className="text-xs font-bold text-slate-200 uppercase tracking-wider block">
|
||||||
|
1. Sprite Sheet 360° (ภาพรวม 16 มุมมอง 4x4 Grid)
|
||||||
|
</label>
|
||||||
|
{spriteSheetUrl ? (
|
||||||
|
<div className="relative aspect-video rounded-xl bg-black border border-white/10 overflow-hidden group">
|
||||||
|
<img
|
||||||
|
src={spriteSheetUrl}
|
||||||
|
alt="360 Sprite Sheet"
|
||||||
|
className="w-full h-full object-contain p-2"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 flex items-center justify-center gap-2 transition-opacity">
|
||||||
|
{onBrowseMedia && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onBrowseMedia((url) => updatePayloadValue('spriteSheetUrl', url))}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-lime-400 text-black text-xs font-bold cursor-pointer"
|
||||||
|
>
|
||||||
|
เปลี่ยนรูป
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onClick={() => onBrowseMedia && onBrowseMedia((url) => updatePayloadValue('spriteSheetUrl', url))}
|
||||||
|
className="aspect-video rounded-xl border border-dashed border-white/20 bg-black/40 flex flex-col items-center justify-center gap-2 cursor-pointer hover:border-lime-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Upload className="w-6 h-6 text-slate-500" />
|
||||||
|
<span className="text-xs text-slate-400">คลิกเพื่อเลือกภาพ Sprite Sheet</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={spriteSheetUrl}
|
||||||
|
onChange={(e) => updatePayloadValue('spriteSheetUrl', e.target.value)}
|
||||||
|
placeholder="/images/3d/sprocket-views.png"
|
||||||
|
className="admin-input w-full text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Macro Detail Shot */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-3">
|
||||||
|
<label className="text-xs font-bold text-slate-200 uppercase tracking-wider block">
|
||||||
|
2. Macro Detail Shot (ภาพซูมระยะประชิดระดับ 4K)
|
||||||
|
</label>
|
||||||
|
{detailImageUrl ? (
|
||||||
|
<div className="relative aspect-video rounded-xl bg-black border border-white/10 overflow-hidden group">
|
||||||
|
<img
|
||||||
|
src={detailImageUrl}
|
||||||
|
alt="Macro Detail"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 flex items-center justify-center gap-2 transition-opacity">
|
||||||
|
{onBrowseMedia && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onBrowseMedia((url) => updatePayloadValue('detailImageUrl', url))}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-lime-400 text-black text-xs font-bold cursor-pointer"
|
||||||
|
>
|
||||||
|
เปลี่ยนรูป
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onClick={() => onBrowseMedia && onBrowseMedia((url) => updatePayloadValue('detailImageUrl', url))}
|
||||||
|
className="aspect-video rounded-xl border border-dashed border-white/20 bg-black/40 flex flex-col items-center justify-center gap-2 cursor-pointer hover:border-lime-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Upload className="w-6 h-6 text-slate-500" />
|
||||||
|
<span className="text-xs text-slate-400">คลิกเพื่อเลือกภาพ Macro Detail</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={detailImageUrl}
|
||||||
|
onChange={(e) => updatePayloadValue('detailImageUrl', e.target.value)}
|
||||||
|
placeholder="/images/3d/sprocket-detail.png"
|
||||||
|
className="admin-input w-full text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Headline & Intro Texts ── */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-4">
|
||||||
|
<div className="text-xs font-bold text-slate-200 uppercase tracking-wider">
|
||||||
|
ข้อความหัวเรื่อง (Header & Intro)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
หมวด/ป้ายกำกับ (Eyebrow Tag)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={eyebrow}
|
||||||
|
onChange={(e) => updatePayloadValue('eyebrow', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs font-mono"
|
||||||
|
placeholder="ENGINEERED TO MOVE"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
ชื่อสินค้า (Product Name)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={productName}
|
||||||
|
onChange={(e) => updatePayloadValue('productName', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="Chrome Sprocket 428H"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
ซีรีส์สินค้า (Badge / Series)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={productBadge}
|
||||||
|
onChange={(e) => updatePayloadValue('productBadge', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs font-mono"
|
||||||
|
placeholder="MONO / 01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
หัวเรื่องหลัก (Main Title)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => updatePayloadValue('title', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="เหล็กหนึ่งชิ้น ที่เล่าได้ทุกองศา"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
คำบรรยายเกริ่นนำ (Intro Subtitle)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={subtitle}
|
||||||
|
onChange={(e) => updatePayloadValue('subtitle', e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="สเตอร์จักรยานยนต์โครเมียม ดีไซน์น้ำหนักเบา ให้ทุกการหมุนมีจังหวะที่แม่นยำ"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Macro Zoom Detail Overlay Texts ── */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-4">
|
||||||
|
<div className="text-xs font-bold text-lime-400 uppercase tracking-wider flex items-center gap-2">
|
||||||
|
<Layers className="w-4 h-4" />
|
||||||
|
<span>ข้อความส่วนซูม Macro Detail (แสดงเมื่อเลื่อนจอลงถึงช่วงท้าย)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
Kicker Tag
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={detailKicker}
|
||||||
|
onChange={(e) => updatePayloadValue('detailKicker', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs font-mono"
|
||||||
|
placeholder="FINISHED TO THE EDGE"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
Headline ตอนซูม
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={detailTitle}
|
||||||
|
onChange={(e) => updatePayloadValue('detailTitle', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs font-bold"
|
||||||
|
placeholder="ฟันแน่น แข็งแรง ผิวเรียบทุกขอบ"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
คำอธิบายรายละเอียด
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={detailCaption}
|
||||||
|
onChange={(e) => updatePayloadValue('detailCaption', e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="ผิวชุบโครเมียมขัดเงา สะท้อนแสงเงางาม ทนทานต่อการเสียดสี และป้องกันสนิมในทุกสภาพการใช้งาน"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 3-Spec Highlights Grid ── */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-4">
|
||||||
|
<div className="text-xs font-bold text-slate-200 uppercase tracking-wider">
|
||||||
|
จุดเด่นด้านสมรรถนะ 3 หัวข้อ (Technical Pillars)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{rawSpecs.map((spec, idx) => (
|
||||||
|
<div key={idx} className="p-3 rounded-xl bg-black/40 border border-white/10 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-mono font-bold text-lime-400">
|
||||||
|
#{idx + 1}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={spec.number || `0${idx + 1}`}
|
||||||
|
onChange={(e) => handleUpdateSpec(idx, 'number', e.target.value)}
|
||||||
|
className="admin-input w-16 text-center text-xs font-mono"
|
||||||
|
placeholder={`0${idx + 1}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={spec.title}
|
||||||
|
onChange={(e) => handleUpdateSpec(idx, 'title', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs font-bold"
|
||||||
|
placeholder="หัวข้อสเปก"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={spec.subtitle || ''}
|
||||||
|
onChange={(e) => handleUpdateSpec(idx, 'subtitle', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs font-mono text-slate-400"
|
||||||
|
placeholder="SUBTITLE EN"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Theme & CTA Config ── */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 rounded-2xl bg-slate-900/60 border border-white/5">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
สี Accent Highlight
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={accentColor}
|
||||||
|
onChange={(e) => updatePayloadValue('accentColor', e.target.value)}
|
||||||
|
className="w-8 h-8 rounded border-0 cursor-pointer bg-transparent"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={accentColor}
|
||||||
|
onChange={(e) => updatePayloadValue('accentColor', e.target.value)}
|
||||||
|
className="admin-input flex-1 text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
หัวข้อแบนเนอร์สั่งซื้อ (CTA Headline)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={endCtaTitle}
|
||||||
|
onChange={(e) => updatePayloadValue('endCtaTitle', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="พร้อมยกระดับสมรรถนะการขับขี่?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
ข้อความปุ่มสั่งซื้อ (CTA Button)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={endCtaButtonText}
|
||||||
|
onChange={(e) => updatePayloadValue('endCtaButtonText', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="สั่งซื้อสินค้าตอนนี้"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
โทนพื้นหลัง (Theme)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={theme}
|
||||||
|
onChange={(e) => updatePayloadValue('theme', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
>
|
||||||
|
<option value="dark">Dark Theater (#0f0f0e - แนะนำ)</option>
|
||||||
|
<option value="light">Light Studio (#f8f9fa)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { X, Search, Loader2 } from 'lucide-react';
|
import { X, Search, Loader2 } from 'lucide-react';
|
||||||
import { API_BASE } from '@homaroma/types';
|
import { API_BASE } from '@homaroma/types';
|
||||||
import { getAdminDisplayString } from '../../utils/localization';
|
import { getAdminDisplayString } from '../../utils/localization';
|
||||||
@@ -63,7 +64,7 @@ export default function ProductPickerModal({
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||||
<div className="absolute inset-0 bg-night-900/80 backdrop-blur-sm" onClick={onClose}></div>
|
<div className="absolute inset-0 bg-night-900/80 backdrop-blur-sm" onClick={onClose}></div>
|
||||||
<div className="relative bg-slate-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden flex flex-col h-[80vh]">
|
<div className="relative bg-slate-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden flex flex-col h-[80vh]">
|
||||||
@@ -160,6 +161,7 @@ export default function ProductPickerModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Film,
|
||||||
|
Package,
|
||||||
|
ExternalLink,
|
||||||
|
RotateCcw,
|
||||||
|
CheckCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { API_BASE } from '@homaroma/types'
|
||||||
|
import FrameSequenceModal, { type FrameSequenceItem } from './FrameSequenceModal'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
updatePayloadValue: (field: string, value: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VdoFrameScrubberForm({
|
||||||
|
payload,
|
||||||
|
updatePayloadValue,
|
||||||
|
}: Props) {
|
||||||
|
const [productsList, setProductsList] = useState<any[]>([])
|
||||||
|
const [loadingProducts, setLoadingProducts] = useState(false)
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
|
|
||||||
|
const folderPath = (payload.folderPath as string) || '/frame-sequences/chrome-sprocket-428h'
|
||||||
|
const pattern = (payload.pattern as string) || 'frame-{index}.webp'
|
||||||
|
const totalFrames = Number(payload.totalFrames) || 432
|
||||||
|
const fps = Number(payload.fps) || 24
|
||||||
|
const durationSeconds = Number(payload.durationSeconds) || 18
|
||||||
|
|
||||||
|
const eyebrow = (payload.eyebrow as string) || ''
|
||||||
|
const title = (payload.title as string) || ''
|
||||||
|
const subtitle = (payload.subtitle as string) || ''
|
||||||
|
const instruction = (payload.instruction as string) || ''
|
||||||
|
const productName = (payload.productName as string) || ''
|
||||||
|
const productBadge = (payload.productBadge as string) || ''
|
||||||
|
const accentColor = (payload.accentColor as string) || '#d5ff63'
|
||||||
|
const theme = (payload.theme as string) || 'dark'
|
||||||
|
const endCtaTitle = (payload.endCtaTitle as string) || ''
|
||||||
|
const endCtaButtonText = (payload.endCtaButtonText as string) || ''
|
||||||
|
const endCtaLink = (payload.endCtaLink as string) || ''
|
||||||
|
const selectedProductId = payload.productId ? String(payload.productId) : ''
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true
|
||||||
|
const fetchCatalog = async () => {
|
||||||
|
setLoadingProducts(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/products?limit=100`)
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
const items = data.data || data.products || (Array.isArray(data) ? data : [])
|
||||||
|
if (isMounted) setProductsList(items)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load products in VdoFrameScrubberForm', err)
|
||||||
|
} finally {
|
||||||
|
if (isMounted) setLoadingProducts(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchCatalog()
|
||||||
|
return () => { isMounted = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Auto-seed defaults on brand new block
|
||||||
|
useEffect(() => {
|
||||||
|
if (!payload.folderPath) updatePayloadValue('folderPath', '/frame-sequences/chrome-sprocket-428h')
|
||||||
|
if (!payload.pattern) updatePayloadValue('pattern', 'frame-{index}.webp')
|
||||||
|
if (!payload.padding) updatePayloadValue('padding', 3)
|
||||||
|
if (!payload.totalFrames) updatePayloadValue('totalFrames', 432)
|
||||||
|
if (!payload.fps) updatePayloadValue('fps', 24)
|
||||||
|
if (!payload.durationSeconds) updatePayloadValue('durationSeconds', 18)
|
||||||
|
if (!payload.eyebrow) updatePayloadValue('eyebrow', 'FROM VIDEO TO INTERACTIVE')
|
||||||
|
if (!payload.title) updatePayloadValue('title', 'หนึ่งวิดีโอ หมุนดูได้ทีละเฟรม')
|
||||||
|
if (!payload.subtitle) updatePayloadValue('subtitle', 'รวมวิดีโอ 18 วินาทีเป็น 432 เฟรม ลากภาพหรือเลื่อนหน้าเว็บเพื่อหมุนต่อ แล้วซูมดูฟันสเตอร์')
|
||||||
|
if (!payload.instruction) updatePayloadValue('instruction', 'ลากซ้าย–ขวา หรือเลื่อนหน้าเว็บ')
|
||||||
|
if (!payload.productName) updatePayloadValue('productName', 'SPROCKET / FRAME SCRUBBER')
|
||||||
|
if (!payload.productBadge) updatePayloadValue('productBadge', '18 SEC / 432 EXTRACTED FRAMES')
|
||||||
|
if (!payload.endCtaTitle) updatePayloadValue('endCtaTitle', 'ทุกมุม อยู่ในมือคุณ พร้อมยกระดับการขับขี่')
|
||||||
|
if (!payload.endCtaButtonText) updatePayloadValue('endCtaButtonText', 'สั่งซื้อสินค้าตอนนี้')
|
||||||
|
if (!payload.theme) updatePayloadValue('theme', 'dark')
|
||||||
|
if (!payload.accentColor) updatePayloadValue('accentColor', '#d5ff63')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSelectProduct = (productIdStr: string) => {
|
||||||
|
if (!productIdStr) {
|
||||||
|
updatePayloadValue('productId', null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const pid = Number(productIdStr)
|
||||||
|
updatePayloadValue('productId', pid)
|
||||||
|
|
||||||
|
const prod = productsList.find((p) => p.id === pid)
|
||||||
|
if (prod) {
|
||||||
|
const prodName = typeof prod.name === 'string' ? prod.name : prod.name?.th || prod.name?.en || ''
|
||||||
|
if (prodName) updatePayloadValue('productName', prodName)
|
||||||
|
const slug = prod.slug || `product-${pid}`
|
||||||
|
updatePayloadValue('endCtaLink', `/products/${slug}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyPreset = () => {
|
||||||
|
updatePayloadValue('folderPath', '/frame-sequences/chrome-sprocket-428h')
|
||||||
|
updatePayloadValue('pattern', 'frame-{index}.webp')
|
||||||
|
updatePayloadValue('padding', 3)
|
||||||
|
updatePayloadValue('totalFrames', 432)
|
||||||
|
updatePayloadValue('fps', 24)
|
||||||
|
updatePayloadValue('durationSeconds', 18)
|
||||||
|
updatePayloadValue('eyebrow', 'FROM VIDEO TO INTERACTIVE')
|
||||||
|
updatePayloadValue('title', 'หนึ่งวิดีโอ หมุนดูได้ทีละเฟรม')
|
||||||
|
updatePayloadValue('subtitle', 'รวมวิดีโอ 18 วินาทีเป็น 432 เฟรม ลากภาพหรือเลื่อนหน้าเว็บเพื่อหมุนต่อ แล้วซูมดูฟันสเตอร์')
|
||||||
|
updatePayloadValue('instruction', 'ลากซ้าย–ขวา หรือเลื่อนหน้าเว็บ')
|
||||||
|
updatePayloadValue('productName', 'SPROCKET / FRAME SCRUBBER')
|
||||||
|
updatePayloadValue('productBadge', '18 SEC / 432 EXTRACTED FRAMES')
|
||||||
|
updatePayloadValue('endCtaTitle', 'ทุกมุม อยู่ในมือคุณ พร้อมยกระดับการขับขี่')
|
||||||
|
updatePayloadValue('endCtaButtonText', 'สั่งซื้อสินค้าตอนนี้')
|
||||||
|
updatePayloadValue('theme', 'dark')
|
||||||
|
updatePayloadValue('accentColor', '#d5ff63')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSequenceSelected = (seq: FrameSequenceItem) => {
|
||||||
|
updatePayloadValue('folderPath', seq.folderPath)
|
||||||
|
updatePayloadValue('pattern', seq.pattern || 'frame-{index}.webp')
|
||||||
|
updatePayloadValue('padding', seq.padding || 3)
|
||||||
|
updatePayloadValue('totalFrames', seq.totalFrames)
|
||||||
|
updatePayloadValue('fps', seq.fps || 24)
|
||||||
|
updatePayloadValue('durationSeconds', seq.durationSeconds || parseFloat((seq.totalFrames / 24).toFixed(1)))
|
||||||
|
updatePayloadValue('productBadge', `${seq.durationSeconds}S / ${seq.totalFrames} FRAMES`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 text-slate-200">
|
||||||
|
{/* ── Preset Banner ── */}
|
||||||
|
<div className="p-4 rounded-2xl bg-gradient-to-r from-[#d5ff63]/10 via-slate-900/40 to-transparent border border-[#d5ff63]/30 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-[#d5ff63] font-bold text-xs uppercase tracking-wider">
|
||||||
|
<Film className="w-4 h-4 text-[#d5ff63]" />
|
||||||
|
<span>VDO Frame-by-Frame Interactive Scrubber</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
แสดงผลวิดีโอแบบ Frame Sequence ควบคุมเฟรมตามการเลื่อนหน้าเว็บ (Scroll) และการลาก (Drag Scrub)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApplyPreset}
|
||||||
|
className="px-4 py-2 rounded-xl bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e] transition-all flex items-center gap-1.5 shrink-0 shadow-lg cursor-pointer"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
<span>รีเซ็ต / โหลดตัวอย่าง Chrome Sprocket (432 เฟรม)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Sequence Collection Selector (Isolated Library) ── */}
|
||||||
|
<div className="p-5 rounded-2xl bg-slate-900/70 border border-white/5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-bold text-white uppercase tracking-wider">
|
||||||
|
<Film className="w-4 h-4 text-[#d5ff63]" />
|
||||||
|
<span>ชุดวิดีโอเฟรมที่เลือกใช้งาน (Frame Sequence Collection)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
className="px-4 py-1.5 rounded-xl bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e] transition-all flex items-center gap-1.5 cursor-pointer shadow-md"
|
||||||
|
>
|
||||||
|
<Film className="w-3.5 h-3.5" />
|
||||||
|
<span>เลือก / อัปโหลดชุด ZIP ใหม่</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Sequence Card */}
|
||||||
|
<div className="p-4 rounded-xl bg-black/50 border border-white/10 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-20 aspect-video rounded-lg bg-slate-800 overflow-hidden border border-white/10 shrink-0">
|
||||||
|
<img
|
||||||
|
src={`${folderPath}/frame-001.webp`}
|
||||||
|
alt="Sequence Poster"
|
||||||
|
className="w-full h-full object-contain p-1"
|
||||||
|
onError={(e) => {
|
||||||
|
// Fallback if not yet loaded
|
||||||
|
(e.target as HTMLImageElement).src = '/images/3d/sprocket-detail.png'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-bold text-white">
|
||||||
|
{folderPath.split('/').pop()?.replace(/[-_]/g, ' ').toUpperCase() || 'FRAME SEQUENCE'}
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-400 text-[9px] font-mono flex items-center gap-1">
|
||||||
|
<CheckCircle className="w-2.5 h-2.5" /> พร้อมใช้งาน
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-slate-400 font-mono">
|
||||||
|
<span>{totalFrames} FRAMES</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{fps} FPS</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{durationSeconds} วินาที</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-slate-500 font-mono">
|
||||||
|
โฟลเดอร์: {folderPath} (รูปแบบ: {pattern})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
className="text-xs text-[#d5ff63] hover:underline font-mono"
|
||||||
|
>
|
||||||
|
เปลี่ยนชุด →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 1-Click Product Linking ── */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-bold text-slate-200 uppercase tracking-wider">
|
||||||
|
<Package className="w-4 h-4 text-[#d5ff63]" />
|
||||||
|
<span>ผูกกับสินค้าในระบบ (1-Click Bind Product)</span>
|
||||||
|
</div>
|
||||||
|
{selectedProductId && (
|
||||||
|
<span className="text-[10px] text-[#d5ff63] font-mono">
|
||||||
|
Product ID: #{selectedProductId}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={selectedProductId}
|
||||||
|
onChange={(e) => handleSelectProduct(e.target.value)}
|
||||||
|
disabled={loadingProducts}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
>
|
||||||
|
<option value="">-- ไม่ระบุ (ใช้เป็นหน้า Brand/Product Story อิสระ) --</option>
|
||||||
|
{productsList.map((p) => {
|
||||||
|
const nameStr = typeof p.name === 'string' ? p.name : p.name?.th || p.name?.en || 'Unnamed'
|
||||||
|
return (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
#{p.id} - {nameStr}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={endCtaLink}
|
||||||
|
onChange={(e) => updatePayloadValue('endCtaLink', e.target.value)}
|
||||||
|
placeholder="ลิงก์ปลายทางเมื่อกดสั่งซื้อ เช่น /products/chain-sprocket-p1"
|
||||||
|
className="admin-input flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
{endCtaLink && (
|
||||||
|
<a
|
||||||
|
href={endCtaLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="p-2 rounded-lg bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Headline & Header Texts ── */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-4">
|
||||||
|
<div className="text-xs font-bold text-slate-200 uppercase tracking-wider">
|
||||||
|
ข้อความหัวเรื่อง (Header & Story Intro)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
หมวด/ป้ายกำกับ (Eyebrow Tag)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={eyebrow}
|
||||||
|
onChange={(e) => updatePayloadValue('eyebrow', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs font-mono"
|
||||||
|
placeholder="FROM VIDEO TO INTERACTIVE"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
ป้ายซีรีส์ (Badge / Tag)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={productBadge}
|
||||||
|
onChange={(e) => updatePayloadValue('productBadge', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs font-mono"
|
||||||
|
placeholder="18 SEC / 432 FRAMES"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
ชื่อสินค้า (Product Name)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={productName}
|
||||||
|
onChange={(e) => updatePayloadValue('productName', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="SPROCKET / FRAME SCRUBBER"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
คำแนะนำการเลื่อน (Instruction Cue)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={instruction}
|
||||||
|
onChange={(e) => updatePayloadValue('instruction', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="ลากซ้าย–ขวา หรือเลื่อนหน้าเว็บ"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
หัวเรื่องหลัก (Main Title)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => updatePayloadValue('title', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="หนึ่งวิดีโอ หมุนดูได้ทีละเฟรม"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
คำบรรยายเกริ่นนำ (Intro Subtitle)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={subtitle}
|
||||||
|
onChange={(e) => updatePayloadValue('subtitle', e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="รวมวิดีโอ 18 วินาทีเป็น 432 เฟรม ลากภาพหรือเลื่อนหน้าเว็บเพื่อหมุนต่อ แล้วซูมดูฟันสเตอร์"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Theme, Accent, and Conversion CTA ── */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 p-4 rounded-2xl bg-slate-900/60 border border-white/5">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
สี Accent Highlight
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={accentColor}
|
||||||
|
onChange={(e) => updatePayloadValue('accentColor', e.target.value)}
|
||||||
|
className="w-8 h-8 rounded border-0 cursor-pointer bg-transparent"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={accentColor}
|
||||||
|
onChange={(e) => updatePayloadValue('accentColor', e.target.value)}
|
||||||
|
className="admin-input flex-1 text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
โทนพื้นหลัง (Theme)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={theme}
|
||||||
|
onChange={(e) => updatePayloadValue('theme', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
>
|
||||||
|
<option value="dark">Dark Theater (#0e0e0d - แนะนำ)</option>
|
||||||
|
<option value="light">Light Studio (#f8f9fa)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
หัวข้อแบนเนอร์สั่งซื้อ (CTA Headline)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={endCtaTitle}
|
||||||
|
onChange={(e) => updatePayloadValue('endCtaTitle', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="ทุกมุม อยู่ในมือคุณ"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
|
||||||
|
ข้อความปุ่มสั่งซื้อ (CTA Button)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={endCtaButtonText}
|
||||||
|
onChange={(e) => updatePayloadValue('endCtaButtonText', e.target.value)}
|
||||||
|
className="admin-input w-full text-xs"
|
||||||
|
placeholder="สั่งซื้อสินค้าตอนนี้"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal for Frame Sequence Collection */}
|
||||||
|
<FrameSequenceModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
onSelectSequence={handleSequenceSelected}
|
||||||
|
selectedSlug={folderPath.split('/').pop()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Receipt,
|
||||||
|
DollarSign,
|
||||||
|
Wallet,
|
||||||
|
BadgePercent,
|
||||||
|
Coins,
|
||||||
|
Package,
|
||||||
|
Truck,
|
||||||
|
ExternalLink,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertTriangle,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
TrendingUp,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { TIKTOK_SHOPS } from '../../pages/TiktokOrderPage';
|
||||||
|
|
||||||
|
function fmt(val: number | null | undefined) {
|
||||||
|
if (val == null) return '0.00';
|
||||||
|
return new Intl.NumberFormat('th-TH', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TiktokFeeBreakdownModalProps {
|
||||||
|
order: any;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TiktokFeeBreakdownModal({ order, onClose }: TiktokFeeBreakdownModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
// Keyboard navigation & body scroll lock
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
// Prevent background page from scrolling
|
||||||
|
const originalOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
document.body.style.overflow = originalOverflow;
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
if (!order || typeof document === 'undefined') return null;
|
||||||
|
|
||||||
|
const settlement = order.settlement;
|
||||||
|
const isSettled = order.isSettled || !!settlement;
|
||||||
|
const shopCfg = TIKTOK_SHOPS.find((s) => s.id === order.shop) || TIKTOK_SHOPS[0];
|
||||||
|
|
||||||
|
// Financial figures (Settlement fees are usually stored as negative numbers from TikTok reports)
|
||||||
|
const grossRevenue = settlement?.grossRevenue ?? settlement?.subtotalAfterSellerDiscount ?? order.orderAmount ?? 0;
|
||||||
|
const sellerDiscount = Math.abs(settlement?.sellerDiscount ?? 0);
|
||||||
|
const subtotalBeforeDiscount = settlement?.subtotalBeforeDiscount ?? (grossRevenue + sellerDiscount);
|
||||||
|
|
||||||
|
const commissionFee = Math.abs(settlement?.commissionFee ?? order.commissionFee ?? 0);
|
||||||
|
const affiliateCommission = Math.abs(settlement?.affiliateCommission ?? order.affiliateFee ?? 0);
|
||||||
|
const affiliatePartnerFee = Math.abs(settlement?.affiliatePartnerFee ?? 0);
|
||||||
|
const totalAffiliate = affiliateCommission + affiliatePartnerFee;
|
||||||
|
const orderFee = Math.abs(settlement?.orderFee ?? 0);
|
||||||
|
const growthSupportFee = Math.abs(settlement?.growthSupportFee ?? 0);
|
||||||
|
const shippingFeeActual = Math.abs(settlement?.shippingFeeActual ?? 0);
|
||||||
|
const platformShippingDiscount = Math.abs(settlement?.platformShippingDiscount ?? 0);
|
||||||
|
const netShippingToSeller = Math.max(0, shippingFeeActual - platformShippingDiscount);
|
||||||
|
|
||||||
|
// Total deductions
|
||||||
|
const rawTotalFee = settlement?.totalFee != null
|
||||||
|
? Math.abs(settlement.totalFee)
|
||||||
|
: (commissionFee + totalAffiliate + orderFee + growthSupportFee + netShippingToSeller);
|
||||||
|
|
||||||
|
// Net payout
|
||||||
|
const netSettlement = settlement?.settlementAmount ?? order.netSettlement ?? (grossRevenue - commissionFee - totalAffiliate);
|
||||||
|
|
||||||
|
// BOM & Net Profit
|
||||||
|
const totalBomCost = order.orderTotalBomCost || 0;
|
||||||
|
const hasMissingBom = order.hasIncompleteBom || (order.missingBomCount ?? 0) > 0;
|
||||||
|
const realNetProfit = netSettlement - totalBomCost;
|
||||||
|
const marginOnSales = grossRevenue > 0 ? (realNetProfit / grossRevenue) * 100 : 0;
|
||||||
|
const markupOnCost = totalBomCost > 0 ? (realNetProfit / totalBomCost) * 100 : 0;
|
||||||
|
const realNetMargin = marginOnSales;
|
||||||
|
|
||||||
|
const handleCopyOrderId = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigator.clipboard.writeText(order.orderId);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[9999] flex items-center justify-center p-3 sm:p-5 md:p-6 bg-black/80 backdrop-blur-md animate-fade-in"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-slate-900 border border-white/15 rounded-3xl w-full max-w-5xl xl:max-w-6xl max-h-[92vh] flex flex-col shadow-2xl shadow-black/90 overflow-hidden text-white"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/[0.02]">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-violet-500/20 to-cyan-500/20 border border-violet-500/30 flex items-center justify-center shadow-lg shadow-violet-500/10 shrink-0">
|
||||||
|
<Receipt className="w-5 h-5 text-violet-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h3 className="font-bold text-white text-base">
|
||||||
|
{t('tiktokOrders.feeModalTitle', 'TikTok Fee & Settlement Breakdown')}
|
||||||
|
</h3>
|
||||||
|
{isSettled ? (
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 inline-flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
{t('tiktokOrders.settlementCompleteBadge', 'โอนเงินแล้ว')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/15 text-amber-400 border border-amber-500/30 inline-flex items-center gap-1">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
{t('tiktokOrders.settlementPendingBadge', 'รอ Settlement')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-400 mt-0.5 flex-wrap">
|
||||||
|
<span className="font-mono text-rose-400 font-bold">#{order.orderId}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyOrderId}
|
||||||
|
className="text-slate-400 hover:text-white p-0.5 rounded transition-colors inline-flex items-center gap-1 text-[11px]"
|
||||||
|
title={t('tiktokOrders.copyOrderId', 'คัดลอก Order ID')}
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-3 h-3 text-emerald-400" /> : <Copy className="w-3 h-3" />}
|
||||||
|
{copied && <span className="text-[10px] text-emerald-400 font-sans">{t('tiktokOrders.copied', 'คัดลอกแล้ว')}</span>}
|
||||||
|
</button>
|
||||||
|
<span>•</span>
|
||||||
|
<span className={`font-semibold ${shopCfg.color}`}>{shopCfg.label}</span>
|
||||||
|
{order.buyerUsername && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="text-slate-300">ผู้ซื้อ: {order.buyerUsername}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{order.createdTime && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="text-slate-400 font-mono">{order.createdTime.slice(0, 16)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-all ml-2"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable Content */}
|
||||||
|
<div className="p-6 space-y-5 overflow-y-auto">
|
||||||
|
{/* Pending Settlement Notice if not settled */}
|
||||||
|
{!settlement && (
|
||||||
|
<div className="p-3.5 rounded-2xl bg-amber-500/10 border border-amber-500/30 flex items-start gap-3 text-amber-300 text-xs">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="font-bold text-amber-200">
|
||||||
|
{t('tiktokOrders.settlementPendingAlert', 'คำสั่งซื้อนี้ยังไม่มีข้อมูลใบแจ้งยอด Settlement')}
|
||||||
|
</p>
|
||||||
|
<p className="text-amber-300/80 leading-relaxed text-[11px]">
|
||||||
|
{t(
|
||||||
|
'tiktokOrders.settlementPendingDesc',
|
||||||
|
'ค่าคอมมิชชั่นและตัวเลขด้านล่างเป็นการคำนวณเบื้องต้นจากข้อมูล Order จนกว่าจะมีการ Import ไฟล์ Settlement XLSX จาก TikTok Seller Center'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 5 Financial Summary Cards (Compact Banner) */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2.5">
|
||||||
|
{/* 1. Revenue */}
|
||||||
|
<div className="p-3.5 rounded-2xl bg-white/5 border border-white/10 flex flex-col justify-between min-h-[96px]">
|
||||||
|
<div className="flex items-center justify-between text-slate-400 text-[11px]">
|
||||||
|
<span className="truncate">{t('tiktokOrders.orderRevenueLabel', 'ยอดขายสินค้า')}</span>
|
||||||
|
<DollarSign className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold font-mono text-white mt-1">฿{fmt(grossRevenue)}</p>
|
||||||
|
<span className="text-[10px] text-slate-500 truncate" title={sellerDiscount > 0 ? `ลด ฿${fmt(sellerDiscount)} จาก ฿${fmt(subtotalBeforeDiscount)}` : 'รวมส่วนลดร้านค้าแล้ว'}>
|
||||||
|
{sellerDiscount > 0 ? `ลด ฿${fmt(sellerDiscount)} จาก ฿${fmt(subtotalBeforeDiscount)}` : 'รวมส่วนลดแล้ว'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. TikTok Deductions */}
|
||||||
|
<div className="p-3.5 rounded-2xl bg-rose-500/10 border border-rose-500/20 flex flex-col justify-between min-h-[96px]">
|
||||||
|
<div className="flex items-center justify-between text-rose-300 text-[11px]">
|
||||||
|
<span className="truncate">{t('tiktokOrders.tiktokDeductionsLabel', 'TikTok หักรวม')}</span>
|
||||||
|
<BadgePercent className="w-3.5 h-3.5 text-rose-400 shrink-0" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold font-mono text-rose-400 mt-1">-฿{fmt(rawTotalFee)}</p>
|
||||||
|
<span className="text-[10px] text-rose-400/80 truncate">
|
||||||
|
{grossRevenue > 0 ? `${((rawTotalFee / grossRevenue) * 100).toFixed(1)}% ของยอดขาย` : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Net Payout */}
|
||||||
|
<div className="p-3.5 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex flex-col justify-between min-h-[96px]">
|
||||||
|
<div className="flex items-center justify-between text-cyan-300 text-[11px]">
|
||||||
|
<span className="truncate">{t('tiktokOrders.netPayoutLabel', 'ยอดโอนสุทธิ')}</span>
|
||||||
|
<Wallet className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold font-mono text-cyan-400 mt-1">฿{fmt(netSettlement)}</p>
|
||||||
|
<span className="text-[10px] text-cyan-400/80 truncate">
|
||||||
|
{settlement?.orderSettledTime ? `โอนเมื่อ ${settlement.orderSettledTime.slice(0, 10)}` : 'รอสรุปยอดโอน'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 4. Product Cost (BOM) */}
|
||||||
|
<div className="p-3.5 rounded-2xl bg-amber-500/10 border border-amber-500/20 flex flex-col justify-between min-h-[96px]">
|
||||||
|
<div className="flex items-center justify-between text-amber-300 text-[11px]">
|
||||||
|
<span className="truncate">{t('tiktokOrders.cardBomCost', 'ต้นทุนสินค้า (BOM)')}</span>
|
||||||
|
<Coins className="w-3.5 h-3.5 text-amber-400 shrink-0" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold font-mono text-amber-400 mt-1">฿{fmt(totalBomCost)}</p>
|
||||||
|
<span className="text-[10px] text-amber-400/80 truncate">
|
||||||
|
{hasMissingBom
|
||||||
|
? '⚠️ มีสินค้ายังไม่ใส่ BOM'
|
||||||
|
: grossRevenue > 0
|
||||||
|
? `${((totalBomCost / grossRevenue) * 100).toFixed(1)}% ของยอดขาย`
|
||||||
|
: 'BOM ครบ 100%'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5. Net Profit (with Margin % on sales and Markup % on cost) */}
|
||||||
|
<div className={`p-3.5 rounded-2xl border flex flex-col justify-between min-h-[96px] ${
|
||||||
|
realNetProfit >= 0 ? 'bg-emerald-500/10 border-emerald-500/25' : 'bg-rose-500/10 border-rose-500/25'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center justify-between text-[11px]">
|
||||||
|
<span className={`truncate font-semibold ${realNetProfit >= 0 ? 'text-emerald-300' : 'text-rose-300'}`}>
|
||||||
|
{t('tiktokOrders.cardNetProfit', 'กำไรสุทธิ')}
|
||||||
|
</span>
|
||||||
|
<TrendingUp className={`w-3.5 h-3.5 shrink-0 ${realNetProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`} />
|
||||||
|
</div>
|
||||||
|
<p className={`text-lg font-bold font-mono mt-1 ${realNetProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||||
|
฿{fmt(realNetProfit)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] font-mono leading-tight flex-wrap">
|
||||||
|
<span className={realNetProfit >= 0 ? 'text-emerald-300 font-semibold' : 'text-rose-300 font-semibold'}>
|
||||||
|
กำไร/ขาย {marginOnSales.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500">•</span>
|
||||||
|
<span className={realNetProfit >= 0 ? 'text-emerald-300 font-semibold' : 'text-rose-300 font-semibold'}>
|
||||||
|
กำไร/ทุน {markupOnCost >= 0 ? '+' : ''}{markupOnCost.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2-Column Wide Landscape Grid Layout */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5 items-start">
|
||||||
|
{/* Left Column (7 Cols): TikTok Platform Fees Breakdown */}
|
||||||
|
<div className="lg:col-span-7 p-4 sm:p-5 rounded-2xl bg-black/30 border border-white/10 space-y-3.5">
|
||||||
|
<div className="flex items-center justify-between pb-2.5 border-b border-white/10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BadgePercent className="w-4 h-4 text-violet-400" />
|
||||||
|
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-300">
|
||||||
|
{t('tiktokOrders.feeBreakdownTitle', 'Breakdown รายการหักค่าใช้จ่ายของ TikTok')}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono font-bold text-rose-400">-฿{fmt(rawTotalFee)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2.5 text-xs">
|
||||||
|
{/* Commission Fee */}
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-xl bg-violet-500/10 border border-violet-500/20">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-violet-400 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-violet-200">
|
||||||
|
{t('tiktokOrders.tiktokCommissionFee', 'ค่าคอมมิชชั่น TikTok Shop (Commission Fee)')}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-violet-300/70">
|
||||||
|
{t('tiktokOrders.tiktokCommissionDesc', 'หักตามอัตราหมวดหมู่สินค้าของแพลตฟอร์ม')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono font-bold text-violet-400 text-sm">
|
||||||
|
{commissionFee > 0 ? `-฿${fmt(commissionFee)}` : '฿0.00'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Affiliate Creator Commission */}
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-xl bg-cyan-500/10 border border-cyan-500/20">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-cyan-400 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-cyan-200">
|
||||||
|
{t('tiktokOrders.affiliateCommissionFee', 'ค่าคอมมิชชั่น Creator แอฟฟิลิเอต (Affiliate)')}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-cyan-300/70">
|
||||||
|
{t('tiktokOrders.affiliateCommissionDesc', 'จ่ายให้ครีเอเตอร์/นายหน้าที่แปะลิงก์หรือไลฟ์')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono font-bold text-cyan-400 text-sm">
|
||||||
|
{affiliateCommission > 0 ? `-฿${fmt(affiliateCommission)}` : '฿0.00'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Affiliate Partner Fee (TAP) */}
|
||||||
|
{(affiliatePartnerFee > 0 || settlement) && (
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/5 text-slate-300">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-cyan-400/60" />
|
||||||
|
<span>{t('tiktokOrders.affiliatePartnerFee', 'ค่าคอมมิชชั่น Partner แอฟฟิลิเอต (TAP)')}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-cyan-300 font-semibold">
|
||||||
|
{affiliatePartnerFee > 0 ? `-฿${fmt(affiliatePartnerFee)}` : '฿0.00'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Order / Transaction Fee */}
|
||||||
|
{(orderFee > 0 || settlement) && (
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/5 text-slate-300">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
||||||
|
<span>{t('tiktokOrders.orderFee', 'ค่าธรรมเนียมคำสั่งซื้อ/ธุรกรรม (Order Fee)')}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-amber-400 font-semibold">
|
||||||
|
{orderFee > 0 ? `-฿${fmt(orderFee)}` : '฿0.00'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Growth Support Fee */}
|
||||||
|
{growthSupportFee > 0 && (
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/5 text-slate-300">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-blue-400" />
|
||||||
|
<span>{t('tiktokOrders.growthSupportFee', 'ค่าธรรมเนียมสนับสนุนการเติบโต (Growth Support)')}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-blue-400 font-semibold">
|
||||||
|
-฿{fmt(growthSupportFee)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Shipping Breakdown */}
|
||||||
|
{(shippingFeeActual > 0 || platformShippingDiscount > 0) && (
|
||||||
|
<div className="p-3 rounded-xl bg-white/[0.02] border border-white/5 space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-slate-300 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Truck className="w-3.5 h-3.5 text-blue-400" />
|
||||||
|
<span>{t('tiktokOrders.shippingBreakdown', 'ค่าจัดส่งขนส่ง')}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono font-semibold text-slate-200">
|
||||||
|
{netShippingToSeller > 0 ? `-฿${fmt(netShippingToSeller)}` : t('tiktokOrders.shippingFreeForSeller', 'ร้านค้าไม่เสียค่าส่ง')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[11px] pl-5 text-slate-400">
|
||||||
|
<span>
|
||||||
|
{t('tiktokOrders.shippingBreakdownDetail', {
|
||||||
|
actual: fmt(shippingFeeActual),
|
||||||
|
subsidy: fmt(platformShippingDiscount),
|
||||||
|
defaultValue: `ค่าส่งจริง ฿${fmt(shippingFeeActual)} | TikTok อุดหนุน -฿${fmt(platformShippingDiscount)}`
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column (5 Cols): BOM Profitability & Order Items */}
|
||||||
|
<div className="lg:col-span-5 space-y-4">
|
||||||
|
{/* BOM Profitability Analysis Card */}
|
||||||
|
<div
|
||||||
|
className={`p-4 sm:p-5 rounded-2xl border space-y-3 ${
|
||||||
|
hasMissingBom ? 'bg-rose-500/10 border-rose-500/25' : 'bg-emerald-500/10 border-emerald-500/25'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between pb-2 border-b border-white/10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Coins className={`w-4 h-4 ${hasMissingBom ? 'text-rose-400' : 'text-emerald-400'}`} />
|
||||||
|
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-200">
|
||||||
|
{t('tiktokOrders.bomProfitAnalysis', 'วิเคราะห์กำไรสุทธิจริงหลังหักต้นทุน BOM')}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
{hasMissingBom ? (
|
||||||
|
<span className="text-[10px] font-bold text-rose-300 px-2 py-0.5 rounded-full bg-rose-500/20 border border-rose-500/30">
|
||||||
|
{t('tiktokOrders.missingBomCount', { count: order.missingBomCount })}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] font-bold text-emerald-300 px-2 py-0.5 rounded-full bg-emerald-500/20 border border-emerald-500/30">
|
||||||
|
{t('tiktokOrders.bomComplete100', 'BOM ครบ 100%')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div className="flex justify-between items-center text-slate-300">
|
||||||
|
<span>{t('tiktokOrders.netPayoutLabel', 'ยอดเงินที่ TikTok โอนให้')}</span>
|
||||||
|
<span className="font-mono font-bold text-white">฿{fmt(netSettlement)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center text-amber-300">
|
||||||
|
<span>{t('tiktokOrders.totalBomCostLabel', 'หัก ต้นทุนสินค้าตามสูตร BOM')}</span>
|
||||||
|
<span className="font-mono font-bold">
|
||||||
|
{totalBomCost > 0 ? `-฿${fmt(totalBomCost)}` : '฿0.00'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2.5 border-t border-white/10 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-white text-sm">
|
||||||
|
{t('tiktokOrders.realNetProfitLabel', 'กำไรสุทธิแท้จริง (Real Net Profit)')}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-400">
|
||||||
|
{t('tiktokOrders.realNetMarginLabel', 'คิดเป็นอัตรากำไร')}:{' '}
|
||||||
|
<span className="font-mono font-bold text-slate-200">{realNetMargin.toFixed(1)}%</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className={`text-2xl font-bold font-mono ${realNetProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||||
|
฿{fmt(realNetProfit)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Order Items Snippet */}
|
||||||
|
{order.items && order.items.length > 0 && (
|
||||||
|
<div className="p-4 rounded-2xl bg-black/20 border border-white/10 space-y-2.5">
|
||||||
|
<div className="flex items-center justify-between text-xs font-bold text-slate-400 pb-1 border-b border-white/5">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Package className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
<span>
|
||||||
|
{t('tiktokOrders.orderItemsTitle', 'รายการสินค้าในออเดอร์นี้')} ({order.items.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-white/5 max-h-48 overflow-y-auto pr-1">
|
||||||
|
{order.items.map((item: any, idx: number) => (
|
||||||
|
<div key={idx} className="py-2 flex items-center justify-between gap-3 text-xs">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-slate-200 font-medium truncate">{item.productName}</p>
|
||||||
|
<p className="text-[11px] text-slate-400 font-mono">
|
||||||
|
{item.variation ? `[${item.variation}] ` : ''}จำนวน: {item.quantity} ชิ้น
|
||||||
|
{item.masterSku ? ` • Master: ${item.masterSku}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0 font-mono">
|
||||||
|
<p className="text-white font-bold">
|
||||||
|
฿{fmt(item.subtotalAfterDiscount || item.unitOriginalPrice * (item.quantity || 1))}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-amber-400">BOM: ฿{fmt(item.totalBomCost || 0)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-t border-white/10 bg-white/[0.02]">
|
||||||
|
<Link
|
||||||
|
to={`/tiktok-order/${encodeURIComponent(order.orderId)}`}
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-rose-500/15 hover:bg-rose-500/25 border border-rose-500/30 text-rose-300 font-bold text-xs transition-colors"
|
||||||
|
>
|
||||||
|
<span>{t('tiktokOrders.viewFullOrderDetail', 'เปิดดูคำสั่งซื้อฉบับเต็ม')}</span>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-6 py-2 rounded-xl bg-white/10 hover:bg-white/15 text-white font-bold text-xs transition-colors"
|
||||||
|
>
|
||||||
|
{t('tiktokOrders.closeModal', 'ปิด')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -116,18 +116,18 @@
|
|||||||
/* Animations */
|
/* Animations */
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; transform: translateY(10px); }
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes zoomIn {
|
@keyframes zoomIn {
|
||||||
from { opacity: 0; transform: scale(0.95); }
|
from { opacity: 0; transform: scale(0.95); }
|
||||||
to { opacity: 1; transform: scale(1); }
|
to { opacity: 1; transform: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-fade-in {
|
.animate-fade-in {
|
||||||
animation: fadeIn 0.4s ease-out forwards;
|
animation: fadeIn 0.4s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-zoom-in {
|
.animate-zoom-in {
|
||||||
animation: zoomIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
animation: zoomIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
}
|
}
|
||||||
@@ -454,6 +454,41 @@
|
|||||||
"thAffiliate": "Affiliate (฿)",
|
"thAffiliate": "Affiliate (฿)",
|
||||||
"thNetProfit": "Net Profit",
|
"thNetProfit": "Net Profit",
|
||||||
"thActions": "Actions",
|
"thActions": "Actions",
|
||||||
|
"viewFeeBreakdown": "Click to view TikTok fee breakdown",
|
||||||
|
"feeModalTitle": "TikTok Fee & Settlement Breakdown",
|
||||||
|
"feeModalSub": "Detailed breakdown of TikTok Shop commission, affiliate fees, and net payout",
|
||||||
|
"settlementPendingAlert": "This order does not have a TikTok Settlement statement yet",
|
||||||
|
"settlementPendingDesc": "Commissions and fees below are preliminary estimates from order data until a Settlement XLSX file is imported from TikTok Seller Center.",
|
||||||
|
"settlementCompleteBadge": "Settled & Transferred",
|
||||||
|
"settlementPendingBadge": "Pending Settlement",
|
||||||
|
"orderRevenueLabel": "Product Revenue",
|
||||||
|
"tiktokDeductionsLabel": "Total TikTok Deductions",
|
||||||
|
"netPayoutLabel": "Net Payout",
|
||||||
|
"cardBomCost": "Product Cost (BOM)",
|
||||||
|
"cardNetProfit": "Net Profit",
|
||||||
|
"profitOnSales": "Profit/Sales",
|
||||||
|
"profitOnCost": "Profit/Cost",
|
||||||
|
"feeBreakdownTitle": "TikTok Platform Fee Deductions Breakdown",
|
||||||
|
"tiktokCommissionFee": "TikTok Shop Commission Fee",
|
||||||
|
"tiktokCommissionDesc": "Platform category commission rate deduction",
|
||||||
|
"affiliateCommissionFee": "Creator Affiliate Commission Fee",
|
||||||
|
"affiliateCommissionDesc": "Commission paid to creators/affiliates",
|
||||||
|
"affiliatePartnerFee": "Affiliate Partner Fee (TAP)",
|
||||||
|
"orderFee": "Order / Transaction Fee",
|
||||||
|
"growthSupportFee": "Growth Support Fee",
|
||||||
|
"shippingBreakdown": "Shipping & Logistics Fees",
|
||||||
|
"shippingBreakdownDetail": "Actual Shipping ฿{{actual}} | Platform Subsidy -฿{{subsidy}}",
|
||||||
|
"shippingFreeForSeller": "Seller shipping covered",
|
||||||
|
"sellerDiscountLabel": "Seller Discount",
|
||||||
|
"bomProfitAnalysis": "Real Net Profit Analysis (After BOM Cost)",
|
||||||
|
"totalBomCostLabel": "Deduct: BOM Material Cost",
|
||||||
|
"realNetProfitLabel": "Real Net Profit",
|
||||||
|
"realNetMarginLabel": "Net Margin",
|
||||||
|
"orderItemsTitle": "Items in this Order",
|
||||||
|
"viewFullOrderDetail": "View Full Order Detail",
|
||||||
|
"closeModal": "Close",
|
||||||
|
"copyOrderId": "Copy Order ID",
|
||||||
|
"copied": "Copied",
|
||||||
"processingData": "Processing data...",
|
"processingData": "Processing data...",
|
||||||
"noOrders": "No data available",
|
"noOrders": "No data available",
|
||||||
"settledTransferred": "Settled ฿{{amount}}",
|
"settledTransferred": "Settled ฿{{amount}}",
|
||||||
|
|||||||
@@ -450,6 +450,41 @@
|
|||||||
"thAffiliate": "Affiliate (฿)",
|
"thAffiliate": "Affiliate (฿)",
|
||||||
"thNetProfit": "純利益",
|
"thNetProfit": "純利益",
|
||||||
"thActions": "操作",
|
"thActions": "操作",
|
||||||
|
"viewFeeBreakdown": "クリックしてTikTok手数料明細を表示",
|
||||||
|
"feeModalTitle": "TikTok 手数料・精算内訳 (Breakdown)",
|
||||||
|
"feeModalSub": "TikTok Shop手数料、アフィリエイト報酬、純利益の詳細内訳",
|
||||||
|
"settlementPendingAlert": "この注文にはまだTikTok精算明細データがありません",
|
||||||
|
"settlementPendingDesc": "以下の手数料と数値は注文データに基づく概算です。TikTokセラーセンターから精算XLSXがインポートされると確定値に更新されます。",
|
||||||
|
"settlementCompleteBadge": "振込完了 (Settled)",
|
||||||
|
"settlementPendingBadge": "精算待ち",
|
||||||
|
"orderRevenueLabel": "商品売上高",
|
||||||
|
"tiktokDeductionsLabel": "TikTok 控除合計",
|
||||||
|
"netPayoutLabel": "振込受取額 (Net Payout)",
|
||||||
|
"cardBomCost": "商品原価 (BOM)",
|
||||||
|
"cardNetProfit": "純利益",
|
||||||
|
"profitOnSales": "売上利益率",
|
||||||
|
"profitOnCost": "原価利益率",
|
||||||
|
"feeBreakdownTitle": "TikTok プラットフォーム控除の内訳",
|
||||||
|
"tiktokCommissionFee": "TikTok Shop プラットフォーム手数料 (Commission)",
|
||||||
|
"tiktokCommissionDesc": "カテゴリ別プラットフォーム手数料率による控除",
|
||||||
|
"affiliateCommissionFee": "クリエイターアフィリエイト報酬 (Affiliate)",
|
||||||
|
"affiliateCommissionDesc": "紹介・ライブ配信クリエイターへの支払報酬",
|
||||||
|
"affiliatePartnerFee": "パートナー手数料 (TAP)",
|
||||||
|
"orderFee": "注文・決済処理手数料 (Order Fee)",
|
||||||
|
"growthSupportFee": "成長支援手数料 (Growth Support)",
|
||||||
|
"shippingBreakdown": "配送・物流手数料内訳",
|
||||||
|
"shippingBreakdownDetail": "実送料 ฿{{actual}} | プラットフォーム補助 -฿{{subsidy}}",
|
||||||
|
"shippingFreeForSeller": "ショップ負担なし",
|
||||||
|
"sellerDiscountLabel": "ショップ割引",
|
||||||
|
"bomProfitAnalysis": "BOM原価控除後の実質純利益分析",
|
||||||
|
"totalBomCostLabel": "控除:BOM原料および包材原価",
|
||||||
|
"realNetProfitLabel": "実質純利益 (Real Net Profit)",
|
||||||
|
"realNetMarginLabel": "純利益率",
|
||||||
|
"orderItemsTitle": "注文内商品一覧",
|
||||||
|
"viewFullOrderDetail": "注文詳細画面を開く",
|
||||||
|
"closeModal": "閉じる",
|
||||||
|
"copyOrderId": "注文番号をコピー",
|
||||||
|
"copied": "コピー完了",
|
||||||
"processingData": "データを処理中...",
|
"processingData": "データを処理中...",
|
||||||
"noOrders": "データがありません",
|
"noOrders": "データがありません",
|
||||||
"settledTransferred": "振込済 ฿{{amount}}",
|
"settledTransferred": "振込済 ฿{{amount}}",
|
||||||
|
|||||||
@@ -454,6 +454,41 @@
|
|||||||
"thAffiliate": "Affiliate (฿)",
|
"thAffiliate": "Affiliate (฿)",
|
||||||
"thNetProfit": "กำไรสุทธิ",
|
"thNetProfit": "กำไรสุทธิ",
|
||||||
"thActions": "การกระทำ",
|
"thActions": "การกระทำ",
|
||||||
|
"viewFeeBreakdown": "คลิกดู Breakdown ค่าธรรมเนียม TikTok",
|
||||||
|
"feeModalTitle": "TikTok Fee & Settlement Breakdown",
|
||||||
|
"feeModalSub": "รายละเอียดค่าธรรมเนียม TikTok Shop, Affiliate และกำไรสุทธิ",
|
||||||
|
"settlementPendingAlert": "คำสั่งซื้อนี้ยังไม่มีข้อมูลใบแจ้งยอด Settlement",
|
||||||
|
"settlementPendingDesc": "ค่าคอมมิชชั่นและตัวเลขด้านล่างเป็นการคำนวณเบื้องต้นจากข้อมูล Order จนกว่าจะมีการ Import ไฟล์ Settlement XLSX จาก TikTok Seller Center",
|
||||||
|
"settlementCompleteBadge": "โอนเงินแล้ว",
|
||||||
|
"settlementPendingBadge": "รอ Settlement",
|
||||||
|
"orderRevenueLabel": "ยอดขายสินค้า",
|
||||||
|
"tiktokDeductionsLabel": "TikTok หักรวม",
|
||||||
|
"netPayoutLabel": "ยอดโอนสุทธิ",
|
||||||
|
"cardBomCost": "ต้นทุนสินค้า (BOM)",
|
||||||
|
"cardNetProfit": "กำไรสุทธิ",
|
||||||
|
"profitOnSales": "กำไร/ยอดขาย",
|
||||||
|
"profitOnCost": "กำไร/ทุน",
|
||||||
|
"feeBreakdownTitle": "Breakdown รายการหักค่าใช้จ่ายของ TikTok",
|
||||||
|
"tiktokCommissionFee": "ค่าคอมมิชชั่น TikTok Shop (Commission Fee)",
|
||||||
|
"tiktokCommissionDesc": "หักตามอัตราหมวดหมู่สินค้าของแพลตฟอร์ม",
|
||||||
|
"affiliateCommissionFee": "ค่าคอมมิชชั่น Creator แอฟฟิลิเอต (Affiliate)",
|
||||||
|
"affiliateCommissionDesc": "จ่ายให้ครีเอเตอร์/นายหน้าที่แปะลิงก์หรือไลฟ์",
|
||||||
|
"affiliatePartnerFee": "ค่าคอมมิชชั่น Partner แอฟฟิลิเอต (TAP)",
|
||||||
|
"orderFee": "ค่าธรรมเนียมคำสั่งซื้อ/ธุรกรรม (Order Fee)",
|
||||||
|
"growthSupportFee": "ค่าธรรมเนียมสนับสนุนการเติบโต (Growth Support)",
|
||||||
|
"shippingBreakdown": "ค่าจัดส่งขนส่ง",
|
||||||
|
"shippingBreakdownDetail": "ค่าส่งจริง ฿{{actual}} | TikTok อุดหนุน -฿{{subsidy}}",
|
||||||
|
"shippingFreeForSeller": "ร้านค้าไม่เสียค่าส่ง",
|
||||||
|
"sellerDiscountLabel": "ส่วนลดจากร้านค้า",
|
||||||
|
"bomProfitAnalysis": "วิเคราะห์กำไรสุทธิจริงหลังหักต้นทุน BOM",
|
||||||
|
"totalBomCostLabel": "หัก ต้นทุนสินค้าตามสูตร BOM",
|
||||||
|
"realNetProfitLabel": "กำไรสุทธิแท้จริง (Real Net Profit)",
|
||||||
|
"realNetMarginLabel": "คิดเป็นอัตรากำไร",
|
||||||
|
"orderItemsTitle": "รายการสินค้าในออเดอร์นี้",
|
||||||
|
"viewFullOrderDetail": "เปิดดูคำสั่งซื้อฉบับเต็ม",
|
||||||
|
"closeModal": "ปิด",
|
||||||
|
"copyOrderId": "คัดลอก Order ID",
|
||||||
|
"copied": "คัดลอกแล้ว",
|
||||||
"processingData": "กำลังประมวลผลข้อมูล...",
|
"processingData": "กำลังประมวลผลข้อมูล...",
|
||||||
"noOrders": "ยังไม่มีข้อมูล",
|
"noOrders": "ยังไม่มีข้อมูล",
|
||||||
"settledTransferred": "โอนแล้ว ฿{{amount}}",
|
"settledTransferred": "โอนแล้ว ฿{{amount}}",
|
||||||
|
|||||||
@@ -450,6 +450,41 @@
|
|||||||
"thAffiliate": "Affiliate (฿)",
|
"thAffiliate": "Affiliate (฿)",
|
||||||
"thNetProfit": "净利润",
|
"thNetProfit": "净利润",
|
||||||
"thActions": "操作",
|
"thActions": "操作",
|
||||||
|
"viewFeeBreakdown": "点击查看 TikTok 费用明细",
|
||||||
|
"feeModalTitle": "TikTok 费用与结算明细",
|
||||||
|
"feeModalSub": "TikTok Shop 佣金、达人佣金与净利润详细拆解",
|
||||||
|
"settlementPendingAlert": "该订单暂无 TikTok 结算对账单数据",
|
||||||
|
"settlementPendingDesc": "以下佣金与费用为基于订单数据的预估,待从 TikTok 卖家中心导入结算 XLSX 文件后更新。",
|
||||||
|
"settlementCompleteBadge": "已完成打款",
|
||||||
|
"settlementPendingBadge": "等待结算",
|
||||||
|
"orderRevenueLabel": "商品销售额",
|
||||||
|
"tiktokDeductionsLabel": "TikTok 扣款合计",
|
||||||
|
"netPayoutLabel": "最终打款金额",
|
||||||
|
"cardBomCost": "商品成本 (BOM)",
|
||||||
|
"cardNetProfit": "净利润",
|
||||||
|
"profitOnSales": "利润率(销售)",
|
||||||
|
"profitOnCost": "利润率(成本)",
|
||||||
|
"feeBreakdownTitle": "TikTok 平台扣款明细",
|
||||||
|
"tiktokCommissionFee": "TikTok Shop 平台佣金 (Commission Fee)",
|
||||||
|
"tiktokCommissionDesc": "按平台品类佣金率扣除",
|
||||||
|
"affiliateCommissionFee": "达人/分销佣金 (Affiliate Commission)",
|
||||||
|
"affiliateCommissionDesc": "支付给带货达人的佣金",
|
||||||
|
"affiliatePartnerFee": "机构/合作伙伴佣金 (TAP)",
|
||||||
|
"orderFee": "订单/交易手续费 (Order Fee)",
|
||||||
|
"growthSupportFee": "成长支持费 (Growth Support Fee)",
|
||||||
|
"shippingBreakdown": "物流运费明细",
|
||||||
|
"shippingBreakdownDetail": "实际运费 ฿{{actual}} | 平台补贴 -฿{{subsidy}}",
|
||||||
|
"shippingFreeForSeller": "商家免运费",
|
||||||
|
"sellerDiscountLabel": "商家折扣",
|
||||||
|
"bomProfitAnalysis": "扣除 BOM 成本后真实净利润分析",
|
||||||
|
"totalBomCostLabel": "扣除:BOM 原料及包装成本",
|
||||||
|
"realNetProfitLabel": "真实净利润 (Real Net Profit)",
|
||||||
|
"realNetMarginLabel": "净利润率",
|
||||||
|
"orderItemsTitle": "订单内商品列表",
|
||||||
|
"viewFullOrderDetail": "查看订单完整详情",
|
||||||
|
"closeModal": "关闭",
|
||||||
|
"copyOrderId": "复制订单号",
|
||||||
|
"copied": "已复制",
|
||||||
"processingData": "正在处理数据...",
|
"processingData": "正在处理数据...",
|
||||||
"noOrders": "暂无数据",
|
"noOrders": "暂无数据",
|
||||||
"settledTransferred": "已打款 ฿{{amount}}",
|
"settledTransferred": "已打款 ฿{{amount}}",
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import { getAuthHeaders } from '../utils/auth';
|
|||||||
|
|
||||||
import CinematicStoryScrollForm from '../components/page-builder/CinematicStoryScrollForm';
|
import CinematicStoryScrollForm from '../components/page-builder/CinematicStoryScrollForm';
|
||||||
import AppleShowcaseForm from '../components/page-builder/AppleShowcaseForm';
|
import AppleShowcaseForm from '../components/page-builder/AppleShowcaseForm';
|
||||||
|
import Interactive360ShowcaseForm from '../components/page-builder/Interactive360ShowcaseForm';
|
||||||
|
import VdoFrameScrubberForm from '../components/page-builder/VdoFrameScrubberForm';
|
||||||
import HeroForm from '../components/page-builder/HeroForm';
|
import HeroForm from '../components/page-builder/HeroForm';
|
||||||
import AnnouncementBarForm from '../components/page-builder/AnnouncementBarForm';
|
import AnnouncementBarForm from '../components/page-builder/AnnouncementBarForm';
|
||||||
import NewsletterForm from '../components/page-builder/NewsletterForm';
|
import NewsletterForm from '../components/page-builder/NewsletterForm';
|
||||||
@@ -1469,6 +1471,8 @@ export default function PageBuilderPage() {
|
|||||||
onChange={(e) => handleTypeChange(e.target.value)}
|
onChange={(e) => handleTypeChange(e.target.value)}
|
||||||
className="admin-input w-full bg-slate-900 border-white/10"
|
className="admin-input w-full bg-slate-900 border-white/10"
|
||||||
>
|
>
|
||||||
|
<option value="VDO_FRAME_SCRUBBER">🎬 VDO FRAME SCRUBBER (432 Frames Video Scrub)</option>
|
||||||
|
<option value="INTERACTIVE_360_SHOWCASE">⚡ INTERACTIVE 360° TURNTABLE (3D Web Sprocket)</option>
|
||||||
<option value="CINEMATIC_STORY_SCROLL">✨ CINEMATIC STORY SCROLL (Tobaki Scrollytelling)</option>
|
<option value="CINEMATIC_STORY_SCROLL">✨ CINEMATIC STORY SCROLL (Tobaki Scrollytelling)</option>
|
||||||
<option value="APPLE_SHOWCASE">✨ APPLE KEYNOTE SHOWCASE (Apple Style Tour)</option>
|
<option value="APPLE_SHOWCASE">✨ APPLE KEYNOTE SHOWCASE (Apple Style Tour)</option>
|
||||||
<option value="HERO">HERO (Campaign Intro)</option>
|
<option value="HERO">HERO (Campaign Intro)</option>
|
||||||
@@ -1593,6 +1597,24 @@ export default function PageBuilderPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editingSection.type?.toUpperCase() === 'VDO_FRAME_SCRUBBER' && (
|
||||||
|
<VdoFrameScrubberForm
|
||||||
|
payload={editingSection.payload || {}}
|
||||||
|
updatePayloadValue={updatePayloadValue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingSection.type?.toUpperCase() === 'INTERACTIVE_360_SHOWCASE' && (
|
||||||
|
<Interactive360ShowcaseForm
|
||||||
|
payload={editingSection.payload || {}}
|
||||||
|
updatePayloadValue={updatePayloadValue}
|
||||||
|
onBrowseMedia={(cb) => {
|
||||||
|
setCustomMediaCallback(() => cb);
|
||||||
|
setIsMediaModalOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{editingSection.type?.toUpperCase() === 'CINEMATIC_STORY_SCROLL' && (
|
{editingSection.type?.toUpperCase() === 'CINEMATIC_STORY_SCROLL' && (
|
||||||
<CinematicStoryScrollForm
|
<CinematicStoryScrollForm
|
||||||
payload={editingSection.payload || {}}
|
payload={editingSection.payload || {}}
|
||||||
|
|||||||
@@ -11,9 +11,13 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Coins,
|
Coins,
|
||||||
Layers,
|
Layers,
|
||||||
Calculator
|
Calculator,
|
||||||
|
Bot,
|
||||||
|
FileJson
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { API_BASE, type Product } from '@homaroma/types'
|
import { API_BASE, type Product } from '@homaroma/types'
|
||||||
|
import ProductBomPromptModal from '../components/ProductBomPromptModal'
|
||||||
|
import ProductBomImportModal from '../components/ProductBomImportModal'
|
||||||
|
|
||||||
function fmtMoney(val: number | null | undefined): string {
|
function fmtMoney(val: number | null | undefined): string {
|
||||||
if (val == null || isNaN(val)) return '฿0.00'
|
if (val == null || isNaN(val)) return '฿0.00'
|
||||||
@@ -38,6 +42,14 @@ export default function ProductBomPage() {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
|
const [isPromptModalOpen, setIsPromptModalOpen] = useState(false)
|
||||||
|
const [isImportModalOpen, setIsImportModalOpen] = useState(false)
|
||||||
|
|
||||||
|
const handleApplyBomJson = (updatedVariants: any[]) => {
|
||||||
|
setVariants(updatedVariants)
|
||||||
|
setSuccess(true)
|
||||||
|
setTimeout(() => setSuccess(false), 3500)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -143,9 +155,14 @@ export default function ProductBomPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const productName =
|
const productName =
|
||||||
typeof product.name === 'string'
|
(typeof product.name === 'string' && product.name.trim())
|
||||||
? product.name
|
? product.name.trim()
|
||||||
: (product.name as any)?.th || (product.name as any)?.en || 'Unknown Product'
|
: (product.name as any)?.th ||
|
||||||
|
(product.name as any)?.en ||
|
||||||
|
(typeof product.name === 'object' && Object.values(product.name || {}).find((v) => typeof v === 'string' && (v as string).trim()) as string) ||
|
||||||
|
((product as any).brand?.name
|
||||||
|
? `${(product as any).brand?.name} ${(product as any).category?.name || ''}`.trim()
|
||||||
|
: `Product #${product.id}`)
|
||||||
|
|
||||||
// Summary Metrics Across All Variants
|
// Summary Metrics Across All Variants
|
||||||
const totalVariantsCount = variants.length
|
const totalVariantsCount = variants.length
|
||||||
@@ -177,6 +194,24 @@ export default function ProductBomPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsPromptModalOpen(true)}
|
||||||
|
className="px-4 py-2.5 rounded-xl bg-purple-500/10 hover:bg-purple-500/20 text-purple-300 border border-purple-500/30 text-sm font-bold flex items-center gap-2 transition-all shadow-sm"
|
||||||
|
title="สร้าง Prompt สำหรับส่งให้ LLM คิดและตอบกลับเป็นสูตร BOM"
|
||||||
|
>
|
||||||
|
<Bot className="w-4 h-4 text-purple-400" />
|
||||||
|
<span>Get Prompt</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsImportModalOpen(true)}
|
||||||
|
className="px-4 py-2.5 rounded-xl bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 text-sm font-bold flex items-center gap-2 transition-all shadow-sm"
|
||||||
|
title="วาง JSON จาก LLM เพื่อกรอกสูตร BOM ลงในตารางอัตโนมัติ"
|
||||||
|
>
|
||||||
|
<FileJson className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span>Import BOM JSON</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/products')}
|
onClick={() => navigate('/products')}
|
||||||
className="px-5 py-2.5 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 text-sm font-bold text-slate-300 transition-all"
|
className="px-5 py-2.5 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 text-sm font-bold text-slate-300 transition-all"
|
||||||
@@ -534,6 +569,25 @@ export default function ProductBomPage() {
|
|||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* LLM Prompt Generator Modal */}
|
||||||
|
<ProductBomPromptModal
|
||||||
|
isOpen={isPromptModalOpen}
|
||||||
|
onClose={() => setIsPromptModalOpen(false)}
|
||||||
|
productId={id || ''}
|
||||||
|
productName={productName}
|
||||||
|
variants={variants}
|
||||||
|
rawMaterials={rawMaterials}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* LLM JSON Import Modal */}
|
||||||
|
<ProductBomImportModal
|
||||||
|
isOpen={isImportModalOpen}
|
||||||
|
onClose={() => setIsImportModalOpen(false)}
|
||||||
|
variants={variants}
|
||||||
|
rawMaterials={rawMaterials}
|
||||||
|
onApply={handleApplyBomJson}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
import { useState, useEffect, type FormEvent } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
PackageOpen,
|
||||||
|
Boxes,
|
||||||
|
ExternalLink,
|
||||||
|
Layers,
|
||||||
|
Sparkles,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { API_BASE } from '@homaroma/types';
|
||||||
|
|
||||||
|
const COMMON_UNITS = [
|
||||||
|
{ label: 'ชิ้น (piece)', value: 'piece' },
|
||||||
|
{ label: 'กก. (kg)', value: 'kg' },
|
||||||
|
{ label: 'กรัม (g)', value: 'g' },
|
||||||
|
{ label: 'ลิตร (liter)', value: 'liter' },
|
||||||
|
{ label: 'มล. (ml)', value: 'ml' },
|
||||||
|
{ label: 'แพ็ค (pack)', value: 'pack' },
|
||||||
|
{ label: 'กล่อง (box)', value: 'box' },
|
||||||
|
{ label: 'หลอด (tube)', value: 'tube' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function RawMaterialDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const isNew = !id || id === 'new';
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(!isNew);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [toast, setToast] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
// Form Fields
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [unit, setUnit] = useState('piece');
|
||||||
|
const [pricePerUnit, setPricePerUnit] = useState<number>(0);
|
||||||
|
const [materialData, setMaterialData] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNew) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
fetch(`${API_BASE}/raw-materials/${id}`)
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error('ไม่พบข้อมูลวัตถุดิบ');
|
||||||
|
return await res.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
setMaterialData(data);
|
||||||
|
setName(data.name || '');
|
||||||
|
setUnit(data.unit || 'piece');
|
||||||
|
setPricePerUnit(Number(data.pricePerUnit) || 0);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
setToast({ type: 'error', text: err.message || 'เกิดข้อผิดพลาดในการโหลดข้อมูล' });
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id, isNew]);
|
||||||
|
|
||||||
|
const showToast = (type: 'success' | 'error', text: string) => {
|
||||||
|
setToast({ type, text });
|
||||||
|
setTimeout(() => setToast(null), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (e?: FormEvent) => {
|
||||||
|
if (e) e.preventDefault();
|
||||||
|
if (!name.trim()) {
|
||||||
|
showToast('error', 'กรุณาระบุชื่อวัตถุดิบ');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!unit.trim()) {
|
||||||
|
showToast('error', 'กรุณาระบุหน่วยนับ');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pricePerUnit < 0) {
|
||||||
|
showToast('error', 'ราคาต้นทุนต่อหน่วยต้องไม่ติดลบ');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: name.trim(),
|
||||||
|
unit: unit.trim(),
|
||||||
|
pricePerUnit: Number(pricePerUnit) || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = isNew ? `${API_BASE}/raw-materials` : `${API_BASE}/raw-materials/${id}`;
|
||||||
|
const method = isNew ? 'POST' : 'PUT';
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.message || 'บันทึกข้อมูลไม่สำเร็จ');
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('success', isNew ? 'สร้างวัตถุดิบใหม่สำเร็จ' : 'บันทึกการแก้ไขสำเร็จ');
|
||||||
|
|
||||||
|
if (isNew && data.id) {
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate(`/raw-materials/${data.id}`, { replace: true });
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast('error', err.message || 'เกิดข้อผิดพลาดในการบันทึก');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบวัตถุดิบ "${name}"?`)) return;
|
||||||
|
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/raw-materials/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || 'ไม่สามารถลบวัตถุดิบนี้ได้');
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate('/raw-materials', { replace: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast('error', err.message || 'เกิดข้อผิดพลาดในการลบ');
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[400px] space-y-4">
|
||||||
|
<Loader2 className="w-8 h-8 text-cyan-400 animate-spin" />
|
||||||
|
<p className="text-slate-400 text-sm">กำลังโหลดข้อมูลวัตถุดิบ...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bomItems = materialData?.bomItems || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-fade-in max-w-5xl mx-auto pb-16">
|
||||||
|
{/* Toast Notification */}
|
||||||
|
{toast && (
|
||||||
|
<div
|
||||||
|
className={`fixed bottom-6 right-6 z-50 p-4 rounded-2xl border shadow-2xl flex items-center gap-3 animate-slide-up ${
|
||||||
|
toast.type === 'success'
|
||||||
|
? 'bg-emerald-950/90 border-emerald-500/40 text-emerald-200'
|
||||||
|
: 'bg-rose-950/90 border-rose-500/40 text-rose-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{toast.type === 'success' ? (
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-5 h-5 text-rose-400" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium">{toast.text}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top Breadcrumb & Navigation */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||||
|
<Link to="/raw-materials" className="hover:text-white transition-colors flex items-center gap-1">
|
||||||
|
<PackageOpen className="w-3.5 h-3.5" />
|
||||||
|
<span>Raw Materials</span>
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-cyan-400 font-medium">
|
||||||
|
{isNew ? 'เพิ่มวัตถุดิบใหม่' : name || `ID #${id}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/raw-materials')}
|
||||||
|
className="p-2.5 rounded-2xl bg-white/5 border border-white/10 hover:bg-white/10 text-slate-300 hover:text-white transition-all shadow-sm"
|
||||||
|
title="ย้อนกลับไปหน้ารายการ"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-white flex items-center gap-2.5">
|
||||||
|
{isNew ? 'เพิ่มวัตถุดิบใหม่' : `แก้ไขวัตถุดิบ`}
|
||||||
|
{!isNew && (
|
||||||
|
<span className="text-xs px-2.5 py-1 rounded-full bg-cyan-500/10 text-cyan-300 border border-cyan-500/20 font-mono">
|
||||||
|
ID: #{id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs md:text-sm text-slate-400">
|
||||||
|
{isNew
|
||||||
|
? 'สร้างรายการวัตถุดิบสำหรับใช้คำนวณต้นทุนสินค้าและผูกสูตรผลิต (BOM)'
|
||||||
|
: 'กำหนดข้อมูลราคาต้นทุนและหน่วยนับสำหรับใช้ในสูตรการผลิตสินค้า'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{!isNew && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting || saving}
|
||||||
|
className="px-4 py-2.5 rounded-xl border border-rose-500/30 text-rose-400 hover:bg-rose-500/10 hover:border-rose-500/50 font-medium text-sm flex items-center gap-2 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||||
|
<span>ลบวัตถุดิบ</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSave()}
|
||||||
|
disabled={saving}
|
||||||
|
className="btn-primary px-6 py-2.5 flex items-center gap-2 text-sm shadow-lg shadow-cyan-500/10"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
<span>{saving ? 'กำลังบันทึก...' : 'บันทึกข้อมูล'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
{/* Left Column: Form Details (2 cols) */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<form onSubmit={handleSave} className="admin-card space-y-6 border-white/10 shadow-2xl p-6 md:p-8">
|
||||||
|
<div className="flex items-center gap-2 pb-4 border-b border-white/5">
|
||||||
|
<Sparkles className="w-4 h-4 text-cyan-400" />
|
||||||
|
<h2 className="text-base font-bold text-white uppercase tracking-wider text-xs">
|
||||||
|
ข้อมูลวัตถุดิบ (Material Information)
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Material Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center justify-between">
|
||||||
|
<span>ชื่อวัตถุดิบ (Material Name) <span className="text-rose-400">*</span></span>
|
||||||
|
<span className="text-[10px] text-slate-500 font-normal">ห้ามซ้ำกับรายการอื่นในระบบ</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="เช่น ขวดแก้ว 100ml, น้ำมันหอมระเหย ลาเวนเดอร์, ก้านไม้ไฟเบอร์"
|
||||||
|
className="admin-input w-full text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unit Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider">
|
||||||
|
หน่วยนับ (Unit of Measurement) <span className="text-rose-400">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={unit}
|
||||||
|
onChange={(e) => setUnit(e.target.value)}
|
||||||
|
placeholder="เช่น piece, kg, liter, pack, ml"
|
||||||
|
className="admin-input w-full text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Quick Preset Unit Chips */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span className="text-[11px] text-slate-400">เลือกหน่วยที่ใช้บ่อย:</span>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{COMMON_UNITS.map((u) => (
|
||||||
|
<button
|
||||||
|
key={u.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUnit(u.value)}
|
||||||
|
className={`text-xs px-2.5 py-1 rounded-lg border transition-all ${
|
||||||
|
unit === u.value
|
||||||
|
? 'bg-cyan-500/20 border-cyan-500/40 text-cyan-300 font-bold'
|
||||||
|
: 'bg-white/5 border-white/5 text-slate-400 hover:text-white hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{u.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cost Price per Unit */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center justify-between">
|
||||||
|
<span>ต้นทุนต่อหน่วย (Cost per Unit) <span className="text-rose-400">*</span></span>
|
||||||
|
<span className="text-[10px] text-slate-500 font-normal">ใช้ในการคำนวณสูตร BOM สินค้า</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 font-bold text-sm">
|
||||||
|
฿
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
value={pricePerUnit === 0 ? '' : pricePerUnit}
|
||||||
|
onChange={(e) => setPricePerUnit(parseFloat(e.target.value) || 0)}
|
||||||
|
placeholder="0.00"
|
||||||
|
className="admin-input w-full pl-9 font-mono text-emerald-400 font-bold text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-500">
|
||||||
|
ระบุราคาต้นทุนเฉลี่ยต่อ 1 {unit || 'หน่วย'} เช่น 18.50 บาท / ขวด
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: BOM Linked Products & Summary (1 col) */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary Card */}
|
||||||
|
<div className="admin-card border-white/10 shadow-xl p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-2 pb-3 border-b border-white/5">
|
||||||
|
<Layers className="w-4 h-4 text-cyan-400" />
|
||||||
|
<h3 className="text-xs font-bold text-white uppercase tracking-wider">
|
||||||
|
สรุปภาพรวม (Overview)
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
<div className="flex justify-between py-1.5 border-b border-white/5 text-slate-300">
|
||||||
|
<span className="text-slate-400">สถานะ:</span>
|
||||||
|
<span className="font-semibold text-emerald-400">พร้อมใช้งาน</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-1.5 border-b border-white/5 text-slate-300">
|
||||||
|
<span className="text-slate-400">หน่วยนับ:</span>
|
||||||
|
<span className="font-mono font-bold text-white">{unit || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-1.5 border-b border-white/5 text-slate-300">
|
||||||
|
<span className="text-slate-400">ต้นทุน / หน่วย:</span>
|
||||||
|
<span className="font-mono font-bold text-emerald-400">
|
||||||
|
฿{Number(pricePerUnit || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isNew && materialData?.updatedAt && (
|
||||||
|
<div className="flex justify-between py-1.5 text-slate-300">
|
||||||
|
<span className="text-slate-400">อัปเดตล่าสุด:</span>
|
||||||
|
<span className="text-slate-400">
|
||||||
|
{new Date(materialData.updatedAt).toLocaleDateString('th-TH')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BOM Usage Section */}
|
||||||
|
{!isNew && (
|
||||||
|
<div className="admin-card border-white/10 shadow-xl p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-white/5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Boxes className="w-4 h-4 text-cyan-400" />
|
||||||
|
<h3 className="text-xs font-bold text-white uppercase tracking-wider">
|
||||||
|
สูตรสินค้าที่ใช้วัตถุดิบนี้ ({bomItems.length})
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/products/bom"
|
||||||
|
className="text-[11px] text-cyan-400 hover:text-cyan-300 flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<span>จัดการ BOM</span>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bomItems.length === 0 ? (
|
||||||
|
<div className="py-6 text-center space-y-2">
|
||||||
|
<Boxes className="w-8 h-8 text-slate-700 mx-auto" />
|
||||||
|
<p className="text-xs text-slate-400 italic">
|
||||||
|
ยังไม่มีสูตรสินค้า (BOM) ที่ผูกกับวัตถุดิบนี้
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-slate-500">
|
||||||
|
สามารถไปผูกวัตถุดิบเข้ากับ Variant สินค้าได้ที่เมนู "BOM สูตรผลิต"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2.5 max-h-[300px] overflow-y-auto pr-1">
|
||||||
|
{bomItems.map((item: any, idx: number) => {
|
||||||
|
const product = item.productVariant?.product;
|
||||||
|
const variant = item.productVariant;
|
||||||
|
const variantLabel = [variant?.option1, variant?.option2, variant?.option3]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' / ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id || idx}
|
||||||
|
className="p-3 rounded-xl bg-white/[0.02] border border-white/5 space-y-1 hover:border-white/10 transition-all"
|
||||||
|
>
|
||||||
|
<p className="text-xs font-bold text-white line-clamp-1">
|
||||||
|
{product?.name || `Product #${variant?.productId}`}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-slate-400">
|
||||||
|
<span>{variantLabel ? `ตัวเลือก: ${variantLabel}` : 'Base Variant'}</span>
|
||||||
|
<span className="font-mono text-cyan-400 font-bold">
|
||||||
|
{item.quantity} {unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,227 +1,395 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { Plus, Edit3, Trash2, Loader2, PackageOpen } from 'lucide-react'
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { API_BASE, type RawMaterial } from '@homaroma/types'
|
import {
|
||||||
|
Plus,
|
||||||
|
Edit3,
|
||||||
|
Trash2,
|
||||||
|
PackageOpen,
|
||||||
|
Search,
|
||||||
|
FileJson,
|
||||||
|
Boxes,
|
||||||
|
Layers,
|
||||||
|
Sparkles,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { API_BASE, type RawMaterial } from '@homaroma/types';
|
||||||
|
import RawMaterialBulkJsonModal from '../components/RawMaterialBulkJsonModal';
|
||||||
|
|
||||||
export default function RawMaterialsPage() {
|
export default function RawMaterialsPage() {
|
||||||
const [materials, setMaterials] = useState<RawMaterial[]>([])
|
const navigate = useNavigate();
|
||||||
const [loading, setLoading] = useState(true)
|
const [materials, setMaterials] = useState<RawMaterial[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isBulkModalOpen, setIsBulkModalOpen] = useState(false);
|
||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
// Inline Price Editing State
|
||||||
const [editingId, setEditingId] = useState<number | null>(null)
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [editingPrice, setEditingPrice] = useState<string>('');
|
||||||
|
const [savingId, setSavingId] = useState<number | null>(null);
|
||||||
|
|
||||||
const [name, setName] = useState('')
|
const startEditing = (item: RawMaterial) => {
|
||||||
const [unit, setUnit] = useState('')
|
setEditingId(item.id);
|
||||||
const [pricePerUnit, setPricePerUnit] = useState<number>(0)
|
setEditingPrice(String(item.pricePerUnit ?? ''));
|
||||||
const [saving, setSaving] = useState(false)
|
};
|
||||||
|
|
||||||
|
const cancelEditing = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditingPrice('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSavePrice = async (item: RawMaterial) => {
|
||||||
|
const val = parseFloat(editingPrice);
|
||||||
|
if (isNaN(val) || val < 0) {
|
||||||
|
alert('กรุณาระบุราคาต้นทุนให้ถูกต้อง (ต้องเป็นตัวเลขมากกว่าหรือเท่ากับ 0)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingId(item.id);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/raw-materials/${item.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
pricePerUnit: val,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.message || 'บันทึกราคาไม่สำเร็จ');
|
||||||
|
}
|
||||||
|
|
||||||
|
setMaterials((prev) =>
|
||||||
|
prev.map((m) => (m.id === item.id ? { ...m, pricePerUnit: val } : m))
|
||||||
|
);
|
||||||
|
setEditingId(null);
|
||||||
|
setEditingPrice('');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
alert(err.message || 'เกิดข้อผิดพลาดในการบันทึกราคา');
|
||||||
|
} finally {
|
||||||
|
setSavingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchMaterials = async () => {
|
const fetchMaterials = async () => {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/raw-materials`)
|
const res = await fetch(`${API_BASE}/raw-materials`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setMaterials(await res.json())
|
setMaterials(await res.json());
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch raw materials', err)
|
console.error('Failed to fetch raw materials', err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMaterials()
|
fetchMaterials();
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const handleOpenModal = (material?: RawMaterial) => {
|
const handleDelete = async (id: number, name: string) => {
|
||||||
if (material) {
|
if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบวัตถุดิบ "${name}"?`)) return;
|
||||||
setEditingId(material.id)
|
|
||||||
setName(material.name)
|
|
||||||
setUnit(material.unit)
|
|
||||||
setPricePerUnit(material.pricePerUnit)
|
|
||||||
} else {
|
|
||||||
setEditingId(null)
|
|
||||||
setName('')
|
|
||||||
setUnit('')
|
|
||||||
setPricePerUnit(0)
|
|
||||||
}
|
|
||||||
setIsModalOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!name || !unit || pricePerUnit <= 0) return alert('Please fill all fields')
|
|
||||||
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
try {
|
||||||
const payload = { name, unit, pricePerUnit }
|
const res = await fetch(`${API_BASE}/raw-materials/${id}`, { method: 'DELETE' });
|
||||||
const url = editingId
|
|
||||||
? `${API_BASE}/raw-materials/${editingId}`
|
|
||||||
: `${API_BASE}/raw-materials`
|
|
||||||
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: editingId ? 'PUT' : 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setIsModalOpen(false)
|
fetchMaterials();
|
||||||
fetchMaterials()
|
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to save raw material')
|
const data = await res.json();
|
||||||
|
alert(data.message || 'ไม่สามารถลบวัตถุดิบนี้ได้');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err);
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
// Filtered materials by search query
|
||||||
if (!confirm('Are you sure you want to delete this raw material?')) return
|
const filteredMaterials = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) return materials;
|
||||||
|
const q = searchQuery.toLowerCase().trim();
|
||||||
|
return materials.filter(
|
||||||
|
(m) =>
|
||||||
|
m.name.toLowerCase().includes(q) ||
|
||||||
|
m.unit.toLowerCase().includes(q) ||
|
||||||
|
String(m.id).includes(q)
|
||||||
|
);
|
||||||
|
}, [materials, searchQuery]);
|
||||||
|
|
||||||
try {
|
// Aggregate metrics
|
||||||
const res = await fetch(`${API_BASE}/raw-materials/${id}`, { method: 'DELETE' })
|
const metrics = useMemo(() => {
|
||||||
if (res.ok) {
|
const totalCount = materials.length;
|
||||||
fetchMaterials()
|
const avgCost = totalCount > 0
|
||||||
}
|
? materials.reduce((sum, m) => sum + (Number(m.pricePerUnit) || 0), 0) / totalCount
|
||||||
} catch (err) {
|
: 0;
|
||||||
console.error(err)
|
const units = new Set(materials.map((m) => m.unit.toLowerCase())).size;
|
||||||
}
|
return { totalCount, avgCost, units };
|
||||||
}
|
}, [materials]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-fade-in">
|
<div className="space-y-8 animate-fade-in pb-12">
|
||||||
|
{/* Page Header */}
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-3xl font-bold">Raw Materials (วัตถุดิบ)</h1>
|
<div className="flex items-center gap-3">
|
||||||
<p className="text-slate-500 text-sm">Manage inventory and costs for BOM calculation.</p>
|
<h1 className="text-3xl font-bold text-white">Raw Materials (วัตถุดิบ)</h1>
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-cyan-500/10 text-cyan-400 border border-cyan-500/20">
|
||||||
|
{materials.length} รายการ
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-400 text-sm">
|
||||||
|
จัดการรายการวัตถุดิบ หน่วยนับ และต้นทุนสำหรับผูกสูตรผลิตสินค้า (BOM)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Bulk JSON Import / Export Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsBulkModalOpen(true)}
|
||||||
|
className="px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 font-medium text-sm flex items-center gap-2 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
<FileJson className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span>Import / Export JSON</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Add Material (Navigates to Full Page Route) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/raw-materials/new')}
|
||||||
|
className="btn-primary flex items-center gap-2 text-sm shadow-lg shadow-cyan-500/10"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>เพิ่มวัตถุดิบ</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => handleOpenModal()} className="btn-primary flex items-center gap-2">
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
Add Material
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics Row */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div className="admin-card p-5 border-white/5 bg-night-900/60 flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400 shrink-0">
|
||||||
|
<PackageOpen className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">วัตถุดิบทั้งหมด</p>
|
||||||
|
<p className="text-2xl font-bold text-white font-mono mt-0.5">{metrics.totalCount} <span className="text-xs font-normal text-slate-400">รายการ</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-card p-5 border-white/5 bg-night-900/60 flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400 shrink-0">
|
||||||
|
<Sparkles className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">ต้นทุนเฉลี่ย / หน่วย</p>
|
||||||
|
<p className="text-2xl font-bold text-emerald-400 font-mono mt-0.5">
|
||||||
|
฿{metrics.avgCost.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-card p-5 border-white/5 bg-night-900/60 flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-400 shrink-0">
|
||||||
|
<Layers className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">ประเภทหน่วยนับ</p>
|
||||||
|
<p className="text-2xl font-bold text-white font-mono mt-0.5">{metrics.units} <span className="text-xs font-normal text-slate-400">แบบ</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="relative w-full sm:w-80">
|
||||||
|
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="ค้นหาตามชื่อ หรือ หน่วยนับ..."
|
||||||
|
className="admin-input w-full pl-10 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-slate-400">
|
||||||
|
แสดง <strong>{filteredMaterials.length}</strong> จากทั้งหมด {materials.length} รายการ
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data Table */}
|
||||||
<div className="admin-card !p-0 overflow-hidden border-white/10 shadow-2xl">
|
<div className="admin-card !p-0 overflow-hidden border-white/10 shadow-2xl">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left border-collapse">
|
<table className="w-full text-left border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-white/[0.02] border-b border-white/5">
|
<tr className="bg-white/[0.02] border-b border-white/5">
|
||||||
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Material Name</th>
|
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 w-16">ID</th>
|
||||||
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Unit</th>
|
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">ชื่อวัตถุดิบ (Material Name)</th>
|
||||||
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Cost Per Unit</th>
|
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">หน่วยนับ (Unit)</th>
|
||||||
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 text-right">Actions</th>
|
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">ต้นทุนต่อหน่วย (Cost)</th>
|
||||||
|
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 text-right">การจัดการ</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-white/[0.03]">
|
<tbody className="divide-y divide-white/[0.03]">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
Array.from({ length: 3 }).map((_, i) => (
|
Array.from({ length: 4 }).map((_, i) => (
|
||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
<td colSpan={4} className="px-6 py-8">
|
<td colSpan={5} className="px-6 py-8">
|
||||||
<div className="h-6 w-full bg-white/5 animate-pulse rounded-lg" />
|
<div className="h-6 w-full bg-white/5 animate-pulse rounded-lg" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
) : materials.length === 0 ? (
|
) : filteredMaterials.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="px-6 py-20 text-center space-y-3">
|
<td colSpan={5} className="px-6 py-20 text-center space-y-3">
|
||||||
<PackageOpen className="w-10 h-10 text-slate-700 mx-auto" />
|
<PackageOpen className="w-10 h-10 text-slate-700 mx-auto" />
|
||||||
<p className="text-slate-500 font-medium italic">No raw materials found.</p>
|
<p className="text-slate-400 font-medium">
|
||||||
</td>
|
{searchQuery ? 'ไม่พบวัตถุดิบที่ตรงกับคำค้นหา' : 'ยังไม่มีข้อมูลวัตถุดิบในระบบ'}
|
||||||
</tr>
|
</p>
|
||||||
) : materials.map((item) => (
|
<div className="flex items-center justify-center gap-3 pt-2">
|
||||||
<tr key={item.id} className="hover:bg-white/[0.01] transition-colors">
|
<button
|
||||||
<td className="px-6 py-4">
|
type="button"
|
||||||
<p className="text-sm font-bold text-white">{item.name}</p>
|
onClick={() => setIsBulkModalOpen(true)}
|
||||||
</td>
|
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-xs font-medium border border-white/10 transition-colors"
|
||||||
<td className="px-6 py-4">
|
>
|
||||||
<span className="inline-block px-2 py-1 rounded-md bg-night-950 border border-white/5 text-slate-400 text-[10px] uppercase font-bold tracking-wider">
|
นำเข้าผ่าน JSON
|
||||||
{item.unit}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<p className="text-sm font-bold text-emerald-400">฿{item.pricePerUnit.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-right">
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<button onClick={() => handleOpenModal(item)} className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-cyan-glow transition-all">
|
|
||||||
<Edit3 className="w-4 h-4" />
|
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => handleDelete(item.id)} className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-rose-500 transition-all">
|
<button
|
||||||
<Trash2 className="w-4 h-4" />
|
type="button"
|
||||||
|
onClick={() => navigate('/raw-materials/new')}
|
||||||
|
className="btn-primary text-xs px-4 py-2"
|
||||||
|
>
|
||||||
|
เพิ่มวัตถุดิบใหม่
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
) : (
|
||||||
|
filteredMaterials.map((item) => (
|
||||||
|
<tr key={item.id} className="hover:bg-white/[0.015] transition-colors group">
|
||||||
|
<td className="px-6 py-4 text-xs font-mono text-slate-500">
|
||||||
|
#{item.id}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400 shrink-0">
|
||||||
|
<Boxes className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to={`/raw-materials/${item.id}`}
|
||||||
|
className="text-sm font-bold text-white hover:text-cyan-400 transition-colors"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="inline-block px-2.5 py-1 rounded-md bg-night-950 border border-white/5 text-slate-300 text-xs font-mono font-medium">
|
||||||
|
{item.unit}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
{editingId === item.id ? (
|
||||||
|
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-xs text-slate-500 font-mono font-bold">
|
||||||
|
฿
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
autoFocus
|
||||||
|
value={editingPrice}
|
||||||
|
onChange={(e) => setEditingPrice(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSavePrice(item);
|
||||||
|
if (e.key === 'Escape') cancelEditing();
|
||||||
|
}}
|
||||||
|
disabled={savingId === item.id}
|
||||||
|
className="w-28 pl-6 pr-2 py-1 text-sm font-bold font-mono text-emerald-400 bg-black/80 border border-cyan-500/60 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500/40 shadow-inner"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={savingId === item.id}
|
||||||
|
onClick={() => handleSavePrice(item)}
|
||||||
|
className="p-1.5 rounded-lg bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-300 border border-emerald-500/40 transition-all disabled:opacity-50"
|
||||||
|
title="บันทึกราคา (Enter)"
|
||||||
|
>
|
||||||
|
{savingId === item.id ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={savingId === item.id}
|
||||||
|
onClick={cancelEditing}
|
||||||
|
className="p-1.5 rounded-lg bg-white/5 hover:bg-rose-500/20 text-slate-400 hover:text-rose-300 border border-white/10 transition-all disabled:opacity-50"
|
||||||
|
title="ยกเลิก (Esc)"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onClick={() => startEditing(item)}
|
||||||
|
className="group/price inline-flex items-center gap-2 cursor-pointer px-2.5 py-1 -ml-2.5 rounded-lg hover:bg-white/5 border border-transparent hover:border-white/10 transition-all"
|
||||||
|
title="คลิกที่นี่เพื่อแก้ไขราคาต่อหน่วย (Inline Edit)"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-bold text-emerald-400 font-mono">
|
||||||
|
฿{Number(item.pricePerUnit).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
<Pencil className="w-3 h-3 text-slate-500 opacity-0 group-hover/price:opacity-100 transition-opacity" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{/* Edit Button navigates to Full Page Route */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(`/raw-materials/${item.id}`)}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-white/5 hover:bg-cyan-500/10 text-slate-400 hover:text-cyan-400 border border-white/5 hover:border-cyan-500/20 text-xs font-medium transition-all flex items-center gap-1.5"
|
||||||
|
title="แก้ไขรายละเอียด"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-3.5 h-3.5" />
|
||||||
|
<span>แก้ไข</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(item.id, item.name)}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-rose-500/10 text-slate-500 hover:text-rose-400 transition-all"
|
||||||
|
title="ลบรายการ"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isModalOpen && (
|
{/* Bulk JSON Import / Export Modal */}
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<RawMaterialBulkJsonModal
|
||||||
<div className="absolute inset-0 bg-night-950/80 backdrop-blur-sm" onClick={() => !saving && setIsModalOpen(false)} />
|
isOpen={isBulkModalOpen}
|
||||||
<div className="relative w-full max-w-md bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-zoom-in p-6 space-y-6">
|
onClose={() => setIsBulkModalOpen(false)}
|
||||||
<h3 className="text-xl font-bold">{editingId ? 'Edit Material' : 'Add New Material'}</h3>
|
materials={materials}
|
||||||
|
onSuccess={fetchMaterials}
|
||||||
<div className="space-y-4">
|
/>
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Material Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={name}
|
|
||||||
onChange={e => setName(e.target.value)}
|
|
||||||
className="admin-input w-full"
|
|
||||||
placeholder="e.g. Alcohol, Glass Bottle 50ml"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Unit</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={unit}
|
|
||||||
onChange={e => setUnit(e.target.value)}
|
|
||||||
className="admin-input w-full"
|
|
||||||
placeholder="e.g. kg, liter, piece"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Price per Unit (฿)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={pricePerUnit || ''}
|
|
||||||
onChange={e => setPricePerUnit(Number(e.target.value))}
|
|
||||||
className="admin-input w-full"
|
|
||||||
placeholder="0.00"
|
|
||||||
step="0.01"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-white/5">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsModalOpen(false)}
|
|
||||||
disabled={saving}
|
|
||||||
className="px-4 py-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 font-medium transition-all"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="btn-primary px-6 py-2 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
||||||
{saving ? 'Saving...' : 'Save Material'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import { API_BASE } from '@homaroma/types';
|
import { API_BASE } from '@homaroma/types';
|
||||||
import { TIKTOK_SHOPS } from './TiktokOrderPage';
|
import { TIKTOK_SHOPS } from './TiktokOrderPage';
|
||||||
import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils';
|
import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils';
|
||||||
|
import { TiktokFeeBreakdownModal } from '../components/tiktok-order/TiktokFeeBreakdownModal';
|
||||||
|
|
||||||
function authHeaders() {
|
function authHeaders() {
|
||||||
return {
|
return {
|
||||||
@@ -66,6 +67,7 @@ export default function TiktokOrderDetailPage() {
|
|||||||
const [order, setOrder] = useState<any | null>(null);
|
const [order, setOrder] = useState<any | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showFeeModal, setShowFeeModal] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!orderId) return;
|
if (!orderId) return;
|
||||||
@@ -410,19 +412,35 @@ export default function TiktokOrderDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center text-violet-300">
|
<button
|
||||||
<span className="text-slate-400">{t('tiktokOrderDetail.commissionSummary')}</span>
|
type="button"
|
||||||
<span className="font-mono font-semibold">
|
onClick={() => setShowFeeModal(true)}
|
||||||
|
className="w-full flex justify-between items-center text-violet-300 hover:bg-violet-500/10 p-1.5 -mx-1.5 rounded-lg transition-colors cursor-pointer group"
|
||||||
|
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||||
|
>
|
||||||
|
<span className="text-slate-400 group-hover:text-slate-200 text-xs flex items-center gap-1">
|
||||||
|
{t('tiktokOrderDetail.commissionSummary')}
|
||||||
|
<Receipt className="w-3 h-3 text-violet-400/50 group-hover:text-violet-300 transition-colors" />
|
||||||
|
</span>
|
||||||
|
<span className="font-mono font-semibold text-xs text-violet-400 group-hover:underline">
|
||||||
{order.commissionFee > 0 ? `-฿${fmt(order.commissionFee)}` : '฿0.00'}
|
{order.commissionFee > 0 ? `-฿${fmt(order.commissionFee)}` : '฿0.00'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<div className="flex justify-between items-center text-cyan-300">
|
<button
|
||||||
<span className="text-slate-400">{t('tiktokOrderDetail.affiliateSummary')}</span>
|
type="button"
|
||||||
<span className="font-mono font-semibold">
|
onClick={() => setShowFeeModal(true)}
|
||||||
|
className="w-full flex justify-between items-center text-cyan-300 hover:bg-cyan-500/10 p-1.5 -mx-1.5 rounded-lg transition-colors cursor-pointer group"
|
||||||
|
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||||
|
>
|
||||||
|
<span className="text-slate-400 group-hover:text-slate-200 text-xs flex items-center gap-1">
|
||||||
|
{t('tiktokOrderDetail.affiliateSummary')}
|
||||||
|
<Receipt className="w-3 h-3 text-cyan-400/50 group-hover:text-cyan-300 transition-colors" />
|
||||||
|
</span>
|
||||||
|
<span className="font-mono font-semibold text-xs text-cyan-400 group-hover:underline">
|
||||||
{order.affiliateFee > 0 ? `-฿${fmt(order.affiliateFee)}` : '฿0.00'}
|
{order.affiliateFee > 0 ? `-฿${fmt(order.affiliateFee)}` : '฿0.00'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<div className="pt-3 border-t border-white/10 flex justify-between items-center">
|
<div className="pt-3 border-t border-white/10 flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
@@ -527,6 +545,13 @@ export default function TiktokOrderDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showFeeModal && (
|
||||||
|
<TiktokFeeBreakdownModal
|
||||||
|
order={order}
|
||||||
|
onClose={() => setShowFeeModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,10 +7,11 @@ import {
|
|||||||
ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign,
|
ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign,
|
||||||
Coins, ChevronLeft, ChevronRight, ExternalLink, Store, Trash2,
|
Coins, ChevronLeft, ChevronRight, ExternalLink, Store, Trash2,
|
||||||
FileSpreadsheet, AlertTriangle, CheckCircle2, Files, Search, X, RotateCcw,
|
FileSpreadsheet, AlertTriangle, CheckCircle2, Files, Search, X, RotateCcw,
|
||||||
BadgePercent, Wallet, Receipt
|
BadgePercent, Wallet, Receipt, Info
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { API_BASE } from '@homaroma/types';
|
import { API_BASE } from '@homaroma/types';
|
||||||
import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils';
|
import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils';
|
||||||
|
import { TiktokFeeBreakdownModal } from '../components/tiktok-order/TiktokFeeBreakdownModal';
|
||||||
|
|
||||||
// ── Shop Config ───────────────────────────────────────────────────────────────
|
// ── Shop Config ───────────────────────────────────────────────────────────────
|
||||||
export const TIKTOK_SHOPS = [
|
export const TIKTOK_SHOPS = [
|
||||||
@@ -164,6 +165,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('all');
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
const [bomFilter, setBomFilter] = useState('all');
|
const [bomFilter, setBomFilter] = useState('all');
|
||||||
|
const [selectedFeeOrder, setSelectedFeeOrder] = useState<any | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -396,8 +398,24 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
|
|||||||
<th className="px-4 py-3.5 whitespace-nowrap">{t('tiktokOrders.thSkusBom')}</th>
|
<th className="px-4 py-3.5 whitespace-nowrap">{t('tiktokOrders.thSkusBom')}</th>
|
||||||
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thOrderAmount')}</th>
|
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thOrderAmount')}</th>
|
||||||
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thBomCost')}</th>
|
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thBomCost')}</th>
|
||||||
<th className="px-4 py-3.5 whitespace-nowrap text-right text-violet-400">{t('tiktokOrders.thCommission')}</th>
|
<th
|
||||||
<th className="px-4 py-3.5 whitespace-nowrap text-right text-cyan-400">{t('tiktokOrders.thAffiliate')}</th>
|
className="px-4 py-3.5 whitespace-nowrap text-right text-violet-400 cursor-help"
|
||||||
|
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1 justify-end">
|
||||||
|
{t('tiktokOrders.thCommission')}
|
||||||
|
<Info className="w-3 h-3 text-violet-400/50" />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-4 py-3.5 whitespace-nowrap text-right text-cyan-400 cursor-help"
|
||||||
|
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1 justify-end">
|
||||||
|
{t('tiktokOrders.thAffiliate')}
|
||||||
|
<Info className="w-3 h-3 text-cyan-400/50" />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thNetProfit')}</th>
|
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thNetProfit')}</th>
|
||||||
<th className="px-4 py-3.5 text-center" />
|
<th className="px-4 py-3.5 text-center" />
|
||||||
</tr>
|
</tr>
|
||||||
@@ -507,20 +525,42 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
|
|||||||
|
|
||||||
{/* Commission (฿) */}
|
{/* Commission (฿) */}
|
||||||
<td className="px-4 py-3.5 text-right font-mono whitespace-nowrap">
|
<td className="px-4 py-3.5 text-right font-mono whitespace-nowrap">
|
||||||
{order.commissionFee > 0 ? (
|
<button
|
||||||
<span className="text-violet-400 font-bold">-฿{fmt(order.commissionFee)}</span>
|
type="button"
|
||||||
) : (
|
onClick={(e) => {
|
||||||
<span className="text-slate-500 font-mono text-[11px]">-</span>
|
e.stopPropagation();
|
||||||
)}
|
setSelectedFeeOrder(order);
|
||||||
|
}}
|
||||||
|
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||||
|
className="inline-flex items-center gap-1 group/btn px-2 py-1 rounded-lg hover:bg-violet-500/15 border border-transparent hover:border-violet-500/30 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
{order.commissionFee > 0 ? (
|
||||||
|
<span className="text-violet-400 font-bold group-hover/btn:underline">-฿{fmt(order.commissionFee)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-500 font-mono text-[11px] group-hover/btn:text-slate-300">-</span>
|
||||||
|
)}
|
||||||
|
<Receipt className="w-3 h-3 text-violet-400/40 group-hover/btn:text-violet-300 transition-colors" />
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Affiliate (฿) */}
|
{/* Affiliate (฿) */}
|
||||||
<td className="px-4 py-3.5 text-right font-mono whitespace-nowrap">
|
<td className="px-4 py-3.5 text-right font-mono whitespace-nowrap">
|
||||||
{order.affiliateFee > 0 ? (
|
<button
|
||||||
<span className="text-cyan-400 font-bold">-฿{fmt(order.affiliateFee)}</span>
|
type="button"
|
||||||
) : (
|
onClick={(e) => {
|
||||||
<span className="text-slate-500 font-mono text-[11px]">-</span>
|
e.stopPropagation();
|
||||||
)}
|
setSelectedFeeOrder(order);
|
||||||
|
}}
|
||||||
|
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||||
|
className="inline-flex items-center gap-1 group/btn px-2 py-1 rounded-lg hover:bg-cyan-500/15 border border-transparent hover:border-cyan-500/30 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
{order.affiliateFee > 0 ? (
|
||||||
|
<span className="text-cyan-400 font-bold group-hover/btn:underline">-฿{fmt(order.affiliateFee)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-500 font-mono text-[11px] group-hover/btn:text-slate-300">-</span>
|
||||||
|
)}
|
||||||
|
<Receipt className="w-3 h-3 text-cyan-400/40 group-hover/btn:text-cyan-300 transition-colors" />
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Profit */}
|
{/* Profit */}
|
||||||
@@ -553,6 +593,14 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Fee Breakdown Modal */}
|
||||||
|
{selectedFeeOrder && (
|
||||||
|
<TiktokFeeBreakdownModal
|
||||||
|
order={selectedFeeOrder}
|
||||||
|
onClose={() => setSelectedFeeOrder(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { API_BASE, type Menu } from '@homaroma/types'
|
import { API_BASE, type Menu } from '@homaroma/types'
|
||||||
import { Save, Loader2, Plus, Trash2, ChevronUp, ChevronDown, Copy, Check, Download, Upload, Star, Pencil, Sparkles, Code2, X } from 'lucide-react'
|
import { Save, Loader2, Plus, Trash2, ChevronUp, ChevronDown, Copy, Check, Download, Upload, Star, Pencil, Sparkles, Code2, X, LayoutGrid, ListFilter, Layers, Image as ImageIcon } from 'lucide-react'
|
||||||
import { ICON_REGISTRY } from '../../utils/iconRegistry'
|
import { ICON_REGISTRY } from '../../utils/iconRegistry'
|
||||||
import FocalPointPicker from '../../components/page-builder/FocalPointPicker'
|
import FocalPointPicker from '../../components/page-builder/FocalPointPicker'
|
||||||
import MenuJsonGuideModal from '../../components/MenuJsonGuideModal'
|
import MenuJsonGuideModal from '../../components/MenuJsonGuideModal'
|
||||||
|
import MediaLibraryModal from '../../components/MediaLibraryModal'
|
||||||
|
|
||||||
const IconSelect = ({ value, onChange }: { value?: string, onChange: (val: string) => void }) => {
|
const IconSelect = ({ value, onChange }: { value?: string, onChange: (val: string) => void }) => {
|
||||||
return (
|
return (
|
||||||
@@ -30,7 +32,7 @@ type MenuEditorPageProps = {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
|
const FeaturedCardEditorFields = ({ featured, onChange, onOpenMediaPicker }: any) => {
|
||||||
const [activeTab, setActiveTab] = useState<'content' | 'media'>('content');
|
const [activeTab, setActiveTab] = useState<'content' | 'media'>('content');
|
||||||
const [activeLang, setActiveLang] = useState('th');
|
const [activeLang, setActiveLang] = useState('th');
|
||||||
|
|
||||||
@@ -54,8 +56,28 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
|
|||||||
['ctaLabel', 'CTA label', 'View details'],
|
['ctaLabel', 'CTA label', 'View details'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const isEnabled = featured.enabled !== false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-xl bg-white/5 border border-white/5">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-bold text-white">Enable Featured Card</div>
|
||||||
|
<div className="text-[10px] text-slate-400">เปิดหรือปิดการแสดงการ์ดโปรโมตใน Mega Menu</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateFeatured('enabled', !isEnabled)}
|
||||||
|
className={`px-3 py-1 text-xs font-bold rounded-lg border transition-colors ${
|
||||||
|
isEnabled
|
||||||
|
? 'border-amber-400/40 bg-amber-400/20 text-amber-300'
|
||||||
|
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isEnabled ? 'Enabled (เปิด)' : 'Disabled (ปิด)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 rounded-xl bg-white/5 p-1">
|
<div className="grid grid-cols-2 rounded-xl bg-white/5 p-1">
|
||||||
{[
|
{[
|
||||||
['content', 'Content'],
|
['content', 'Content'],
|
||||||
@@ -136,22 +158,59 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 rounded-xl border border-white/10 bg-white/5 p-4">
|
<div className="space-y-4 rounded-xl border border-white/10 bg-white/5 p-4">
|
||||||
<div className="aspect-[4/3] overflow-hidden rounded-xl border border-white/10 bg-white/5 flex items-center justify-center">
|
<div
|
||||||
|
onClick={() => onOpenMediaPicker && onOpenMediaPicker((url: string) => updateFeatured('imageUrl', url))}
|
||||||
|
className="aspect-[4/3] relative overflow-hidden rounded-xl border-2 border-dashed border-white/10 hover:border-cyan-glow/50 bg-night-950/40 flex items-center justify-center cursor-pointer group transition-all"
|
||||||
|
title="คลิกเพื่อเลือกรูปจากคลังภาพ (Media Library)"
|
||||||
|
>
|
||||||
{featured.imageUrl ? (
|
{featured.imageUrl ? (
|
||||||
<img src={featured.imageUrl} alt="Featured preview" className="h-full w-full object-cover" />
|
<>
|
||||||
|
<img src={featured.imageUrl} alt="Featured preview" className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-300" />
|
||||||
|
<div className="absolute inset-0 bg-night-950/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1.5 backdrop-blur-xs">
|
||||||
|
<ImageIcon className="w-6 h-6 text-cyan-glow" />
|
||||||
|
<span className="text-xs font-bold text-white">เปลี่ยนรูปจากคลังภาพ</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="px-4 text-center text-xs text-slate-600">No featured image</span>
|
<div className="flex flex-col items-center gap-2 text-slate-500 group-hover:text-cyan-glow transition-colors py-4">
|
||||||
|
<ImageIcon className="w-8 h-8" />
|
||||||
|
<span className="text-xs font-bold">เลือกรูปจากคลังภาพ (Images Lib)</span>
|
||||||
|
<span className="text-[10px] text-slate-600">คลิกที่นี่เพื่อเปิดคลังสื่อของระบบ</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[10px] uppercase font-bold text-slate-500">Featured Image URL</label>
|
<div className="flex items-center justify-between">
|
||||||
<input
|
<label className="text-[10px] uppercase font-bold text-slate-500">Featured Image URL</label>
|
||||||
className="admin-input w-full text-xs py-1 px-2"
|
{onOpenMediaPicker && (
|
||||||
value={featured.imageUrl || ''}
|
<button
|
||||||
placeholder="https://cdn.example.com/product.webp"
|
type="button"
|
||||||
onChange={e => updateFeatured('imageUrl', e.target.value || null)}
|
onClick={() => onOpenMediaPicker((url: string) => updateFeatured('imageUrl', url))}
|
||||||
/>
|
className="text-[10px] font-bold text-cyan-glow hover:underline flex items-center gap-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
<ImageIcon className="w-3 h-3" /> เลือกจากคลังภาพ
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="admin-input w-full text-xs py-1 px-2"
|
||||||
|
value={featured.imageUrl || ''}
|
||||||
|
placeholder="https://cdn.example.com/product.webp"
|
||||||
|
onChange={e => updateFeatured('imageUrl', e.target.value || null)}
|
||||||
|
/>
|
||||||
|
{featured.imageUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateFeatured('imageUrl', null)}
|
||||||
|
className="px-2.5 py-1 text-xs text-rose-400 hover:bg-rose-500/10 rounded border border-white/10 shrink-0 cursor-pointer"
|
||||||
|
title="ลบรูปภาพ"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{featured.imageUrl && (
|
{featured.imageUrl && (
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
@@ -188,7 +247,7 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const FeaturedCardSidePanel = ({ node, onChange }: any) => {
|
const FeaturedCardSidePanel = ({ node, onChange, onOpenMediaPicker }: any) => {
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return (
|
return (
|
||||||
<div className="admin-card p-6 space-y-3">
|
<div className="admin-card p-6 space-y-3">
|
||||||
@@ -223,12 +282,13 @@ const FeaturedCardSidePanel = ({ node, onChange }: any) => {
|
|||||||
<FeaturedCardEditorFields
|
<FeaturedCardEditorFields
|
||||||
featured={featured}
|
featured={featured}
|
||||||
onChange={(newFeatured: any) => onChange({ ...node, featured: newFeatured })}
|
onChange={(newFeatured: any) => onChange({ ...node, featured: newFeatured })}
|
||||||
|
onOpenMediaPicker={onOpenMediaPicker}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured, isFeaturedSelected }: any) => {
|
const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured, isFeaturedSelected, onOpenMediaPicker }: any) => {
|
||||||
const childrenKey = type === 'root' ? 'columns' : 'links';
|
const childrenKey = type === 'root' ? 'columns' : 'links';
|
||||||
const labelKey = type === 'column' ? 'title' : 'label';
|
const labelKey = type === 'column' ? 'title' : 'label';
|
||||||
const hasUrl = type !== 'column';
|
const hasUrl = type !== 'column';
|
||||||
@@ -300,21 +360,56 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
|
|||||||
{/* Image URL field */}
|
{/* Image URL field */}
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{/* Preview thumbnail */}
|
{/* Preview thumbnail */}
|
||||||
<div className="w-14 h-14 shrink-0 rounded-lg overflow-hidden border border-white/10 bg-white/5 flex items-center justify-center">
|
<div
|
||||||
|
onClick={() => onOpenMediaPicker && onOpenMediaPicker((url: string) => onChange({ ...node, [imageFieldKey]: url }))}
|
||||||
|
className="w-14 h-14 shrink-0 rounded-lg overflow-hidden border border-white/10 hover:border-cyan-glow/50 bg-white/5 flex items-center justify-center cursor-pointer group relative transition-colors"
|
||||||
|
title="คลิกเพื่อเลือกรูปจากคลังภาพ (Media Library)"
|
||||||
|
>
|
||||||
{imageValue ? (
|
{imageValue ? (
|
||||||
<img src={imageValue} alt="preview" className="w-full h-full object-cover" />
|
<>
|
||||||
|
<img src={imageValue} alt="preview" className="w-full h-full object-cover" />
|
||||||
|
<div className="absolute inset-0 bg-night-950/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<ImageIcon className="w-4 h-4 text-cyan-glow" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[9px] text-slate-600 text-center leading-tight px-1">No Image</span>
|
<div className="flex flex-col items-center justify-center text-slate-600 group-hover:text-cyan-glow transition-colors">
|
||||||
|
<ImageIcon className="w-4 h-4 mb-0.5" />
|
||||||
|
<span className="text-[8px] text-center leading-tight">Library</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 space-y-1">
|
<div className="flex-1 space-y-1">
|
||||||
<label className="text-[10px] uppercase font-bold text-slate-500">{imageFieldLabel}</label>
|
<div className="flex items-center justify-between">
|
||||||
<input
|
<label className="text-[10px] uppercase font-bold text-slate-500">{imageFieldLabel}</label>
|
||||||
className="admin-input w-full text-xs py-1 px-2"
|
{onOpenMediaPicker && (
|
||||||
value={imageValue}
|
<button
|
||||||
placeholder="https://cdn.example.com/image.webp"
|
type="button"
|
||||||
onChange={e => onChange({ ...node, [imageFieldKey]: e.target.value || null })}
|
onClick={() => onOpenMediaPicker((url: string) => onChange({ ...node, [imageFieldKey]: url }))}
|
||||||
/>
|
className="text-[10px] font-bold text-cyan-glow hover:underline flex items-center gap-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
<ImageIcon className="w-3 h-3" /> เลือกจากคลังภาพ
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="admin-input w-full text-xs py-1 px-2"
|
||||||
|
value={imageValue}
|
||||||
|
placeholder="https://cdn.example.com/image.webp"
|
||||||
|
onChange={e => onChange({ ...node, [imageFieldKey]: e.target.value || null })}
|
||||||
|
/>
|
||||||
|
{imageValue && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({ ...node, [imageFieldKey]: null })}
|
||||||
|
className="px-2 py-1 text-xs text-rose-400 hover:bg-rose-500/10 rounded border border-white/10 shrink-0 cursor-pointer"
|
||||||
|
title="ลบรูปภาพ"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{type !== 'root' && (
|
{type !== 'root' && (
|
||||||
<IconSelect
|
<IconSelect
|
||||||
value={node.icon}
|
value={node.icon}
|
||||||
@@ -325,30 +420,193 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{type === 'root' && (
|
{type === 'root' && (
|
||||||
<div className="rounded-xl border border-amber-400/20 bg-amber-400/5 p-4">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between gap-4">
|
{/* Layout Mode Selector */}
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-3">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-400/10 text-amber-300">
|
<div className="flex items-center justify-between">
|
||||||
<Star className="w-5 h-5" />
|
<label className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">
|
||||||
|
Sub-Menu Layout Preset
|
||||||
|
</label>
|
||||||
|
<span className="text-[10px] text-cyan-glow font-medium">
|
||||||
|
{node.layout === 'classic'
|
||||||
|
? 'Classic Dropdown'
|
||||||
|
: node.layout === 'tabbed'
|
||||||
|
? 'Tabbed Hierarchy'
|
||||||
|
: node.layout === 'visual-cards'
|
||||||
|
? 'Visual Cards'
|
||||||
|
: `Mega Menu (${node.layoutConfig?.columnCount || 2} Cols)`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({ ...node, layout: 'columns' })}
|
||||||
|
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
|
||||||
|
(!node.layout || node.layout === 'columns')
|
||||||
|
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
|
||||||
|
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="w-4 h-4 shrink-0 text-cyan-glow" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-bold leading-tight truncate">Column Grid</div>
|
||||||
|
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">2-4 คอลัมน์</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({ ...node, layout: 'tabbed' })}
|
||||||
|
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
|
||||||
|
node.layout === 'tabbed'
|
||||||
|
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
|
||||||
|
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Layers className="w-4 h-4 shrink-0 text-cyan-glow" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-bold leading-tight truncate">Tabbed Catalog</div>
|
||||||
|
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">แท็บซ้าย + ลิงก์ขวา</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({ ...node, layout: 'visual-cards' })}
|
||||||
|
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
|
||||||
|
node.layout === 'visual-cards'
|
||||||
|
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
|
||||||
|
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 shrink-0 text-cyan-glow" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-bold leading-tight truncate">Visual Cards</div>
|
||||||
|
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">การ์ดรูปถ่ายหรูหรา</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({ ...node, layout: 'classic' })}
|
||||||
|
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
|
||||||
|
node.layout === 'classic'
|
||||||
|
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
|
||||||
|
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ListFilter className="w-4 h-4 shrink-0 text-cyan-glow" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-bold leading-tight truncate">Classic Dropdown</div>
|
||||||
|
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">แนวตั้งมินิมอล</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submenu Layout Modifier / Description */}
|
||||||
|
{(!node.layout || node.layout === 'columns') && (
|
||||||
|
<div className="pt-3 border-t border-white/5 space-y-3">
|
||||||
|
{/* Column Count */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-slate-300">จำนวนคอลัมน์ (Columns)</span>
|
||||||
|
<div className="flex rounded-lg bg-black/40 p-1 border border-white/5">
|
||||||
|
{[2, 3, 4].map((count) => {
|
||||||
|
const currentCols = node.layoutConfig?.columnCount || 2;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={count}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({
|
||||||
|
...node,
|
||||||
|
layoutConfig: { ...(node.layoutConfig || {}), columnCount: count }
|
||||||
|
})}
|
||||||
|
className={`px-3 py-1 text-xs font-bold rounded-md transition-colors ${
|
||||||
|
currentCols === count
|
||||||
|
? 'bg-cyan-glow text-night-900'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{count} Cols
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle Featured Card */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-slate-300">แสดง Featured Card</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const isCurrentlyShown = node.layoutConfig?.showFeatured !== false && featured.enabled !== false;
|
||||||
|
const nextState = !isCurrentlyShown;
|
||||||
|
onChange({
|
||||||
|
...node,
|
||||||
|
layoutConfig: { ...(node.layoutConfig || {}), showFeatured: nextState },
|
||||||
|
featured: { ...featured, enabled: nextState }
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1 text-xs font-bold rounded-lg border transition-colors ${
|
||||||
|
(node.layoutConfig?.showFeatured !== false && featured.enabled !== false)
|
||||||
|
? 'border-amber-400/40 bg-amber-400/20 text-amber-300'
|
||||||
|
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{(node.layoutConfig?.showFeatured !== false && featured.enabled !== false) ? 'ON (เปิด)' : 'OFF (ปิด)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
)}
|
||||||
<h3 className="text-xs font-bold uppercase tracking-widest text-amber-200">Featured card</h3>
|
|
||||||
<p className="truncate text-[11px] text-slate-500">
|
{node.layout === 'tabbed' && (
|
||||||
{featured.enabled === false ? 'Disabled' : featuredTitle}
|
<div className="pt-3 border-t border-white/5 text-xs text-slate-400 leading-relaxed bg-white/2 p-2.5 rounded-lg">
|
||||||
</p>
|
💡 <strong className="text-slate-200">Tabbed Hierarchy:</strong> แต่ละ Column จะทำหน้าที่เป็นแท็บหมวดหมู่ฝั่งซ้าย เมื่อเอาเมาส์ Hover แท็บใด รายการ Links ย่อยจะปรากฏฝั่งขวาทันที เหมาะสำหรับร้านค้าที่มีหมวดหมู่แคตตาล็อกลึก (เช่น อะไหล่ยนต์, เครื่องใช้ไฟฟ้า, ไอที)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{node.layout === 'visual-cards' && (
|
||||||
|
<div className="pt-3 border-t border-white/5 text-xs text-slate-400 leading-relaxed bg-white/2 p-2.5 rounded-lg">
|
||||||
|
✨ <strong className="text-slate-200">Visual Image Cards:</strong> แสดงเป็นการ์ดรูปถ่าย 4 ใบแนวนอน โดยดึงรูปภาพจาก Column Icon URL หรือ Hover Image ของลิงก์แรก เหมาะสำหรับสินค้าที่เน้นความงามและดีไซน์ (เช่น ก้านไม้หอม, เทียนหอม, คอลเลกชันแฟชั่น)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{node.layout === 'classic' && (
|
||||||
|
<div className="pt-3 border-t border-white/5 text-xs text-slate-400 leading-relaxed bg-white/2 p-2.5 rounded-lg">
|
||||||
|
📄 <strong className="text-slate-200">Classic Dropdown:</strong> แสดงเป็นกล่องแนวตั้งกะทัดรัดใต้ปุ่มเมนูบน Navbar เหมาะสำหรับเมนูข้อมูลทั่วไปที่มี 3-7 ลิงก์ (เช่น เกี่ยวกับเรา, ศูนย์บริการ, ข่าวสาร)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Featured Card editor trigger (only shown when in columns mode and featured card is enabled) */}
|
||||||
|
{(!node.layout || node.layout === 'columns') && (node.layoutConfig?.showFeatured !== false && featured.enabled !== false) && (
|
||||||
|
<div className="rounded-xl border border-amber-400/20 bg-amber-400/5 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-400/10 text-amber-300">
|
||||||
|
<Star className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-widest text-amber-200">Featured card</h3>
|
||||||
|
<p className="truncate text-[11px] text-slate-500">
|
||||||
|
{featuredTitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEditFeatured}
|
||||||
|
className={`flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-xs font-bold transition-colors ${
|
||||||
|
isFeaturedSelected ? 'bg-amber-300 text-night-900' : 'bg-amber-400/10 text-amber-200 hover:bg-amber-400/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
{isFeaturedSelected ? 'Editing' : 'Edit'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
)}
|
||||||
type="button"
|
|
||||||
onClick={onEditFeatured}
|
|
||||||
className={`flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-xs font-bold transition-colors ${
|
|
||||||
isFeaturedSelected ? 'bg-amber-300 text-night-900' : 'bg-amber-400/10 text-amber-200 hover:bg-amber-400/20'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
|
||||||
{isFeaturedSelected ? 'Editing' : 'Edit'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -383,6 +641,7 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
|
|||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
onChange={(newChild: any) => handleUpdateChild(i, newChild)}
|
onChange={(newChild: any) => handleUpdateChild(i, newChild)}
|
||||||
onRemove={() => handleRemoveChild(i)}
|
onRemove={() => handleRemoveChild(i)}
|
||||||
|
onOpenMediaPicker={onOpenMediaPicker}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -406,6 +665,13 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
|
|||||||
const [pastedJsonText, setPastedJsonText] = useState('')
|
const [pastedJsonText, setPastedJsonText] = useState('')
|
||||||
const [pasteError, setPasteError] = useState<string | null>(null)
|
const [pasteError, setPasteError] = useState<string | null>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
|
||||||
|
const [mediaSelectCallback, setMediaSelectCallback] = useState<((url: string) => void) | null>(null)
|
||||||
|
|
||||||
|
const handleOpenMediaPicker = (onSelectUrl: (url: string) => void) => {
|
||||||
|
setMediaSelectCallback(() => onSelectUrl)
|
||||||
|
setIsMediaModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -708,6 +974,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
|
|||||||
}}
|
}}
|
||||||
onEditFeatured={() => setSelectedFeaturedIndex(idx)}
|
onEditFeatured={() => setSelectedFeaturedIndex(idx)}
|
||||||
isFeaturedSelected={safeFeaturedIndex === idx}
|
isFeaturedSelected={safeFeaturedIndex === idx}
|
||||||
|
onOpenMediaPicker={handleOpenMediaPicker}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -829,6 +1096,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
|
|||||||
<FeaturedCardSidePanel
|
<FeaturedCardSidePanel
|
||||||
node={selectedFeaturedNode}
|
node={selectedFeaturedNode}
|
||||||
onChange={handleUpdateFeaturedNode}
|
onChange={handleUpdateFeaturedNode}
|
||||||
|
onOpenMediaPicker={handleOpenMediaPicker}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-card p-6 space-y-4">
|
<div className="admin-card p-6 space-y-4">
|
||||||
@@ -884,7 +1152,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Paste JSON Modal */}
|
{/* Paste JSON Modal */}
|
||||||
{showPasteModal && (
|
{showPasteModal && createPortal(
|
||||||
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-4 animate-fade-in">
|
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-4 animate-fade-in">
|
||||||
<div className="w-full max-w-2xl bg-night-900 border border-white/10 rounded-2xl shadow-2xl p-6 space-y-4 animate-zoom-in">
|
<div className="w-full max-w-2xl bg-night-900 border border-white/10 rounded-2xl shadow-2xl p-6 space-y-4 animate-zoom-in">
|
||||||
<div className="flex items-center justify-between border-b border-white/10 pb-3">
|
<div className="flex items-center justify-between border-b border-white/10 pb-3">
|
||||||
@@ -932,7 +1200,8 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* LLM Mega Menu Guide Modal */}
|
{/* LLM Mega Menu Guide Modal */}
|
||||||
@@ -942,6 +1211,23 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
|
|||||||
onOpenPasteJson={() => setShowPasteModal(true)}
|
onOpenPasteJson={() => setShowPasteModal(true)}
|
||||||
onOpenImportFile={() => fileInputRef.current?.click()}
|
onOpenImportFile={() => fileInputRef.current?.click()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Media Library Modal */}
|
||||||
|
<MediaLibraryModal
|
||||||
|
isOpen={isMediaModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsMediaModalOpen(false)
|
||||||
|
setMediaSelectCallback(null)
|
||||||
|
}}
|
||||||
|
onSelect={(url) => {
|
||||||
|
if (mediaSelectCallback) {
|
||||||
|
mediaSelectCallback(url)
|
||||||
|
}
|
||||||
|
setIsMediaModalOpen(false)
|
||||||
|
setMediaSelectCallback(null)
|
||||||
|
}}
|
||||||
|
mediaType="image"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
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 |