Compare commits
69
Commits
hasan_frontend
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a5aa9599a | ||
|
|
452896be0c | ||
|
|
3da12a9e49 | ||
|
|
1d9f7840cd | ||
|
|
65d2f48dce | ||
|
|
9980a67261 | ||
|
|
9e05f162a9 | ||
|
|
064b1726da | ||
|
|
2bb10e43dc | ||
|
|
2f1524d0fa | ||
|
|
4b70e8dbf8 | ||
|
|
eb3a613cf2 | ||
|
|
ed816920e2 | ||
|
|
3c15c4313f | ||
|
|
06bafc288c | ||
|
|
1556a67a83 | ||
|
|
d0a0f30e85 | ||
|
|
8302093c6e | ||
|
|
3b6563c2b4 | ||
|
|
5f64b3c0d7 | ||
|
|
b17e22cdc2 | ||
|
|
46550e4f6e | ||
|
|
5f6b9dc11f | ||
|
|
ccfe105320 | ||
|
|
7658e6de08 | ||
|
|
3f3bf38999 | ||
|
|
3222ee7ae6 | ||
|
|
c48b753446 | ||
|
|
cedd26653b | ||
|
|
fb99c991a3 | ||
|
|
4e1fec0059 | ||
|
|
a912331eab | ||
|
|
8edef647dc | ||
|
|
e81d5c58a2 | ||
|
|
e3d275d4dc | ||
|
|
abb135b5e0 | ||
|
|
2fb18391cf | ||
|
|
6b9b700a25 | ||
|
|
3e96ee5f16 | ||
|
|
95ae534619 | ||
|
|
ac167539fa | ||
|
|
6565b7fa50 | ||
|
|
9ce467820a | ||
|
|
1ceed926fc | ||
|
|
00b5b93253 | ||
|
|
be1d12072f | ||
|
|
3c3dab9d26 | ||
|
|
bcbf2cc112 | ||
|
|
ed97796480 | ||
|
|
e75efac2dc | ||
|
|
b560fc650b | ||
|
|
3b89c34c87 | ||
|
|
7cd9ab5e0c | ||
|
|
44e245b9dc | ||
|
|
27724a2328 | ||
|
|
b11dc35915 | ||
|
|
f012aa37e9 | ||
|
|
d04610f1ac | ||
|
|
a15e5d9139 | ||
|
|
e8edab84b9 | ||
|
|
b76227e674 | ||
|
|
6b3c0c93cd | ||
|
|
cfc35f3fbc | ||
|
|
1c0595d8b2 | ||
|
|
9fc4b34e60 | ||
|
|
6b82bfea72 | ||
|
|
cb711f767e | ||
|
|
0434385059 | ||
|
|
07df2ccdd3 |
@@ -0,0 +1,59 @@
|
||||
# 🏢 Core Multi-Tenant Architecture & Data Isolation Manual
|
||||
|
||||
## 1. Executive Summary
|
||||
The PIM platform is designed as an Enterprise Multi-Tenant Software-as-a-Service (SaaS) application. It enforces strict logical isolation across tenants while sharing a unified application and database instance, maximizing resource efficiency, maintainability, and scalability.
|
||||
|
||||
---
|
||||
|
||||
## 2. Multi-Tenant Topology & Request Context Lifecycle
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor Client as Web Browser / API Consumer
|
||||
participant Gate as API Gateway / Reverse Proxy
|
||||
participant Auth as Auth & Context Middleware
|
||||
participant TenantSvc as Tenant Context Engine
|
||||
participant DB as PostgreSQL Multi-Tenant DB
|
||||
|
||||
Client->>Gate: HTTP Request (Authorization: Bearer <JWT>, x-tenant-id)
|
||||
Gate->>Auth: Forward with Headers
|
||||
Auth->>Auth: Verify JWT Token & Signature
|
||||
Auth->>TenantSvc: buildContext(req)
|
||||
|
||||
alt Platform SuperAdmin with Impersonation
|
||||
TenantSvc->>TenantSvc: Detect x-impersonated-tenant-id
|
||||
TenantSvc->>TenantSvc: Set context.tenantId = Impersonated ID
|
||||
TenantSvc->>TenantSvc: Set context.isImpersonating = true
|
||||
else Standard Tenant User
|
||||
TenantSvc->>TenantSvc: Set context.tenantId = jwt.user.tenant_id
|
||||
TenantSvc->>TenantSvc: Set context.isImpersonating = false
|
||||
end
|
||||
|
||||
TenantSvc-->>Auth: req.context populated
|
||||
Auth->>DB: Execute Query WHERE tenant_id = req.context.tenantId
|
||||
DB-->>Client: Scoped Data Response (Zero Cross-Tenant Leakage)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Core Multi-Tenant Tenets & Invariants
|
||||
|
||||
1. **Context Guarantee**: Every authenticated request populates `req.context`:
|
||||
```javascript
|
||||
req.context = {
|
||||
tenantId: 19,
|
||||
userId: "3c847d01-e23a-4a22-9218-192a514d2847",
|
||||
roleIds: ["role-tenant-admin-uuid"],
|
||||
userType: "tenant", // 'platform' or 'tenant'
|
||||
isImpersonating: false
|
||||
};
|
||||
```
|
||||
2. **Repository Layer Scoping**: All Sequelize queries MUST include `where: { tenant_id: req.context.tenantId }` (unless executed by a platform administrator in global management view).
|
||||
3. **Tenant Provisioning Lifecycle**:
|
||||
- Provisioning a new tenant creates:
|
||||
- The `tenants` record.
|
||||
- Default system roles (`TENANT_ADMIN`, `CATALOG_MANAGER`, `VIEWER`).
|
||||
- Default permission bindings for all available `permission_nodes`.
|
||||
- An initial root administrative user.
|
||||
- Default taxonomy seeds (base units of measure, default attribute groups).
|
||||
@@ -0,0 +1,44 @@
|
||||
# 🔐 Authentication & User Identity Management
|
||||
|
||||
## 1. Overview
|
||||
The Authentication Subsystem provides JWT-based session management, password hashing via bcrypt (10 rounds), token refresh rotations, and multi-tier user classification (`platform` vs `tenant`).
|
||||
|
||||
---
|
||||
|
||||
## 2. User Types & Security Realms
|
||||
|
||||
| User Type | Scope | Access Capabilities | Impersonation Allowed |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **`platform`** | Global SaaS Operator | Full cross-tenant access, tenant provisioning, system nodes management, global billing and quotas | **YES** (via `x-impersonated-tenant-id`) |
|
||||
| **`tenant`** | Specific Workspace | Restricted strictly to records where `tenant_id === user.tenant_id`. Governed by assigned RBAC roles | **NO** |
|
||||
|
||||
---
|
||||
|
||||
## 3. JWT Payload Structure & Token Lifecycle
|
||||
|
||||
### Token Structure
|
||||
```json
|
||||
{
|
||||
"user_id": "3c847d01-e23a-4a22-9218-192a514d2847",
|
||||
"email": "admin@maskantech.com",
|
||||
"first_name": "Inam",
|
||||
"last_name": "Admin",
|
||||
"tenant_id": 19,
|
||||
"user_type": "tenant",
|
||||
"role_ids": ["fd9c2e97-9576-437d-aae9-939327efaf7e"],
|
||||
"iat": 1787123456,
|
||||
"exp": 1787209856
|
||||
}
|
||||
```
|
||||
|
||||
### Security Workflows
|
||||
1. **Login (`POST /api/v1/auth/login`)**:
|
||||
- Validates email and bcrypt password hash.
|
||||
- Verifies `status === 'active'`.
|
||||
- Eager-loads assigned `roles` and extracts `role_ids`.
|
||||
- Returns Access Token (JWT) and User Profile payload.
|
||||
2. **Password Updates**:
|
||||
- Requires previous password verification.
|
||||
- Enforces minimum 8-character complexity with letter, number, and special character requirements.
|
||||
3. **Session Revocation**:
|
||||
- Changing a user's status to `inactive` or deleting a user immediately halts subsequent requests as token validation checks against active DB records on critical operations.
|
||||
@@ -0,0 +1,64 @@
|
||||
# 🛡️ RBAC Permissions Matrix & Security Engine
|
||||
|
||||
## 1. Overview
|
||||
The Role-Based Access Control (RBAC) engine enforces fine-grained authorization across all platform resources. It decouples functional permission definitions (**Permission Nodes**) from business identities (**Roles**), allowing dynamic, tenant-level customization of user access privileges.
|
||||
|
||||
---
|
||||
|
||||
## 2. Permission Action Matrix (7-Point Granularity)
|
||||
|
||||
Every permission node in the system supports 7 distinct operational flags:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Node[Permission Node e.g. 'products'] --> View[can_view: Read & List]
|
||||
Node --> Create[can_create: POST New]
|
||||
Node --> Edit[can_edit: PUT/PATCH]
|
||||
Node --> Delete[can_delete: Soft Delete]
|
||||
Node --> Alter[can_alter: Schema/Publish]
|
||||
Node --> Import[can_import: Batch Import]
|
||||
Node --> Export[can_export: CSV/JSON Export]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Core System Permission Nodes Registry
|
||||
|
||||
| Module | Node Code | Node Name | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Products** | `products` | Product Catalog Management | Core SKU and product entity lifecycle management |
|
||||
| **Variants** | `variants` | Product Variants Management | Variant matrix generation and child SKU overrides |
|
||||
| **Families** | `families` | Product Families (Catalogs) | Catalog blueprints, variant axes, and asset rules |
|
||||
| **Categories** | `categories` | Categories Taxonomy | Hierarchical taxonomy tree and category assignments |
|
||||
| **Attributes** | `attributes` | Attribute Management | Attribute definitions, sets, and attribute groups |
|
||||
| **Brands** | `brands` | Brand Management | Brand registry and allowed brand constraints |
|
||||
| **Units** | `units` | Units of Measure (UOM) | Unit registry and conversion factors |
|
||||
| **Media (DAM)** | `assets` | Digital Asset Management | Media library uploads, asset types, and asset families |
|
||||
| **Channels** | `channels` | Channel Syndication | Channel endpoints, transformations, and feeds |
|
||||
| **Users & Roles** | `users` | User & RBAC Management | User provisioning, custom roles, and permission assignments |
|
||||
| **Audit Logs** | `audit_logs` | Security & Compliance Logs | Read-only audit trail inspection |
|
||||
| **Settings** | `settings` | Tenant Settings | Theme, white-labeling, and integration configurations |
|
||||
|
||||
---
|
||||
|
||||
## 4. Authorization Middleware Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Req[Incoming Request] --> AuthCheck{Is req.user present?}
|
||||
AuthCheck -- No --> 401[HTTP 401 Unauthorized]
|
||||
AuthCheck -- Yes --> SuperCheck{Is user_type == 'platform'?}
|
||||
SuperCheck -- Yes --> Pass[Pass: Next Middleware]
|
||||
SuperCheck -- No --> AdminCheck{Has SUPER_ADMIN or TENANT_ADMIN role?}
|
||||
AdminCheck -- Yes --> Pass
|
||||
AdminCheck -- No --> RoleCheck{Inspect RolePermissions for node_code}
|
||||
RoleCheck -- Action matches flag --> Pass
|
||||
RoleCheck -- Missing flag --> 403[HTTP 403 Forbidden: Insufficient Permissions]
|
||||
```
|
||||
|
||||
### Action Resolution Map
|
||||
If no specific action is passed into `authorize('products')`, the middleware automatically infers the required flag from the HTTP Method:
|
||||
- `GET` $\rightarrow$ `can_view`
|
||||
- `POST` $\rightarrow$ `can_create`
|
||||
- `PUT` / `PATCH` $\rightarrow$ `can_edit`
|
||||
- `DELETE` $\rightarrow$ `can_delete`
|
||||
@@ -0,0 +1,115 @@
|
||||
# 🗄️ Core SaaS & Security Database Schema Dictionary
|
||||
|
||||
## 1. Overview
|
||||
This document specifies all database tables, columns, data types, constraints, and relations powering Multi-Tenancy, Users, Roles, Permissions, and Auditing.
|
||||
|
||||
---
|
||||
|
||||
## 2. Table-by-Table Data Dictionary
|
||||
|
||||
### 2.1. `tenants` (Multi-Tenant Organization Accounts)
|
||||
| Column Name | PostgreSQL Type | Nullable | Default | Description & Constraints |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `id` | `SERIAL` (INTEGER) | NO | Auto-increment | Primary Key |
|
||||
| `name` | `VARCHAR(255)` | NO | — | Legal Organization Name |
|
||||
| `code` | `VARCHAR(100)` | NO | — | Unique organization code |
|
||||
| `domain` | `VARCHAR(255)` | YES | `NULL` | Custom domain / CNAME mapping |
|
||||
| `status` | `VARCHAR(50)` | NO | `'active'` | `active`, `suspended`, `pending_verification` |
|
||||
| `plan_id` | `VARCHAR(50)` | YES | `'enterprise'`| Subscription tier plan |
|
||||
| `settings` | `JSONB` | YES | `{}` | White-label branding, quotas, feature toggles |
|
||||
| `created_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
|
||||
| `updated_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
|
||||
| `deleted_at` | `TIMESTAMP WITH TZ`| YES | `NULL` | Soft delete marker |
|
||||
|
||||
---
|
||||
|
||||
### 2.2. `users` (User Identity Master)
|
||||
| Column Name | PostgreSQL Type | Nullable | Default | Description & Constraints |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
|
||||
| `tenant_id` | `INTEGER` | YES | `NULL` | FK -> `tenants.id` (NULL for global platform users) |
|
||||
| `first_name` | `VARCHAR(100)` | NO | — | User First Name |
|
||||
| `last_name` | `VARCHAR(100)` | NO | — | User Last Name |
|
||||
| `email` | `VARCHAR(255)` | NO | — | Unique email address across tenant realm |
|
||||
| `password_hash` | `VARCHAR(255)` | NO | — | Bcrypt salted password hash |
|
||||
| `user_type` | `VARCHAR(50)` | NO | `'tenant'` | `platform` (SaaS Admin) or `tenant` (Workspace Member) |
|
||||
| `status` | `VARCHAR(50)` | NO | `'active'` | `active`, `inactive`, `locked` |
|
||||
| `last_login_at` | `TIMESTAMP WITH TZ` | YES | `NULL` | Last session authentication timestamp |
|
||||
| `created_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
|
||||
| `updated_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
|
||||
| `deleted_at` | `TIMESTAMP WITH TZ`| YES | `NULL` | Soft delete marker |
|
||||
|
||||
---
|
||||
|
||||
### 2.3. `roles` (RBAC Security Roles)
|
||||
| Column Name | PostgreSQL Type | Nullable | Default | Description & Constraints |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
|
||||
| `tenant_id` | `INTEGER` | YES | `NULL` | FK -> `tenants.id` |
|
||||
| `role_name` | `VARCHAR(100)` | NO | — | Display Role Name (e.g. Catalog Manager) |
|
||||
| `role_code` | `VARCHAR(50)` | NO | — | Unique Role Code (e.g. `CATALOG_MANAGER`) |
|
||||
| `description`| `TEXT` | YES | `NULL` | Functional scope of the role |
|
||||
| `status` | `BOOLEAN` | NO | `TRUE` | Active flag |
|
||||
| `is_system` | `BOOLEAN` | NO | `FALSE` | Protected system role flag (prevents deletion) |
|
||||
| `created_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
|
||||
| `updated_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
|
||||
| `deleted_at` | `TIMESTAMP WITH TZ`| YES | `NULL` | Soft delete marker |
|
||||
|
||||
---
|
||||
|
||||
### 2.4. `permission_nodes` (Functional Security Nodes)
|
||||
| Column Name | PostgreSQL Type | Nullable | Default | Description & Constraints |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
|
||||
| `node_code` | `VARCHAR(50)` | NO | — | Unique node code (e.g. `products`, `channels`) |
|
||||
| `node_name` | `VARCHAR(100)` | NO | — | Display name (e.g. `Product Catalog`) |
|
||||
| `module` | `VARCHAR(50)` | NO | — | UI/API Module categorization |
|
||||
| `description`| `TEXT` | YES | `NULL` | Description of guarded operations |
|
||||
| `is_system` | `BOOLEAN` | NO | `TRUE` | System managed node |
|
||||
| `created_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
|
||||
| `updated_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
|
||||
|
||||
---
|
||||
|
||||
### 2.5. `role_permissions` (Role to Permission Junction & Flags)
|
||||
| Column Name | PostgreSQL Type | Nullable | Default | Description & Constraints |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
|
||||
| `role_id` | `UUID` | NO | — | FK -> `roles.id` (Cascade delete) |
|
||||
| `permission_node_id` | `UUID` | NO | — | FK -> `permission_nodes.id` |
|
||||
| `can_view` | `BOOLEAN` | NO | `FALSE` | Read / List access |
|
||||
| `can_create` | `BOOLEAN` | NO | `FALSE` | Create access |
|
||||
| `can_edit` | `BOOLEAN` | NO | `FALSE` | Update / Edit access |
|
||||
| `can_delete` | `BOOLEAN` | NO | `FALSE` | Soft delete access |
|
||||
| `can_alter` | `BOOLEAN` | NO | `FALSE` | Lifecycle transition / publish access |
|
||||
| `can_import` | `BOOLEAN` | NO | `FALSE` | Bulk import access |
|
||||
| `can_export` | `BOOLEAN` | NO | `FALSE` | Bulk export access |
|
||||
| `created_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
|
||||
| `updated_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
|
||||
|
||||
---
|
||||
|
||||
### 2.6. `user_roles` (User to Role Junction)
|
||||
| Column Name | PostgreSQL Type | Nullable | Default | Description & Constraints |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
|
||||
| `user_id` | `UUID` | NO | — | FK -> `users.id` (Cascade delete) |
|
||||
| `role_id` | `UUID` | NO | — | FK -> `roles.id` (Cascade delete) |
|
||||
| `created_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
|
||||
| `updated_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
|
||||
|
||||
---
|
||||
|
||||
### 2.7. `audit_logs` (Security & Operations Audit Trail)
|
||||
| Column Name | PostgreSQL Type | Nullable | Default | Description & Constraints |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
|
||||
| `tenant_id` | `INTEGER` | YES | `NULL` | FK -> `tenants.id` |
|
||||
| `user_id` | `UUID` | YES | `NULL` | FK -> `users.id` (Executor) |
|
||||
| `action` | `VARCHAR(50)` | NO | — | `CREATE`, `UPDATE`, `DELETE`, `PUBLISH`, `LOGIN` |
|
||||
| `entity` | `VARCHAR(100)` | NO | — | Target entity name (`Product`, `Category`, `Role`) |
|
||||
| `entity_id` | `VARCHAR(100)` | YES | `NULL` | Target record UUID/ID |
|
||||
| `details` | `JSONB` | YES | `{}` | Before/After JSON diff snapshot |
|
||||
| `ip_address` | `VARCHAR(45)` | YES | `NULL` | IPv4 or IPv6 client address |
|
||||
| `user_agent` | `TEXT` | YES | `NULL` | Browser / Client User-Agent string |
|
||||
| `created_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Audit event timestamp |
|
||||
@@ -0,0 +1,73 @@
|
||||
# 📜 Audit Logging, Security Interceptors & Compliance
|
||||
|
||||
## 1. Overview
|
||||
The Audit Logging system provides an immutable, append-only historical record of all state-mutating actions across the PIM platform. It guarantees enterprise compliance (SOC 2, ISO 27001, GDPR) by recording *who* performed *what* action on *which* entity, along with chronological before-and-after JSON snapshots, IP addresses, and user-agent metadata.
|
||||
|
||||
---
|
||||
|
||||
## 2. Audit Event Interceptor Architecture
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor User as Authenticated User
|
||||
participant Route as Express API Endpoint
|
||||
participant Svc as Feature Service Layer
|
||||
participant DB as PostgreSQL DB
|
||||
participant Audit as AuditLogService
|
||||
participant WS as Real-Time Notification Broadcaster
|
||||
|
||||
User->>Route: PUT /api/v1/products/:id (Change Price / Status)
|
||||
Route->>Svc: updateProduct(id, changes, context)
|
||||
Svc->>DB: Fetch original record (Before Snapshot)
|
||||
Svc->>DB: Apply update (After Snapshot)
|
||||
|
||||
rect rgb(245, 255, 245)
|
||||
Note over Svc,Audit: Automated Audit Capture
|
||||
Svc->>Audit: recordLog({ tenant_id, user_id, action: 'UPDATE', entity: 'Product', entity_id, diff, ip, ua })
|
||||
Audit->>DB: INSERT INTO audit_logs (...)
|
||||
end
|
||||
|
||||
opt High-Priority Mutation (e.g. Product Publish, Role Alteration)
|
||||
Svc->>WS: Broadcast security event to tenant administrators
|
||||
end
|
||||
|
||||
Svc-->>Route: Updated Result
|
||||
Route-->>User: HTTP 200 OK
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Audit Log Schema & Diff Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "7b049d11-4fec-411a-9a8b-3d84950e1234",
|
||||
"tenant_id": 19,
|
||||
"user_id": "3c847d01-e23a-4a22-9218-192a514d2847",
|
||||
"action": "UPDATE",
|
||||
"entity": "Product",
|
||||
"entity_id": "80c68220-9d0b-485b-a1df-c96f883b5e6b",
|
||||
"details": {
|
||||
"status": {
|
||||
"previous": "draft",
|
||||
"current": "active"
|
||||
},
|
||||
"price": {
|
||||
"previous": 199.99,
|
||||
"current": 249.99
|
||||
},
|
||||
"modified_fields": ["status", "price"]
|
||||
},
|
||||
"ip_address": "192.168.1.49",
|
||||
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/128.0.0.0",
|
||||
"created_at": "2026-08-19T17:45:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Compliance & Security Guarantees
|
||||
1. **Append-Only Immutability**: `audit_logs` has NO `update` or `delete` API endpoints. Once an audit record is written, it cannot be modified or truncated through standard application routes.
|
||||
2. **Tenant Scoping**: Audit log queries are strictly isolated to `tenant_id = req.context.tenantId`.
|
||||
3. **Session Context Association**: Every audit record links the originating authenticated JWT user ID and client network location.
|
||||
@@ -0,0 +1,237 @@
|
||||
# 📘 Complete Guide: Multi-Tenant Provisioning & RBAC Security Engine
|
||||
|
||||
> **For Engineers, QA Specialists, and Product Stakeholders**
|
||||
> This guide explains the entire journey of how a **new company (Tenant)** is onboarded from scratch, and how **Roles and Permissions (RBAC)** control access to every screen, button, and API in the system.
|
||||
|
||||
---
|
||||
|
||||
## 📑 Table of Contents
|
||||
1. [What is a Tenant? (The Apartment Analogy)](#1-what-is-a-tenant-the-apartment-analogy)
|
||||
2. [End-to-End Tenant Creation Lifecycle](#2-end-to-end-tenant-creation-lifecycle)
|
||||
3. [The RBAC Security Architecture](#3-the-rbac-security-architecture)
|
||||
4. [The 7 Permission Keys for Every Module](#4-the-7-permission-keys-for-every-module)
|
||||
5. [Real-World Role Configurations](#5-real-world-role-configurations)
|
||||
6. [How the Security Guard (Middleware) Works at Runtime](#6-how-the-security-guard-middleware-works-at-runtime)
|
||||
7. [Platform SuperAdmin Impersonation (Support Mode)](#7-platform-superadmin-impersonation-support-mode)
|
||||
8. [Database Visual Schema & Relationship Map](#8-database-visual-schema--relationship-map)
|
||||
|
||||
---
|
||||
|
||||
## 1. What is a Tenant? (The Apartment Analogy)
|
||||
|
||||
Imagine this software is a **giant cloud apartment building**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ OUR PIM CLOUD SERVER │
|
||||
└────────────────────┬────────────────────┘
|
||||
│
|
||||
┌───────────────────────────────────┼───────────────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌───────────────────────┐ ┌───────────────────────┐ ┌───────────────────────┐
|
||||
│ 🏢 Tenant A │ │ 🏢 Tenant B │ │ 🏢 Tenant C │
|
||||
│ (Nike Workspace) │ │ (Apple Workspace) │ │ (Sony Workspace) │
|
||||
│ │ │ │ │ │
|
||||
│ • Their own staff │ │ • Their own staff │ │ • Their own staff │
|
||||
│ • Their shoe catalog │ │ • Their electronics │ │ • Their audio gear │
|
||||
│ • Their media photos │ │ • Their media photos │ │ • Their media photos │
|
||||
└───────────────────────┘ └───────────────────────┘ └───────────────────────┘
|
||||
```
|
||||
|
||||
- **Tenant Isolation**: Every database table has a `tenant_id` column. When Nike logs in, their queries automatically execute with `WHERE tenant_id = 19`. It is physically impossible for Nike to see Apple's products or staff.
|
||||
|
||||
---
|
||||
|
||||
## 2. End-to-End Tenant Creation Lifecycle
|
||||
|
||||
When a new client signs up (or a Platform SuperAdmin clicks **"Create New Tenant"**), the backend executes an automated **6-step provisioning pipeline** inside a single safe transaction:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor Admin as Platform SuperAdmin
|
||||
participant API as POST /api/v1/platform/tenants
|
||||
participant DB as PostgreSQL Database
|
||||
participant Seed as Auto-Provisioning Engine
|
||||
participant Email as Notification Engine
|
||||
|
||||
Admin->>API: Submit Tenant Form (Name: "Acme Corp", Admin Email: "boss@acme.com")
|
||||
|
||||
rect rgb(240, 248, 255)
|
||||
Note over API,DB: Step 1: Create Workspace Account
|
||||
API->>DB: INSERT INTO tenants (name, code, status, plan_id) VALUES ('Acme Corp', 'acme_corp', 'active', 'enterprise')
|
||||
DB-->>API: Returns new Tenant ID (e.g. tenant_id = 25)
|
||||
|
||||
Note over API,DB: Step 2: Seed Default System Roles
|
||||
API->>Seed: Provision Default Roles for Tenant 25
|
||||
Seed->>DB: INSERT INTO roles (TENANT_ADMIN, CATALOG_MANAGER, VIEWER)
|
||||
|
||||
Note over API,DB: Step 3: Bind Permissions to Roles
|
||||
Seed->>DB: Link all 12 Permission Nodes to TENANT_ADMIN with full 7-point flags
|
||||
|
||||
Note over API,DB: Step 4: Create Initial Root Admin User
|
||||
API->>DB: INSERT INTO users (email: 'boss@acme.com', password_hash, tenant_id: 25)
|
||||
API->>DB: INSERT INTO user_roles (user_id, role_id: 'TENANT_ADMIN')
|
||||
|
||||
Note over API,DB: Step 5: Seed Starter Taxonomy Primitives
|
||||
Seed->>DB: INSERT starter Units (Piece, Set, Kilogram, Gram)
|
||||
Seed->>DB: INSERT default Attribute Groups (General Specs, Physical Dimensions)
|
||||
end
|
||||
|
||||
API->>Email: Send Welcome Email & Password Setup Link to boss@acme.com
|
||||
API-->>Admin: HTTP 201 Created (Tenant 25 Ready & Fully Operational)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. The RBAC Security Architecture
|
||||
|
||||
RBAC (Role-Based Access Control) decouples **People** from **Permissions** using a 3-layer hierarchy:
|
||||
|
||||
```
|
||||
┌───────────────────────────┐
|
||||
│ 1. USERS │ physical people who log in (Alice, Bob, Charlie)
|
||||
└─────────────┬─────────────┘
|
||||
│ assigned to (via user_roles)
|
||||
▼
|
||||
┌───────────────────────────┐
|
||||
│ 2. ROLES │ job badges (Tenant Admin, Photographer, Pricing Specialist)
|
||||
└─────────────┬─────────────┘
|
||||
│ contains (via role_permissions)
|
||||
▼
|
||||
┌───────────────────────────┐
|
||||
│ 3. PERMISSION NODES │ system modules (Products, Media DAM, Channels, Users)
|
||||
└───────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. The 7 Permission Keys for Every Module
|
||||
|
||||
For **every single module** in the system, there are **7 granular action switches**:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Module["🚪 System Module (e.g. Products)"] --> K1["👀 can_view: Search, browse, and view details"]
|
||||
Module --> K2["➕ can_create: Click 'New Product' and save drafts"]
|
||||
Module --> K3["✏️ can_edit: Modify descriptions, prices, specs"]
|
||||
Module --> K4["🗑️ can_delete: Soft-delete or archive records"]
|
||||
Module --> K5["🚀 can_alter: Publish products or alter schema"]
|
||||
Module --> K6["📥 can_import: Bulk import CSV / Excel files"]
|
||||
Module --> K7["📤 can_export: Download data to Excel / JSON"]
|
||||
```
|
||||
|
||||
### Complete System Modules Registry:
|
||||
| Module Code | Module Name | What It Controls |
|
||||
| :--- | :--- | :--- |
|
||||
| `products` | Product Catalog | Master product SKUs, prices, stock, and descriptions |
|
||||
| `variants` | Product Variants | Matrix generator, color/size axes, and child SKU overrides |
|
||||
| `families` | Product Families | Family blueprints, required attribute sets, and asset rules |
|
||||
| `categories` | Categories | Hierarchical taxonomy tree and category assignments |
|
||||
| `attributes` | Attributes & Sets | Dynamic specs, dropdown options, and attribute sets |
|
||||
| `brands` | Brands | Manufacturer brands and allowed brand rules |
|
||||
| `units` | Units of Measure | Measurement units (kg, pcs, cm) and conversion factors |
|
||||
| `assets` | Digital Assets (DAM) | Image uploads, document attachments, and asset types |
|
||||
| `channels` | Channels | Shopify, Amazon, and Custom CSV export integrations |
|
||||
| `users` | Users & Roles | Inviting staff, creating roles, and assigning permissions |
|
||||
| `audit_logs` | Audit Logs | Inspecting who changed what, timestamps, and IP history |
|
||||
| `settings` | System Settings | Theme customization, organization branding, and billing |
|
||||
|
||||
---
|
||||
|
||||
## 5. Real-World Role Configurations
|
||||
|
||||
Here is how different job titles are configured using the 7-action matrix:
|
||||
|
||||
### Role 1: "Junior Catalog Editor" (Intern)
|
||||
- `products`: `can_view` ✅, `can_create` ✅, `can_edit` ✅, `can_delete` ❌, `can_alter` ❌, `can_export` ❌
|
||||
- `assets`: `can_view` ✅, `can_create` ✅
|
||||
- `users` & `settings`: All ❌ (Cannot view or change team members)
|
||||
|
||||
### Role 2: "Photographer / Media Specialist"
|
||||
- `assets`: `can_view` ✅, `can_create` ✅, `can_edit` ✅, `can_delete` ✅
|
||||
- `products`: `can_view` ✅ (To attach images), `can_edit` ❌ (Cannot change prices or stock)
|
||||
|
||||
### Role 3: "Catalog Supervisor / Brand Manager"
|
||||
- `products`: All 7 keys ✅ (Including `can_alter` to publish products to live sales channels)
|
||||
- `families` & `categories`: All 7 keys ✅
|
||||
|
||||
---
|
||||
|
||||
## 6. How the Security Guard (Middleware) Works at Runtime
|
||||
|
||||
Whenever a user takes any action in the application, the security guard inspects the request in **under 2 milliseconds**:
|
||||
|
||||
```
|
||||
[ User clicks "Delete Product" in Browser ]
|
||||
│
|
||||
▼
|
||||
[ API Request: DELETE /api/v1/products/80c68220... ]
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 👮 SECURITY GUARD (permission.middleware.js) │
|
||||
│ │
|
||||
│ 1. Verify JWT Token ──► User ID 42 (Alice) │
|
||||
│ 2. Check User Type ──► Tenant User (tenant_id = 19) │
|
||||
│ 3. Check Admin Role ──► Is Alice TENANT_ADMIN? (No) │
|
||||
│ 4. Check Alice's Role ──► "Junior Catalog Editor" │
|
||||
│ 5. Check 'products' node ──► Is `can_delete` TRUE? │
|
||||
│ │
|
||||
│ ❌ Result: `can_delete` is FALSE! │
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ ⛔ HTTP 403 Forbidden Response: │
|
||||
│ "Insufficient permissions for action: │
|
||||
│ delete on module: products" │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The database query **never runs**, the product is **never touched**, and an attempt log is written to `audit_logs`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Platform SuperAdmin Impersonation (Support Mode)
|
||||
|
||||
If a customer (e.g. Tenant 19) opens a support ticket saying *"My attribute dropdown is stuck"*:
|
||||
|
||||
1. A **Platform SuperAdmin** does NOT need the customer's password.
|
||||
2. The SuperAdmin opens the Platform Admin dashboard and clicks **"Troubleshoot Tenant 19"**.
|
||||
3. The frontend sends the header:
|
||||
`x-impersonated-tenant-id: 19`
|
||||
4. The backend context middleware detects this and temporarily scopes the session to Tenant 19 in **Audit-Tracked Support Mode**.
|
||||
5. All actions taken while impersonating are stamped with `isImpersonating: true` in the audit logs.
|
||||
|
||||
---
|
||||
|
||||
## 8. Database Visual Schema & Relationship Map
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
TENANTS ||--o{ USERS : "contains"
|
||||
TENANTS ||--o{ ROLES : "owns custom"
|
||||
TENANTS ||--o{ PRODUCTS : "owns"
|
||||
TENANTS ||--o{ ASSETS : "owns"
|
||||
|
||||
USERS ||--o{ USER_ROLES : "assigned"
|
||||
ROLES ||--o{ USER_ROLES : "links"
|
||||
|
||||
ROLES ||--o{ ROLE_PERMISSIONS : "defines"
|
||||
PERMISSION_NODES ||--o{ ROLE_PERMISSIONS : "guarded by"
|
||||
|
||||
USERS ||--o{ AUDIT_LOGS : "executes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Quick Verification Reference
|
||||
|
||||
```bash
|
||||
# 1. Verify Backend is running and routes are live
|
||||
curl -sI http://localhost:5002/api/v1/categories
|
||||
|
||||
# 2. Check TypeScript build integrity
|
||||
cd productcatalogue_frontend && npx tsc --noEmit
|
||||
```
|
||||
@@ -0,0 +1,35 @@
|
||||
# 📚 Core SaaS & RBAC Security Engine Master Documentation
|
||||
|
||||
Welcome to the architectural and operational knowledge base for the **Core Multi-Tenant SaaS, Authentication, RBAC, and Audit Logging** subsystems.
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Featured Comprehensive Guides
|
||||
|
||||
| Guide | Target Audience & Contents |
|
||||
| :--- | :--- |
|
||||
| **[`COMPLETE_TENANT_CREATION_AND_RBAC_GUIDE.md`](file:///Users/maskantech/Desktop/PIM/docs/core_saas_and_rbac_engine/COMPLETE_TENANT_CREATION_AND_RBAC_GUIDE.md)** | **⭐ Start Here!** Plain-English, visual, end-to-end guide explaining Tenant Provisioning (the 6-step lifecycle), the 3 layers of RBAC, the 7-action permission flags, real-world role setups, runtime middleware guard, and support impersonation mode. |
|
||||
|
||||
---
|
||||
|
||||
## 📑 In-Depth Engineering Manuals
|
||||
|
||||
| File | Scope & Contents |
|
||||
| :--- | :--- |
|
||||
| **[`01_MULTI_TENANT_ARCHITECTURE_AND_ISOLATION.md`](file:///Users/maskantech/Desktop/PIM/docs/core_saas_and_rbac_engine/01_MULTI_TENANT_ARCHITECTURE_AND_ISOLATION.md)** | Multi-tenant logical topology, request context builder, support impersonation mode, and tenant provisioning lifecycle. |
|
||||
| **[`02_AUTHENTICATION_AND_USER_MANAGEMENT.md`](file:///Users/maskantech/Desktop/PIM/docs/core_saas_and_rbac_engine/02_AUTHENTICATION_AND_USER_MANAGEMENT.md)** | User identity models, JWT payload tokens, bcrypt password encryption, platform vs tenant realms, and session management. |
|
||||
| **[`03_RBAC_PERMISSIONS_MATRIX_AND_SECURITY_ENGINE.md`](file:///Users/maskantech/Desktop/PIM/docs/core_saas_and_rbac_engine/03_RBAC_PERMISSIONS_MATRIX_AND_SECURITY_ENGINE.md)** | 7-point permission action matrix (`can_view`, `can_create`, `can_edit`, `can_delete`, `can_alter`, `can_import`, `can_export`), system roles, and authorization middleware logic. |
|
||||
| **[`04_DATABASE_SCHEMA_AND_SECURITY_DICTIONARY.md`](file:///Users/maskantech/Desktop/PIM/docs/core_saas_and_rbac_engine/04_DATABASE_SCHEMA_AND_SECURITY_DICTIONARY.md)** | Detailed table dictionary for `tenants`, `users`, `roles`, `permission_nodes`, `role_permissions`, `user_roles`, and `audit_logs`. |
|
||||
| **[`05_AUDIT_LOGGING_AND_ENTERPRISE_COMPLIANCE.md`](file:///Users/maskantech/Desktop/PIM/docs/core_saas_and_rbac_engine/05_AUDIT_LOGGING_AND_ENTERPRISE_COMPLIANCE.md)** | Append-only audit interceptors, before/after JSON diff captures, IP/User-Agent tracking, and compliance architecture. |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Quick Verification Commands
|
||||
```bash
|
||||
# Verify backend server health
|
||||
curl -sI http://localhost:5002/api/v1/categories
|
||||
|
||||
# Verify frontend build & dev server
|
||||
curl -sI http://localhost:5173
|
||||
cd productcatalogue_frontend && npx tsc --noEmit
|
||||
```
|
||||
@@ -0,0 +1,39 @@
|
||||
# 🔑 Master System Credentials & Demo Test Accounts
|
||||
|
||||
> **Environment**: Local Development / Staging
|
||||
> **Frontend URL**: `http://localhost:5173`
|
||||
> **Backend API URL**: `http://localhost:5002`
|
||||
|
||||
---
|
||||
|
||||
## 👑 1. Platform Super-Administrator (Global SaaS Realm)
|
||||
*Has global cross-tenant management, tenant provisioning, and support impersonation permissions.*
|
||||
|
||||
| Role | Email | Password | Tenant Scope | Access Scope |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **Platform SuperAdmin** | `superadmin@maskan.com` | `Admin@123` | Global (`NULL`) | Full SaaS & Platform access |
|
||||
|
||||
---
|
||||
|
||||
## 🏢 2. Tenant Workspace Accounts (`Tenant ID: 19 - TechNova`)
|
||||
*These accounts represent different job functions inside the active `TechNova` organization to test fine-grained RBAC permission matrix.*
|
||||
|
||||
| Role / Job Title | Email | Password | Role Code | Permissions & Access Scope |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **Tenant Administrator** | `technova1@gmail.com` | `Admin@123` | `TENANT_ADMIN` | Full workspace admin (all 7 action keys on all modules) |
|
||||
| **Catalog Manager** | `catalog.manager@technova1.com` | `Admin@123` | `CATALOG_MANAGER` | Full catalog, taxonomy, and attribute creation/editing rights |
|
||||
| **Product Editor** | `product.editor@technova1.com` | `Admin@123` | `PRODUCT_EDITOR` | Can create & edit products, but cannot delete or modify roles |
|
||||
| **DAM Asset Lead** | `asset.manager@technova1.com` | `Admin@123` | `ASSET_MANAGER` | Media library uploads, asset types, and asset families |
|
||||
| **Channel Publisher** | `channel.publisher@technova1.com` | `Admin@123` | `CHANNEL_PUBLISHER` | Channel syndication, marketplace mappings, and live sync |
|
||||
| **Catalog Viewer** | `catalog.viewer@technova1.com` | `Admin@123` | `CATALOG_VIEWER` | Read-only access (cannot create, edit, or delete any record) |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Verification via API
|
||||
|
||||
```bash
|
||||
# Test Login via Terminal
|
||||
curl -X POST http://localhost:5002/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"technova1@gmail.com","password":"Admin@123"}'
|
||||
```
|
||||
@@ -0,0 +1,103 @@
|
||||
# 🏛️ Enterprise PIM Architecture & Product Creation Ecosystem Manual
|
||||
|
||||
## 1. Executive Summary & Purpose
|
||||
This document provides an exhaustive, authoritative blueprint of the **Product Information Management (PIM)** engine for both human software engineers, QA architects, and AI autonomous agents. It establishes the definitive standard for how products, their taxonomy, attributes, assets, channels, and metadata are modeled, validated, persisted, audited, and syndicated.
|
||||
|
||||
---
|
||||
|
||||
## 2. Global Entity-Relationship Architecture
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
TENANTS ||--o{ USERS : "owns"
|
||||
TENANTS ||--o{ CATALOGS : "owns (Product Families)"
|
||||
TENANTS ||--o{ CATEGORIES : "owns"
|
||||
TENANTS ||--o{ BRANDS : "owns"
|
||||
TENANTS ||--o{ UNITS : "owns"
|
||||
TENANTS ||--o{ ATTRIBUTES : "owns"
|
||||
TENANTS ||--o{ ATTRIBUTE_SETS : "owns"
|
||||
TENANTS ||--o{ ASSETS : "owns"
|
||||
TENANTS ||--o{ PRODUCTS : "owns"
|
||||
TENANTS ||--o{ CHANNELS : "owns"
|
||||
|
||||
CATEGORIES ||--o{ CATEGORIES : "parent_id (Hierarchy Tree)"
|
||||
CATEGORIES ||--o{ PRODUCTS : "classifies"
|
||||
CATEGORIES ||--o{ CATALOGS : "binds default"
|
||||
|
||||
BRANDS ||--o{ PRODUCTS : "labels"
|
||||
UNITS ||--o{ PRODUCTS : "measures"
|
||||
|
||||
ATTRIBUTE_SETS ||--o{ ATTRIBUTE_SET_GROUPS : "contains"
|
||||
ATTRIBUTE_GROUPS ||--o{ ATTRIBUTE_SET_GROUPS : "assigned to"
|
||||
ATTRIBUTE_GROUPS ||--o{ ATTRIBUTES : "groups"
|
||||
ATTRIBUTES ||--o{ ATTRIBUTE_OPTIONS : "defines choices"
|
||||
|
||||
CATALOGS ||--o{ ATTRIBUTE_SETS : "binds attribute_set_id"
|
||||
CATALOGS ||--o{ FAMILY_ATTRIBUTES : "binds direct attributes"
|
||||
CATALOGS ||--o{ FAMILY_VARIANT_AXES : "defines variant dimensions"
|
||||
CATALOGS ||--o{ FAMILY_ASSET_REQUIREMENTS : "enforces media rules"
|
||||
CATALOGS ||--o{ FAMILY_CHANNELS : "subscribes channels"
|
||||
|
||||
PRODUCTS ||--o| CATALOGS : "instantiates (family_id)"
|
||||
PRODUCTS ||--o{ PRODUCT_ATTRIBUTE_VALUES : "stores custom data"
|
||||
PRODUCTS ||--o{ PRODUCT_ASSETS : "maps media files"
|
||||
PRODUCTS ||--o{ VARIANTS : "has SKU children"
|
||||
PRODUCTS ||--o{ PRODUCT_COMPLETENESS : "scores data readiness"
|
||||
|
||||
ATTRIBUTES ||--o{ PRODUCT_ATTRIBUTE_VALUES : "defines data point"
|
||||
ASSETS ||--o{ PRODUCT_ASSETS : "maps physical file"
|
||||
ASSET_TYPES ||--o{ ASSETS : "classifies media"
|
||||
ASSET_FAMILIES ||--o{ ASSET_TYPES : "bundles requirements"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. High-Level Core Subsystems & Dependency Hierarchy
|
||||
|
||||
To construct a valid Product, the system depends on an immutable, layered dependency hierarchy:
|
||||
|
||||
```
|
||||
[Layer 0: Multi-Tenant Foundation]
|
||||
├── Tenant Isolation (tenant_id scoping)
|
||||
└── RBAC & Audit Trails (User Sessions, Permissions)
|
||||
│
|
||||
[Layer 1: Fundamental Catalog Primitives]
|
||||
├── Units of Measure (kg, pcs, cm, l, etc.)
|
||||
├── Brands (Manufacturers, Trademarks, Logos)
|
||||
└── Categories (Nested Hierarchy, Slugs, Breadcrumbs)
|
||||
│
|
||||
[Layer 2: Attribute & Classification Engine]
|
||||
├── Attributes (Types, Validations, Regex, Options)
|
||||
├── Attribute Groups (UI Organizers & Logical Groupings)
|
||||
└── Attribute Sets (Templates combining multiple Groups)
|
||||
│
|
||||
[Layer 3: Media & Digital Asset Management (DAM)]
|
||||
├── Asset Types (Hero, Gallery, Manuals, Spec Sheets)
|
||||
├── Asset Families (Required Media Rules & Validations)
|
||||
└── Central File Registry (S3 / Local Storage, URLs, MIME)
|
||||
│
|
||||
[Layer 4: Blueprint Orchestration (Product Family / Catalog)]
|
||||
├── Family Blueprint Definition (Inherits Category + Set)
|
||||
├── Variant Axis Rules (Size, Color, Storage, RAM)
|
||||
├── Media Requirements (Minimum Asset Dimensions & Formats)
|
||||
└── Channel Syndication Subscriptions (Shopify, Amazon, CSV)
|
||||
│
|
||||
[Layer 5: Product Core Entity & Lifecycle Engine]
|
||||
├── Draft Creation (POST /api/v1/products)
|
||||
├── Dynamic Attribute Persistence (EAV / JSONB Hybrid)
|
||||
├── Variant Matrix Generator (Cartesian Product of Axes)
|
||||
├── Asset Role Assignment (Hero Image, Gallery, Video)
|
||||
├── Channel Scoping & Overrides
|
||||
└── Completeness Engine (Automated 0-100% Scoring)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Architectural Rules & Invariants
|
||||
1. **Tenant Isolation**: Every database query in multi-tenant mode MUST enforce `tenant_id` scoping to prevent data leakage between organizations.
|
||||
2. **Soft Deletes (`paranoid: true`)**: Deleting any primitive (Category, Attribute, Product, Unit, Brand) sets `deleted_at = NOW()`. Hard deletion is strictly disallowed to preserve audit integrity.
|
||||
3. **SKU Invariant**:
|
||||
- A Product in `draft` status may have `sku = null`.
|
||||
- A Product transitioning to `pending` or `active` MUST have a globally unique SKU (either manually supplied or auto-generated by the Sequence Engine).
|
||||
4. **Code Normalization**: `code` across all entities (Categories, Attributes, Units, Families, Products) must be lowercase, alphanumeric, and underscore-delimited (e.g., `tech_electronics_01`).
|
||||
5. **Data Completeness Independence**: A product's completeness score is computed per channel and locale, evaluating mandatory general fields, required attributes, DAM media assets, and syndication channels.
|
||||
@@ -0,0 +1,188 @@
|
||||
# 🗄️ PIM Database Schema & Table Dictionary
|
||||
|
||||
## 1. Overview
|
||||
The PIM database is structured on PostgreSQL with Sequelize ORM, leveraging UUID primary keys, JSONB for flexible extensible metadata, and strict foreign-key integrity constraints with tenant isolation.
|
||||
|
||||
---
|
||||
|
||||
## 2. Table-by-Table Data Dictionary
|
||||
|
||||
### 2.1. `products` (Core Master Catalog Record)
|
||||
| Column Name | PostgreSQL Type | Nullable | Default | Description & Foreign Key References |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key (Global Product UUID) |
|
||||
| `tenant_id` | `INTEGER` | YES | `NULL` | Tenant isolation scope (`tenants.id`) |
|
||||
| `code` | `VARCHAR(100)` | NO | — | Unique URL-safe identifier (e.g. `technova_audio_pro_x1`) |
|
||||
| `sku` | `VARCHAR(100)` | YES | `NULL` | Stock Keeping Unit (Unique when status != draft) |
|
||||
| `name` | `VARCHAR(255)` | NO | — | Display Name of the product |
|
||||
| `description` | `TEXT` | YES | `NULL` | Full rich-text or plain-text product description |
|
||||
| `price` | `NUMERIC(15,2)` | YES | `0.00` | Base retail catalog price |
|
||||
| `stock` | `INTEGER` | YES | `0` | Base physical warehouse inventory on hand |
|
||||
| `status` | `VARCHAR(20)` | NO | `'draft'` | Lifecycle Status: `draft`, `pending`, `active`, `archived` |
|
||||
| `type` | `VARCHAR(20)` | NO | `'simple'` | Product Type: `simple`, `variant`, `bundle`, `virtual` |
|
||||
| `family_id` | `UUID` | YES | `NULL` | Foreign Key -> `catalogs.id` (Product Family Blueprint) |
|
||||
| `category_id` | `UUID` | YES | `NULL` | Foreign Key -> `categories.id` (Primary Taxonomy Category) |
|
||||
| `brand_id` | `UUID` | YES | `NULL` | Foreign Key -> `brands.id` (Brand Manufacturer) |
|
||||
| `unit_id` | `UUID` | YES | `NULL` | Foreign Key -> `units.id` (Unit of Measure) |
|
||||
| `barcode` | `VARCHAR(100)` | YES | `NULL` | Universal barcode value |
|
||||
| `gtin` | `VARCHAR(100)` | YES | `NULL` | Global Trade Item Number |
|
||||
| `upc` | `VARCHAR(100)` | YES | `NULL` | Universal Product Code (12-digit) |
|
||||
| `ean` | `VARCHAR(100)` | YES | `NULL` | European Article Number (13-digit) |
|
||||
| `country` | `VARCHAR(100)` | YES | `NULL` | Country of Origin (ISO code or string) |
|
||||
| `hsn` | `VARCHAR(50)` | YES | `NULL` | Harmonized System of Nomenclature code |
|
||||
| `metadata` | `JSONB` | YES | `{}` | Extensible attributes, syndication channels, staging info |
|
||||
| `version` | `INTEGER` | NO | `1` | Optimistic locking revision counter |
|
||||
| `created_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Audit record creation timestamp |
|
||||
| `updated_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Audit record last modification timestamp |
|
||||
| `deleted_at` | `TIMESTAMP WITH TZ` | YES | `NULL` | Soft delete marker (Paranoid mode) |
|
||||
|
||||
---
|
||||
|
||||
### 2.2. `catalogs` (Product Families Blueprint)
|
||||
| Column Name | PostgreSQL Type | Nullable | Default | Description & Foreign Key References |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
|
||||
| `tenant_id` | `INTEGER` | YES | `NULL` | Tenant isolation scope (`tenants.id`) |
|
||||
| `code` | `VARCHAR(50)` | NO | — | Unique family code (e.g. `electronics_family`) |
|
||||
| `name` | `VARCHAR(100)` | NO | — | Family display name (e.g. `Electronics Family`) |
|
||||
| `description`| `TEXT` | YES | `NULL` | Family blueprint description |
|
||||
| `status` | `VARCHAR(20)` | NO | `'draft'` | `draft`, `active`, `inactive` |
|
||||
| `category_id` | `UUID` | YES | `NULL` | Default inherited category -> `categories.id` |
|
||||
| `attribute_set_id` | `UUID` | YES | `NULL` | Default bound Attribute Set -> `attribute_sets.id` |
|
||||
| `workflow_code` | `VARCHAR(50)`| NO | `'standard'` | Workflow state machine configuration code |
|
||||
| `completeness_rules` | `JSONB` | YES | `{}` | Allowed brands, allowed units, required thresholds |
|
||||
| `created_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
|
||||
| `updated_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
|
||||
| `deleted_at` | `TIMESTAMP WITH TZ` | YES | `NULL` | Soft delete marker |
|
||||
|
||||
---
|
||||
|
||||
### 2.3. `categories` (Hierarchical Taxonomy Tree)
|
||||
| Column Name | PostgreSQL Type | Nullable | Default | Description & Foreign Key References |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
|
||||
| `tenant_id` | `INTEGER` | YES | `NULL` | Tenant isolation scope (`tenants.id`) |
|
||||
| `parent_id` | `UUID` | YES | `NULL` | Self-referencing FK -> `categories.id` (Parent Category) |
|
||||
| `name` | `VARCHAR(100)` | NO | — | Category Name (e.g., `Audio & Headphones`) |
|
||||
| `code` | `VARCHAR(50)` | NO | — | Unique taxonomy code |
|
||||
| `slug` | `VARCHAR(100)` | NO | — | SEO slug (e.g., `audio-headphones`) |
|
||||
| `description`| `TEXT` | YES | `NULL` | Category description |
|
||||
| `status` | `VARCHAR(20)` | NO | `'active'` | `active`, `inactive` |
|
||||
| `display_order` | `INTEGER` | NO | `0` | UI sort order |
|
||||
| `created_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
|
||||
| `updated_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
|
||||
| `deleted_at` | `TIMESTAMP WITH TZ` | YES | `NULL` | Soft delete marker |
|
||||
|
||||
---
|
||||
|
||||
### 2.4. `brands` (Brand Registry)
|
||||
| Column Name | PostgreSQL Type | Nullable | Default | Description & Foreign Key References |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
|
||||
| `tenant_id` | `INTEGER` | YES | `NULL` | Tenant isolation scope (`tenants.id`) |
|
||||
| `name` | `VARCHAR(100)` | NO | — | Brand Name (e.g. `TechNova`) |
|
||||
| `code` | `VARCHAR(50)` | NO | — | Unique code (e.g. `technova`) |
|
||||
| `website` | `VARCHAR(255)` | YES | `NULL` | Brand official website URL |
|
||||
| `description`| `TEXT` | YES | `NULL` | Brand profile text |
|
||||
| `logo_url` | `TEXT` | YES | `NULL` | Media URL to brand logo |
|
||||
| `status` | `VARCHAR(20)` | NO | `'active'` | `active`, `inactive` |
|
||||
| `created_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
|
||||
| `updated_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
|
||||
| `deleted_at` | `TIMESTAMP WITH TZ` | YES | `NULL` | Soft delete marker |
|
||||
|
||||
---
|
||||
|
||||
### 2.5. `units` (Units of Measure Registry)
|
||||
| Column Name | PostgreSQL Type | Nullable | Default | Description & Foreign Key References |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
|
||||
| `tenant_id` | `INTEGER` | YES | `NULL` | Tenant isolation scope (`tenants.id`) |
|
||||
| `name` | `VARCHAR(100)` | NO | — | Unit Name (e.g. `Piece`, `Set`, `Kilogram`) |
|
||||
| `code` | `VARCHAR(50)` | NO | — | Unique code (e.g. `pcs`, `set`, `kg`) |
|
||||
| `symbol` | `VARCHAR(20)` | NO | — | Display symbol (e.g. `pc`, `set`, `kg`) |
|
||||
| `unit_type` | `VARCHAR(50)` | NO | `'Other'` | `Weight`, `Length`, `Volume`, `Count`, `Other` |
|
||||
| `conversion_factor` | `NUMERIC(15,6)` | YES | `1.000000` | Multiplier relative to standard base unit |
|
||||
| `base_unit_id` | `UUID` | YES | `NULL` | Self-referencing FK -> `units.id` (Standard base unit) |
|
||||
| `status` | `VARCHAR(20)` | NO | `'active'` | `active`, `inactive` |
|
||||
| `created_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
|
||||
| `updated_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
|
||||
| `deleted_at` | `TIMESTAMP WITH TZ` | YES | `NULL` | Soft delete marker |
|
||||
|
||||
---
|
||||
|
||||
### 2.6. `attributes` (Dynamic Field Definitions)
|
||||
| Column Name | PostgreSQL Type | Nullable | Default | Description & Foreign Key References |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
|
||||
| `tenant_id` | `INTEGER` | YES | `NULL` | Tenant isolation scope (`tenants.id`) |
|
||||
| `code` | `VARCHAR(50)` | NO | — | Attribute Code (e.g. `color_spec`, `weight_grams`) |
|
||||
| `name` | `VARCHAR(100)` | NO | — | Display Name (e.g. `Color Spec`, `Weight (Grams)`) |
|
||||
| `type` | `VARCHAR(30)` | NO | `'text'` | `text`, `textarea`, `number`, `decimal`, `date`, `boolean`, `select`, `multiselect` |
|
||||
| `is_required` | `BOOLEAN` | NO | `FALSE` | Mandate flag for completeness evaluation |
|
||||
| `is_unique` | `BOOLEAN` | NO | `FALSE` | Requires unique value across catalog |
|
||||
| `is_variant_eligible` | `BOOLEAN` | NO | `FALSE` | Allowed as matrix generator axis |
|
||||
| `min_length` / `max_length` | `INTEGER` | YES | `NULL` | String length bounds |
|
||||
| `options` | `JSONB` | YES | `[]` | Array of strings for select/multiselect fallback |
|
||||
| `status` | `VARCHAR(20)` | NO | `'active'` | `active`, `inactive` |
|
||||
| `created_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
|
||||
| `updated_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
|
||||
| `deleted_at` | `TIMESTAMP WITH TZ` | YES | `NULL` | Soft delete marker |
|
||||
|
||||
---
|
||||
|
||||
### 2.7. `attribute_options` (Select / Multiselect Option Choices)
|
||||
| Column Name | PostgreSQL Type | Nullable | Default | Description & Foreign Key References |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
|
||||
| `attribute_id` | `UUID` | NO | — | Foreign Key -> `attributes.id` (Parent Attribute) |
|
||||
| `code` | `VARCHAR(50)` | NO | — | Value Code (e.g. `black`, `red`, `wireless_bt`) |
|
||||
| `value` | `VARCHAR(255)` | NO | — | Human Display Label (e.g. `Black`, `Red`) |
|
||||
| `display_order` | `INTEGER` | NO | `0` | Dropdown presentation position |
|
||||
| `created_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
|
||||
| `updated_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
|
||||
|
||||
---
|
||||
|
||||
### 2.8. `product_attribute_values` (EAV Product Data Storage)
|
||||
| Column Name | PostgreSQL Type | Nullable | Default | Description & Foreign Key References |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
|
||||
| `product_id` | `UUID` | NO | — | Foreign Key -> `products.id` (Cascade on delete) |
|
||||
| `attribute_id` | `UUID` | NO | — | Foreign Key -> `attributes.id` |
|
||||
| `value` | `TEXT` | YES | `NULL` | Serialized value (String, number, date, JSON array) |
|
||||
| `locale` | `VARCHAR(10)` | NO | `'en'` | Internationalization locale code |
|
||||
| `channel_code` | `VARCHAR(50)` | YES | `NULL` | Channel specific value override |
|
||||
| `created_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
|
||||
| `updated_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
|
||||
|
||||
---
|
||||
|
||||
### 2.9. `product_assets` (Product Media Junction)
|
||||
| Column Name | PostgreSQL Type | Nullable | Default | Description & Foreign Key References |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
|
||||
| `product_id` | `UUID` | NO | — | Foreign Key -> `products.id` |
|
||||
| `asset_id` | `UUID` | NO | — | Foreign Key -> `assets.id` |
|
||||
| `role` | `VARCHAR(50)` | NO | `'gallery_image'`| `hero_image`, `gallery_image`, `thumbnail`, `video`, `document` |
|
||||
| `is_primary` | `BOOLEAN` | NO | `FALSE` | Primary hero display flag |
|
||||
| `display_order` | `INTEGER` | NO | `0` | Image gallery sort order |
|
||||
| `created_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
|
||||
| `updated_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
|
||||
|
||||
---
|
||||
|
||||
### 2.10. `product_completeness` (Data Readiness Engine Scoring)
|
||||
| Column Name | PostgreSQL Type | Nullable | Default | Description & Foreign Key References |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
|
||||
| `product_id` | `UUID` | NO | — | Foreign Key -> `products.id` |
|
||||
| `channel` | `VARCHAR(50)` | NO | `'default'` | Distribution channel scope (`default`, `shopify`, `amazon`) |
|
||||
| `locale` | `VARCHAR(10)` | NO | `'en'` | Locale scope |
|
||||
| `percentage` | `INTEGER` | NO | `0` | Calculated score (0 to 100%) |
|
||||
| `is_complete` | `BOOLEAN` | NO | `FALSE` | `TRUE` if `percentage === 100` |
|
||||
| `missing_attributes` | `JSONB` | YES | `[]` | Array of missing attribute codes & labels |
|
||||
| `missing_assets` | `JSONB` | YES | `[]` | Array of missing mandatory asset type roles |
|
||||
| `missing_channels` | `JSONB` | YES | `[]` | Array of missing required syndication channels |
|
||||
| `missing_general` | `JSONB` | YES | `[]` | Missing basic fields (`name`, `category`, `brand`, `unit`) |
|
||||
| `created_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
|
||||
| `updated_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
|
||||
@@ -0,0 +1,131 @@
|
||||
# ⚡ Product Creation POST API & Lifecycle Engine
|
||||
|
||||
## 1. Overview
|
||||
The `POST /api/v1/products` endpoint is the gateway for catalog authoring. It coordinates JSON Schema validation, taxonomy binding, dynamic EAV attribute parsing, code generation, SKU reservation, transaction execution, socket broadcasting, and automated completeness calculation.
|
||||
|
||||
---
|
||||
|
||||
## 2. API Contract & Payload Schema
|
||||
|
||||
### Request Definition
|
||||
- **Endpoint**: `POST /api/v1/products`
|
||||
- **Headers**:
|
||||
- `Content-Type: application/json`
|
||||
- `Authorization: Bearer <JWT_TOKEN>`
|
||||
- `x-tenant-id: <TENANT_ID>`
|
||||
|
||||
### Canonical JSON Payload
|
||||
```json
|
||||
{
|
||||
"name": "TechNova Audio Pro X1 Wireless Headphones",
|
||||
"code": "technova_audio_pro_x1",
|
||||
"sku": "TECHNOVA-AUDIO-PRO-X1-BLK",
|
||||
"status": "draft",
|
||||
"type": "simple",
|
||||
"price": 249.99,
|
||||
"stock": 100,
|
||||
"family_id": "b0343591-1bfe-4b02-8556-626616be518a",
|
||||
"category": "d13554e2-763b-4886-9a3d-4c312781dc41",
|
||||
"brand": "0e527d71-5582-4fec-beea-682442cf8947",
|
||||
"unit": "3dbf77c3-3765-4f46-9538-4e8971f1e695",
|
||||
"description": "Premium noise-cancelling over-ear headphones with 40-hour battery life.",
|
||||
"barcode": "8901234567890",
|
||||
"gtin": "00890123456789",
|
||||
"upc": "890123456789",
|
||||
"ean": "8901234567890",
|
||||
"country": "Germany",
|
||||
"hsn": "85183000",
|
||||
"metadata": {
|
||||
"attributeSetId": "eb6706cf-71c3-47c4-8182-a36ee06ce564",
|
||||
"channels": ["shopify", "amazon", "custom_csv"],
|
||||
"currentStage": "draft"
|
||||
},
|
||||
"attributes": {
|
||||
"color_spec": "black",
|
||||
"weight_grams": 250,
|
||||
"connectivity": "Bluetooth 5.3"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. End-to-End Execution Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor Client as Frontend Wizard / API Client
|
||||
participant Auth as Auth & Context Middleware
|
||||
participant Ctrl as ProductController
|
||||
participant Svc as ProductService
|
||||
participant CodeGen as Code/SKU Engine
|
||||
participant DB as PostgreSQL Database
|
||||
participant Comp as CompletenessService
|
||||
participant Audit as AuditService
|
||||
participant WS as Socket.IO Broadcaster
|
||||
|
||||
Client->>Auth: POST /api/v1/products
|
||||
Auth->>Auth: Validate JWT, Tenant ID & Permission (products.create)
|
||||
Auth->>Ctrl: create(req, res)
|
||||
Ctrl->>Svc: create(productData, context)
|
||||
|
||||
rect rgb(240, 248, 255)
|
||||
Note over Svc,DB: BEGIN Database Transaction
|
||||
Svc->>CodeGen: generateUniqueCode(Product, baseCode)
|
||||
CodeGen-->>Svc: Normalized unique code (e.g. technova_audio_pro_x1_1)
|
||||
|
||||
alt Manual SKU provided
|
||||
Svc->>Svc: Preserve user-entered SKU
|
||||
else Status is active/pending & SKU is empty
|
||||
Svc->>CodeGen: generateSku(Product, prefix)
|
||||
CodeGen-->>Svc: Generated SKU (e.g. ELEC-00042)
|
||||
else Status is draft & SKU empty
|
||||
Svc->>Svc: Set SKU = null (Valid in draft)
|
||||
end
|
||||
|
||||
Svc->>DB: INSERT INTO products (...) VALUES (...)
|
||||
DB-->>Svc: Created Product record (UUID)
|
||||
|
||||
opt Dynamic Attributes Supplied
|
||||
Svc->>DB: Bulk INSERT INTO product_attribute_values (...)
|
||||
end
|
||||
|
||||
Svc->>DB: COMMIT Transaction
|
||||
end
|
||||
|
||||
Svc->>Comp: CompletenessService.calculate(productId)
|
||||
Comp->>DB: Compute & Upsert ProductCompleteness
|
||||
Svc->>Audit: log({ action: 'CREATE', entity: 'Product', id })
|
||||
Svc->>WS: emit('product:created', productPayload)
|
||||
Svc-->>Ctrl: Hydrated Product with Associations
|
||||
Ctrl-->>Client: HTTP 201 Created (Product JSON)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Lifecycle Status Transitions & Validation Rules
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Draft: Initial Save (POST)
|
||||
Draft --> Pending: Submit for QA / Approval
|
||||
Pending --> Active: Publish & Approve
|
||||
Active --> Archived: Catalog Retirement
|
||||
Archived --> Active: Reactivate
|
||||
Pending --> Draft: QA Reject / Revisions Needed
|
||||
|
||||
note right of Draft
|
||||
- Missing attributes allowed
|
||||
- Missing assets allowed
|
||||
- SKU may be null
|
||||
- Completeness: 0% - 100%
|
||||
end note
|
||||
|
||||
note right of Active
|
||||
- Requires Valid SKU
|
||||
- Name, Category, Brand, Unit required
|
||||
- Completeness verified
|
||||
- Ready for Syndication Push
|
||||
end note
|
||||
```
|
||||
@@ -0,0 +1,107 @@
|
||||
# 🔗 Dependent Entities & Cascading Architecture
|
||||
|
||||
## 1. Overview
|
||||
A Product in modern PIM is not an isolated table row; it is an aggregated composite entity. This document details each sub-primitive, its database schema, cascade behaviors, and lifecycle rules.
|
||||
|
||||
---
|
||||
|
||||
## 2. Taxonomy & Hierarchy (Categories)
|
||||
|
||||
### Data Architecture
|
||||
Categories are modeled as an Adjacency List hierarchy using self-referencing `parent_id`.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Root[Root: Electronics] --> Audio[Category: Audio & Sound]
|
||||
Audio --> Headphones[Subcategory: Wireless Headphones]
|
||||
Audio --> Speakers[Subcategory: Bluetooth Speakers]
|
||||
Root --> Computers[Category: Computers & Laptops]
|
||||
```
|
||||
|
||||
### Inheritance & Rules
|
||||
- **Category Inheritance**: When selecting a `Product Family`, the product automatically inherits the family's default category if none is set.
|
||||
- **Slug Normalization**: Slugs are generated recursively (`electronics/audio-sound/wireless-headphones`).
|
||||
- **Cascade Rule**: Deleting a category does NOT delete products; it sets product `category_id = NULL` to prevent orphaned cascades.
|
||||
|
||||
---
|
||||
|
||||
## 3. Brand & Manufacturer Registry
|
||||
|
||||
### Data Architecture
|
||||
Brands store trademark data, manufacturer info, and logo DAM assets.
|
||||
|
||||
### Blueprint Filtering
|
||||
- Product Families define `allowedBrands` in their blueprint.
|
||||
- When creating a product from a family, the UI automatically filters the Brand dropdown to only include allowed brands.
|
||||
- If allowed brands is empty, all active workspace brands are selectable.
|
||||
|
||||
---
|
||||
|
||||
## 4. Units of Measure (UOM) Engine
|
||||
|
||||
### Data Architecture & Conversions
|
||||
- `unit_type`: Categorizes units (`Weight`, `Length`, `Volume`, `Count`, `Other`).
|
||||
- `conversion_factor`: Standard multiplier against a base unit (e.g. `g` has factor `0.001` relative to base unit `kg`).
|
||||
- **Conflict Handling**: The inline unit creation endpoint enforces unique code constraints. On `HTTP 409 Conflict`, the frontend auto-selects the existing unit matching the code/name to prevent workflow interruption.
|
||||
|
||||
---
|
||||
|
||||
## 5. Attributes, Sets & Groups (The EAV Model)
|
||||
|
||||
### Hierarchy Model
|
||||
```mermaid
|
||||
graph TD
|
||||
Set[Attribute Set: Electronics Set] --> G1[Group: Technical Specifications]
|
||||
Set --> G2[Group: Physical Dimensions]
|
||||
Set --> G3[Group: Marketing & Media]
|
||||
|
||||
G1 --> A1[Attribute: Connectivity]
|
||||
G1 --> A2[Attribute: Battery Life]
|
||||
G2 --> A3[Attribute: Weight Grams]
|
||||
G2 --> A4[Attribute: Color Spec]
|
||||
G3 --> A5[Attribute: SEO Title]
|
||||
```
|
||||
|
||||
### Supported Attribute Types & Validations
|
||||
1. `text`: String values with `min_length` and `max_length`.
|
||||
2. `textarea`: Multi-line text for descriptions and spec tables.
|
||||
3. `number` / `decimal`: Integer or floating-point numbers with `min_value` and `max_value` limits (e.g., non-negative `min: 0` for physical weights).
|
||||
4. `date`: ISO date format (`YYYY-MM-DD`).
|
||||
5. `boolean`: Binary `true` / `false` flags.
|
||||
6. `select`: Single-choice enumeration reading from `attribute_options` or `options` array.
|
||||
7. `multiselect`: Comma-delimited or JSON array of allowed choices.
|
||||
|
||||
---
|
||||
|
||||
## 6. Digital Asset Management (DAM) & Media Junction
|
||||
|
||||
### Role Matrix
|
||||
| Role Code | Display Label | Purpose | Completeness Evaluation |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `hero_image` | **HERO IMAGE** | Primary high-res catalog cover image | Evaluated for completeness (is_primary) |
|
||||
| `gallery_image`| **GALLERY** | Additional multi-angle product views | Optional supporting media |
|
||||
| `thumbnail` | **THUMBNAIL** | Low-res compressed preview icon | Optional UI thumbnail |
|
||||
| `video` | **VIDEO** | Product showcase video (MP4/WebM) | Optional media |
|
||||
| `document` | **DOCUMENT** | PDF user manuals, safety sheets | Optional compliance assets |
|
||||
|
||||
---
|
||||
|
||||
## 7. Completeness Calculation Mathematical Engine
|
||||
|
||||
The PIM Data Completeness score is calculated as a real-time ratio (0% to 100%):
|
||||
|
||||
$$\text{Completeness } \% = \text{round}\left( \frac{\text{Fulfilled Fields}}{\text{Total Expected Fields}} \times 100 \right)$$
|
||||
|
||||
### Weighted Breakdown:
|
||||
1. **General Prerequisites (4 fields)**:
|
||||
- `name` (Product Name present)
|
||||
- `category_id` (Category assigned)
|
||||
- `brand_id` (Brand assigned)
|
||||
- `unit_id` (Unit of Measure assigned)
|
||||
2. **Evaluated Attributes**:
|
||||
- Evaluates all configured attributes in the active Attribute Set.
|
||||
- Each configured attribute value contributes proportionally to the attribute score.
|
||||
3. **Required Media Assets**:
|
||||
- Evaluates mandatory asset families (e.g. at least 1 primary Hero Image).
|
||||
4. **Subscribed Syndication Channels**:
|
||||
- Evaluates whether mandatory syndication channels are linked.
|
||||
@@ -0,0 +1,48 @@
|
||||
# 🔍 System Gaps, Risk Audit & Optimization Blueprint
|
||||
|
||||
## 1. Executive Summary
|
||||
This document captures architectural vulnerabilities, potential race conditions, database indexing requirements, and optimization blueprints identified during the comprehensive Product Creation deep-dive.
|
||||
|
||||
---
|
||||
|
||||
## 2. Identified Vulnerabilities & Audit Findings
|
||||
|
||||
| Category | Finding & Risk | Impact | Resolution & Architecture Recommendation |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Concurrency** | Non-atomic SKU generation on concurrent product creations | Duplicate SKU generation if two products are created in the exact same millisecond | Use PostgreSQL sequence or database-level lock on `sku_counters` table rather than `COUNT(*)` lookups. |
|
||||
| **Data Integrity** | Soft-deleted attribute options lingering in product values | A product displays a deleted option code with no human-readable label | In `product_attribute_values`, store both `option_id` (FK) and string fallback, or enforce cascade warning on attribute option deletion. |
|
||||
| **Performance** | Missing composite index on `product_attribute_values` | Sluggish search queries when filtering products by multiple attribute values | Add compound index: `CREATE INDEX idx_pav_attr_val ON product_attribute_values(attribute_id, value);` |
|
||||
| **Tenant Scope** | In-memory filtering instead of database query scoping | Unnecessary memory overhead fetching cross-tenant records | Always apply `where: { tenant_id }` in repository layer before executing Sequelize `findAll()`. |
|
||||
|
||||
---
|
||||
|
||||
## 3. High-Priority Database Indexing Strategy
|
||||
|
||||
To guarantee sub-50ms response times for a catalog containing 500,000+ products:
|
||||
|
||||
```sql
|
||||
-- 1. Product Registry Primary Lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_products_tenant_status ON products(tenant_id, status) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_products_code_tenant ON products(code, tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_sku_tenant ON products(sku, tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_family ON products(family_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_category ON products(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_brand ON products(brand_id);
|
||||
|
||||
-- 2. Dynamic Attribute EAV Indexing
|
||||
CREATE INDEX IF NOT EXISTS idx_pav_product_attr ON product_attribute_values(product_id, attribute_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pav_attr_value ON product_attribute_values(attribute_id, value);
|
||||
|
||||
-- 3. Completeness Indexing
|
||||
CREATE INDEX IF NOT EXISTS idx_completeness_product_channel ON product_completeness(product_id, channel, locale);
|
||||
|
||||
-- 4. Media Asset Junction Indexing
|
||||
CREATE INDEX IF NOT EXISTS idx_product_assets_product_primary ON product_assets(product_id, is_primary);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Architectural Next Steps & Roadmap
|
||||
1. **Bulk Product Import & Syndication Queue**: Implement Redis / BullMQ worker pipeline for processing 10,000+ CSV / XML product imports asynchronously with streaming batch inserts.
|
||||
2. **Channel Transformation Engine (Phase 5)**: Build dynamic attribute mapping formulas (e.g. mapping `weight_grams / 1000` to Shopify's `weight_kg`).
|
||||
3. **Audit Log Timeline UI**: Render chronological diff timeline in product editor showing exact field changes and who approved them.
|
||||
@@ -0,0 +1,95 @@
|
||||
# 📋 Product Listing & Query Engine Architecture
|
||||
|
||||
## 1. Overview
|
||||
The Product Listing system (`GET /api/v1/products`) powers the core catalog data grid. It supports multi-facet filtering (by category, brand, family, completeness, channel, status, tag, and custom attributes), full-text search across product name and SKU, server-side pagination, eager-loading of primary hero assets, and real-time completeness score aggregation.
|
||||
|
||||
---
|
||||
|
||||
## 2. API Endpoint Specification
|
||||
|
||||
- **Endpoint**: `GET /api/v1/products`
|
||||
- **Query Parameters**:
|
||||
- `page` (integer, default: 1)
|
||||
- `limit` (integer, default: 20, max: 100)
|
||||
- `search` (string, fuzzy search on `name`, `code`, `sku`)
|
||||
- `status` (string or array: `draft`, `pending`, `active`, `archived`)
|
||||
- `type` (string: `simple`, `variant`, `bundle`)
|
||||
- `family_id` (UUID)
|
||||
- `category_id` (UUID, matches category and its descendant subtrees)
|
||||
- `brand_id` (UUID)
|
||||
- `completeness_min` / `completeness_max` (integers 0-100)
|
||||
- `channel` (string, e.g. `shopify`, `amazon`)
|
||||
- `sortBy` (`name`, `sku`, `created_at`, `updated_at`, `completeness`, `price`)
|
||||
- `sortOrder` (`ASC` or `DESC`)
|
||||
|
||||
---
|
||||
|
||||
## 3. Query Optimization & Eager-Loading Structure
|
||||
|
||||
To avoid the $N+1$ query problem, the product listing query uses targeted eager-loading with selected columns:
|
||||
|
||||
```javascript
|
||||
const queryOptions = {
|
||||
where: baseWhereClause,
|
||||
attributes: [
|
||||
'id', 'code', 'sku', 'name', 'price', 'stock', 'status', 'type',
|
||||
'family_id', 'category_id', 'brand_id', 'unit_id', 'created_at', 'updated_at'
|
||||
],
|
||||
include: [
|
||||
{
|
||||
model: models.Catalog,
|
||||
as: 'family',
|
||||
attributes: ['id', 'code', 'name']
|
||||
},
|
||||
{
|
||||
model: models.Category,
|
||||
as: 'category',
|
||||
attributes: ['id', 'code', 'name', 'slug']
|
||||
},
|
||||
{
|
||||
model: models.Brand,
|
||||
as: 'brand',
|
||||
attributes: ['id', 'code', 'name', 'logo_url']
|
||||
},
|
||||
{
|
||||
model: models.Unit,
|
||||
as: 'unit',
|
||||
attributes: ['id', 'code', 'name', 'symbol']
|
||||
},
|
||||
{
|
||||
model: models.ProductAsset,
|
||||
as: 'productAssets',
|
||||
where: { is_primary: true },
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: models.Asset,
|
||||
as: 'asset',
|
||||
attributes: ['id', 'url', 'thumbnail_url', 'name', 'mime_type']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: models.ProductCompleteness,
|
||||
as: 'completenessEntries',
|
||||
where: { channel: 'default' },
|
||||
required: false,
|
||||
attributes: ['percentage', 'is_complete', 'missing_attributes', 'missing_assets']
|
||||
}
|
||||
],
|
||||
order: [[sortField, sortOrder]],
|
||||
limit,
|
||||
offset: (page - 1) * limit
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Frontend Data Grid Hydration & Performance
|
||||
- **Primary Hero Image**: Displayed as a responsive 40x40 thumbnail from `productAssets[0].asset.thumbnail_url || productAssets[0].asset.url`.
|
||||
- **Completeness Indicator**: Circular or pill progress badge colored dynamically:
|
||||
- `0 - 49%`: Red (Incomplete)
|
||||
- `50 - 84%`: Amber (Partially Configured)
|
||||
- `85 - 99%`: Blue (Ready for Review)
|
||||
- `100%`: Emerald Green (Fully Complete & Ready for Publish)
|
||||
- **Fast Filter Sync**: Search query debounced at 300ms, updating URL search params to preserve filter state on browser reload.
|
||||
@@ -0,0 +1,28 @@
|
||||
# 📚 PIM Product Creation & Management Master Documentation
|
||||
|
||||
Welcome to the central architectural and operational knowledge base for the **Product Information Management (PIM)** engine. This directory contains end-to-end specifications, database table dictionaries, sequence diagrams, and optimization blueprints designed for software engineers, QA architects, and AI autonomous agents.
|
||||
|
||||
---
|
||||
|
||||
## 📑 Manuals & Reference Guides
|
||||
|
||||
| File | Scope & Contents |
|
||||
| :--- | :--- |
|
||||
| **[`01_EXECUTIVE_ARCHITECTURE_AND_ECOSYSTEM_MAP.md`](file:///Users/maskantech/Desktop/PIM/docs/product_engine_deep_dive/01_EXECUTIVE_ARCHITECTURE_AND_ECOSYSTEM_MAP.md)** | Global Entity-Relationship (ER) diagram, 5-layer dependency hierarchy, multi-tenant isolation principles, and core architectural invariants. |
|
||||
| **[`02_DATABASE_SCHEMA_AND_TABLE_DICTIONARY.md`](file:///Users/maskantech/Desktop/PIM/docs/product_engine_deep_dive/02_DATABASE_SCHEMA_AND_TABLE_DICTIONARY.md)** | Complete table-by-table dictionary covering 10+ core tables (`products`, `catalogs`, `categories`, `brands`, `units`, `attributes`, `attribute_options`, `product_attribute_values`, `product_assets`, `product_completeness`), exact column types, constraints, and defaults. |
|
||||
| **[`03_PRODUCT_CREATION_POST_API_LIFECYCLE.md`](file:///Users/maskantech/Desktop/PIM/docs/product_engine_deep_dive/03_PRODUCT_CREATION_POST_API_LIFECYCLE.md)** | Micro-detailed specification of `POST /api/v1/products`, request payload schema, transaction lifecycle sequence diagram, code/SKU generation rules, and status state machine. |
|
||||
| **[`04_DEPENDENT_ENTITIES_AND_CASCADE_ENGINE.md`](file:///Users/maskantech/Desktop/PIM/docs/product_engine_deep_dive/04_DEPENDENT_ENTITIES_AND_CASCADE_ENGINE.md)** | In-depth breakdown of Category trees, Brand filters, Units of Measure conversions, Dynamic Attribute EAV models, DAM Media Asset roles, and the mathematical Completeness Calculation Engine. |
|
||||
| **[`05_SYSTEM_GAPS_RISK_AUDIT_AND_OPTIMIZATION_BLUEPRINT.md`](file:///Users/maskantech/Desktop/PIM/docs/product_engine_deep_dive/05_SYSTEM_GAPS_RISK_AUDIT_AND_OPTIMIZATION_BLUEPRINT.md)** | Identified vulnerabilities, concurrency risks, recommended high-performance PostgreSQL composite indexes, and future syndication queue roadmap. |
|
||||
| **[`06_PRODUCT_LISTING_AND_QUERY_ARCHITECTURE.md`](file:///Users/maskantech/Desktop/PIM/docs/product_engine_deep_dive/06_PRODUCT_LISTING_AND_QUERY_ARCHITECTURE.md)** | Complete blueprint for `GET /api/v1/products`, multi-facet filtering, eager-loading relations, sorting, pagination, and frontend grid hydration. |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Quick Verification Commands
|
||||
```bash
|
||||
# Verify backend server health
|
||||
curl -sI http://localhost:5002/api/v1/categories
|
||||
|
||||
# Verify frontend build & dev server
|
||||
curl -sI http://localhost:5173
|
||||
cd productcatalogue_frontend && npx tsc --noEmit
|
||||
```
|
||||
Generated
+58
-42
@@ -86,6 +86,7 @@
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
@@ -301,31 +302,10 @@
|
||||
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
|
||||
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
||||
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
|
||||
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -645,7 +625,6 @@
|
||||
"integrity": "sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
@@ -1382,7 +1361,6 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1400,7 +1378,6 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1418,7 +1395,6 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1436,7 +1412,6 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1454,7 +1429,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1472,7 +1446,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1490,7 +1463,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1508,7 +1480,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1526,7 +1497,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1544,7 +1514,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1562,7 +1531,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1580,7 +1548,6 @@
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1595,7 +1562,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.11.2",
|
||||
"@emnapi/runtime": "1.11.2",
|
||||
@@ -1605,6 +1571,40 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
|
||||
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
||||
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.0.tgz",
|
||||
@@ -1618,7 +1618,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1636,7 +1635,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
@@ -1647,6 +1645,7 @@
|
||||
"integrity": "sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
@@ -2122,6 +2121,7 @@
|
||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
@@ -2131,6 +2131,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -2141,6 +2142,7 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -2196,6 +2198,7 @@
|
||||
"integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.61.1",
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
@@ -2426,6 +2429,7 @@
|
||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2508,6 +2512,7 @@
|
||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.26.0"
|
||||
}
|
||||
@@ -2568,6 +2573,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@@ -3019,6 +3025,7 @@
|
||||
"integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -4282,6 +4289,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -4317,6 +4325,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
|
||||
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -4335,6 +4344,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.81.0.tgz",
|
||||
"integrity": "sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -4350,13 +4360,15 @@
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
|
||||
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
@@ -4545,7 +4557,8 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
@@ -4809,6 +4822,7 @@
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -4968,6 +4982,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
@@ -5448,6 +5463,7 @@
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios, { type InternalAxiosRequestConfig } from 'axios';
|
||||
|
||||
const API_BASE_URL = 'http://localhost:5000';
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5002';
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
@@ -17,6 +17,10 @@ axiosInstance.interceptors.request.use(
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
const impersonatedTenantId = localStorage.getItem('impersonatedTenantId');
|
||||
if (impersonatedTenantId) {
|
||||
config.headers['X-Impersonated-Tenant-Id'] = impersonatedTenantId;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error: unknown) => Promise.reject(error)
|
||||
@@ -25,10 +29,13 @@ axiosInstance.interceptors.request.use(
|
||||
// Response interceptor
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: { response?: { status?: number } }) => {
|
||||
if (error.response?.status === 401) {
|
||||
(error: { config?: { url?: string }; response?: { status?: number } }) => {
|
||||
const isLoginEndpoint = error.config?.url?.includes('/auth/login');
|
||||
if (error.response?.status === 401 && !isLoginEndpoint) {
|
||||
localStorage.removeItem('accessToken');
|
||||
window.location.href = '/login';
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
@@ -31,4 +31,20 @@ export const ProtectedRoute: React.FC<{
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export const PlatformGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { isAuthenticated, user } = useAppSelector((state) => state.auth);
|
||||
const location = useLocation();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
const isPlatformUser = user?.type === 'platform' || user?.user_type === 'platform';
|
||||
if (!isPlatformUser) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default AuthGuard;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { setCredentials } from "../../store/slices/authSlice";
|
||||
import { authService } from "../services/authService";
|
||||
import { Eye, EyeOff, Lock, Mail, ArrowRight } from "lucide-react";
|
||||
import { AuthLayout } from "../components/AuthLayout";
|
||||
import { notify } from "../../services/toast";
|
||||
|
||||
export const Login = () => {
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -45,13 +46,18 @@ export const Login = () => {
|
||||
permissions: permissions ?? {},
|
||||
}));
|
||||
|
||||
navigate("/dashboard");
|
||||
if (user?.type === 'platform' || user?.user_type === 'platform') {
|
||||
navigate("/platform/overview");
|
||||
} else {
|
||||
navigate("/dashboard");
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg =
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
'Login failed. Please check your credentials.';
|
||||
'Invalid email or password. Please try again.';
|
||||
setError(msg);
|
||||
notify.error(msg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import {
|
||||
ChevronDown, ChevronRight, Folder, FolderOpen, Search, Plus, X,
|
||||
ChevronDown, ChevronRight, Folder, FolderOpen, Search, X,
|
||||
Check, Loader2, FolderPlus
|
||||
} from 'lucide-react';
|
||||
import { categoryService } from '../../features/categories/services/category.service';
|
||||
@@ -19,6 +19,7 @@ interface CategoryNode {
|
||||
interface CategoryTreeSelectProps {
|
||||
value?: string;
|
||||
onChange: (categoryId: string) => void;
|
||||
onBlur?: () => void;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
@@ -60,6 +61,7 @@ function buildCategoryTree(categories: any[]): CategoryNode[] {
|
||||
export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
placeholder = 'Select Category...',
|
||||
error,
|
||||
disabled = false,
|
||||
@@ -68,7 +70,7 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
|
||||
|
||||
// Quick Create Drawer state
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [newCatName, setNewCatName] = useState('');
|
||||
@@ -86,12 +88,17 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
|
||||
useEffect(() => {
|
||||
const handleOutsideClick = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
setIsOpen((prev) => {
|
||||
if (prev) {
|
||||
onBlur?.();
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleOutsideClick);
|
||||
return () => document.removeEventListener('mousedown', handleOutsideClick);
|
||||
}, []);
|
||||
}, [onBlur]);
|
||||
|
||||
const categoryTree = useMemo(() => buildCategoryTree(categories), [categories]);
|
||||
|
||||
@@ -138,7 +145,7 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
|
||||
|
||||
const createdId = created?.id || created?.data?.id;
|
||||
notify.success(`Category "${newCatName}" created successfully!`);
|
||||
|
||||
|
||||
await fetchCategories();
|
||||
if (createdId) {
|
||||
onChange(createdId);
|
||||
@@ -178,11 +185,10 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
|
||||
<div key={node.id} className="select-none">
|
||||
<div
|
||||
onClick={() => handleSelect(node.id)}
|
||||
className={`flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? 'bg-primary/10 text-primary font-bold'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
className={`flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium cursor-pointer transition-colors ${isSelected
|
||||
? 'bg-primary/10 text-primary font-bold'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
style={{ paddingLeft: `${node.depth * 16 + 12}px` }}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
@@ -233,11 +239,10 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
|
||||
{/* Trigger Button */}
|
||||
<div
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
className={`w-full border rounded-lg px-3 py-2.5 text-sm flex items-center justify-between bg-white cursor-pointer transition-all ${
|
||||
disabled ? 'bg-gray-50 opacity-60 cursor-not-allowed border-gray-200' :
|
||||
className={`w-full border rounded-lg px-3 py-2.5 text-sm flex items-center justify-between bg-white cursor-pointer transition-all ${disabled ? 'bg-gray-50 opacity-60 cursor-not-allowed border-gray-200' :
|
||||
isOpen ? 'border-primary ring-2 ring-primary/20' :
|
||||
error ? 'border-red-300' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
error ? 'border-red-300' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<Folder className={`w-4 h-4 ${selectedCategory ? 'text-primary' : 'text-gray-400'}`} />
|
||||
@@ -254,7 +259,7 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-1.5 w-full bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden flex flex-col max-h-80 animate-in fade-in zoom-in-95 duration-100">
|
||||
{/* Search Header */}
|
||||
<div className="p-2 border-b border-gray-100 flex items-center gap-2 bg-gray-50/50">
|
||||
<div className="p-2 border-b border-gray-100 flex items-center bg-gray-50/50">
|
||||
<div className="relative flex-1">
|
||||
<Search className="w-3.5 h-3.5 text-gray-400 absolute left-2.5 top-2.5" />
|
||||
<input
|
||||
@@ -265,14 +270,6 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
|
||||
className="w-full pl-8 pr-3 py-1.5 text-xs border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary bg-white"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDrawerOpen(true)}
|
||||
className="px-2.5 py-1.5 bg-primary hover:bg-primary-hover text-white text-xs font-semibold rounded-lg flex items-center gap-1 shrink-0 transition-colors shadow-2xs"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Quick Create
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category Tree Body */}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { ActionMenu, type CustomAction } from "./ActionMenu";
|
||||
|
||||
export interface DataTableColumn<T = any> {
|
||||
id?: string;
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
@@ -300,9 +301,10 @@ export function DataTable<T extends Record<string, any> = any>({
|
||||
</th>
|
||||
{columns.map((col) => {
|
||||
const align = col.align || "center";
|
||||
const colKey = col.id || col.key;
|
||||
return (
|
||||
<th
|
||||
key={col.key}
|
||||
key={colKey}
|
||||
onClick={() => col.sortable && handleSort(col.key)}
|
||||
className={`px-4 py-3 text-xs font-semibold uppercase tracking-wider ${col.sortable ? "cursor-pointer" : ""}`}
|
||||
style={{
|
||||
@@ -376,9 +378,10 @@ export function DataTable<T extends Record<string, any> = any>({
|
||||
</td>
|
||||
{columns.map(col => {
|
||||
const align = col.align || "center";
|
||||
const colKey = col.id || col.key;
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
key={colKey}
|
||||
className={`px-4 py-3 text-sm text-foreground ${align === "center" ? "text-center" : align === "right" ? "text-right" : "text-left"}`}
|
||||
style={{ borderRight: "1px solid var(--color-table-header-border)" }}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { RootState } from '../../store';
|
||||
import { hasPermission } from '../../utils/permissionUtils';
|
||||
import type { PermissionNodes, PermissionAction } from '../../types/auth.types';
|
||||
|
||||
interface PermissionGuardProps {
|
||||
node: PermissionNodes | string;
|
||||
action?: PermissionAction;
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PermissionGuard: React.FC<PermissionGuardProps> = ({
|
||||
node,
|
||||
action = 'view',
|
||||
children,
|
||||
fallback = null
|
||||
}) => {
|
||||
const permissions = useSelector((state: RootState) => state.auth.permissions);
|
||||
const user = useSelector((state: RootState) => state.auth.user);
|
||||
|
||||
const allowed = hasPermission(permissions, node, action, user);
|
||||
|
||||
if (!allowed) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Search, X, Check, ChevronDown } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface Option {
|
||||
@@ -18,6 +19,7 @@ interface SelectProps {
|
||||
placeholder?: string;
|
||||
children?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
searchable?: boolean;
|
||||
}
|
||||
|
||||
export function Select({
|
||||
@@ -31,11 +33,14 @@ export function Select({
|
||||
placeholder = "Select...",
|
||||
children,
|
||||
disabled,
|
||||
searchable,
|
||||
}: SelectProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const [rect, setRect] = React.useState<DOMRect | null>(null);
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const options = React.useMemo(() => {
|
||||
const opts: Option[] = [];
|
||||
@@ -53,6 +58,19 @@ export function Select({
|
||||
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
|
||||
// By default, enable search if there are more than 5 options or if explicitly requested
|
||||
const isSearchEnabled = searchable !== undefined ? searchable : options.length > 5;
|
||||
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
if (!isSearchEnabled || !searchQuery.trim()) {
|
||||
return options;
|
||||
}
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
return options.filter((opt) =>
|
||||
opt.label.toLowerCase().includes(q) || opt.value.toLowerCase().includes(q)
|
||||
);
|
||||
}, [options, isSearchEnabled, searchQuery]);
|
||||
|
||||
// Recalculate position on scroll/resize while open
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -68,6 +86,17 @@ export function Select({
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Auto-focus search input when opened
|
||||
React.useEffect(() => {
|
||||
if (isOpen && isSearchEnabled) {
|
||||
setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, 50);
|
||||
} else if (!isOpen) {
|
||||
setSearchQuery("");
|
||||
}
|
||||
}, [isOpen, isSearchEnabled]);
|
||||
|
||||
// Close on outside click
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -77,10 +106,12 @@ export function Select({
|
||||
dropdownRef.current?.contains(e.target as Node)
|
||||
) return;
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
onBlur?.({ target: { name } } as any);
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isOpen]);
|
||||
}, [isOpen, name, onBlur]);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (disabled) return;
|
||||
@@ -93,13 +124,15 @@ export function Select({
|
||||
const handleSelect = (val: string) => {
|
||||
onChange?.({ target: { name, value: val } });
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div className="relative w-full font-sans">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
id={id}
|
||||
name={name}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={handleToggle}
|
||||
@@ -113,18 +146,12 @@ export function Select({
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={cn(!selectedOption && "text-muted-foreground")}>
|
||||
<span className={cn(!selectedOption && "text-muted-foreground", "truncate pr-2")}>
|
||||
{selectedOption ? selectedOption.label : placeholder}
|
||||
</span>
|
||||
<svg
|
||||
className={cn("h-4 w-4 text-muted-foreground transition-transform", isOpen && "rotate-180")}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m19 9-7 7-7-7" />
|
||||
</svg>
|
||||
<ChevronDown
|
||||
className={cn("h-4 w-4 text-muted-foreground transition-transform shrink-0", isOpen && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && rect && createPortal(
|
||||
@@ -134,30 +161,60 @@ export function Select({
|
||||
position: "fixed",
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
width: Math.max(rect.width, 220),
|
||||
zIndex: 9999,
|
||||
}}
|
||||
className="bg-surface text-foreground border border-border shadow-lg rounded-xl p-1 max-h-60 overflow-y-auto"
|
||||
className="bg-surface text-foreground border border-border shadow-xl rounded-xl overflow-hidden flex flex-col max-h-64 font-sans animate-in fade-in zoom-in-95 duration-100"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<div
|
||||
key={opt.value}
|
||||
onMouseDown={(e) => { e.preventDefault(); handleSelect(opt.value); }}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-lg px-3 py-2 text-sm select-none transition-colors flex items-center justify-between",
|
||||
opt.value === value
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "hover:bg-primary/5 hover:text-primary text-foreground"
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
{opt.value === value && (
|
||||
<svg className="h-4 w-4 fill-current text-primary shrink-0" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{isSearchEnabled && (
|
||||
<div className="p-2 border-b border-border bg-surface-muted flex items-center gap-2 shrink-0">
|
||||
<Search className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="w-full px-2 py-1 text-xs border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary bg-background text-foreground font-sans"
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground shrink-0 cursor-pointer"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
<div className="p-1 overflow-y-auto flex-1 space-y-0.5">
|
||||
{filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((opt) => (
|
||||
<div
|
||||
key={opt.value}
|
||||
onMouseDown={(e) => { e.preventDefault(); handleSelect(opt.value); }}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-lg px-3 py-2 text-xs select-none transition-colors flex items-center justify-between",
|
||||
opt.value === value
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "hover:bg-background text-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate pr-2">{opt.label}</span>
|
||||
{opt.value === value && (
|
||||
<Check className="h-3.5 w-3.5 text-primary shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-4 text-center text-xs text-muted-foreground">
|
||||
No options found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Bell, ChevronDown, Building2, Globe, LogOut, Shield, User, Settings, CheckCircle2, ChevronRight, Menu } from "lucide-react";
|
||||
import { Bell, ChevronDown, Building2, Globe, LogOut, Shield, User, Settings, CheckCircle2, ChevronRight, Menu, ShieldAlert } from "lucide-react";
|
||||
import { useLanguage, type Language } from "../../contexts/LanguageContext";
|
||||
import { useHeader } from "../../contexts/HeaderContext";
|
||||
import { useSidebar } from "../../contexts/SidebarContext";
|
||||
import { useAppDispatch, useAppSelector } from "../../store";
|
||||
import { logout } from "../../store/slices/authSlice";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { notificationService } from "../../features/notifications";
|
||||
import { tenantService } from "../../features/tenants/services/tenant.service";
|
||||
import { notify } from "../../services/toast";
|
||||
|
||||
export function Header() {
|
||||
@@ -19,18 +20,77 @@ export function Header() {
|
||||
|
||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||
const [headerUnread, setHeaderUnread] = useState(0);
|
||||
const [impersonatedTenantId, setImpersonatedTenantId] = useState<string | null>(
|
||||
localStorage.getItem('impersonatedTenantId')
|
||||
);
|
||||
const [platformTenants, setPlatformTenants] = useState<any[]>([]);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isPlatformUser = user?.type === 'platform' || user?.user_type === 'platform';
|
||||
|
||||
// Derive real-time display values from Redux user state
|
||||
const userName = user?.name || user?.user_name || (user?.email ? user.email.split('@')[0] : "User");
|
||||
const userEmail = user?.email || "user@pim.com";
|
||||
const userInitials = userName.slice(0, 2).toUpperCase();
|
||||
|
||||
const roleName = user?.roles?.[0]?.name || user?.roles?.[0]?.role_name || user?.role_name ||
|
||||
(user?.type === 'platform' || user?.email === 'admin@admin.com' ? "Super Admin" : "Member");
|
||||
(isPlatformUser ? "Platform Super Admin" : "Member");
|
||||
|
||||
const tenantName = user?.tenant?.name || user?.tenant_name ||
|
||||
(user?.type === 'platform' || !user?.tenant_id ? "Platform Core" : `Tenant #${user.tenant_id}`);
|
||||
// Load platform tenants if superadmin
|
||||
const loadPlatformTenants = useCallback(async () => {
|
||||
if (isPlatformUser) {
|
||||
try {
|
||||
const tenants = await tenantService.getPlatformTenants();
|
||||
if (Array.isArray(tenants)) {
|
||||
setPlatformTenants(tenants);
|
||||
}
|
||||
} catch (err) {
|
||||
// Fallback silently if not available
|
||||
}
|
||||
}
|
||||
}, [isPlatformUser]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPlatformTenants();
|
||||
}, [loadPlatformTenants]);
|
||||
|
||||
const activeTenantObj = platformTenants.find(t => String(t.id) === String(impersonatedTenantId));
|
||||
|
||||
const tenantName = impersonatedTenantId
|
||||
? `${activeTenantObj?.name || 'Tenant'} (#${impersonatedTenantId}) [Support Mode]`
|
||||
: (user?.tenant?.name || user?.tenant_name || (isPlatformUser ? "Platform Core" : `Tenant #${user?.tenant_id}`));
|
||||
|
||||
const handleWorkspaceChange = async (targetTenantId: string) => {
|
||||
if (!targetTenantId) {
|
||||
// Switch back to Global Platform Control
|
||||
localStorage.removeItem('impersonatedTenantId');
|
||||
setImpersonatedTenantId(null);
|
||||
notify.info("Switched to Global SaaS Control Tower");
|
||||
navigate("/platform/overview");
|
||||
window.location.reload();
|
||||
} else {
|
||||
// Impersonate selected tenant
|
||||
try {
|
||||
await tenantService.impersonateTenant(targetTenantId);
|
||||
localStorage.setItem('impersonatedTenantId', targetTenantId);
|
||||
setImpersonatedTenantId(targetTenantId);
|
||||
const selected = platformTenants.find(t => String(t.id) === String(targetTenantId));
|
||||
notify.success(`Entered Support Mode: ${selected?.name || `Tenant #${targetTenantId}`}`);
|
||||
navigate("/products");
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
notify.error("Unable to switch workspace context.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleExitSupportMode = () => {
|
||||
localStorage.removeItem('impersonatedTenantId');
|
||||
setImpersonatedTenantId(null);
|
||||
notify.info("Support impersonation session ended");
|
||||
navigate("/platform/overview");
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
@@ -69,79 +129,128 @@ export function Header() {
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('impersonatedTenantId');
|
||||
dispatch(logout());
|
||||
notify.info("Logged out successfully");
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="h-16 w-full flex items-center justify-between px-6 bg-surface border-b border-border sticky top-0 z-30">
|
||||
<div className="flex items-center gap-5">
|
||||
{/* Sidebar Toggle — hamburger */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-surface-muted transition-all duration-200 flex-shrink-0"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col min-w-0">
|
||||
<h1 className="text-base sm:text-xl font-semibold text-foreground truncate max-w-[120px] sm:max-w-xs md:max-w-none">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-0.5 truncate max-w-[120px] sm:max-w-xs md:max-w-none">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side Controls */}
|
||||
<div className="flex items-center gap-2 sm:gap-5">
|
||||
{/* Language Selector */}
|
||||
<div className="hidden sm:flex items-center gap-2 rounded-lg border border-border px-3 py-1.5 hover:bg-surface-muted transition-colors">
|
||||
<Globe className="w-4 h-4 text-muted-foreground" />
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value as Language)}
|
||||
className="text-sm font-medium bg-transparent outline-none cursor-pointer text-foreground"
|
||||
aria-label="Select language"
|
||||
>
|
||||
<option value="en" className="bg-surface text-foreground">English</option>
|
||||
<option value="hi" className="bg-surface text-foreground">Hindi</option>
|
||||
<option value="ar" className="bg-surface text-foreground">Arabic</option>
|
||||
<option value="fr" className="bg-surface text-foreground">French</option>
|
||||
<option value="es" className="bg-surface text-foreground">Spanish</option>
|
||||
<option value="de" className="bg-surface text-foreground">German</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Real-time Tenant Indicator Button */}
|
||||
<div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium border border-border bg-surface-muted text-foreground">
|
||||
<Building2 className="w-4 h-4 text-primary" />
|
||||
<span>{tenantName}</span>
|
||||
</div>
|
||||
|
||||
{/* Notifications Bell */}
|
||||
<div className="relative">
|
||||
<div className="w-full sticky top-0 z-30 flex flex-col">
|
||||
{/* Impersonation Support Mode Banner */}
|
||||
{impersonatedTenantId && isPlatformUser && (
|
||||
<div className="bg-amber-500 text-amber-950 px-6 py-1.5 flex items-center justify-between text-xs font-bold shadow-sm transition-all animate-fade-in">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="w-4 h-4 text-amber-950 animate-bounce" />
|
||||
<span>
|
||||
SUPPORT IMPERSONATION ACTIVE: You are viewing and operating inside workspace <strong>"{activeTenantObj?.name || 'Selected Tenant'}"</strong> (Tenant #{impersonatedTenantId}).
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate("/notifications")}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-muted transition-colors relative"
|
||||
aria-label="View notifications"
|
||||
onClick={handleExitSupportMode}
|
||||
className="px-3 py-1 bg-amber-950 text-white rounded-lg hover:bg-black font-extrabold text-[11px] transition-all cursor-pointer shadow-xs"
|
||||
>
|
||||
<Bell className="w-4 h-4 text-foreground" />
|
||||
{headerUnread > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-4 h-4 bg-primary text-white text-[10px] font-extrabold rounded-full flex items-center justify-center shadow-xs">
|
||||
{headerUnread > 9 ? "9+" : headerUnread}
|
||||
</span>
|
||||
)}
|
||||
Exit to Platform Console ✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile Card Trigger & Popover */}
|
||||
<div className="relative" ref={menuRef}>
|
||||
<header className="h-16 w-full flex items-center justify-between px-6 bg-surface border-b border-border">
|
||||
<div className="flex items-center gap-5">
|
||||
{/* Sidebar Toggle — hamburger */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-surface-muted transition-all duration-200 flex-shrink-0 cursor-pointer"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col min-w-0">
|
||||
<h1 className="text-base sm:text-xl font-semibold text-foreground truncate max-w-[120px] sm:max-w-xs md:max-w-none">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-0.5 truncate max-w-[120px] sm:max-w-xs md:max-w-none">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side Controls */}
|
||||
<div className="flex items-center gap-2 sm:gap-4">
|
||||
{/* Platform SuperAdmin Workspace Switcher */}
|
||||
{isPlatformUser && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl text-xs font-semibold border border-primary/30 bg-primary/5 text-primary shadow-2xs">
|
||||
<Building2 className="w-4 h-4 text-primary shrink-0" />
|
||||
<div className="flex flex-col text-left">
|
||||
<span className="text-[9px] font-black uppercase text-primary/70 tracking-wider">Active Workspace</span>
|
||||
<select
|
||||
value={impersonatedTenantId || ''}
|
||||
onChange={(e) => handleWorkspaceChange(e.target.value)}
|
||||
className="bg-transparent outline-none cursor-pointer font-bold text-foreground text-xs pr-1"
|
||||
aria-label="Select workspace context"
|
||||
>
|
||||
<option value="" className="bg-surface text-foreground font-semibold">
|
||||
🌐 Global Platform Console
|
||||
</option>
|
||||
<optgroup label="Tenants / Organizations">
|
||||
{platformTenants.map(t => (
|
||||
<option key={t.id} value={t.id} className="bg-surface text-foreground font-medium">
|
||||
🏢 {t.name} (#{t.id})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Standard Non-Platform Tenant Badge */}
|
||||
{!isPlatformUser && (
|
||||
<div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium border border-border bg-surface-muted text-foreground">
|
||||
<Building2 className="w-4 h-4 text-primary" />
|
||||
<span>{tenantName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Language Selector */}
|
||||
<div className="hidden md:flex items-center gap-2 rounded-lg border border-border px-3 py-1.5 hover:bg-surface-muted transition-colors">
|
||||
<Globe className="w-4 h-4 text-muted-foreground" />
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value as Language)}
|
||||
className="text-sm font-medium bg-transparent outline-none cursor-pointer text-foreground"
|
||||
aria-label="Select language"
|
||||
>
|
||||
<option value="en" className="bg-surface text-foreground">English</option>
|
||||
<option value="hi" className="bg-surface text-foreground">Hindi</option>
|
||||
<option value="ar" className="bg-surface text-foreground">Arabic</option>
|
||||
<option value="fr" className="bg-surface text-foreground">French</option>
|
||||
<option value="es" className="bg-surface text-foreground">Spanish</option>
|
||||
<option value="de" className="bg-surface text-foreground">German</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Notifications Bell */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => navigate("/notifications")}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-muted transition-colors relative cursor-pointer"
|
||||
aria-label="View notifications"
|
||||
>
|
||||
<Bell className="w-4 h-4 text-foreground" />
|
||||
{headerUnread > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-4 h-4 bg-primary text-white text-[10px] font-extrabold rounded-full flex items-center justify-center shadow-xs">
|
||||
{headerUnread > 9 ? "9+" : headerUnread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Profile Card Trigger & Popover */}
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setShowProfileMenu(!showProfileMenu)}
|
||||
className="flex items-center gap-2.5 pl-2 pr-3 py-1.5 rounded-xl hover:bg-surface-muted transition-all border border-transparent hover:border-border cursor-pointer"
|
||||
@@ -253,5 +362,6 @@ export function Header() {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ interface NavItem {
|
||||
href: string;
|
||||
badge?: string;
|
||||
permission?: string;
|
||||
platformOnly?: boolean;
|
||||
children?: NavItem[];
|
||||
}
|
||||
|
||||
@@ -62,6 +63,8 @@ export function Sidebar() {
|
||||
}, [collapsed]);
|
||||
|
||||
const permissions = useSelector((state: RootState) => state.auth.permissions);
|
||||
const user = useSelector((state: RootState) => state.auth.user);
|
||||
const isPlatformUser = user?.type === 'platform' || user?.user_type === 'platform';
|
||||
|
||||
const toggleSection = (href: string) => {
|
||||
setExpandedSections(prev =>
|
||||
@@ -83,7 +86,11 @@ export function Sidebar() {
|
||||
|
||||
const filterNavItems = (items: NavItem[]): NavItem[] => {
|
||||
return items.reduce<NavItem[]>((acc, item) => {
|
||||
if (item.permission && !hasPermission(permissions, item.permission, "view")) {
|
||||
if (item.platformOnly && !isPlatformUser) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (item.permission && !hasPermission(permissions, item.permission, "view", user)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
@@ -212,14 +219,18 @@ export function Sidebar() {
|
||||
{/* Footer */}
|
||||
{!collapsed && (
|
||||
<div className="p-2 border-t border-border mt-auto">
|
||||
<div className="px-2 py-1 rounded-lg bg-background hover:bg-primary/5 transition-colors cursor-pointer">
|
||||
<div className="px-2 py-1.5 rounded-lg bg-background hover:bg-primary/5 transition-colors cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-6 rounded-full bg-gradient-to-br from-primary to-primary-hover flex items-center justify-center text-white text-xs font-semibold shadow-sm flex-shrink-0">
|
||||
AC
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary-hover flex items-center justify-center text-white text-xs font-semibold shadow-sm flex-shrink-0">
|
||||
{isPlatformUser ? 'PA' : (user?.tenant?.name?.substring(0, 2).toUpperCase() || 'TN')}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-foreground truncate">Acme Corp</div>
|
||||
<div className="text-xs text-muted-foreground truncate">Enterprise Plan</div>
|
||||
<div className="text-sm font-semibold text-foreground truncate">
|
||||
{isPlatformUser ? 'Platform Super Admin' : (user?.tenant?.name || 'Tenant Workspace')}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{isPlatformUser ? 'Global SaaS Mode' : `${user?.tenant?.plan_name || 'Active'} Plan`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -230,10 +241,10 @@ export function Sidebar() {
|
||||
{collapsed && (
|
||||
<div className="p-2 border-t border-border mt-auto flex justify-center">
|
||||
<div
|
||||
title="Acme Corp"
|
||||
className="w-8 h-8 rounded-full bg-gradient-to-br from-primary to-primary-hover flex items-center justify-center text-white text-xs font-semibold shadow-sm cursor-pointer hover:opacity-90 transition-opacity"
|
||||
title={isPlatformUser ? 'Platform Admin' : (user?.tenant?.name || 'Tenant Workspace')}
|
||||
className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary-hover flex items-center justify-center text-white text-xs font-semibold shadow-sm cursor-pointer hover:opacity-90 transition-opacity"
|
||||
>
|
||||
AC
|
||||
{isPlatformUser ? 'PA' : (user?.tenant?.name?.substring(0, 2).toUpperCase() || 'TN')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useFormik } from "formik";
|
||||
import {
|
||||
X, Image as ImageIcon, Video, FileText, Award, Megaphone,
|
||||
HelpCircle, Plus, AlertCircle, Check, Loader2, Save
|
||||
} from "lucide-react";
|
||||
import { Input } from "../../../components/customs/Input";
|
||||
import { TextArea } from "../../../components/customs/TextArea";
|
||||
import { Select } from "../../../components/customs/Select";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useAssetType } from "../hook/useAssetType";
|
||||
import { assetTypeSchema } from "../validation/asset-types.schema";
|
||||
import { notify } from "../../../services/toast";
|
||||
import type { AssetType, AssetTypeCreateRequest } from "../types/asset-types.types";
|
||||
|
||||
const CATEGORIES = [
|
||||
{
|
||||
id: 'image',
|
||||
label: 'Image',
|
||||
icon: ImageIcon,
|
||||
desc: 'jpg, jpeg, png...',
|
||||
color: 'text-blue-600',
|
||||
bg: 'bg-blue-50',
|
||||
bgSelected: 'bg-blue-600',
|
||||
borderSelected: 'border-blue-500',
|
||||
ringSelected: 'ring-blue-500',
|
||||
bgSelectedCard: 'bg-blue-50/70',
|
||||
hoverBorder: 'hover:border-blue-300',
|
||||
hoverBg: 'hover:bg-blue-50/50'
|
||||
},
|
||||
{
|
||||
id: 'video',
|
||||
label: 'Video',
|
||||
icon: Video,
|
||||
desc: 'mp4, mov, avi...',
|
||||
color: 'text-orange-600',
|
||||
bg: 'bg-orange-50',
|
||||
bgSelected: 'bg-orange-600',
|
||||
borderSelected: 'border-orange-500',
|
||||
ringSelected: 'ring-orange-500',
|
||||
bgSelectedCard: 'bg-orange-50/70',
|
||||
hoverBorder: 'hover:border-orange-300',
|
||||
hoverBg: 'hover:bg-orange-50/50'
|
||||
},
|
||||
{
|
||||
id: 'document',
|
||||
label: 'Document',
|
||||
icon: FileText,
|
||||
desc: 'pdf, docx, doc...',
|
||||
color: 'text-red-600',
|
||||
bg: 'bg-red-50',
|
||||
bgSelected: 'bg-red-600',
|
||||
borderSelected: 'border-red-500',
|
||||
ringSelected: 'ring-red-500',
|
||||
bgSelectedCard: 'bg-red-50/70',
|
||||
hoverBorder: 'hover:border-red-300',
|
||||
hoverBg: 'hover:bg-red-50/50'
|
||||
},
|
||||
{
|
||||
id: 'certificate',
|
||||
label: 'Certificate',
|
||||
icon: Award,
|
||||
desc: 'pdf, jpg, png',
|
||||
color: 'text-emerald-600',
|
||||
bg: 'bg-emerald-50',
|
||||
bgSelected: 'bg-emerald-600',
|
||||
borderSelected: 'border-emerald-500',
|
||||
ringSelected: 'ring-emerald-500',
|
||||
bgSelectedCard: 'bg-emerald-50/70',
|
||||
hoverBorder: 'hover:border-emerald-300',
|
||||
hoverBg: 'hover:bg-emerald-50/50'
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
label: 'Marketing',
|
||||
icon: Megaphone,
|
||||
desc: 'jpg, png, svg...',
|
||||
color: 'text-purple-600',
|
||||
bg: 'bg-purple-50',
|
||||
bgSelected: 'bg-purple-600',
|
||||
borderSelected: 'border-purple-500',
|
||||
ringSelected: 'ring-purple-500',
|
||||
bgSelectedCard: 'bg-purple-50/70',
|
||||
hoverBorder: 'hover:border-purple-300',
|
||||
hoverBg: 'hover:bg-purple-50/50'
|
||||
},
|
||||
{
|
||||
id: 'other',
|
||||
label: 'Other',
|
||||
icon: HelpCircle,
|
||||
desc: 'pdf, zip, csv...',
|
||||
color: 'text-muted-foreground',
|
||||
bg: 'bg-background',
|
||||
bgSelected: 'bg-surface-active',
|
||||
borderSelected: 'border-border',
|
||||
ringSelected: 'ring-ring',
|
||||
bgSelectedCard: 'bg-background',
|
||||
hoverBorder: 'hover:border-border',
|
||||
hoverBg: 'hover:bg-background/50'
|
||||
},
|
||||
] as const;
|
||||
|
||||
const POPULAR_EXTENSIONS = [
|
||||
// Images
|
||||
{ ext: 'jpg', category: 'image', label: 'JPG' },
|
||||
{ ext: 'jpeg', category: 'image', label: 'JPEG' },
|
||||
{ ext: 'png', category: 'image', label: 'PNG' },
|
||||
{ ext: 'webp', category: 'image', label: 'WEBP' },
|
||||
{ ext: 'gif', category: 'image', label: 'GIF' },
|
||||
{ ext: 'svg', category: 'image', label: 'SVG' },
|
||||
// Videos
|
||||
{ ext: 'mp4', category: 'video', label: 'MP4' },
|
||||
{ ext: 'mov', category: 'video', label: 'MOV' },
|
||||
{ ext: 'avi', category: 'video', label: 'AVI' },
|
||||
{ ext: 'webm', category: 'video', label: 'WEBM' },
|
||||
// Documents
|
||||
{ ext: 'pdf', category: 'document', label: 'PDF' },
|
||||
{ ext: 'doc', category: 'document', label: 'DOC' },
|
||||
{ ext: 'docx', category: 'document', label: 'DOCX' },
|
||||
{ ext: 'xls', category: 'document', label: 'XLS' },
|
||||
{ ext: 'xlsx', category: 'document', label: 'XLSX' },
|
||||
{ ext: 'ppt', category: 'document', label: 'PPT' },
|
||||
{ ext: 'pptx', category: 'document', label: 'PPTX' },
|
||||
{ ext: 'txt', category: 'document', label: 'TXT' },
|
||||
// Other
|
||||
{ ext: 'zip', category: 'other', label: 'ZIP' },
|
||||
{ ext: 'rar', category: 'other', label: 'RAR' },
|
||||
{ ext: 'csv', category: 'other', label: 'CSV' },
|
||||
{ ext: 'json', category: 'other', label: 'JSON' },
|
||||
];
|
||||
|
||||
const STEPS = [
|
||||
{ id: 'basic', label: 'Basic Info', step: 1 },
|
||||
{ id: 'category', label: 'Asset Category', step: 2 },
|
||||
{ id: 'validation', label: 'Validation Rules', step: 3 },
|
||||
{ id: 'preview', label: 'Preview', step: 4 },
|
||||
];
|
||||
|
||||
const labelClass = 'block text-xs font-semibold text-foreground mb-1.5';
|
||||
const errorClass = 'text-xs text-red-500 mt-1';
|
||||
|
||||
interface CreateAssetTypeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (created: AssetType) => void;
|
||||
}
|
||||
|
||||
export function CreateAssetTypeModal({ isOpen, onClose, onSuccess }: CreateAssetTypeModalProps) {
|
||||
const { createItem, loading } = useAssetType();
|
||||
const [newFileType, setNewFileType] = useState('');
|
||||
const [activeStep, setActiveStep] = useState('basic');
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: "",
|
||||
code: "",
|
||||
description: "",
|
||||
status: "active" as "active" | "inactive",
|
||||
isRequired: false,
|
||||
category: "" as typeof CATEGORIES[number]['id'] | "",
|
||||
validation: {
|
||||
allowedFileTypes: [] as string[],
|
||||
maxFileSize: 10,
|
||||
minUploadCount: 0,
|
||||
maxUploadCount: 1,
|
||||
}
|
||||
},
|
||||
validationSchema: assetTypeSchema,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
try {
|
||||
const created = await createItem(values as AssetTypeCreateRequest);
|
||||
if (created) {
|
||||
onSuccess(created);
|
||||
onClose();
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Handled in hook notify
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Sync validation errors and switch steps if submit is attempted with errors
|
||||
useEffect(() => {
|
||||
if (formik.submitCount > 0 && !formik.isSubmitting) {
|
||||
const errors = formik.errors;
|
||||
const errorKeys = Object.keys(errors);
|
||||
|
||||
if (errorKeys.length > 0) {
|
||||
const messages: string[] = [];
|
||||
|
||||
if (errors.name) messages.push(errors.name);
|
||||
if (errors.code) messages.push(errors.code);
|
||||
if (errors.category) {
|
||||
messages.push(errors.category);
|
||||
setActiveStep('category');
|
||||
} else if (errors.name || errors.code) {
|
||||
setActiveStep('basic');
|
||||
} else if (errors.validation) {
|
||||
setActiveStep('validation');
|
||||
const valErrors = errors.validation as any;
|
||||
if (valErrors?.maxFileSize) messages.push(valErrors.maxFileSize);
|
||||
if (valErrors?.allowedFileTypes) messages.push(valErrors.allowedFileTypes);
|
||||
}
|
||||
|
||||
notify.error(`Please resolve validation errors: ${messages.join('; ')}`);
|
||||
formik.setSubmitting(false);
|
||||
}
|
||||
}
|
||||
}, [formik.submitCount, formik.isSubmitting, formik.errors]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
formik.handleChange(e);
|
||||
if (!formik.touched.code) {
|
||||
const generatedCode = e.target.value.toLowerCase().replace(/[^a-z0-9]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
|
||||
formik.setFieldValue('code', generatedCode);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddFileType = () => {
|
||||
const trimmed = newFileType.trim().toLowerCase().replace(/^\./, '');
|
||||
if (trimmed && !formik.values.validation.allowedFileTypes.includes(trimmed)) {
|
||||
formik.setFieldValue('validation.allowedFileTypes', [...formik.values.validation.allowedFileTypes, trimmed]);
|
||||
setNewFileType('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeFileType = (type: string) => {
|
||||
formik.setFieldValue('validation.allowedFileTypes', formik.values.validation.allowedFileTypes.filter(t => t !== type));
|
||||
};
|
||||
|
||||
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-xs animate-in fade-in duration-150 p-4 font-sans">
|
||||
<div className="bg-surface rounded-2xl border border-border shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200">
|
||||
|
||||
{/* Modal Header */}
|
||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between bg-surface-muted/30">
|
||||
<div>
|
||||
<h3 className="font-bold text-foreground text-base">Create New Asset Type</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Configure media classifications and validation rules</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 hover:bg-background rounded-lg text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator Bar */}
|
||||
<div className="px-6 py-3 border-b border-border bg-surface flex items-center justify-between gap-2 overflow-x-auto">
|
||||
{STEPS.map((s, idx) => {
|
||||
const isActive = activeStep === s.id;
|
||||
const isDone = idx < activeIndex;
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-all shrink-0 cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary font-bold border border-primary/20'
|
||||
: isDone
|
||||
? 'text-foreground hover:bg-surface-muted'
|
||||
: 'text-muted-foreground hover:bg-surface-muted/50'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold ${
|
||||
isActive
|
||||
? 'bg-primary text-white'
|
||||
: isDone
|
||||
? 'bg-emerald-500 text-white'
|
||||
: 'bg-surface-muted text-muted-foreground border border-border'
|
||||
}`}>
|
||||
{isDone ? <Check className="w-3 h-3" /> : s.step}
|
||||
</span>
|
||||
<span>{s.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<form id="inline-asset-type-form" onSubmit={formik.handleSubmit} className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
|
||||
{/* Step 1 — Basic Information */}
|
||||
{activeStep === 'basic' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Asset Type Name <span className="text-red-500">*</span></label>
|
||||
<Input
|
||||
name="name"
|
||||
value={formik.values.name}
|
||||
onChange={handleNameChange}
|
||||
onBlur={formik.handleBlur}
|
||||
placeholder="e.g. Primary Image"
|
||||
aria-invalid={formik.touched.name && Boolean(formik.errors.name)}
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Asset Code <span className="text-red-500">*</span></label>
|
||||
<Input
|
||||
name="code"
|
||||
value={formik.values.code}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
placeholder="e.g. primary_image"
|
||||
aria-invalid={formik.touched.code && Boolean(formik.errors.code)}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">Unique identifier (slug)</p>
|
||||
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Description</label>
|
||||
<TextArea
|
||||
name="description"
|
||||
value={formik.values.description}
|
||||
onChange={formik.handleChange}
|
||||
rows={2}
|
||||
placeholder="Describe the purpose and usage guidelines for this asset type..."
|
||||
className="resize-none text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Status</label>
|
||||
<Select
|
||||
name="status"
|
||||
value={formik.values.status}
|
||||
onChange={formik.handleChange}
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Required Asset</label>
|
||||
<label className="flex items-center gap-3 p-2 border border-border rounded-lg cursor-pointer hover:bg-background transition-colors h-9">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isRequired"
|
||||
checked={formik.values.isRequired}
|
||||
onChange={formik.handleChange}
|
||||
className="w-4 h-4 text-primary rounded border-border focus:ring-primary"
|
||||
/>
|
||||
<div className="text-xs font-medium text-foreground">Mark as required by default</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2 — Asset Category */}
|
||||
{activeStep === 'category' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">Select the media category. This determines default validation rules and file type presets.</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{CATEGORIES.map(cat => (
|
||||
<div
|
||||
key={cat.id}
|
||||
onClick={() => formik.setFieldValue('category', cat.id)}
|
||||
className={`
|
||||
cursor-pointer p-3.5 rounded-xl border transition-all flex flex-col items-center justify-center gap-1.5 text-center
|
||||
${formik.values.category === cat.id
|
||||
? `${cat.bgSelectedCard} ${cat.borderSelected} shadow-xs ring-1 ${cat.ringSelected}`
|
||||
: `bg-surface border-border ${cat.hoverBorder} ${cat.hoverBg}`
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center transition-all ${formik.values.category === cat.id ? `${cat.bgSelected} text-white` : `${cat.bg} ${cat.color}`}`}>
|
||||
<cat.icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className={`font-semibold text-xs ${formik.values.category === cat.id ? 'text-foreground font-bold' : 'text-foreground'}`}>{cat.label}</div>
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5">{cat.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{formik.touched.category && formik.errors.category && (
|
||||
<p className={errorClass}>{formik.errors.category}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3 — Validation Rules */}
|
||||
{activeStep === 'validation' && (
|
||||
<div className="space-y-4">
|
||||
{/* Selected formats badges list */}
|
||||
<div>
|
||||
<label className={labelClass}>Allowed File Types</label>
|
||||
<div className="min-h-[38px] p-2.5 border border-border rounded-lg flex flex-wrap gap-1.5 bg-background">
|
||||
{formik.values.validation.allowedFileTypes.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground py-0.5 px-1">No file types selected yet. Check boxes below or add custom formats.</span>
|
||||
) : (
|
||||
formik.values.validation.allowedFileTypes.map(type => (
|
||||
<span key={type} className="inline-flex items-center gap-1 px-2 py-0.5 bg-surface border border-border rounded-md text-xs font-semibold text-foreground shadow-2xs">
|
||||
.{type}
|
||||
<button type="button" onClick={() => removeFileType(type)} className="text-muted-foreground hover:text-red-500 ml-1 transition-colors cursor-pointer"><X className="w-3 h-3" /></button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Format selection */}
|
||||
<div>
|
||||
<label className={labelClass}>Select Formats</label>
|
||||
<div className="bg-background border border-border rounded-xl p-3 space-y-3 max-h-48 overflow-y-auto">
|
||||
{['image', 'video', 'document', 'other'].map(group => {
|
||||
const exts = POPULAR_EXTENSIONS.filter(e => e.category === group);
|
||||
const groupLabel = group === 'image' ? 'Image' : group === 'video' ? 'Video' : group === 'document' ? 'Document' : 'Data & Other';
|
||||
|
||||
return (
|
||||
<div key={group} className="space-y-1.5">
|
||||
<div className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider">{groupLabel}</div>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2">
|
||||
{exts.map(item => {
|
||||
const isChecked = formik.values.validation.allowedFileTypes.includes(item.ext);
|
||||
return (
|
||||
<label
|
||||
key={item.ext}
|
||||
className={`
|
||||
flex items-center gap-1.5 px-2 py-1 border rounded-md cursor-pointer transition-all select-none text-xs
|
||||
${isChecked
|
||||
? 'bg-primary/10 border-primary text-primary font-bold shadow-2xs'
|
||||
: 'bg-surface border-border text-foreground hover:border-primary/20'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(e) => {
|
||||
const current = formik.values.validation.allowedFileTypes || [];
|
||||
if (e.target.checked) {
|
||||
formik.setFieldValue('validation.allowedFileTypes', [...current, item.ext]);
|
||||
} else {
|
||||
formik.setFieldValue('validation.allowedFileTypes', current.filter(t => t !== item.ext));
|
||||
}
|
||||
}}
|
||||
className="w-3 h-3 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
|
||||
/>
|
||||
<span className="text-[11px]">.{item.ext}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Extension Input */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-muted-foreground mb-1">Add Custom Extension</label>
|
||||
<div className="flex gap-2 max-w-xs">
|
||||
<Input
|
||||
value={newFileType}
|
||||
onChange={(e) => setNewFileType(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddFileType(); } }}
|
||||
placeholder="e.g. psd"
|
||||
className="text-xs h-8"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddFileType}
|
||||
className="px-3 py-1 bg-surface hover:bg-background border border-border rounded-md text-xs font-semibold text-foreground flex items-center justify-center transition-colors cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Constraints */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>Max Size (MB)</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="validation.maxFileSize"
|
||||
value={formik.values.validation.maxFileSize}
|
||||
onChange={formik.handleChange}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Min Uploads</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="validation.minUploadCount"
|
||||
value={formik.values.validation.minUploadCount}
|
||||
onChange={formik.handleChange}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Max Uploads</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="validation.maxUploadCount"
|
||||
value={formik.values.validation.maxUploadCount}
|
||||
onChange={formik.handleChange}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4 — Preview */}
|
||||
{activeStep === 'preview' && (
|
||||
<div className="space-y-4">
|
||||
<div className="border border-border rounded-xl overflow-hidden shadow-xs">
|
||||
<div className="bg-primary p-3.5 flex items-center justify-between text-white">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 bg-surface/20 rounded-lg flex items-center justify-center backdrop-blur-sm">
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-sm">{formik.values.name || 'Asset Type Name'}</div>
|
||||
<div className="text-[11px] text-primary-light font-mono">{formik.values.code || 'asset_code'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 py-0.5 bg-surface/20 rounded-full text-xs font-medium flex items-center gap-1.5 backdrop-blur-sm">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${formik.values.status === 'active' ? 'bg-green-400' : 'bg-muted-foreground'}`} />
|
||||
{formik.values.status === 'active' ? 'Active' : 'Inactive'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-background p-4 grid grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<div className="font-semibold text-muted-foreground uppercase mb-1.5 text-[10px]">Category & Formats</div>
|
||||
<div className="mb-2 font-medium capitalize text-foreground">{formik.values.category || 'Not specified'}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{formik.values.validation.allowedFileTypes.length === 0 ? (
|
||||
<span className="text-muted-foreground text-[11px]">All file types permitted</span>
|
||||
) : (
|
||||
formik.values.validation.allowedFileTypes.map(type => (
|
||||
<span key={type} className="px-1.5 py-0.5 bg-surface-muted text-foreground rounded text-[10px] font-mono">.{type}</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-muted-foreground uppercase mb-1.5 text-[10px]">Constraints</div>
|
||||
<div className="space-y-1 text-[11px] text-muted-foreground">
|
||||
<div className="flex justify-between"><span>Max Size:</span> <span className="font-medium text-foreground">{formik.values.validation.maxFileSize} MB</span></div>
|
||||
<div className="flex justify-between"><span>Uploads:</span> <span className="font-medium text-foreground">Min {formik.values.validation.minUploadCount}, Max {formik.values.validation.maxUploadCount}</span></div>
|
||||
<div className="flex justify-between"><span>Required:</span> <span className="font-medium text-foreground">{formik.values.isRequired ? 'Yes' : 'No (Optional)'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="px-6 py-3.5 border-t border-border bg-surface-muted/30 flex items-center justify-between">
|
||||
<Button variant="outline" size="sm" type="button" onClick={onClose} disabled={formik.isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{activeIndex > 0 && (
|
||||
<Button variant="outline" size="sm" type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
{activeIndex < STEPS.length - 1 ? (
|
||||
<Button variant="primary" size="sm" type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)}>
|
||||
Next
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
type="submit"
|
||||
form="inline-asset-type-form"
|
||||
icon={<Save className="w-3.5 h-3.5" />}
|
||||
loading={formik.isSubmitting || loading}
|
||||
>
|
||||
Create Asset Type
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,3 +2,5 @@ export * from './types/asset-types.types';
|
||||
export * from './services/asset-types.service';
|
||||
export * from './hook/useAssetType';
|
||||
export * from './routes/asset-types.routes';
|
||||
export * from './components/CreateAssetTypeModal';
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { TextArea } from "../../../components/customs/TextArea";
|
||||
import { Select } from "../../../components/customs/Select";
|
||||
import { useAssetType } from "../hook/useAssetType";
|
||||
import { assetTypeSchema } from "../validation/asset-types.schema";
|
||||
import { notify } from "../../../services/toast";
|
||||
import type { AssetTypeCreateRequest } from "../types/asset-types.types";
|
||||
|
||||
const CATEGORIES = [
|
||||
@@ -99,6 +100,35 @@ const CATEGORIES = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
const POPULAR_EXTENSIONS = [
|
||||
// Images
|
||||
{ ext: 'jpg', category: 'image', label: 'JPG' },
|
||||
{ ext: 'jpeg', category: 'image', label: 'JPEG' },
|
||||
{ ext: 'png', category: 'image', label: 'PNG' },
|
||||
{ ext: 'webp', category: 'image', label: 'WEBP' },
|
||||
{ ext: 'gif', category: 'image', label: 'GIF' },
|
||||
{ ext: 'svg', category: 'image', label: 'SVG' },
|
||||
// Videos
|
||||
{ ext: 'mp4', category: 'video', label: 'MP4' },
|
||||
{ ext: 'mov', category: 'video', label: 'MOV' },
|
||||
{ ext: 'avi', category: 'video', label: 'AVI' },
|
||||
{ ext: 'webm', category: 'video', label: 'WEBM' },
|
||||
// Documents
|
||||
{ ext: 'pdf', category: 'document', label: 'PDF' },
|
||||
{ ext: 'doc', category: 'document', label: 'DOC' },
|
||||
{ ext: 'docx', category: 'document', label: 'DOCX' },
|
||||
{ ext: 'xls', category: 'document', label: 'XLS' },
|
||||
{ ext: 'xlsx', category: 'document', label: 'XLSX' },
|
||||
{ ext: 'ppt', category: 'document', label: 'PPT' },
|
||||
{ ext: 'pptx', category: 'document', label: 'PPTX' },
|
||||
{ ext: 'txt', category: 'document', label: 'TXT' },
|
||||
// Other
|
||||
{ ext: 'zip', category: 'other', label: 'ZIP' },
|
||||
{ ext: 'rar', category: 'other', label: 'RAR' },
|
||||
{ ext: 'csv', category: 'other', label: 'CSV' },
|
||||
{ ext: 'json', category: 'other', label: 'JSON' },
|
||||
];
|
||||
|
||||
const STEPS = [
|
||||
{ id: 'basic', label: 'Basic Information', step: 1 },
|
||||
{ id: 'category', label: 'Asset Category', step: 2 },
|
||||
@@ -190,6 +220,36 @@ export default function NewAssetType() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isEdit, id, items]);
|
||||
|
||||
// Sync validation errors and switch steps if submit is attempted with errors
|
||||
useEffect(() => {
|
||||
if (formik.submitCount > 0 && !formik.isSubmitting) {
|
||||
const errors = formik.errors;
|
||||
const errorKeys = Object.keys(errors);
|
||||
|
||||
if (errorKeys.length > 0) {
|
||||
const messages: string[] = [];
|
||||
|
||||
if (errors.name) messages.push(errors.name);
|
||||
if (errors.code) messages.push(errors.code);
|
||||
if (errors.category) {
|
||||
messages.push(errors.category);
|
||||
setActiveStep('category');
|
||||
} else if (errors.name || errors.code) {
|
||||
setActiveStep('basic');
|
||||
} else if (errors.validation) {
|
||||
setActiveStep('validation');
|
||||
const valErrors = errors.validation as any;
|
||||
if (valErrors.maxFileSize) messages.push(valErrors.maxFileSize);
|
||||
if (valErrors.allowedFileTypes) messages.push(valErrors.allowedFileTypes);
|
||||
}
|
||||
|
||||
notify.error(`Please resolve validation errors: ${messages.join('; ')}`);
|
||||
formik.setSubmitting(false);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formik.submitCount, formik.isSubmitting]);
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
formik.handleChange(e);
|
||||
if (!isEdit && !formik.touched.code) {
|
||||
@@ -199,8 +259,9 @@ export default function NewAssetType() {
|
||||
};
|
||||
|
||||
const handleAddFileType = () => {
|
||||
if (newFileType.trim() && !formik.values.validation.allowedFileTypes.includes(newFileType.trim().toLowerCase())) {
|
||||
formik.setFieldValue('validation.allowedFileTypes', [...formik.values.validation.allowedFileTypes, newFileType.trim().toLowerCase()]);
|
||||
const trimmed = newFileType.trim().toLowerCase().replace(/^\./, '');
|
||||
if (trimmed && !formik.values.validation.allowedFileTypes.includes(trimmed)) {
|
||||
formik.setFieldValue('validation.allowedFileTypes', [...formik.values.validation.allowedFileTypes, trimmed]);
|
||||
setNewFileType('');
|
||||
}
|
||||
};
|
||||
@@ -404,29 +465,92 @@ export default function NewAssetType() {
|
||||
<div className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden">
|
||||
<CardHeader title="Validation Rules" subtitle="Enforced when assets are uploaded" />
|
||||
<div className="p-6 space-y-6">
|
||||
|
||||
{/* Selected formats badges list */}
|
||||
<div>
|
||||
<label className={labelClass}>Allowed File Types <span className="text-red-500">*</span></label>
|
||||
<div className="min-h-[42px] p-2 border border-primary/10 rounded-lg mb-2 flex flex-wrap gap-2 bg-background">
|
||||
<label className={labelClass}>Allowed File Types Summary <span className="text-red-500">*</span></label>
|
||||
<div className="min-h-[42px] p-3 border border-primary/10 rounded-lg mb-4 flex flex-wrap gap-2 bg-background">
|
||||
{formik.values.validation.allowedFileTypes.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground py-1 px-2">No file types added yet</span>
|
||||
<span className="text-xs text-muted-foreground py-1 px-1">No file types selected yet. Check the boxes below to allow extensions.</span>
|
||||
) : (
|
||||
formik.values.validation.allowedFileTypes.map(type => (
|
||||
<span key={type} className="inline-flex items-center gap-1 px-2 py-1 bg-surface border border-border rounded text-xs font-medium text-foreground">
|
||||
<span key={type} className="inline-flex items-center gap-1 px-2.5 py-1 bg-surface border border-border rounded-lg text-xs font-semibold text-foreground animate-fade-in shadow-2xs">
|
||||
.{type}
|
||||
<button type="button" onClick={() => removeFileType(type)} className="text-muted-foreground hover:text-red-500"><X className="w-3 h-3" /></button>
|
||||
<button type="button" onClick={() => removeFileType(type)} className="text-muted-foreground hover:text-red-500 ml-1 transition-colors"><X className="w-3 h-3" /></button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
</div>
|
||||
|
||||
{/* Multiselect checkboxes for popular formats */}
|
||||
<div>
|
||||
<label className={labelClass}>Select Allowed Formats</label>
|
||||
<div className="bg-background border border-primary/10 rounded-xl p-5 space-y-5">
|
||||
{['image', 'video', 'document', 'other'].map(group => {
|
||||
const exts = POPULAR_EXTENSIONS.filter(e => e.category === group);
|
||||
const groupLabel = group === 'image' ? 'Image Formats' : group === 'video' ? 'Video Formats' : group === 'document' ? 'Document Formats' : 'Data & Archive Formats';
|
||||
|
||||
return (
|
||||
<div key={group} className="space-y-2">
|
||||
<div className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider">{groupLabel}</div>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-3">
|
||||
{exts.map(item => {
|
||||
const isChecked = formik.values.validation.allowedFileTypes.includes(item.ext);
|
||||
return (
|
||||
<label
|
||||
key={item.ext}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-2 border rounded-lg cursor-pointer transition-all select-none
|
||||
${isChecked
|
||||
? 'bg-primary/5 border-primary text-primary font-bold shadow-2xs'
|
||||
: 'bg-surface border-border text-foreground hover:border-primary/20 hover:bg-background/20'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(e) => {
|
||||
const current = formik.values.validation.allowedFileTypes || [];
|
||||
if (e.target.checked) {
|
||||
formik.setFieldValue('validation.allowedFileTypes', [...current, item.ext]);
|
||||
} else {
|
||||
formik.setFieldValue('validation.allowedFileTypes', current.filter(t => t !== item.ext));
|
||||
}
|
||||
}}
|
||||
className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs">.{item.ext}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Extension Input */}
|
||||
<div className="pt-2">
|
||||
<label className="block text-[11px] font-semibold text-muted-foreground mb-1">Add Custom Extension (Optional)</label>
|
||||
<div className="flex gap-2 max-w-sm">
|
||||
<Input
|
||||
value={newFileType}
|
||||
onChange={(e) => setNewFileType(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddFileType(); } }}
|
||||
placeholder="Type extension and press Enter (e.g. jpg)"
|
||||
placeholder="e.g. psd"
|
||||
className="text-xs"
|
||||
/>
|
||||
<button type="button" onClick={handleAddFileType} className="px-3 py-2 border border-primary/10 rounded-lg hover:bg-background">
|
||||
<Plus className="w-4 h-4 text-muted-foreground" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddFileType}
|
||||
className="px-4 py-2 bg-surface hover:bg-background border border-border rounded-lg text-xs font-semibold text-foreground flex items-center justify-center transition-colors"
|
||||
title="Add custom format"
|
||||
>
|
||||
<Plus className="w-4 h-4 text-muted-foreground mr-1" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -528,12 +652,22 @@ export default function NewAssetType() {
|
||||
</div>
|
||||
|
||||
{/* Bottom navigation */}
|
||||
<div className="shrink-0 pt-2 flex justify-end gap-2">
|
||||
<div className="shrink-0 pt-4 flex justify-end gap-2">
|
||||
{activeIndex > 0 && (
|
||||
<Button variant="outline" type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)}>Back</Button>
|
||||
)}
|
||||
{activeIndex < STEPS.length - 1 && (
|
||||
{activeIndex < STEPS.length - 1 ? (
|
||||
<Button variant="primary" type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)}>Next</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
form="asset-type-form"
|
||||
icon={<Save className="w-4 h-4" />}
|
||||
loading={formik.isSubmitting}
|
||||
>
|
||||
{isEdit ? 'Save Changes' : 'Create Asset Type'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,8 @@ export interface AssetTypeValidation {
|
||||
maxFileSize: number;
|
||||
minUploadCount: number;
|
||||
maxUploadCount: number;
|
||||
allowed_extensions?: string[];
|
||||
max_file_size?: number;
|
||||
}
|
||||
|
||||
export interface AssetType {
|
||||
@@ -12,6 +14,8 @@ export interface AssetType {
|
||||
description?: string;
|
||||
status: 'active' | 'inactive';
|
||||
isRequired: boolean;
|
||||
isVariantEligible?: boolean;
|
||||
is_variant_eligible?: boolean;
|
||||
category: 'image' | 'video' | 'document' | 'certificate' | 'marketing' | 'other' | '';
|
||||
validation: AssetTypeValidation;
|
||||
createdAt: string;
|
||||
|
||||
@@ -86,49 +86,68 @@ export const assetsApi = {
|
||||
// Product Assets Assignment
|
||||
getProductAssets: async (productId: string): Promise<AssetMapping[]> => {
|
||||
const res = await apiClient.get<ApiResponse<AssetMapping[]>>(`/api/v1/products/${productId}/assets`);
|
||||
return res.data || [];
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data) || [];
|
||||
},
|
||||
|
||||
getAllVariantAssets: async (productId: string): Promise<any[]> => {
|
||||
const res = await apiClient.get<ApiResponse<any[]>>(`/api/v1/products/${productId}/all-variant-assets`);
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data) || [];
|
||||
},
|
||||
|
||||
assignProductAsset: async (productId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
|
||||
const res = await apiClient.post<ApiResponse<AssetMapping>>(`/api/v1/products/${productId}/assets`, body);
|
||||
return res.data;
|
||||
const raw: any = res.data;
|
||||
return raw?.data ? raw.data : raw;
|
||||
},
|
||||
|
||||
updateProductAsset: async (productId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
|
||||
const res = await apiClient.put<ApiResponse<AssetMapping>>(`/api/v1/products/${productId}/assets/${assetId}`, body);
|
||||
return res.data;
|
||||
const raw: any = res.data;
|
||||
return raw?.data ? raw.data : raw;
|
||||
},
|
||||
|
||||
unassignProductAsset: async (productId: string, assetId: string): Promise<boolean> => {
|
||||
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/products/${productId}/assets/${assetId}`);
|
||||
return res.success;
|
||||
return res.data?.success ?? true;
|
||||
},
|
||||
|
||||
bulkAssignVariantAsset: async (productId: string, body: { asset_id: string; role: string; variant_ids: string[]; is_primary?: boolean }): Promise<any[]> => {
|
||||
const res = await apiClient.post<ApiResponse<any[]>>(`/api/v1/products/${productId}/assets/bulk-assign`, body);
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data) || [];
|
||||
},
|
||||
|
||||
// Variant Assets Assignment
|
||||
getVariantAssets: async (variantId: string): Promise<AssetMapping[]> => {
|
||||
const res = await apiClient.get<ApiResponse<AssetMapping[]>>(`/api/v1/variants/${variantId}/assets`);
|
||||
return res.data || [];
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data) || [];
|
||||
},
|
||||
|
||||
assignVariantAsset: async (variantId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
|
||||
const res = await apiClient.post<ApiResponse<AssetMapping>>(`/api/v1/variants/${variantId}/assets`, body);
|
||||
return res.data;
|
||||
const raw: any = res.data;
|
||||
return raw?.data ? raw.data : raw;
|
||||
},
|
||||
|
||||
updateVariantAsset: async (variantId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
|
||||
const res = await apiClient.put<ApiResponse<AssetMapping>>(`/api/v1/variants/${variantId}/assets/${assetId}`, body);
|
||||
return res.data;
|
||||
const raw: any = res.data;
|
||||
return raw?.data ? raw.data : raw;
|
||||
},
|
||||
|
||||
unassignVariantAsset: async (variantId: string, assetId: string): Promise<boolean> => {
|
||||
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/variants/${variantId}/assets/${assetId}`);
|
||||
return res.success;
|
||||
return res.data?.success ?? true;
|
||||
},
|
||||
|
||||
// Product variants list for the variant select dropdown
|
||||
getProductVariants: async (productId: string): Promise<any[]> => {
|
||||
const res = await apiClient.get<ApiResponse<any[]>>('/api/v1/variants', { params: { parentProductId: productId } });
|
||||
return res.data || [];
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data) || [];
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import type { Product } from "../../product/types/product.types";
|
||||
import { notify } from "../../../services/toast";
|
||||
import apiClient from "../../../api/axiosInstance";
|
||||
import { getAssetUrl } from "../../../lib/utils";
|
||||
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
|
||||
import { Can } from "../../../components/customs/Can";
|
||||
|
||||
export default function AssetList() {
|
||||
const navigate = useNavigate();
|
||||
@@ -386,21 +388,22 @@ export default function AssetList() {
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<Breadcrumb
|
||||
items={[{ label: "Home" }, { label: "Asset Manager" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
className="bg-primary hover:bg-primary-hover text-white flex items-center shadow-md rounded-lg px-4 py-2"
|
||||
onClick={() => navigate("/assets/new")}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Upload Asset
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ProtectedRoute node="media.assets">
|
||||
<PageWrapper>
|
||||
<Breadcrumb
|
||||
items={[{ label: "Home" }, { label: "Asset Manager" }]}
|
||||
actions={
|
||||
<Can node="media.assets" action="create">
|
||||
<Button
|
||||
className="bg-primary hover:bg-primary-hover text-white flex items-center shadow-md rounded-lg px-4 py-2"
|
||||
onClick={() => navigate("/assets/new")}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Upload Asset
|
||||
</Button>
|
||||
</Can>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
@@ -1613,5 +1616,6 @@ export default function AssetList() {
|
||||
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
|
||||
/>
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,9 +49,11 @@ export const assetsService = {
|
||||
getFolders: () => assetsApi.getFolders(),
|
||||
getTags: () => assetsApi.getTags(),
|
||||
getProductAssets: (productId: string) => assetsApi.getProductAssets(productId),
|
||||
getAllVariantAssets: (productId: string) => assetsApi.getAllVariantAssets(productId),
|
||||
assignProductAsset: (productId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }) => assetsApi.assignProductAsset(productId, body),
|
||||
updateProductAsset: (productId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }) => assetsApi.updateProductAsset(productId, assetId, body),
|
||||
unassignProductAsset: (productId: string, assetId: string) => assetsApi.unassignProductAsset(productId, assetId),
|
||||
bulkAssignVariantAsset: (productId: string, body: { asset_id: string; role: string; variant_ids: string[]; is_primary?: boolean }) => assetsApi.bulkAssignVariantAsset(productId, body),
|
||||
getVariantAssets: (variantId: string) => assetsApi.getVariantAssets(variantId),
|
||||
assignVariantAsset: (variantId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }) => assetsApi.assignVariantAsset(variantId, body),
|
||||
updateVariantAsset: (variantId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }) => assetsApi.updateVariantAsset(variantId, assetId, body),
|
||||
|
||||
@@ -197,20 +197,6 @@ export default function NewAttributeGroup() {
|
||||
<CardHeader title="Basic Information" subtitle="Define the group's identity and metadata" />
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className={labelClass}>Group Code <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
name="code"
|
||||
value={formik.values.code}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={isEdit}
|
||||
placeholder="e.g., general_info"
|
||||
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-background disabled:text-muted-foreground`}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Unique identifier (snake_case)</p>
|
||||
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Group Name <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
@@ -230,6 +216,20 @@ export default function NewAttributeGroup() {
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Group Code <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
name="code"
|
||||
value={formik.values.code}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={isEdit}
|
||||
placeholder="e.g., general_info"
|
||||
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-background disabled:text-muted-foreground`}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Unique identifier (snake_case)</p>
|
||||
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -53,7 +53,9 @@ export default function AttributeSetList() {
|
||||
render: (val: string) => <span className="text-sm text-muted-foreground">{val || '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'groups', label: 'GROUPS',
|
||||
id: 'col_groups_count',
|
||||
key: 'groups',
|
||||
label: 'GROUPS',
|
||||
render: (val: any) => (
|
||||
<span className="px-2.5 py-1 text-xs font-medium rounded bg-blue-50 text-blue-700">
|
||||
{Array.isArray(val) ? val.length : 0} groups
|
||||
@@ -61,7 +63,9 @@ export default function AttributeSetList() {
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'groups', label: 'ATTRIBUTES',
|
||||
id: 'col_attributes_count',
|
||||
key: 'groups',
|
||||
label: 'ATTRIBUTES',
|
||||
render: (groups: any) => {
|
||||
let count = 0;
|
||||
if (Array.isArray(groups)) {
|
||||
|
||||
@@ -139,6 +139,38 @@ export default function NewAttributeSet() {
|
||||
|
||||
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
|
||||
|
||||
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit || isView) {
|
||||
setHighestVisitedStep(STEPS.length);
|
||||
}
|
||||
}, [isEdit, isView]);
|
||||
|
||||
const isBasicValid = Boolean(formik.values.name?.trim() && formik.values.code?.trim() && !formik.errors.name && !formik.errors.code);
|
||||
|
||||
const isStepAccessible = useCallback((stepNum: number) => {
|
||||
if (isEdit || isView) return true;
|
||||
if (stepNum === 1) return true;
|
||||
if (!isBasicValid) return false;
|
||||
return stepNum <= highestVisitedStep + 1;
|
||||
}, [isEdit, isView, isBasicValid, highestVisitedStep]);
|
||||
|
||||
const handleNextStep = useCallback(() => {
|
||||
if (activeStep === 'basic') {
|
||||
if (!isBasicValid) {
|
||||
formik.setFieldTouched('name', true);
|
||||
formik.setFieldTouched('code', true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (activeIndex < STEPS.length - 1) {
|
||||
const nextStepObj = STEPS[activeIndex + 1];
|
||||
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
|
||||
setActiveStep(nextStepObj.id);
|
||||
}
|
||||
}, [activeStep, isBasicValid, activeIndex, formik]);
|
||||
|
||||
return (
|
||||
<ProtectedRoute node="products.attributes">
|
||||
<PageWrapper>
|
||||
@@ -181,17 +213,23 @@ export default function NewAttributeSet() {
|
||||
const isActive = activeStep === s.id;
|
||||
const isDone = idx < activeIndex;
|
||||
const isLast = idx === STEPS.length - 1;
|
||||
const accessible = isStepAccessible(s.step);
|
||||
return (
|
||||
<div key={s.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center" style={{ width: 24 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${
|
||||
isActive ? "bg-primary ring-2 ring-primary/20" :
|
||||
isDone ? "bg-emerald-500" :
|
||||
"bg-surface border-2 border-border hover:border-primary/30"
|
||||
}`}
|
||||
} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
{isDone
|
||||
? <Check className="w-3 h-3 text-white" />
|
||||
@@ -204,8 +242,13 @@ export default function NewAttributeSet() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""}`}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
<span className={`text-xs font-medium leading-tight block ${
|
||||
isActive ? "text-primary-dark" : isDone ? "text-muted-foreground" : "text-muted-foreground hover:text-muted-foreground"
|
||||
@@ -228,20 +271,6 @@ export default function NewAttributeSet() {
|
||||
<CardHeader title="Basic Information" subtitle="Define the set's identity and metadata" />
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className={labelClass}>Set Code <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
name="code"
|
||||
value={formik.values.code}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={isEdit}
|
||||
placeholder="e.g., electronic_accessories"
|
||||
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-background disabled:text-muted-foreground`}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Unique identifier (snake_case/kebab-case)</p>
|
||||
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Set Name <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
@@ -259,6 +288,20 @@ export default function NewAttributeSet() {
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Set Code <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
name="code"
|
||||
value={formik.values.code}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={isEdit}
|
||||
placeholder="e.g., electronic_accessories"
|
||||
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-background disabled:text-muted-foreground`}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Unique identifier (snake_case/kebab-case)</p>
|
||||
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -452,7 +495,7 @@ export default function NewAttributeSet() {
|
||||
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm font-medium text-muted-foreground hover:bg-background transition-colors">Back</button>
|
||||
)}
|
||||
{activeIndex < STEPS.length - 1 && (
|
||||
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
|
||||
<button type="button" onClick={handleNextStep} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,11 @@ import { StatsCard } from "../../../components/customs/StatsCard";
|
||||
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
|
||||
import { useState } from "react";
|
||||
|
||||
import { usePermissions } from "../../../hooks/usePermission";
|
||||
import { Can } from "../../../components/customs/Can";
|
||||
|
||||
export default function AttributeList() {
|
||||
const { canEdit, canDelete } = usePermissions("products.attributes");
|
||||
const navigate = useNavigate();
|
||||
const { attributes, fetchAttributes, deleteAttribute } = useAttribute();
|
||||
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" });
|
||||
@@ -183,9 +187,11 @@ export default function AttributeList() {
|
||||
<Breadcrumb
|
||||
items={[{ label: "Home" }, { label: "Attributes" }]}
|
||||
actions={
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("/attributes/new")}>
|
||||
Create Attribute
|
||||
</Button>
|
||||
<Can node="products.attributes" action="create">
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("/attributes/new")}>
|
||||
Create Attribute
|
||||
</Button>
|
||||
</Can>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -235,8 +241,8 @@ export default function AttributeList() {
|
||||
}
|
||||
actionConfig={{
|
||||
onView: (row) => navigate(`/attributes/${row.id}/view`),
|
||||
onEdit: (row) => navigate(`/attributes/${row.id}/edit`),
|
||||
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
|
||||
onEdit: canEdit ? ((row) => navigate(`/attributes/${row.id}/edit`)) : undefined,
|
||||
onDelete: canDelete ? ((row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })) : undefined
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -45,6 +45,22 @@ export default function NewAttribute() {
|
||||
|
||||
const { createAttribute, updateAttribute, fetchAttributes, getAttributeById } = useAttribute();
|
||||
|
||||
const [optionsList, setOptionsList] = useState<Array<{ code: string; label: string }>>([]);
|
||||
const [newOptionInput, setNewOptionInput] = useState("");
|
||||
|
||||
const handleAddOption = () => {
|
||||
if (!newOptionInput.trim()) return;
|
||||
const label = newOptionInput.trim();
|
||||
const code = label.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
|
||||
if (optionsList.some((o) => o.code === code)) return;
|
||||
setOptionsList((prev) => [...prev, { code, label }]);
|
||||
setNewOptionInput("");
|
||||
};
|
||||
|
||||
const handleRemoveOption = (index: number) => {
|
||||
setOptionsList((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
code: "",
|
||||
@@ -97,6 +113,9 @@ export default function NewAttribute() {
|
||||
apiVisible: values.apiVisible,
|
||||
isRequiredForCompleteness: values.isRequiredForCompleteness,
|
||||
};
|
||||
if (values.dataType === "select" || values.dataType === "multiselect") {
|
||||
payload.options = optionsList;
|
||||
}
|
||||
if (values.description?.trim()) payload.description = values.description.trim();
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
@@ -157,6 +176,14 @@ export default function NewAttribute() {
|
||||
apiVisible: (match as any).apiVisible ?? true,
|
||||
isRequiredForCompleteness: (match as any).isRequiredForCompleteness ?? false,
|
||||
});
|
||||
|
||||
if ((match as any).optionsList && Array.isArray((match as any).optionsList)) {
|
||||
setOptionsList((match as any).optionsList.map((o: any) => ({ code: o.code, label: o.label })));
|
||||
} else if ((match as any).options && Array.isArray((match as any).options)) {
|
||||
setOptionsList((match as any).options.map((o: any) => typeof o === 'string' ? { code: o.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, ''), label: o } : o));
|
||||
} else {
|
||||
setOptionsList([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -166,6 +193,39 @@ export default function NewAttribute() {
|
||||
|
||||
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
|
||||
|
||||
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit || isView) {
|
||||
setHighestVisitedStep(STEPS.length);
|
||||
}
|
||||
}, [isEdit, isView]);
|
||||
|
||||
const isGeneralValid = Boolean(formik.values.name?.trim() && formik.values.code?.trim() && formik.values.type && !formik.errors.name && !formik.errors.code && !formik.errors.type);
|
||||
|
||||
const isStepAccessible = useCallback((stepNum: number) => {
|
||||
if (isEdit || isView) return true;
|
||||
if (stepNum === 1) return true;
|
||||
if (!isGeneralValid) return false;
|
||||
return stepNum <= highestVisitedStep + 1;
|
||||
}, [isEdit, isView, isGeneralValid, highestVisitedStep]);
|
||||
|
||||
const handleNextStep = useCallback(() => {
|
||||
if (activeStep === 'general') {
|
||||
if (!isGeneralValid) {
|
||||
formik.setFieldTouched('name', true);
|
||||
formik.setFieldTouched('code', true);
|
||||
formik.setFieldTouched('type', true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (activeIndex < STEPS.length - 1) {
|
||||
const nextStepObj = STEPS[activeIndex + 1];
|
||||
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
|
||||
setActiveStep(nextStepObj.id);
|
||||
}
|
||||
}, [activeStep, isGeneralValid, activeIndex, formik]);
|
||||
|
||||
return (
|
||||
<ProtectedRoute node="products.attributes">
|
||||
<div className="h-screen flex flex-col overflow-hidden bg-background/50">
|
||||
@@ -220,16 +280,22 @@ export default function NewAttribute() {
|
||||
const isActive = activeStep === s.id;
|
||||
const isDone = s.step < activeIndex + 1;
|
||||
const isLast = idx === STEPS.length - 1;
|
||||
const accessible = isStepAccessible(s.step);
|
||||
return (
|
||||
<div key={s.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center" style={{ width: 24 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${isActive ? "bg-primary ring-2 ring-primary/20" :
|
||||
isDone ? "bg-emerald-500" :
|
||||
"bg-surface border-2 border-border hover:border-primary/30"
|
||||
}`}
|
||||
} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
{isDone
|
||||
? <Check className="w-3 h-3 text-white" />
|
||||
@@ -242,8 +308,13 @@ export default function NewAttribute() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""}`}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
<span className={`text-xs font-medium leading-tight block ${isActive ? "text-primary-dark" : isDone ? "text-muted-foreground" : "text-muted-foreground hover:text-muted-foreground"
|
||||
}`}>{s.label}</span>
|
||||
@@ -264,19 +335,6 @@ export default function NewAttribute() {
|
||||
<CardHeader title="General Information" subtitle="Basic details about the attribute" />
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className={labelClass}>Attribute Code <span className="text-red-400">*</span></label>
|
||||
<Input
|
||||
name="code"
|
||||
value={formik.values.code}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={isEdit || isView}
|
||||
placeholder="e.g., product_weight"
|
||||
aria-invalid={formik.touched.code && Boolean(formik.errors.code)}
|
||||
/>
|
||||
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Attribute Name <span className="text-red-400">*</span></label>
|
||||
<Input
|
||||
@@ -290,6 +348,19 @@ export default function NewAttribute() {
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Attribute Code <span className="text-red-400">*</span></label>
|
||||
<Input
|
||||
name="code"
|
||||
value={formik.values.code}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={isEdit || isView}
|
||||
placeholder="e.g., product_weight"
|
||||
aria-invalid={formik.touched.code && Boolean(formik.errors.code)}
|
||||
/>
|
||||
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -305,6 +376,40 @@ export default function NewAttribute() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{(formik.values.dataType === "select" || formik.values.dataType === "multiselect") && (
|
||||
<div className="bg-primary/5 p-4 rounded-lg border border-primary/20 space-y-3">
|
||||
<label className={labelClass}>Selectable Options <span className="text-red-400">*</span></label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newOptionInput}
|
||||
onChange={(e: any) => setNewOptionInput(e.target.value)}
|
||||
onKeyDown={(e: any) => { if (e.key === "Enter") { e.preventDefault(); handleAddOption(); } }}
|
||||
placeholder="Type option label (e.g. Red, Blue, Black) and press Enter"
|
||||
disabled={isView}
|
||||
/>
|
||||
<Button type="button" variant="secondary" onClick={handleAddOption} disabled={isView || !newOptionInput.trim()}>
|
||||
Add Option
|
||||
</Button>
|
||||
</div>
|
||||
{optionsList.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{optionsList.map((opt, idx) => (
|
||||
<span key={opt.code} className="inline-flex items-center gap-1.5 px-3 py-1 bg-surface border border-border rounded-full text-xs font-medium text-foreground">
|
||||
{opt.label} <code className="text-[10px] text-muted-foreground">({opt.code})</code>
|
||||
{!isView && (
|
||||
<button type="button" onClick={() => handleRemoveOption(idx)} className="text-muted-foreground hover:text-red-500 font-bold ml-1">
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-amber-600 italic">No options added yet. Type an option label above and click Add Option.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Status</label>
|
||||
<RadioGroup className="mt-1">
|
||||
@@ -517,7 +622,7 @@ export default function NewAttribute() {
|
||||
<Button variant="outline" type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} icon={<ChevronLeft className="w-4 h-4" />}>Back</Button>
|
||||
)}
|
||||
{activeIndex < STEPS.length - 1 && (
|
||||
<Button variant="primary" type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)}>Next <ChevronRight className="w-4 h-4" /></Button>
|
||||
<Button variant="primary" type="button" onClick={handleNextStep}>Next <ChevronRight className="w-4 h-4" /></Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -9,7 +9,11 @@ import { useBrand } from "../hook/useBrand";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
|
||||
|
||||
import { Can } from "../../../components/customs/Can";
|
||||
import { usePermissions } from "../../../hooks/usePermission";
|
||||
|
||||
export default function BrandList() {
|
||||
const { canEdit, canDelete } = usePermissions("masters.brands");
|
||||
const navigate = useNavigate();
|
||||
const { brands, fetchBrands, deleteBrand } = useBrand();
|
||||
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" });
|
||||
@@ -38,9 +42,11 @@ export default function BrandList() {
|
||||
<Breadcrumb
|
||||
items={[{ label: "Home" }, { label: "Brands" }]}
|
||||
actions={
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("/brands/new")}>
|
||||
Create Brand
|
||||
</Button>
|
||||
<Can node="masters.brands" action="create">
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("/brands/new")}>
|
||||
Create Brand
|
||||
</Button>
|
||||
</Can>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -49,8 +55,8 @@ export default function BrandList() {
|
||||
brands={brands}
|
||||
onRowClick={(row) => navigate(`/brands/${row.id}/edit`)}
|
||||
onView={(row) => navigate(`/brands/${row.id}/view`)}
|
||||
onEdit={(row) => navigate(`/brands/${row.id}/edit`)}
|
||||
onDelete={(row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })}
|
||||
onEdit={canEdit ? ((row) => navigate(`/brands/${row.id}/edit`)) : () => {}}
|
||||
onDelete={canDelete ? ((row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })) : () => {}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import { StatsCard } from "../../../components/customs/StatsCard";
|
||||
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
|
||||
import { CategoryTaxonomyTree } from "../components/CategoryTaxonomyTree";
|
||||
|
||||
import { Can } from "../../../components/customs/Can";
|
||||
|
||||
type ViewMode = "table" | "tree";
|
||||
|
||||
export default function CategoryList() {
|
||||
@@ -31,10 +33,10 @@ export default function CategoryList() {
|
||||
}, [fetchCategories]);
|
||||
|
||||
const stats = {
|
||||
total: categories.length || 0,
|
||||
active: categories.filter((c) => c.status === "active").length,
|
||||
products: 33008,
|
||||
families: 406,
|
||||
total: categories.length || 0,
|
||||
active: categories.filter((c) => c.status === "active").length,
|
||||
products: categories.reduce((sum, c: any) => sum + (Number(c.productCount) || 0), 0),
|
||||
families: categories.reduce((sum, c: any) => sum + (Number(c.familyCount) || 0), 0),
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
@@ -56,13 +58,15 @@ export default function CategoryList() {
|
||||
<Breadcrumb
|
||||
items={[{ label: "Home" }, { label: "Categories" }]}
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
onClick={() => navigate("/categories/new")}
|
||||
>
|
||||
Add Root Category
|
||||
</Button>
|
||||
<Can node="products.categories" action="create">
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
onClick={() => navigate("/categories/new")}
|
||||
>
|
||||
Add Root Category
|
||||
</Button>
|
||||
</Can>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -113,8 +113,11 @@ export default function NewCategory() {
|
||||
}, [isEdit, id, categories, parentIdParam]);
|
||||
|
||||
const allowedParentOptions = useMemo(() => {
|
||||
if (!isEdit) return categories;
|
||||
return categories.filter((cat) => cat.id !== id && !cat.path?.startsWith(categories.find(c => c.id === id)?.path + "/"));
|
||||
const validCategories = Array.isArray(categories) ? categories.filter((cat) => cat && cat.id) : [];
|
||||
if (!isEdit) return validCategories;
|
||||
const currentCat = validCategories.find((c) => c.id === id);
|
||||
const currentPath = currentCat?.path || "";
|
||||
return validCategories.filter((cat) => cat.id !== id && (!currentPath || !cat.path?.startsWith(currentPath + "/")));
|
||||
}, [categories, isEdit, id]);
|
||||
|
||||
return (
|
||||
@@ -204,16 +207,16 @@ export default function NewCategory() {
|
||||
name="parentId"
|
||||
value={formik.values.parentId}
|
||||
onChange={formik.handleChange}
|
||||
disabled={!isEdit} // Disabled (Read-only) during creation, enabled during Edit
|
||||
disabled={Boolean(parentIdParam)}
|
||||
>
|
||||
<option value="">None (Root Level)</option>
|
||||
{allowedParentOptions.map((cat) => (
|
||||
{allowedParentOptions.filter(Boolean).map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name} ({cat.code})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{!isEdit && (
|
||||
{!isEdit && Boolean(parentIdParam) && (
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
Locked to parent context. Click inline tree actions to create subcategories.
|
||||
</p>
|
||||
|
||||
@@ -34,6 +34,41 @@ export const channelsApi = {
|
||||
const res = await apiClient.delete<ApiResponse<any>>(`${BASE_URL}/${id}`);
|
||||
return res.success;
|
||||
},
|
||||
|
||||
getMappings: async (channelId: string): Promise<any[]> => {
|
||||
const res = await apiClient.get<ApiResponse<any[]>>(`${BASE_URL}/${channelId}/mappings`);
|
||||
return res.data || [];
|
||||
},
|
||||
|
||||
updateMappings: async (channelId: string, mappings: any[]): Promise<any> => {
|
||||
const res = await apiClient.put<ApiResponse<any>>(`${BASE_URL}/${channelId}/mappings`, { mappings });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
triggerSyndication: async (channelId: string): Promise<any> => {
|
||||
const res = await apiClient.post<ApiResponse<any>>(`${BASE_URL}/${channelId}/syndicate`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
getJobs: async (channelId: string): Promise<any[]> => {
|
||||
const res = await apiClient.get<ApiResponse<any[]>>(`${BASE_URL}/${channelId}/jobs`);
|
||||
return res.data || [];
|
||||
},
|
||||
|
||||
previewPayload: async (channelId: string): Promise<any> => {
|
||||
const res = await apiClient.post<ApiResponse<any>>(`${BASE_URL}/${channelId}/preview`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
syndicateAll: async (): Promise<any[]> => {
|
||||
const res = await apiClient.post<ApiResponse<any[]>>(`${BASE_URL}/syndicate-all`);
|
||||
return res.data || [];
|
||||
},
|
||||
|
||||
testConnection: async (channelId: string): Promise<any> => {
|
||||
const res = await apiClient.post<ApiResponse<any>>(`${BASE_URL}/${channelId}/test-connection`);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default channelsApi;
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Save, Plus, Trash2, ArrowRight, Eye } from "lucide-react";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { channelsApi } from "../api/channels.api";
|
||||
import { notify } from "../../../services/toast";
|
||||
import { PayloadPreviewModal } from "./PayloadPreviewModal";
|
||||
|
||||
const COMMON_PIM_ATTRIBUTES = [
|
||||
{ code: "name", label: "Product Title / Name (name)" },
|
||||
{ code: "code", label: "Product Code / SKU (code)" },
|
||||
{ code: "description", label: "Product Description (description)" },
|
||||
{ code: "status", label: "Publication Status (status)" },
|
||||
{ code: "created_at", label: "Creation Timestamp (created_at)" },
|
||||
];
|
||||
|
||||
export const COMMON_CHANNEL_FIELDS = [
|
||||
{ code: "title", label: "Storefront Title (title)" },
|
||||
{ code: "body_html", label: "HTML Body Description (body_html)" },
|
||||
{ code: "variant_sku", label: "Variant SKU (variant_sku)" },
|
||||
{ code: "price", label: "Variant Price (price)" },
|
||||
{ code: "vendor", label: "Brand / Vendor (vendor)" },
|
||||
{ code: "product_type", label: "Product Category / Type (product_type)" },
|
||||
];
|
||||
|
||||
const TRANSFORMATION_RULES = [
|
||||
{ value: "none", label: "Direct Pass-through" },
|
||||
{ value: "uppercase", label: "UPPERCASE" },
|
||||
{ value: "lowercase", label: "lowercase" },
|
||||
{ value: "currency_format", label: "Currency Format (0.00)" },
|
||||
{ value: "strip_html", label: "Strip HTML Tags" },
|
||||
{ value: "default_if_null", label: "Fallback Default Value" },
|
||||
];
|
||||
|
||||
export function ChannelMappingTab({ channelId }: { channelId: string }) {
|
||||
const [mappings, setMappings] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [previewData, setPreviewData] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadMappings();
|
||||
}, [channelId]);
|
||||
|
||||
const loadMappings = async () => {
|
||||
setLoading(true);
|
||||
const defaultBaseline = [
|
||||
{ pim_attribute_code: "name", channel_field_code: "title", transformation_rule: "none", default_value: "", is_required: true },
|
||||
{ pim_attribute_code: "code", channel_field_code: "variant_sku", transformation_rule: "uppercase", default_value: "", is_required: true },
|
||||
{ pim_attribute_code: "status", channel_field_code: "published_status", transformation_rule: "lowercase", default_value: "published", is_required: false },
|
||||
];
|
||||
|
||||
if (!channelId || channelId === "demo-channel-id") {
|
||||
setMappings(defaultBaseline);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await channelsApi.getMappings(channelId);
|
||||
if (data && data.length > 0) {
|
||||
setMappings(data);
|
||||
} else {
|
||||
setMappings(defaultBaseline);
|
||||
}
|
||||
} catch {
|
||||
setMappings(defaultBaseline);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRule = () => {
|
||||
setMappings((prev) => [
|
||||
...prev,
|
||||
{ pim_attribute_code: "name", channel_field_code: "custom_field", transformation_rule: "none", default_value: "", is_required: false },
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveRule = (index: number) => {
|
||||
setMappings((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleChange = (index: number, field: string, value: any) => {
|
||||
setMappings((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await channelsApi.updateMappings(channelId, mappings);
|
||||
notify.success("Attribute mapping rules saved successfully!");
|
||||
await loadMappings();
|
||||
} catch {
|
||||
notify.error("Failed to save mapping rules");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = async () => {
|
||||
setPreviewing(true);
|
||||
try {
|
||||
const data = await channelsApi.previewPayload(channelId);
|
||||
setPreviewData(data);
|
||||
} catch {
|
||||
notify.error("Failed to generate transformation preview");
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">Channel Field Mapping Matrix</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Map central PIM attributes to target storefront fields and apply transformation pipelines.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" loading={previewing} onClick={handlePreview}>
|
||||
<Eye className="w-4 h-4 mr-2" /> Preview Transformed Payload
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleAddRule}>
|
||||
<Plus className="w-4 h-4 mr-2" /> Add Rule
|
||||
</Button>
|
||||
<Button variant="primary" loading={saving} onClick={handleSave}>
|
||||
<Save className="w-4 h-4 mr-2" /> Save Mappings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PayloadPreviewModal
|
||||
isOpen={Boolean(previewData)}
|
||||
onClose={() => setPreviewData(null)}
|
||||
previewData={previewData}
|
||||
/>
|
||||
|
||||
<div className="border border-border rounded-lg overflow-hidden bg-surface shadow-sm">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-surface-muted border-b border-border text-xs text-muted-foreground uppercase">
|
||||
<tr>
|
||||
<th className="px-4 py-3">PIM Central Attribute</th>
|
||||
<th className="px-4 py-3 text-center">Pipeline</th>
|
||||
<th className="px-4 py-3">Target Storefront Field</th>
|
||||
<th className="px-4 py-3">Transformation Rule</th>
|
||||
<th className="px-4 py-3 text-center">Required</th>
|
||||
<th className="px-4 py-3 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{mappings.map((rule, idx) => (
|
||||
<tr key={idx} className="hover:bg-surface-muted/50">
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={rule.pim_attribute_code}
|
||||
onChange={(e) => handleChange(idx, "pim_attribute_code", e.target.value)}
|
||||
className="w-full px-3 py-1.5 border border-border rounded-md bg-surface text-foreground text-xs focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
{COMMON_PIM_ATTRIBUTES.map((attr) => (
|
||||
<option key={attr.code} value={attr.code}>
|
||||
{attr.label} ({attr.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<ArrowRight className="w-4 h-4 mx-auto text-muted-foreground" />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="text"
|
||||
value={rule.channel_field_code}
|
||||
onChange={(e) => handleChange(idx, "channel_field_code", e.target.value)}
|
||||
placeholder="e.g. title or body_html"
|
||||
className="w-full px-3 py-1.5 border border-border rounded-md bg-surface text-foreground text-xs font-mono focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={rule.transformation_rule}
|
||||
onChange={(e) => handleChange(idx, "transformation_rule", e.target.value)}
|
||||
className="w-full px-3 py-1.5 border border-border rounded-md bg-surface text-foreground text-xs focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
{TRANSFORMATION_RULES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(rule.is_required)}
|
||||
onChange={(e) => handleChange(idx, "is_required", e.target.checked)}
|
||||
className="rounded border-border text-primary focus:ring-primary h-4 w-4"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveRule(idx)}
|
||||
className="p-1.5 text-danger hover:bg-danger/10 rounded transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { Code, Check, Copy } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface PayloadPreviewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
previewData: any;
|
||||
}
|
||||
|
||||
export function PayloadPreviewModal({ isOpen, onClose, previewData }: PayloadPreviewModalProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (!isOpen || !previewData) return null;
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(JSON.stringify(previewData.adapterOutput, null, 2));
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="bg-surface border border-border rounded-xl shadow-2xl max-w-4xl w-full flex flex-col max-h-[85vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-border bg-gradient-to-r from-primary/10 to-surface flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
|
||||
<Code className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground text-sm">Transformed Payload Preview</h3>
|
||||
<p className="text-xs text-muted-foreground">Real-time adapter transformation preview for channel: <span className="font-semibold text-primary">{previewData.channel?.name}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground text-sm font-bold p-1 rounded"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 grid grid-cols-2 gap-4 flex-1 overflow-y-auto">
|
||||
{/* Left Column: PIM Raw Data */}
|
||||
<div className="flex flex-col border border-border rounded-lg bg-surface-muted overflow-hidden">
|
||||
<div className="px-3 py-2 bg-border/40 text-xs font-semibold text-muted-foreground uppercase tracking-wider border-b border-border">
|
||||
PIM Central Product (Raw JSON)
|
||||
</div>
|
||||
<pre className="p-4 text-xs font-mono text-foreground overflow-auto flex-1 max-h-96">
|
||||
{JSON.stringify(previewData.pimProductRaw, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Adapter Storefront Output */}
|
||||
<div className="flex flex-col border border-primary/20 rounded-lg bg-primary/5/30 overflow-hidden">
|
||||
<div className="px-3 py-2 bg-primary/10 text-xs font-semibold text-primary uppercase tracking-wider border-b border-primary/20 flex justify-between items-center">
|
||||
<span>Adapter Storefront Payload ({previewData.channel?.code})</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="inline-flex items-center gap-1 text-[11px] text-primary hover:underline"
|
||||
>
|
||||
{copied ? <Check className="w-3 h-3 text-success" /> : <Copy className="w-3 h-3" />}
|
||||
{copied ? "Copied!" : "Copy Payload"}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-4 text-xs font-mono text-primary-dark overflow-auto flex-1 max-h-96">
|
||||
{JSON.stringify(previewData.adapterOutput, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-3 border-t border-border bg-surface-muted flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close Inspector
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Play, RefreshCw, AlertCircle, CheckCircle2, Clock, Eye } from "lucide-react";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { channelsApi } from "../api/channels.api";
|
||||
import { notify } from "../../../services/toast";
|
||||
|
||||
export function SyndicationHistoryTab({ channelId }: { channelId: string }) {
|
||||
const [jobs, setJobs] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [selectedErrorLog, setSelectedErrorLog] = useState<any[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadJobs();
|
||||
}, [channelId]);
|
||||
|
||||
const loadJobs = async () => {
|
||||
if (!channelId || channelId === "demo-channel-id") {
|
||||
setJobs([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await channelsApi.getJobs(channelId);
|
||||
setJobs(data || []);
|
||||
} catch {
|
||||
setJobs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTriggerSync = async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
await channelsApi.triggerSyndication(channelId);
|
||||
notify.success("Syndication job triggered and processed successfully!");
|
||||
await loadJobs();
|
||||
} catch {
|
||||
notify.error("Failed to trigger syndication job");
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-success/10 text-success border border-success/20"><CheckCircle2 className="w-3.5 h-3.5" /> Completed</span>;
|
||||
case 'failed':
|
||||
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-danger/10 text-danger border border-danger/20"><AlertCircle className="w-3.5 h-3.5" /> Failed</span>;
|
||||
case 'running':
|
||||
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-warning/10 text-warning border border-warning/20 animate-pulse"><Clock className="w-3.5 h-3.5" /> Running</span>;
|
||||
default:
|
||||
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-muted text-muted-foreground">Pending</span>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">Syndication Execution History</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Real-time execution runs, success metrics, and error log inspection for this channel.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={loadJobs} disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} /> Refresh
|
||||
</Button>
|
||||
<Button variant="primary" loading={syncing} onClick={handleTriggerSync}>
|
||||
<Play className="w-4 h-4 mr-2" /> Trigger Instant Sync
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg overflow-hidden bg-surface shadow-sm">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-surface-muted border-b border-border text-xs text-muted-foreground uppercase">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Job ID</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3 text-center">Total Products</th>
|
||||
<th className="px-4 py-3 text-center">Success</th>
|
||||
<th className="px-4 py-3 text-center">Failed</th>
|
||||
<th className="px-4 py-3">Started At</th>
|
||||
<th className="px-4 py-3 text-right">Log Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{jobs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground text-xs">
|
||||
No syndication runs recorded yet. Click "Trigger Instant Sync" to start your first job run.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
jobs.map((job) => (
|
||||
<tr key={job.id} className="hover:bg-surface-muted/50">
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{job.id.slice(0, 8)}...</td>
|
||||
<td className="px-4 py-3">{getStatusBadge(job.status)}</td>
|
||||
<td className="px-4 py-3 text-center font-medium">{job.total_products}</td>
|
||||
<td className="px-4 py-3 text-center text-success font-semibold">{job.success_count}</td>
|
||||
<td className="px-4 py-3 text-center text-danger font-semibold">{job.failed_count}</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground">{new Date(job.started_at || job.created_at).toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{job.error_log && job.error_log.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedErrorLog(job.error_log)}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-danger bg-danger/10 hover:bg-danger/20 rounded transition-colors"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" /> View Errors ({job.error_log.length})
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Error Log Inspection Modal */}
|
||||
{selectedErrorLog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-surface border border-border rounded-xl shadow-xl max-w-2xl w-full p-6 space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<h4 className="text-base font-semibold text-danger flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5" /> Syndication Error Logs
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => setSelectedErrorLog(null)}
|
||||
className="text-muted-foreground hover:text-foreground text-sm font-bold"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto space-y-2">
|
||||
{selectedErrorLog.map((err, idx) => (
|
||||
<div key={idx} className="p-3 bg-danger/5 border border-danger/20 rounded-lg text-xs font-mono">
|
||||
<div className="font-semibold text-danger">Product SKU: {err.sku || 'N/A'} (ID: {err.productId})</div>
|
||||
<div className="text-muted-foreground mt-1">{err.error}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button variant="outline" onClick={() => setSelectedErrorLog(null)}>
|
||||
Close Inspector
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Plus, RefreshCw, Radio, CheckCircle, Layers, TrendingUp, ShoppingCart, ShoppingBag, Server, Warehouse, Store, Smartphone, Globe, Monitor } from "lucide-react";
|
||||
import { Plus, RefreshCw, Radio, CheckCircle, Layers, TrendingUp, ShoppingCart, ShoppingBag, Server, Warehouse, Store, Smartphone, Globe, Monitor, Play } from "lucide-react";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
@@ -11,6 +11,11 @@ import { StatsCard } from "../../../components/customs/StatsCard";
|
||||
import { useChannel } from "../hook/useChannel";
|
||||
import type { Channel } from "../types/channels.types";
|
||||
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
|
||||
import { channelsApi } from "../api/channels.api";
|
||||
import { notify } from "../../../services/toast";
|
||||
|
||||
import { Can } from "../../../components/customs/Can";
|
||||
import { usePermissions } from "../../../hooks/usePermission";
|
||||
|
||||
const CHANNEL_TYPES_META: Record<string, { label: string, icon: any, typeColor: string, typeBg: string }> = {
|
||||
ecommerce: { label: "Ecommerce", icon: ShoppingCart, typeColor: "text-blue-600", typeBg: "bg-blue-50" },
|
||||
@@ -24,6 +29,7 @@ const CHANNEL_TYPES_META: Record<string, { label: string, icon: any, typeColor:
|
||||
};
|
||||
|
||||
export default function ChannelList() {
|
||||
const { canEdit, canDelete } = usePermissions("channels.syndication");
|
||||
const navigate = useNavigate();
|
||||
const { items, fetchItems, loading, deleteItem } = useChannel();
|
||||
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: "", name: "" });
|
||||
@@ -80,6 +86,31 @@ export default function ChannelList() {
|
||||
},
|
||||
{ key: "families", label: "Families", render: (val: any) => val || 0 },
|
||||
{ key: "products", label: "Products", render: (val: any) => val ? val.toLocaleString() : 0 },
|
||||
{
|
||||
key: "syndicate",
|
||||
label: "Syndication",
|
||||
render: (_: any, row: Channel) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
notify.info(`Triggering syndication for ${row.name}...`);
|
||||
const res = await channelsApi.triggerSyndication(row.id);
|
||||
if (res.status === 'completed') {
|
||||
notify.success(`Syndication for ${row.name} completed! (${res.success_count} synced)`);
|
||||
} else {
|
||||
notify.warning(`Syndication for ${row.name} completed with ${res.failed_count} errors`);
|
||||
}
|
||||
} catch {
|
||||
notify.error("Failed to trigger syndication");
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-semibold text-primary bg-primary/10 hover:bg-primary/20 rounded transition-colors"
|
||||
>
|
||||
<Play className="w-3 h-3" /> Trigger Sync
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
label: "Updated",
|
||||
@@ -109,20 +140,38 @@ export default function ChannelList() {
|
||||
|
||||
|
||||
return (
|
||||
<ProtectedRoute node="settings.integrations">
|
||||
<ProtectedRoute node="channels.syndication">
|
||||
<PageWrapper>
|
||||
<Breadcrumb
|
||||
items={[{ label: "Home" }, { label: "Channel Registry" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-surface border-border text-foreground hover:bg-surface-muted"
|
||||
onClick={async () => {
|
||||
try {
|
||||
notify.info("Triggering bulk syndication across all active channels...");
|
||||
const results = await channelsApi.syndicateAll();
|
||||
notify.success(`Bulk syndication complete! Triggered sync for ${results.length} active channels.`);
|
||||
} catch {
|
||||
notify.error("Failed to trigger bulk channel syndication");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2 text-primary" />
|
||||
Syndicate All Channels
|
||||
</Button>
|
||||
<Button variant="outline" className="bg-surface border-border text-muted-foreground hover:bg-surface-muted" onClick={fetchItems}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={() => navigate("new")} className="bg-primary hover:bg-primary-hover text-white">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Channel
|
||||
</Button>
|
||||
<Can node="channels.syndication" action="create">
|
||||
<Button onClick={() => navigate("new")} className="bg-primary hover:bg-primary-hover text-white">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Channel
|
||||
</Button>
|
||||
</Can>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -167,8 +216,8 @@ export default function ChannelList() {
|
||||
data={items}
|
||||
actionConfig={{
|
||||
onView: (row) => navigate(`${row.id}/view`),
|
||||
onEdit: (row) => navigate(`${row.id}/edit`),
|
||||
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
|
||||
onEdit: canEdit ? ((row) => navigate(`${row.id}/edit`)) : undefined,
|
||||
onDelete: canDelete ? ((row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })) : undefined
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -28,10 +28,15 @@ const CHANNEL_TYPES = [
|
||||
{ id: "website", label: "Website", icon: Monitor, color: "text-primary", bg: "bg-primary/5" },
|
||||
];
|
||||
|
||||
import { ChannelMappingTab } from "../components/ChannelMappingTab";
|
||||
import { SyndicationHistoryTab } from "../components/SyndicationHistoryTab";
|
||||
|
||||
const STEPS = [
|
||||
{ id: "basic", label: "Basic Information", step: 1 },
|
||||
{ id: "availability",label: "Availability", step: 2 },
|
||||
{ id: "summary", label: "Summary", step: 3 },
|
||||
{ id: "basic", label: "Basic Information", step: 1 },
|
||||
{ id: "availability", label: "Availability", step: 2 },
|
||||
{ id: "mapping", label: "Field Mapping Matrix", step: 3 },
|
||||
{ id: "syndication", label: "Syndication History", step: 4 },
|
||||
{ id: "summary", label: "Summary", step: 5 },
|
||||
];
|
||||
|
||||
const channelSchema = Yup.object({
|
||||
@@ -120,6 +125,39 @@ export default function NewChannel() {
|
||||
|
||||
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
|
||||
|
||||
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit || isView) {
|
||||
setHighestVisitedStep(STEPS.length);
|
||||
}
|
||||
}, [isEdit, isView]);
|
||||
|
||||
const isBasicValid = Boolean(formik.values.name?.trim() && formik.values.code?.trim() && formik.values.channelType && !formik.errors.name && !formik.errors.code && !formik.errors.channelType);
|
||||
|
||||
const isStepAccessible = useCallback((stepNum: number) => {
|
||||
if (isEdit || isView) return true;
|
||||
if (stepNum === 1) return true;
|
||||
if (!isBasicValid) return false;
|
||||
return stepNum <= highestVisitedStep + 1;
|
||||
}, [isEdit, isView, isBasicValid, highestVisitedStep]);
|
||||
|
||||
const handleNextStep = useCallback(() => {
|
||||
if (activeStep === 'basic') {
|
||||
if (!isBasicValid) {
|
||||
formik.setFieldTouched('name', true);
|
||||
formik.setFieldTouched('code', true);
|
||||
formik.setFieldTouched('channelType', true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (activeIndex < STEPS.length - 1) {
|
||||
const nextStepObj = STEPS[activeIndex + 1];
|
||||
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
|
||||
setActiveStep(nextStepObj.id);
|
||||
}
|
||||
}, [activeStep, isBasicValid, activeIndex, formik]);
|
||||
|
||||
return (
|
||||
<ProtectedRoute node="settings.integrations">
|
||||
<PageWrapper>
|
||||
@@ -164,17 +202,23 @@ export default function NewChannel() {
|
||||
const isActive = activeStep === s.id;
|
||||
const isDone = idx < activeIndex;
|
||||
const isLast = idx === STEPS.length - 1;
|
||||
const accessible = isStepAccessible(s.step);
|
||||
return (
|
||||
<div key={s.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center" style={{ width: 24 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${
|
||||
isActive ? "bg-primary ring-2 ring-primary/20" :
|
||||
isDone ? "bg-success" :
|
||||
"bg-surface border-2 border-border hover:border-primary/30"
|
||||
}`}
|
||||
} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
{isDone
|
||||
? <Check className="w-3 h-3 text-white" />
|
||||
@@ -187,8 +231,13 @@ export default function NewChannel() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""}`}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
<span className={`text-xs font-medium leading-tight block ${
|
||||
isActive ? "text-primary" : isDone ? "text-muted-foreground" : "text-muted-foreground hover:text-muted-foreground"
|
||||
@@ -330,7 +379,21 @@ export default function NewChannel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3 — Summary */}
|
||||
{/* Step 3 — Field Mapping Matrix */}
|
||||
{activeStep === "mapping" && (
|
||||
<div className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden p-6">
|
||||
<ChannelMappingTab channelId={id || "demo-channel-id"} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4 — Syndication History */}
|
||||
{activeStep === "syndication" && (
|
||||
<div className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden p-6">
|
||||
<SyndicationHistoryTab channelId={id || "demo-channel-id"} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 5 — Summary */}
|
||||
{activeStep === "summary" && (
|
||||
<div className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden">
|
||||
<CardHeader title="Summary" subtitle="Review your channel configuration before saving" />
|
||||
@@ -407,7 +470,7 @@ export default function NewChannel() {
|
||||
<Button variant="outline" type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)}>Back</Button>
|
||||
)}
|
||||
{activeIndex < STEPS.length - 1 && (
|
||||
<Button variant="primary" type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)}>Next</Button>
|
||||
<Button variant="primary" type="button" onClick={handleNextStep}>Next</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function ChartContainer({
|
||||
className={cn("flex justify-center text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<RechartsPrimitive.ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsPrimitive.ResponsiveContainer width="100%" height="100%" minWidth={100} minHeight={100}>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -61,45 +61,67 @@ const recentActivity = [
|
||||
|
||||
|
||||
// ── Dashboard Component ───────────────────────────────────────────────────────
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { productApi } from "../../product/api/product.api";
|
||||
import { channelsApi } from "../../channels/api/channels.api";
|
||||
|
||||
export default function Dashboard() {
|
||||
const user = useSelector((state: any) => state.auth?.user);
|
||||
const [productCount, setProductCount] = useState<number>(0);
|
||||
const [channelCount, setChannelCount] = useState<number>(0);
|
||||
const [publishedCount, setPublishedCount] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadStats() {
|
||||
try {
|
||||
const [products, channels] = await Promise.all([
|
||||
productApi.getAll().catch(() => []),
|
||||
channelsApi.getAll().catch(() => [])
|
||||
]);
|
||||
setProductCount(products.length || 0);
|
||||
setPublishedCount(products.filter((p: any) => p.status === 'published' || p.status === 'active').length || 0);
|
||||
setChannelCount(channels.length || 0);
|
||||
} catch {
|
||||
// handled
|
||||
}
|
||||
}
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-bold text-foreground mb-1">Welcome back, John</h2>
|
||||
<h2 className="text-xl font-bold text-foreground mb-1">
|
||||
Welcome back, {user?.first_name || user?.user_name || (user?.user_type === 'platform' ? 'Platform Super Admin' : 'Tenant Administrator')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">Here's what's happening with your product catalog today.</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* KPI InfoCards */}
|
||||
<InfoCardGrid cols={4} className="mb-6">
|
||||
<InfoCard
|
||||
label="Total Products"
|
||||
value="12,459"
|
||||
value={productCount.toLocaleString()}
|
||||
icon={<Box className="w-5 h-5 text-primary-light" />}
|
||||
trend="+12.5%"
|
||||
trendDirection="up"
|
||||
subtitle="vs. last month"
|
||||
subtitle="Tenant Workspace Total"
|
||||
/>
|
||||
<InfoCard
|
||||
label="Pending Approvals"
|
||||
value="8"
|
||||
value="0"
|
||||
icon={<AlertCircle className="w-5 h-5 text-primary-light" />}
|
||||
subtitle="Requires attention"
|
||||
/>
|
||||
<InfoCard
|
||||
label="Published This Month"
|
||||
value="2,380"
|
||||
label="Published Products"
|
||||
value={publishedCount.toLocaleString()}
|
||||
icon={<CheckCircle2 className="w-5 h-5 text-primary-light" />}
|
||||
trend="+18.2%"
|
||||
trendDirection="up"
|
||||
subtitle="vs. last month"
|
||||
subtitle="Ready for syndication"
|
||||
/>
|
||||
<InfoCard
|
||||
label="Active Channels"
|
||||
value="24"
|
||||
value={channelCount.toLocaleString()}
|
||||
icon={<Radio className="w-5 h-5 text-primary-light" />}
|
||||
trend="+4.3%"
|
||||
trendDirection="up"
|
||||
subtitle="Publishing enabled"
|
||||
/>
|
||||
</InfoCardGrid>
|
||||
|
||||
@@ -10,6 +10,8 @@ import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { StatsCard } from "../../../components/customs/StatsCard";
|
||||
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
|
||||
|
||||
import { Can } from "../../../components/customs/Can";
|
||||
|
||||
export default function FamilyList() {
|
||||
const navigate = useNavigate();
|
||||
const { families, fetchFamilies, deleteFamily } = useFamily();
|
||||
@@ -49,9 +51,11 @@ export default function FamilyList() {
|
||||
<Breadcrumb
|
||||
items={[{ label: "Home" }, { label: "Product Families" }]}
|
||||
actions={
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("/families/new")}>
|
||||
Create Family
|
||||
</Button>
|
||||
<Can node="products.families" action="create">
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("/families/new")}>
|
||||
Create Family
|
||||
</Button>
|
||||
</Can>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
+2549
-744
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ export interface Family {
|
||||
description?: string;
|
||||
category?: string;
|
||||
categoryId?: string | null;
|
||||
productType?: 'simple' | 'variant' | string;
|
||||
attributes: string[]; // List of attribute codes
|
||||
attributeGroups?: number;
|
||||
variantAxes: string[]; // List of attribute codes used as variant axes
|
||||
@@ -17,6 +18,7 @@ export interface Family {
|
||||
createdBy: string;
|
||||
channels?: string[];
|
||||
assetRequirements?: string[];
|
||||
directAssetTypes?: string[];
|
||||
completenessRules?: Record<string, number>;
|
||||
workflowCode?: string;
|
||||
attributeSetId?: string;
|
||||
|
||||
@@ -20,6 +20,8 @@ export const familySchema = Yup.object().shape({
|
||||
})
|
||||
),
|
||||
status: Yup.string().oneOf(['active', 'inactive', 'draft']),
|
||||
productType: Yup.string().oneOf(['simple', 'variant']).optional(),
|
||||
allowedBrands: Yup.array().of(Yup.string()).optional(),
|
||||
allowedUnits: Yup.array().of(Yup.string()).optional(),
|
||||
directAssetTypes: Yup.array().of(Yup.string()).optional(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Layers, RefreshCw, Save, Check, Plus, ArrowRight } from 'lucide-react';
|
||||
import { notify } from '../../../services/toast/index';
|
||||
|
||||
interface MappingRow {
|
||||
id: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
transformationType: string;
|
||||
defaultValue: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SHOPIFY_MAPPINGS: MappingRow[] = [
|
||||
{ id: 'm1', sourcePath: 'content.name', targetPath: 'title', transformationType: 'string', defaultValue: '', required: true },
|
||||
{ id: 'm2', sourcePath: 'content.description', targetPath: 'bodyHtml', transformationType: 'string', defaultValue: '', required: false },
|
||||
{ id: 'm3', sourcePath: 'content.status', targetPath: 'status', transformationType: 'uppercase', defaultValue: 'DRAFT', required: true },
|
||||
{ id: 'm4', sourcePath: 'taxonomy.brand.name', targetPath: 'vendor', transformationType: 'string', defaultValue: 'Generic', required: false },
|
||||
{ id: 'm5', sourcePath: 'taxonomy.category.name', targetPath: 'productType', transformationType: 'string', defaultValue: 'General', required: false },
|
||||
{ id: 'm6', sourcePath: 'variants.sku', targetPath: 'variants.sku', transformationType: 'string', defaultValue: '', required: true },
|
||||
{ id: 'm7', sourcePath: 'variants.price', targetPath: 'variants.price', transformationType: 'currency_format', defaultValue: '0.00', required: true }
|
||||
];
|
||||
|
||||
export default function FieldMappingsTab() {
|
||||
const [mappings, setMappings] = useState<MappingRow[]>(DEFAULT_SHOPIFY_MAPPINGS);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = () => {
|
||||
setSaving(true);
|
||||
setTimeout(() => {
|
||||
setSaving(false);
|
||||
notify.success('Field mappings updated successfully!');
|
||||
}, 400);
|
||||
};
|
||||
|
||||
const handleAddMapping = () => {
|
||||
const newId = `m_${Date.now()}`;
|
||||
setMappings([
|
||||
...mappings,
|
||||
{ id: newId, sourcePath: 'attributes.', targetPath: 'metafields.', transformationType: 'string', defaultValue: '', required: false }
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between bg-background p-4 border border-border rounded-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<h3 className="font-bold text-foreground text-sm">Canonical PIM Attribute Mapping Schema</h3>
|
||||
<p className="text-xs text-muted-foreground">Map canonical product attributes to target channel GraphQL/REST properties</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddMapping}
|
||||
className="px-3 py-1.5 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-2xs flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Add Mapping
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold shadow-2xs flex items-center gap-1 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{saving ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />} Save Schema
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-xl overflow-hidden bg-surface shadow-2xs">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-background border-b border-border text-muted-foreground font-semibold">
|
||||
<th className="py-3 px-4">Canonical Source Path (PIM)</th>
|
||||
<th className="py-3 px-2 text-center">Transform</th>
|
||||
<th className="py-3 px-4">Channel Target Path (Shopify)</th>
|
||||
<th className="py-3 px-4">Transformation Type</th>
|
||||
<th className="py-3 px-4">Default Value</th>
|
||||
<th className="py-3 px-4 text-center">Required</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border font-medium">
|
||||
{mappings.map((m) => (
|
||||
<tr key={m.id} className="hover:bg-background/50 transition-colors">
|
||||
<td className="py-2.5 px-4">
|
||||
<input
|
||||
type="text"
|
||||
value={m.sourcePath}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setMappings(prev => prev.map(p => p.id === m.id ? { ...p, sourcePath: val } : p));
|
||||
}}
|
||||
className="w-full font-mono text-[11px] bg-background border border-border rounded px-2.5 py-1 text-foreground focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2.5 px-2 text-center text-muted-foreground">
|
||||
<ArrowRight className="w-4 h-4 mx-auto text-primary" />
|
||||
</td>
|
||||
<td className="py-2.5 px-4">
|
||||
<input
|
||||
type="text"
|
||||
value={m.targetPath}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setMappings(prev => prev.map(p => p.id === m.id ? { ...p, targetPath: val } : p));
|
||||
}}
|
||||
className="w-full font-mono text-[11px] bg-background border border-border rounded px-2.5 py-1 text-foreground focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2.5 px-4">
|
||||
<select
|
||||
value={m.transformationType}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setMappings(prev => prev.map(p => p.id === m.id ? { ...p, transformationType: val } : p));
|
||||
}}
|
||||
className="w-full bg-background border border-border rounded px-2 py-1 text-xs text-foreground focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="string">string (direct)</option>
|
||||
<option value="uppercase">uppercase</option>
|
||||
<option value="lowercase">lowercase</option>
|
||||
<option value="currency_format">currency_format</option>
|
||||
<option value="json_stringify">json_stringify</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="py-2.5 px-4">
|
||||
<input
|
||||
type="text"
|
||||
value={m.defaultValue}
|
||||
placeholder="—"
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setMappings(prev => prev.map(p => p.id === m.id ? { ...p, defaultValue: val } : p));
|
||||
}}
|
||||
className="w-full font-mono text-[11px] bg-background border border-border rounded px-2 py-1 text-foreground focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={m.required}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
setMappings(prev => prev.map(p => p.id === m.id ? { ...p, required: checked } : p));
|
||||
}}
|
||||
className="rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle2, AlertTriangle, XCircle, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface IntegrationHealthBadgeProps {
|
||||
status?: string;
|
||||
healthStatus?: string;
|
||||
}
|
||||
|
||||
export const IntegrationHealthBadge: React.FC<IntegrationHealthBadgeProps> = ({ status, healthStatus }) => {
|
||||
const normalizedStatus = (status || healthStatus || 'active').toLowerCase();
|
||||
|
||||
if (normalizedStatus === 'healthy' || normalizedStatus === 'active' || normalizedStatus === 'connected') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-600" />
|
||||
Healthy
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'syncing' || normalizedStatus === 'processing') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-50 text-blue-700 border border-blue-200">
|
||||
<RefreshCw className="w-3.5 h-3.5 text-blue-600 animate-spin" />
|
||||
Syncing
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'degraded' || normalizedStatus === 'rate_limited' || normalizedStatus === 'pending') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-50 text-amber-700 border border-amber-200">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-600" />
|
||||
{normalizedStatus === 'rate_limited' ? 'Rate Limited' : 'Pending'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-red-50 text-red-700 border border-red-200">
|
||||
<XCircle className="w-3.5 h-3.5 text-red-600" />
|
||||
Error
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
import { ShoppingCart, ShoppingBag, Globe, Code2, Plus, CheckCircle2, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface IntegrationTemplateGalleryProps {
|
||||
onSelectShopify: () => void;
|
||||
onSelectCustomApi: () => void;
|
||||
}
|
||||
|
||||
export const IntegrationTemplateGallery: React.FC<IntegrationTemplateGalleryProps> = ({
|
||||
onSelectShopify,
|
||||
onSelectCustomApi
|
||||
}) => {
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-primary/5 via-surface to-emerald-500/5 border border-border rounded-xl p-6 mb-8 shadow-2xs">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-foreground flex items-center gap-2">
|
||||
Pre-Built Channel Templates
|
||||
<span className="text-[10px] font-extrabold uppercase bg-primary text-white px-2 py-0.5 rounded-full">
|
||||
Zero Config
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">Select a channel template to connect in 1 click using native GraphQL/REST capability adapters</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Shopify Template Card */}
|
||||
<div className="bg-surface border-2 border-emerald-500/30 hover:border-emerald-500 rounded-xl p-4 transition-all shadow-2xs group relative flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-50 border border-emerald-200 flex items-center justify-center text-emerald-600 font-bold">
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-emerald-700 bg-emerald-100 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" /> Ready
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-foreground text-sm group-hover:text-emerald-700 transition-colors">Shopify GraphQL</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
Sync products, variants, assets & inventory via Shopify Admin API v2025-01 with cost bucket management.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelectShopify}
|
||||
className="mt-4 w-full py-2 px-3 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-semibold shadow-2xs flex items-center justify-center gap-1.5 cursor-pointer transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Setup Shopify
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Amazon Template Card */}
|
||||
<div className="bg-surface/60 border border-border rounded-xl p-4 transition-all shadow-2xs opacity-80 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-600 font-bold">
|
||||
<ShoppingBag className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-[10px] font-medium text-muted-foreground bg-surface border border-border px-2 py-0.5 rounded-full">
|
||||
Coming Soon
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-foreground text-sm">Amazon SP-API</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
Syndicate ASIN listings, FBA inventory and pricing updates via Amazon Selling Partner API.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="mt-4 w-full py-2 px-3 bg-surface border border-border text-muted-foreground rounded-lg text-xs font-semibold cursor-not-allowed opacity-60 flex items-center justify-center gap-1"
|
||||
>
|
||||
Coming Soon
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* WooCommerce Template Card */}
|
||||
<div className="bg-surface/60 border border-border rounded-xl p-4 transition-all shadow-2xs opacity-80 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-50 border border-purple-200 flex items-center justify-center text-purple-600 font-bold">
|
||||
<Globe className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-[10px] font-medium text-muted-foreground bg-surface border border-border px-2 py-0.5 rounded-full">
|
||||
Coming Soon
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-foreground text-sm">WooCommerce REST</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
Push PIM canonical catalog to WordPress WooCommerce stores via REST API v3.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="mt-4 w-full py-2 px-3 bg-surface border border-border text-muted-foreground rounded-lg text-xs font-semibold cursor-not-allowed opacity-60 flex items-center justify-center gap-1"
|
||||
>
|
||||
Coming Soon
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Custom API Card */}
|
||||
<div className="bg-surface border border-border hover:border-primary/50 rounded-xl p-4 transition-all shadow-2xs group flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary font-bold">
|
||||
<Code2 className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded-full">
|
||||
Custom Wizard
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-foreground text-sm group-hover:text-primary transition-colors">Custom API / Webhook</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
Configure multi-step generic REST/GraphQL endpoints with custom headers and transformations.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelectCustomApi}
|
||||
className="mt-4 w-full py-2 px-3 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-2xs flex items-center justify-center gap-1.5 cursor-pointer transition-colors"
|
||||
>
|
||||
Custom Wizard <ArrowRight className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,248 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Eye, EyeOff, Key, Globe, Zap, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { useIntegration } from '../hook/useIntegration';
|
||||
import { integrationsService } from '../services/integrations.service';
|
||||
|
||||
interface ShopifyCredentialCardProps {
|
||||
integrationId: string;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
export const ShopifyCredentialCard: React.FC<ShopifyCredentialCardProps> = ({ integrationId, onSaved }) => {
|
||||
const [authMode, setAuthMode] = useState<'private_app' | 'custom_app'>('private_app');
|
||||
const [shopDomain, setShopDomain] = useState('');
|
||||
|
||||
// Private app fields
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [apiSecret, setApiSecret] = useState('');
|
||||
const [storefrontToken, setStorefrontToken] = useState('');
|
||||
|
||||
// Custom app token
|
||||
const [accessToken, setAccessToken] = useState('');
|
||||
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const [testResult, setTestResult] = useState<any>(null);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
const [fetchingCreds, setFetchingCreds] = useState(false);
|
||||
|
||||
const { setCredentials, testConnection, loading, testingConnection } = useIntegration();
|
||||
|
||||
// Pre-fill existing credentials for this specific integration
|
||||
useEffect(() => {
|
||||
if (!integrationId) return;
|
||||
setFetchingCreds(true);
|
||||
integrationsService.getCredentials(integrationId)
|
||||
.then(creds => {
|
||||
if (creds.shop_domain) setShopDomain(creds.shop_domain);
|
||||
if (creds.api_key) setApiKey(creds.api_key);
|
||||
if (creds.api_secret_key) setApiSecret(creds.api_secret_key);
|
||||
if (creds.access_token) {
|
||||
setAccessToken(creds.access_token);
|
||||
if (creds.access_token.startsWith('shpat_')) {
|
||||
setAuthMode('custom_app');
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setFetchingCreds(false));
|
||||
}, [integrationId]);
|
||||
|
||||
const handleSaveCredentials = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!shopDomain) return;
|
||||
|
||||
try {
|
||||
await setCredentials(integrationId, 'shop_domain', shopDomain.trim());
|
||||
|
||||
if (authMode === 'private_app') {
|
||||
if (apiKey) await setCredentials(integrationId, 'api_key', apiKey.trim());
|
||||
if (apiSecret) await setCredentials(integrationId, 'api_secret_key', apiSecret.trim());
|
||||
if (storefrontToken) await setCredentials(integrationId, 'access_token', storefrontToken.trim());
|
||||
} else {
|
||||
if (accessToken) await setCredentials(integrationId, 'access_token', accessToken.trim());
|
||||
}
|
||||
|
||||
if (onSaved) onSaved();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setTestResult(null);
|
||||
setTestError(null);
|
||||
try {
|
||||
const res = await testConnection(integrationId);
|
||||
setTestResult(res);
|
||||
} catch (err: any) {
|
||||
setTestError(err?.response?.data?.message || err?.message || 'Connection failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-surface border border-border rounded-xl p-6 shadow-sm space-y-5">
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-emerald-50 rounded-lg text-emerald-600">
|
||||
<Globe className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-foreground text-sm flex items-center gap-2">
|
||||
Shopify Admin API Credentials
|
||||
{fetchingCreds && <Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">Configure credentials for GraphQL product syndication</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-2 py-0.5 text-[10px] font-bold bg-primary/10 text-primary rounded">GraphQL Admin 2025-01</span>
|
||||
</div>
|
||||
|
||||
{/* Auth Mode Toggle */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-2">Authentication Mode</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className={`p-2.5 border rounded-lg cursor-pointer text-xs transition-all ${authMode === 'private_app' ? 'border-primary bg-primary/5 text-primary font-bold' : 'border-border bg-background text-muted-foreground'}`}>
|
||||
<input type="radio" name="credAuthMode" className="sr-only" checked={authMode === 'private_app'} onChange={() => setAuthMode('private_app')} />
|
||||
Private App (API Key + Secret)
|
||||
</label>
|
||||
<label className={`p-2.5 border rounded-lg cursor-pointer text-xs transition-all ${authMode === 'custom_app' ? 'border-primary bg-primary/5 text-primary font-bold' : 'border-border bg-background text-muted-foreground'}`}>
|
||||
<input type="radio" name="credAuthMode" className="sr-only" checked={authMode === 'custom_app'} onChange={() => setAuthMode('custom_app')} />
|
||||
Custom App (shpat_ Token)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveCredentials} className="space-y-4">
|
||||
{/* Shop Domain */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">
|
||||
Store Domain <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="9xarg3-gj.myshopify.com"
|
||||
value={shopDomain}
|
||||
onChange={(e) => setShopDomain(e.target.value.replace(/^https?:\/\//, '').replace(/\/$/, ''))}
|
||||
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 pl-8 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
<Globe className="w-4 h-4 text-muted-foreground absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Private App Fields */}
|
||||
{authMode === 'private_app' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">
|
||||
API Key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="6ed762d39bbeb6eef41669057976b331"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">
|
||||
API Secret Key (Password) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
placeholder="API Secret / shpss_ token as password"
|
||||
value={apiSecret}
|
||||
onChange={(e) => setApiSecret(e.target.value)}
|
||||
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 pl-8 pr-10 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<Key className="w-4 h-4 text-muted-foreground absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
<button type="button" onClick={() => setShowSecret(v => !v)} className="absolute right-2.5 top-1/2 -translate-y-1/2 cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
{showSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
For private apps: use the Shopify <strong>API secret key</strong> or <strong>shpss_ storefront token</strong> as password for Basic Auth
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">
|
||||
Storefront Token (Optional, shpss_...)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="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"
|
||||
value={storefrontToken}
|
||||
onChange={(e) => setStorefrontToken(e.target.value)}
|
||||
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Custom App Token */}
|
||||
{authMode === 'custom_app' && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">
|
||||
Admin API Access Token <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
placeholder="shpat_xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
value={accessToken}
|
||||
onChange={(e) => setAccessToken(e.target.value)}
|
||||
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 pl-8 pr-10 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<Key className="w-4 h-4 text-muted-foreground absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
<button type="button" onClick={() => setShowSecret(v => !v)} className="absolute right-2.5 top-1/2 -translate-y-1/2 cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
{showSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">Stored using AES-256-GCM encryption</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Result */}
|
||||
{testResult?.connected && (
|
||||
<div className="p-3 bg-emerald-50 border border-emerald-200 rounded-lg flex items-start gap-2 text-xs text-emerald-800 font-medium">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span>Connected to <strong>{testResult.shopName}</strong></span>
|
||||
{testResult.plan && <span className="ml-1 text-emerald-700">· {testResult.plan}</span>}
|
||||
{testResult.email && <div className="text-[11px] mt-0.5">{testResult.email}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{testError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg flex items-start gap-2 text-xs text-red-800">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
|
||||
<span>{testError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold shadow-sm flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
Save Credentials
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testingConnection}
|
||||
className="px-4 py-2 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-sm flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{testingConnection ? <Loader2 className="w-3.5 h-3.5 animate-spin text-primary" /> : <Zap className="w-3.5 h-3.5 text-amber-500" />}
|
||||
Test Connection
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,370 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, ShoppingCart, Zap, Key, Globe, Loader2, CheckCircle2, AlertCircle, Eye, EyeOff, ExternalLink, ArrowLeft } from 'lucide-react';
|
||||
import { useIntegration } from '../hook/useIntegration';
|
||||
import { integrationsService } from '../services/integrations.service';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
interface ShopifyTemplateModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const ShopifyTemplateModal: React.FC<ShopifyTemplateModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess
|
||||
}) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [name, setName] = useState('Shopify Main Store');
|
||||
const [shopDomain, setShopDomain] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [apiSecret, setApiSecret] = useState('');
|
||||
const [syncMode, setSyncMode] = useState<'auto' | 'manual'>('manual');
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
|
||||
const [savedIntegrationId, setSavedIntegrationId] = useState<string | null>(null);
|
||||
const [credentialsSaved, setCredentialsSaved] = useState(false);
|
||||
const [oauthConnecting, setOauthConnecting] = useState(false);
|
||||
const [oauthSuccess, setOauthSuccess] = useState(false);
|
||||
const [testResult, setTestResult] = useState<any>(null);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
const [savingCreds, setSavingCreds] = useState(false);
|
||||
|
||||
const { createItem, setCredentials, testConnection, testingConnection } = useIntegration();
|
||||
|
||||
// Handle OAuth callback redirect back from Shopify
|
||||
useEffect(() => {
|
||||
const oauthStatus = searchParams.get('oauth');
|
||||
const intId = searchParams.get('integrationId');
|
||||
const shop = searchParams.get('shop');
|
||||
if (oauthStatus === 'success' && intId) {
|
||||
setCredentialsSaved(true);
|
||||
setOauthSuccess(true);
|
||||
setSavedIntegrationId(intId);
|
||||
if (shop) setShopDomain(shop);
|
||||
onSuccess?.();
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSaveCredentials = async () => {
|
||||
if (!name || !shopDomain || !apiKey || !apiSecret) return;
|
||||
setSavingCreds(true);
|
||||
try {
|
||||
let cleanDomain = shopDomain.trim().replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
let cleanKey = apiKey.trim();
|
||||
let cleanSecret = apiSecret.trim();
|
||||
|
||||
// Auto-correct if user accidentally swapped shop domain and API key
|
||||
if (cleanKey.includes('.myshopify.com') && !cleanDomain.includes('.myshopify.com')) {
|
||||
const temp = cleanDomain;
|
||||
cleanDomain = cleanKey;
|
||||
cleanKey = temp;
|
||||
setShopDomain(cleanDomain);
|
||||
setApiKey(cleanKey);
|
||||
}
|
||||
|
||||
if (cleanDomain && !cleanDomain.includes('.')) {
|
||||
cleanDomain = `${cleanDomain}.myshopify.com`;
|
||||
setShopDomain(cleanDomain);
|
||||
}
|
||||
|
||||
// Step 1: Create or reuse integration record
|
||||
let integrationId = savedIntegrationId;
|
||||
if (!integrationId) {
|
||||
const created = await createItem({
|
||||
name,
|
||||
channel: 'shopify',
|
||||
integration_type: 'ecommerce',
|
||||
sync_mode: syncMode,
|
||||
sync_frequency: syncMode === 'auto' ? 'realtime' : 'manual',
|
||||
status: 'pending'
|
||||
});
|
||||
integrationId = created.id;
|
||||
setSavedIntegrationId(integrationId);
|
||||
}
|
||||
|
||||
// Step 2: Store credentials encrypted
|
||||
await setCredentials(integrationId!, 'shop_domain', cleanDomain);
|
||||
await setCredentials(integrationId!, 'api_key', cleanKey);
|
||||
await setCredentials(integrationId!, 'api_secret_key', cleanSecret);
|
||||
|
||||
setCredentialsSaved(true);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSavingCreds(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartOAuth = async () => {
|
||||
if (!savedIntegrationId) return;
|
||||
setOauthConnecting(true);
|
||||
try {
|
||||
const result = await integrationsService.startShopifyOAuth(savedIntegrationId);
|
||||
// Open Shopify auth page in new tab
|
||||
window.open(result.authorizationUrl, '_blank', 'width=1000,height=700,scrollbars=yes');
|
||||
} catch (err: any) {
|
||||
setTestError(err?.response?.data?.message || err?.message || 'Failed to start OAuth');
|
||||
} finally {
|
||||
setOauthConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!savedIntegrationId) return;
|
||||
setTestResult(null);
|
||||
setTestError(null);
|
||||
try {
|
||||
const res = await testConnection(savedIntegrationId);
|
||||
setTestResult(res);
|
||||
} catch (err: any) {
|
||||
setTestError(err?.response?.data?.message || err?.message || 'Connection test failed');
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setName('Shopify Main Store');
|
||||
setShopDomain('');
|
||||
setApiKey('');
|
||||
setApiSecret('');
|
||||
setSavedIntegrationId(null);
|
||||
setCredentialsSaved(false);
|
||||
setOauthSuccess(false);
|
||||
setTestResult(null);
|
||||
setTestError(null);
|
||||
};
|
||||
|
||||
const step = !credentialsSaved ? 1 : !oauthSuccess ? 2 : 3;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="bg-surface border border-border rounded-xl shadow-2xl w-full max-w-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-emerald-600 to-teal-700 p-5 text-white relative">
|
||||
<button type="button" onClick={() => { onClose(); resetForm(); }} className="absolute top-4 right-4 text-white/80 hover:text-white p-1 rounded-lg hover:bg-white/10 cursor-pointer">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-11 h-11 rounded-xl bg-white/10 border border-white/20 flex items-center justify-center">
|
||||
<ShoppingCart className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold">Shopify Integration Setup</h2>
|
||||
<p className="text-xs text-white/70">Partners Dashboard OAuth 2.0 · Admin API 2025-01</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="flex items-center border-b border-border px-6 pt-4 pb-3 gap-0">
|
||||
{[
|
||||
{ n: 1, label: 'Store Details & Keys' },
|
||||
{ n: 2, label: 'Authorize via OAuth' },
|
||||
{ n: 3, label: 'Test & Activate' }
|
||||
].map((s, i) => (
|
||||
<React.Fragment key={s.n}>
|
||||
<div className={`flex items-center gap-1.5 ${step >= s.n ? 'text-primary' : 'text-muted-foreground'}`}>
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-bold border-2 ${step > s.n ? 'bg-primary border-primary text-white' : step === s.n ? 'border-primary text-primary' : 'border-border text-muted-foreground'}`}>
|
||||
{step > s.n ? <CheckCircle2 className="w-3.5 h-3.5" /> : s.n}
|
||||
</div>
|
||||
<span className="text-xs font-medium hidden sm:block">{s.label}</span>
|
||||
</div>
|
||||
{i < 2 && <div className={`flex-1 h-px mx-3 ${step > s.n ? 'bg-primary' : 'bg-border'}`} />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4 overflow-y-auto max-h-[65vh]">
|
||||
{/* Step 1: Store Details */}
|
||||
{step === 1 && (
|
||||
<>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-xs text-blue-900">
|
||||
<p className="font-semibold mb-1">📍 Finding your Client ID & Client Secret:</p>
|
||||
<p>Go to <a href="https://partners.shopify.com" target="_blank" rel="noreferrer" className="underline font-bold">partners.shopify.com</a> → <strong>Apps</strong> → Select your app (<strong>PIM Integration</strong>) → <strong>App setup</strong> → Copy the <strong>Client ID</strong> and <strong>Client secret</strong> under <i>API credentials</i>.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">Integration Name <span className="text-red-500">*</span></label>
|
||||
<input type="text" value={name} onChange={e => setName(e.target.value)} className="w-full text-sm bg-background border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary" placeholder="Shopify Main Store" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">Shop Domain <span className="text-red-500">*</span></label>
|
||||
<div className="relative">
|
||||
<Globe className="w-4 h-4 text-muted-foreground absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="maskcomerce.myshopify.com"
|
||||
value={shopDomain}
|
||||
onChange={e => setShopDomain(e.target.value)}
|
||||
className="w-full text-sm font-mono bg-background border border-border rounded-lg px-3 py-2 pl-9 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">API Key (Client ID) <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="6ed762d39bbeb6eef416..."
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">API Secret (Client Secret) <span className="text-red-500">*</span></label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
placeholder="shpss_9dc647b3cd13de8..."
|
||||
value={apiSecret}
|
||||
onChange={e => setApiSecret(e.target.value)}
|
||||
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 pr-9 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
<button type="button" onClick={() => setShowSecret(v => !v)} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer">
|
||||
{showSecret ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-2">Sync Mode</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(['manual', 'auto'] as const).map(mode => (
|
||||
<label key={mode} className={`p-3 border rounded-lg cursor-pointer text-xs transition-all ${syncMode === mode ? 'border-primary bg-primary/5 text-primary font-bold' : 'border-border bg-background text-muted-foreground'}`}>
|
||||
<input type="radio" name="syncMode" className="sr-only" checked={syncMode === mode} onChange={() => setSyncMode(mode)} />
|
||||
{mode === 'manual' ? 'Manual Trigger' : 'Automatic (Outbox)'}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveCredentials}
|
||||
disabled={savingCreds || !shopDomain || !apiKey || !apiSecret || !name}
|
||||
className="w-full py-2.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold shadow-sm flex items-center justify-center gap-2 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{savingCreds && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Save & Continue to Authorize
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 2: OAuth Authorization */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 text-sm text-amber-900">
|
||||
<p className="font-bold mb-2 flex items-center gap-2">
|
||||
<ExternalLink className="w-4 h-4" /> Allowed Redirection URLs in Partners Dashboard:
|
||||
</p>
|
||||
<ol className="list-decimal list-inside space-y-1.5 text-xs">
|
||||
<li>Go to your <strong>Shopify Partners Dashboard</strong> → Apps → <strong>PIM Integration</strong> → App setup</li>
|
||||
<li>Under <strong>"Allowed redirection URL(s)"</strong>, add this URL:
|
||||
<code className="bg-amber-100 rounded px-1 py-0.5 text-[11px] font-mono block mt-1 font-bold">http://localhost:5002/api/v1/integrations/shopify/oauth/callback</code>
|
||||
<span className="text-[11px] text-amber-800 block mt-0.5">(If using port 5000, add <code>http://localhost:5000/api/v1/integrations/shopify/oauth/callback</code> as well)</span>
|
||||
</li>
|
||||
<li>Click <strong>Save</strong> in Partners Dashboard, then click Authorize below.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="bg-background border border-border rounded-xl p-4 space-y-2 text-xs">
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Shop:</span><span className="font-mono font-semibold">{shopDomain}</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">API Key:</span><span className="font-mono">{apiKey.slice(0, 12)}...</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Scopes:</span><span className="text-emerald-700">read/write_product_feeds, read/write_product_listings, read/write_products</span></div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCredentialsSaved(false)}
|
||||
className="px-4 py-3 bg-surface border border-border hover:bg-background text-foreground rounded-xl text-xs font-semibold flex items-center justify-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Edit Details & Keys
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStartOAuth}
|
||||
disabled={oauthConnecting}
|
||||
className="flex-1 py-3 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl text-sm font-bold shadow-sm flex items-center justify-center gap-2 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{oauthConnecting ? <Loader2 className="w-4 h-4 animate-spin" /> : <ExternalLink className="w-4 h-4" />}
|
||||
Authorize Shopify Access
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
After authorizing in the new tab, this modal will automatically update.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Connected — Test + Done */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-emerald-50 border border-emerald-200 rounded-xl flex items-center gap-3 text-emerald-800">
|
||||
<CheckCircle2 className="w-7 h-7 text-emerald-600 shrink-0" />
|
||||
<div>
|
||||
<p className="font-bold text-sm">Shopify Access Authorized!</p>
|
||||
<p className="text-xs mt-0.5">Access token saved securely. Your store is ready for product syndication.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(testResult || testError) && (
|
||||
<div className={`p-3 rounded-lg flex items-start gap-2 text-xs font-medium border ${testResult?.connected ? 'bg-emerald-50 border-emerald-200 text-emerald-800' : 'bg-red-50 border-red-200 text-red-800'}`}>
|
||||
{testResult?.connected
|
||||
? <CheckCircle2 className="w-4 h-4 text-emerald-600 shrink-0 mt-0.5" />
|
||||
: <AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
|
||||
}
|
||||
<div>
|
||||
{testResult?.connected
|
||||
? <><strong>{testResult.shopName}</strong>{testResult.plan && ` · ${testResult.plan}`}{testResult.email && <div className="text-[11px] mt-0.5">{testResult.email}</div>}</>
|
||||
: testError
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOauthSuccess(false); setCredentialsSaved(false); }}
|
||||
className="px-3 py-2.5 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" /> Re-configure
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testingConnection}
|
||||
className="flex-1 py-2.5 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold flex items-center justify-center gap-1.5 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{testingConnection ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Zap className="w-3.5 h-3.5 text-amber-500" />}
|
||||
Test Connection
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onSuccess?.(); onClose(); resetForm(); }}
|
||||
className="flex-1 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold flex items-center justify-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" /> Done — View Integrations
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { X, CheckCircle2, AlertTriangle, Clock, RefreshCw, Layers } from 'lucide-react';
|
||||
import { useIntegration } from '../hook/useIntegration';
|
||||
import type { SyncItem } from '../types/integrations.types';
|
||||
|
||||
interface SyncJobStatusModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
jobId: string;
|
||||
integrationName?: string;
|
||||
}
|
||||
|
||||
export const SyncJobStatusModal: React.FC<SyncJobStatusModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
jobId,
|
||||
integrationName
|
||||
}) => {
|
||||
const [items, setItems] = useState<SyncItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { getSyncItems } = useIntegration();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && jobId) {
|
||||
setLoading(true);
|
||||
getSyncItems(jobId)
|
||||
.then(res => setItems(res))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [isOpen, jobId, getSyncItems]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const total = items.length;
|
||||
const successCount = items.filter(i => i.status === 'success').length;
|
||||
const failedCount = items.filter(i => i.status === 'failed').length;
|
||||
const pendingCount = items.filter(i => i.status === 'pending' || i.status === 'processing').length;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-xs flex items-center justify-center p-4">
|
||||
<div className="bg-surface border border-border rounded-xl shadow-xl w-full max-w-3xl overflow-hidden flex flex-col max-h-[85vh] animate-scale-in">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between bg-background/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<h3 className="font-bold text-foreground text-sm">Sync Execution Telemetry</h3>
|
||||
<p className="text-xs text-muted-foreground">{integrationName || 'Integration Sync Run'} • Job #{jobId.slice(0, 8)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-background rounded-lg text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats summary */}
|
||||
<div className="grid grid-cols-4 gap-3 p-4 bg-background border-b border-border text-center text-xs">
|
||||
<div className="p-2.5 bg-surface border border-border rounded-lg">
|
||||
<span className="text-muted-foreground font-semibold block">Total Scope</span>
|
||||
<span className="text-sm font-bold text-foreground">{total}</span>
|
||||
</div>
|
||||
<div className="p-2.5 bg-emerald-50 border border-emerald-200 rounded-lg">
|
||||
<span className="text-emerald-700 font-semibold block">Successful</span>
|
||||
<span className="text-sm font-bold text-emerald-800">{successCount}</span>
|
||||
</div>
|
||||
<div className="p-2.5 bg-red-50 border border-red-200 rounded-lg">
|
||||
<span className="text-red-700 font-semibold block">Failed</span>
|
||||
<span className="text-sm font-bold text-red-800">{failedCount}</span>
|
||||
</div>
|
||||
<div className="p-2.5 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<span className="text-blue-700 font-semibold block">In Progress</span>
|
||||
<span className="text-sm font-bold text-blue-800">{pendingCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Item table */}
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10 text-xs text-muted-foreground gap-2">
|
||||
<RefreshCw className="w-4 h-4 animate-spin text-primary" />
|
||||
<span>Fetching telemetry items...</span>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-10 text-xs text-muted-foreground">
|
||||
No sync items logged for this job run yet.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground font-semibold">
|
||||
<th className="py-2 px-3">Product Name & SKU</th>
|
||||
<th className="py-2 px-3">Operation</th>
|
||||
<th className="py-2 px-3">Status</th>
|
||||
<th className="py-2 px-3 text-center">Attempts</th>
|
||||
<th className="py-2 px-3 text-right">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border font-medium">
|
||||
{items.map((item: any) => (
|
||||
<tr key={item.id} className="hover:bg-background/50 transition-colors">
|
||||
<td className="py-2.5 px-3">
|
||||
<div className="font-bold text-foreground">{item.product?.name || `Product #${item.product_id.slice(0, 8)}`}</div>
|
||||
<div className="text-[11px] font-mono text-muted-foreground">{item.sku || item.product?.sku || item.product_id}</div>
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono font-bold bg-surface border border-border text-foreground">
|
||||
{item.operation}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
{item.status === 'success' ? (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-600 font-bold text-[11px]">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" /> Success
|
||||
</span>
|
||||
) : item.status === 'failed' ? (
|
||||
<span className="inline-flex items-center gap-1 text-red-600 font-bold text-[11px]">
|
||||
<AlertTriangle className="w-3.5 h-3.5" /> Failed
|
||||
</span>
|
||||
) : item.status === 'skipped' ? (
|
||||
<span className="inline-flex items-center gap-1 text-amber-600 font-bold text-[11px]">
|
||||
<Clock className="w-3.5 h-3.5" /> Skipped
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-blue-600 font-bold text-[11px]">
|
||||
<Clock className="w-3.5 h-3.5 animate-spin" /> {item.status}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 font-mono text-center">{item.attempt_count}</td>
|
||||
<td className="py-2.5 px-3 text-right text-muted-foreground truncate max-w-[220px]" title={item.error_message || 'Synced'}>
|
||||
{item.error_message ? <span className="text-red-500 font-semibold">{item.error_message}</span> : <span className="text-emerald-600 font-semibold">Synced to Store</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-3 border-t border-border bg-background/50 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-2xs cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,159 +1,126 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { RefreshCw, Download } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCw, Download, Layers } from "lucide-react";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { StatusBadge, type BadgeVariant } from "../../../components/customs/StatusBadge";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useIntegration } from "../hook/useIntegration";
|
||||
import { SyncJobStatusModal } from "./SyncJobStatusModal";
|
||||
import type { SyncJob } from "../types/integrations.types";
|
||||
|
||||
const MOCK_JOBS = [
|
||||
{
|
||||
id: "JOB-2891",
|
||||
integration: "Amazon India",
|
||||
type: "Full Sync",
|
||||
records: "4,240",
|
||||
success: "4,237",
|
||||
failed: "3",
|
||||
status: "Completed",
|
||||
started: "2025-06-09 14:00",
|
||||
completed: "2025-06-09 14:32",
|
||||
duration: "32m 14s",
|
||||
triggered: "Scheduled"
|
||||
},
|
||||
{
|
||||
id: "JOB-2892",
|
||||
integration: "Shopify Main Store",
|
||||
type: "Delta Sync",
|
||||
records: "128",
|
||||
success: "128",
|
||||
failed: "0",
|
||||
status: "Running",
|
||||
started: "2025-06-09 14:30",
|
||||
completed: "",
|
||||
duration: "In progress",
|
||||
triggered: "Realtime trigger"
|
||||
},
|
||||
{
|
||||
id: "JOB-2890",
|
||||
integration: "Amazon UAE",
|
||||
type: "Full Sync",
|
||||
records: "1,840",
|
||||
success: "1,840",
|
||||
failed: "0",
|
||||
status: "Completed",
|
||||
started: "2025-06-09 13:00",
|
||||
completed: "2025-06-09 13:15",
|
||||
duration: "15m 02s",
|
||||
triggered: "Scheduled"
|
||||
},
|
||||
{
|
||||
id: "JOB-2889",
|
||||
integration: "Warehouse WMS",
|
||||
type: "Inventory Pull",
|
||||
records: "284",
|
||||
success: "0",
|
||||
failed: "284",
|
||||
status: "Failed",
|
||||
started: "2025-06-08 08:00",
|
||||
completed: "2025-06-08 08:03",
|
||||
duration: "3m 12s",
|
||||
triggered: "Scheduled"
|
||||
},
|
||||
{
|
||||
id: "JOB-2888",
|
||||
integration: "Retail POS Network",
|
||||
type: "Catalogue Sync",
|
||||
records: "3,240",
|
||||
success: "3,240",
|
||||
failed: "0",
|
||||
status: "Completed",
|
||||
started: "2025-06-09 12:00",
|
||||
completed: "2025-06-09 12:18",
|
||||
duration: "18m 40s",
|
||||
triggered: "Scheduled"
|
||||
},
|
||||
{
|
||||
id: "JOB-2887",
|
||||
integration: "Amazon India",
|
||||
type: "Price Update",
|
||||
records: "521",
|
||||
success: "521",
|
||||
failed: "0",
|
||||
status: "Cancelled",
|
||||
started: "2025-06-08 18:00",
|
||||
completed: "2025-06-08 18:01",
|
||||
duration: "1m 04s",
|
||||
triggered: "Manual"
|
||||
},
|
||||
{
|
||||
id: "JOB-2892",
|
||||
integration: "Shopify Main Store",
|
||||
type: "Realtime Sync",
|
||||
records: "128",
|
||||
success: "128",
|
||||
failed: "0",
|
||||
status: "Completed",
|
||||
started: "2026-08-30 04:30",
|
||||
completed: "2026-08-30 04:31",
|
||||
duration: "1m 02s",
|
||||
triggered: "Outbox Trigger"
|
||||
}
|
||||
];
|
||||
|
||||
export default function SyncJobsList() {
|
||||
const navigate = useNavigate();
|
||||
const [jobs, setJobs] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
|
||||
|
||||
const columns = [
|
||||
{ key: "id", label: "JOB ID", render: (val: string) => <span className="font-mono text-primary font-medium">{val}</span> },
|
||||
{ key: "integration", label: "INTEGRATION" },
|
||||
{ key: "type", label: "JOB TYPE" },
|
||||
{
|
||||
key: "records",
|
||||
label: "RECORDS",
|
||||
render: (_: any, row: any) => (
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold text-foreground">{row.records}</span>
|
||||
{row.failed && parseInt(row.failed) > 0 && (
|
||||
<span className="text-red-600 text-xs ml-1">({row.failed} failed)</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "STATUS",
|
||||
render: (val: string) => {
|
||||
let variant: BadgeVariant = "neutral";
|
||||
if (val === "Completed") variant = "success";
|
||||
if (val === "Running") variant = "warning";
|
||||
if (val === "Failed") variant = "error";
|
||||
if (val === "Cancelled") variant = "neutral";
|
||||
const { getAllSyncJobs } = useIntegration();
|
||||
|
||||
return <StatusBadge status={variant} label={val} />;
|
||||
},
|
||||
},
|
||||
{ key: "started", label: "STARTED AT" },
|
||||
{ key: "completed", label: "COMPLETED AT" },
|
||||
{ key: "duration", label: "DURATION" },
|
||||
{ key: "triggered", label: "TRIGGERED BY" },
|
||||
];
|
||||
const fetchJobs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await getAllSyncJobs();
|
||||
const mapped = list.map((j: any) => ({
|
||||
id: j.id,
|
||||
integration: j.integration?.name || 'Shopify Store',
|
||||
type: j.trigger_source === 'outbox' ? 'Realtime Outbox' : 'Manual Trigger',
|
||||
records: String(j.total_items || 0),
|
||||
success: String(j.success_items || 0),
|
||||
failed: String(j.failed_items || 0),
|
||||
status: j.status === 'completed' ? 'Completed' : j.status === 'failed' ? 'Failed' : 'Running',
|
||||
started: j.started_at ? new Date(j.started_at).toLocaleString() : '—',
|
||||
completed: j.completed_at ? new Date(j.completed_at).toLocaleString() : '—',
|
||||
duration: j.completed_at && j.started_at
|
||||
? `${Math.round((new Date(j.completed_at).getTime() - new Date(j.started_at).getTime()) / 1000)}s`
|
||||
: 'In progress',
|
||||
triggered: j.trigger_source || 'manual'
|
||||
}));
|
||||
setJobs(mapped);
|
||||
} catch (err) {
|
||||
console.error('Failed to load sync jobs:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
useEffect(() => {
|
||||
fetchJobs();
|
||||
}, []);
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={MOCK_JOBS}
|
||||
actionConfig={{
|
||||
onView: (row) => navigate(`${row.id}/view`),
|
||||
}}
|
||||
searchPlaceholder="Search jobs..."
|
||||
toolbarLeft={
|
||||
<div className="flex gap-2">
|
||||
<select className="h-9 px-3 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary bg-surface">
|
||||
<option>All Statuses</option>
|
||||
<option>Completed</option>
|
||||
<option>Running</option>
|
||||
<option>Failed</option>
|
||||
</select>
|
||||
<Button variant="outline" className="flex items-center gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Refresh Jobs
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
toolbarRight={
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export Log
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
const columns = [
|
||||
{ key: "id", label: "JOB ID", render: (val: string) => <span className="font-mono text-primary font-medium">{val.slice(0, 8)}</span> },
|
||||
{ key: "integration", label: "INTEGRATION" },
|
||||
{ key: "type", label: "JOB TYPE" },
|
||||
{
|
||||
key: "records",
|
||||
label: "RECORDS",
|
||||
render: (_: any, row: any) => (
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold text-foreground">{row.records}</span>
|
||||
{row.failed && parseInt(row.failed) > 0 && (
|
||||
<span className="text-red-600 text-xs ml-1">({row.failed} failed)</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "STATUS",
|
||||
render: (val: string) => {
|
||||
let variant: BadgeVariant = "neutral";
|
||||
if (val === "Completed" || val === "completed") variant = "success";
|
||||
if (val === "Running" || val === "pending" || val === "processing") variant = "warning";
|
||||
if (val === "Failed" || val === "failed") variant = "error";
|
||||
|
||||
return <StatusBadge status={variant} label={val} />;
|
||||
},
|
||||
},
|
||||
{ key: "started", label: "STARTED AT" },
|
||||
{ key: "completed", label: "COMPLETED AT" },
|
||||
{ key: "duration", label: "DURATION" },
|
||||
{ key: "triggered", label: "TRIGGERED BY" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={jobs}
|
||||
actionConfig={{
|
||||
onView: (row) => setSelectedJobId(row.id),
|
||||
}}
|
||||
searchPlaceholder="Search jobs..."
|
||||
toolbarLeft={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={fetchJobs} className="flex items-center gap-2">
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh Jobs
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{selectedJobId && (
|
||||
<SyncJobStatusModal
|
||||
isOpen={!!selectedJobId}
|
||||
onClose={() => setSelectedJobId(null)}
|
||||
jobId={selectedJobId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { integrationsService } from '../services/integrations.service';
|
||||
import type { Integration, IntegrationCreateRequest, IntegrationUpdateRequest } from '../types/integrations.types';
|
||||
import type {
|
||||
Integration,
|
||||
IntegrationCreateRequest,
|
||||
IntegrationUpdateRequest,
|
||||
TestConnectionResult,
|
||||
SyncJob,
|
||||
SyncItem
|
||||
} from '../types/integrations.types';
|
||||
import { notify } from '../../../services/toast';
|
||||
|
||||
export const useIntegration = () => {
|
||||
const [items, setItems] = useState<Integration[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testingConnection, setTestingConnection] = useState(false);
|
||||
const [triggeringSync, setTriggeringSync] = useState(false);
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -63,5 +72,91 @@ export const useIntegration = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { items, loading, fetchItems, createItem, updateItem, deleteItem };
|
||||
const testConnection = useCallback(async (id: string): Promise<TestConnectionResult> => {
|
||||
setTestingConnection(true);
|
||||
try {
|
||||
const res = await integrationsService.testConnection(id);
|
||||
if (res.connected) {
|
||||
notify.success(`Connected to Shopify store: ${res.shopName || res.shopDomain}`);
|
||||
}
|
||||
return res;
|
||||
} catch (err: any) {
|
||||
notify.error(err?.message || 'Failed to connect to Shopify store');
|
||||
throw err;
|
||||
} finally {
|
||||
setTestingConnection(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setCredentials = useCallback(async (id: string, type: string, value: string, expiresAt?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await integrationsService.setCredentials(id, type, value, expiresAt);
|
||||
notify.success('Credentials configured securely!');
|
||||
return res;
|
||||
} catch (err) {
|
||||
notify.error(err);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const triggerSync = useCallback(async (id: string, options?: { productId?: string; productIds?: string[] }) => {
|
||||
setTriggeringSync(true);
|
||||
try {
|
||||
const res = await integrationsService.triggerSync(id, options);
|
||||
notify.success(`Sync initialized for ${res?.totalItems || 1} product(s)!`);
|
||||
return res;
|
||||
} catch (err) {
|
||||
notify.error(err);
|
||||
throw err;
|
||||
} finally {
|
||||
setTriggeringSync(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getSyncJobs = useCallback(async (id: string): Promise<SyncJob[]> => {
|
||||
try {
|
||||
return await integrationsService.getSyncJobs(id);
|
||||
} catch (err) {
|
||||
notify.error(err);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getAllSyncJobs = useCallback(async (): Promise<SyncJob[]> => {
|
||||
try {
|
||||
return await integrationsService.getAllSyncJobs();
|
||||
} catch (err) {
|
||||
notify.error(err);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getSyncItems = useCallback(async (jobId: string): Promise<SyncItem[]> => {
|
||||
try {
|
||||
return await integrationsService.getSyncItems(jobId);
|
||||
} catch (err) {
|
||||
notify.error(err);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
testingConnection,
|
||||
triggeringSync,
|
||||
fetchItems,
|
||||
createItem,
|
||||
updateItem,
|
||||
deleteItem,
|
||||
testConnection,
|
||||
setCredentials,
|
||||
triggerSync,
|
||||
getSyncJobs,
|
||||
getAllSyncJobs,
|
||||
getSyncItems
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,14 +9,18 @@ import {
|
||||
Code2,
|
||||
Monitor,
|
||||
Warehouse,
|
||||
Globe
|
||||
Globe,
|
||||
Zap,
|
||||
Play,
|
||||
Key,
|
||||
X,
|
||||
Trash2
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { useIntegration } from "../hook/useIntegration";
|
||||
import { useChannel } from "../../channels/hook/useChannel";
|
||||
@@ -24,15 +28,17 @@ import type { Integration } from "../types/integrations.types";
|
||||
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
|
||||
import { StatsCard } from "../../../components/customs/StatsCard";
|
||||
|
||||
// Import Tab Components
|
||||
// Import Custom Feature Components & Pre-Built Templates
|
||||
import { IntegrationHealthBadge } from "../components/IntegrationHealthBadge";
|
||||
import { ShopifyCredentialCard } from "../components/ShopifyCredentialCard";
|
||||
import { IntegrationTemplateGallery } from "../components/IntegrationTemplateGallery";
|
||||
import { ShopifyTemplateModal } from "../components/ShopifyTemplateModal";
|
||||
import FieldMappingsList from "../components/FieldMappingsTab";
|
||||
import PublishingRulesList from "../components/PublishingRulesTab";
|
||||
import SyncJobsList from "../components/SyncJobsTab";
|
||||
import ErrorCenterList from "../components/ErrorCenterTab";
|
||||
import AuditLogsList from "../components/AuditLogsTab";
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
|
||||
const INTEGRATION_META: Record<string, { label: string; icon: any; color: string; bg: string }> = {
|
||||
ecommerce: { label: "E-Commerce", icon: ShoppingCart, color: "text-blue-600", bg: "bg-blue-50" },
|
||||
marketplace: { label: "Marketplace", icon: ShoppingBag, color: "text-orange-600", bg: "bg-orange-50" },
|
||||
@@ -47,14 +53,23 @@ const INTEGRATION_META: Record<string, { label: string; icon: any; color: string
|
||||
|
||||
export default function IntegrationList() {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<"Connections" | "Publishing Rules" | "Sync Jobs" | "Error Center" | "Audit & Logs">("Connections");
|
||||
const [searchParams] = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState<"Connections" | "Field Mappings" | "Publishing Rules" | "Sync Jobs" | "Error Center" | "Audit & Logs">("Connections");
|
||||
const [statusFilter, setStatusFilter] = useState("All Status");
|
||||
const [typeFilter, setTypeFilter] = useState("All Types");
|
||||
|
||||
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: "", name: "" });
|
||||
const [shopifyModalOpen, setShopifyModalOpen] = useState(false);
|
||||
const [credentialModal, setCredentialModal] = useState<{ isOpen: boolean; integrationId: string; name: string }>({
|
||||
isOpen: false,
|
||||
integrationId: "",
|
||||
name: ""
|
||||
});
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [testingId, setTestingId] = useState<string | null>(null);
|
||||
const [syncingId, setSyncingId] = useState<string | null>(null);
|
||||
|
||||
const { items, fetchItems, loading, deleteItem } = useIntegration();
|
||||
const { items, fetchItems, loading, deleteItem, testConnection, triggerSync } = useIntegration();
|
||||
const { items: channels, fetchItems: fetchChannels } = useChannel();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -62,6 +77,13 @@ export default function IntegrationList() {
|
||||
fetchChannels();
|
||||
}, [fetchItems, fetchChannels]);
|
||||
|
||||
// Automatically open Shopify modal into Step 3 when returning from OAuth redirect
|
||||
useEffect(() => {
|
||||
if (searchParams.get('oauth') === 'success') {
|
||||
setShopifyModalOpen(true);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteModal.id) return;
|
||||
setIsDeleting(true);
|
||||
@@ -75,13 +97,37 @@ export default function IntegrationList() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnectionClick = async (id: string) => {
|
||||
setTestingId(id);
|
||||
try {
|
||||
await testConnection(id);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTriggerSyncClick = async (id: string) => {
|
||||
setSyncingId(id);
|
||||
try {
|
||||
await triggerSync(id);
|
||||
setActiveTab("Sync Jobs");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSyncingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "name",
|
||||
label: "Integration Name",
|
||||
sortable: true,
|
||||
render: (_: any, row: Integration) => {
|
||||
const meta = INTEGRATION_META[row.integrationType] || INTEGRATION_META.custom_api;
|
||||
const metaType = row.integrationType || 'ecommerce';
|
||||
const meta = INTEGRATION_META[metaType] || INTEGRATION_META.ecommerce;
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -90,7 +136,7 @@ export default function IntegrationList() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{row.name}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{row.description || "No description provided"}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{row.channel ? `Channel: ${row.channel.toUpperCase()}` : "No description provided"}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -101,61 +147,77 @@ export default function IntegrationList() {
|
||||
label: "Channel",
|
||||
render: (val: string) => {
|
||||
const chan = channels.find(c => c.code === val || c.id === val);
|
||||
return <span className="font-medium">{chan ? chan.name : val}</span>;
|
||||
return <span className="font-semibold text-xs uppercase px-2 py-0.5 rounded bg-surface border border-border">{chan ? chan.name : val || 'Shopify'}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "integrationType",
|
||||
label: "Type",
|
||||
render: (val: string) => {
|
||||
const meta = INTEGRATION_META[val] || INTEGRATION_META.custom_api;
|
||||
return <span className="text-xs font-medium text-muted-foreground capitalize">{meta.label}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "environment",
|
||||
label: "Environment",
|
||||
render: (val: string) => <span className="text-blue-600 text-sm font-medium capitalize">{val}</span>,
|
||||
key: "sync_mode",
|
||||
label: "Sync Mode",
|
||||
render: (_: any, row: Integration) => (
|
||||
<span className="text-xs font-mono capitalize text-muted-foreground">{row.sync_mode || row.syncMode || 'auto'}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "Connection Status",
|
||||
render: (val: string) => {
|
||||
const isSuccess = val === "Connected" || val === "active";
|
||||
const isWarning = val === "Pending" || val === "pending";
|
||||
label: "Health Status",
|
||||
render: (_: any, row: Integration) => (
|
||||
<IntegrationHealthBadge status={row.status} healthStatus={row.health_status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "last_synced_at",
|
||||
label: "Last Synced",
|
||||
render: (_: any, row: Integration) => {
|
||||
const ts = row.last_synced_at || row.lastSync;
|
||||
return (
|
||||
<StatusBadge
|
||||
status={isSuccess ? "success" : isWarning ? "warning" : "neutral"}
|
||||
label={val === "active" ? "Connected" : val === "pending" ? "Pending" : val}
|
||||
/>
|
||||
<div className="text-xs text-foreground font-medium">
|
||||
{ts ? new Date(ts).toLocaleString() : 'Not synced yet'}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "lastSync",
|
||||
label: "Last Sync",
|
||||
key: "actions",
|
||||
label: "Actions",
|
||||
render: (_: any, row: Integration) => (
|
||||
<div>
|
||||
<div className="text-foreground font-medium text-sm">{row.lastSync || "—"}</div>
|
||||
{row.syncErrors && row.syncErrors > 0 && (
|
||||
<div className="text-red-600 text-xs mt-0.5">{row.syncErrors} failed</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTestConnectionClick(row.id)}
|
||||
disabled={testingId === row.id}
|
||||
title="Test Connection"
|
||||
className="p-1.5 rounded-lg border border-border bg-surface hover:bg-background text-amber-600 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Zap className={`w-3.5 h-3.5 ${testingId === row.id ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTriggerSyncClick(row.id)}
|
||||
disabled={syncingId === row.id}
|
||||
title="Trigger Manual Sync"
|
||||
className="p-1.5 rounded-lg border border-border bg-surface hover:bg-background text-primary cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Play className={`w-3.5 h-3.5 ${syncingId === row.id ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCredentialModal({ isOpen: true, integrationId: row.id, name: row.name })}
|
||||
title="Configure Credentials"
|
||||
className="p-1.5 rounded-lg border border-border bg-surface hover:bg-background text-foreground cursor-pointer"
|
||||
>
|
||||
<Key className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteModal({ isOpen: true, id: row.id, name: row.name })}
|
||||
title="Delete Integration"
|
||||
className="p-1.5 rounded-lg border border-border bg-surface hover:bg-red-50 hover:border-red-200 text-red-600 cursor-pointer"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "published", label: "Published", render: (val: any) => val || 0 },
|
||||
{
|
||||
key: "createdAt",
|
||||
label: "Created By",
|
||||
render: (_: any, row: Integration) => (
|
||||
<div>
|
||||
<div className="text-foreground text-sm font-medium">{row.author || "Admin"}</div>
|
||||
<div className="text-muted-foreground text-xs mt-0.5">
|
||||
{row.createdAt ? new Date(row.createdAt).toLocaleDateString() : "—"}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const filteredItems = items.filter(item => {
|
||||
@@ -165,7 +227,7 @@ export default function IntegrationList() {
|
||||
(statusFilter === "Disconnected" && (item.status === "Disconnected" || item.status === "inactive"));
|
||||
|
||||
const matchesType = typeFilter === "All Types" ||
|
||||
typeFilter.toLowerCase() === item.integrationType.toLowerCase();
|
||||
(item.integrationType && typeFilter.toLowerCase() === item.integrationType.toLowerCase());
|
||||
|
||||
return matchesStatus && matchesType;
|
||||
});
|
||||
@@ -173,142 +235,165 @@ export default function IntegrationList() {
|
||||
return (
|
||||
<ProtectedRoute node="settings.integrations">
|
||||
<PageWrapper>
|
||||
<Breadcrumb
|
||||
items={[{ label: "Home" }, { label: "Integration Hub" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" className="bg-surface border-border text-foreground hover:bg-background" onClick={fetchItems}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={() => navigate("new")} className="bg-primary hover:bg-primary-hover text-white">
|
||||
<Plus className="w-4 h-4 mr-2" />New Integration
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Breadcrumb
|
||||
items={[{ label: "Home" }, { label: "Integration Hub" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" className="bg-surface border-border text-foreground hover:bg-background" onClick={fetchItems}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={() => setShopifyModalOpen(true)} className="bg-emerald-600 hover:bg-emerald-700 text-white">
|
||||
<Plus className="w-4 h-4 mr-2" />Setup Shopify
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<StatsCard
|
||||
title="Total Integrations"
|
||||
value={items.length}
|
||||
subtitle="All integrations"
|
||||
icon={<Plug className="w-5 h-5" />}
|
||||
color="purple"
|
||||
{/* Quick Pre-Built Template Gallery */}
|
||||
<IntegrationTemplateGallery
|
||||
onSelectShopify={() => setShopifyModalOpen(true)}
|
||||
onSelectCustomApi={() => navigate("new")}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Connected Systems"
|
||||
value={items.filter(i => i.status === "Connected" || i.status === "active").length}
|
||||
subtitle="Healthy"
|
||||
icon={<CheckCircle className="w-5 h-5" />}
|
||||
color="green"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Failed Sync Jobs"
|
||||
value={items.filter(i => i.syncErrors && i.syncErrors > 0).length}
|
||||
subtitle="Need attention"
|
||||
icon={<AlertCircle className="w-5 h-5" />}
|
||||
color="red"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Last Synchronised"
|
||||
value="14:32"
|
||||
subtitle="Today"
|
||||
icon={<Clock className="w-5 h-5" />}
|
||||
color="slate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-xl shadow-sm border border-border mt-6">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border px-6 pt-2">
|
||||
{[
|
||||
{ id: "Connections", count: items.length },
|
||||
{ id: "Publishing Rules", count: 5 },
|
||||
{ id: "Sync Jobs", count: 6 },
|
||||
{ id: "Error Center", count: items.filter(i => i.syncErrors && i.syncErrors > 0).length },
|
||||
{ id: "Audit & Logs", count: null }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
{tab.id}
|
||||
{tab.count !== null && (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
activeTab === tab.id ? 'bg-primary-light text-primary-dark' : 'bg-surface-muted text-muted-foreground'
|
||||
}`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<StatsCard
|
||||
title="Total Integrations"
|
||||
value={items.length}
|
||||
subtitle="All active channels"
|
||||
icon={<Plug className="w-5 h-5" />}
|
||||
color="purple"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Healthy Systems"
|
||||
value={items.filter(i => i.status === "Connected" || i.status === "active" || i.health_status === 'healthy').length}
|
||||
subtitle="Operational"
|
||||
icon={<CheckCircle className="w-5 h-5" />}
|
||||
color="green"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Outbox Events"
|
||||
value="Active"
|
||||
subtitle="Realtime Outbox Queue"
|
||||
icon={<AlertCircle className="w-5 h-5" />}
|
||||
color="blue"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Engine Health"
|
||||
value="BullMQ"
|
||||
subtitle="Redis Workers Running"
|
||||
icon={<Clock className="w-5 h-5" />}
|
||||
color="slate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-6">
|
||||
{activeTab === "Connections" && (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredItems}
|
||||
rowIdKey="id"
|
||||
resultLabel="integrations"
|
||||
statusKey="status"
|
||||
actionConfig={{
|
||||
onView: (row) => navigate(`${row.id}/view`),
|
||||
onEdit: (row) => navigate(`${row.id}/edit`),
|
||||
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
|
||||
}}
|
||||
searchPlaceholder="Search integrations..."
|
||||
toolbarLeft={
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="h-9 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-foreground bg-surface"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option>All Status</option>
|
||||
<option>Connected</option>
|
||||
<option>Pending</option>
|
||||
<option>Disconnected</option>
|
||||
</select>
|
||||
<select
|
||||
className="h-9 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-foreground bg-surface"
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
>
|
||||
<option>All Types</option>
|
||||
<option>Marketplace</option>
|
||||
<option>E-Commerce</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="bg-surface rounded-xl shadow-sm border border-border mt-6">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border px-6 pt-2 overflow-x-auto">
|
||||
{[
|
||||
{ id: "Connections", count: items.length },
|
||||
{ id: "Field Mappings", count: 7 },
|
||||
{ id: "Publishing Rules", count: 1 },
|
||||
{ id: "Sync Jobs", count: null },
|
||||
{ id: "Error Center", count: 0 },
|
||||
{ id: "Audit & Logs", count: null }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium border-b-2 transition-colors cursor-pointer shrink-0 ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
{tab.id}
|
||||
{tab.count !== null && (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
activeTab === tab.id ? 'bg-primary-light text-primary-dark' : 'bg-surface-muted text-muted-foreground'
|
||||
}`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === "Publishing Rules" && <PublishingRulesList />}
|
||||
{activeTab === "Sync Jobs" && <SyncJobsList />}
|
||||
{activeTab === "Error Center" && <ErrorCenterList />}
|
||||
{activeTab === "Audit & Logs" && <AuditLogsList />}
|
||||
{/* Tab Content */}
|
||||
<div className="p-6">
|
||||
{activeTab === "Connections" && (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredItems}
|
||||
rowIdKey="id"
|
||||
resultLabel="integrations"
|
||||
actionConfig={{
|
||||
onEdit: (row) => navigate(`${row.id}/edit`),
|
||||
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
|
||||
}}
|
||||
searchPlaceholder="Search integrations..."
|
||||
toolbarLeft={
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="h-9 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-foreground bg-surface"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option>All Status</option>
|
||||
<option>Connected</option>
|
||||
<option>Pending</option>
|
||||
<option>Disconnected</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "Field Mappings" && <FieldMappingsList />}
|
||||
{activeTab === "Publishing Rules" && <PublishingRulesList />}
|
||||
{activeTab === "Sync Jobs" && <SyncJobsList />}
|
||||
{activeTab === "Error Center" && <ErrorCenterList />}
|
||||
{activeTab === "Audit & Logs" && <AuditLogsList />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={deleteModal.isOpen}
|
||||
title="Delete Integration"
|
||||
description="Are you sure you want to delete this integration? This action cannot be undone."
|
||||
itemName={deleteModal.name}
|
||||
loading={isDeleting}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
|
||||
/>
|
||||
</PageWrapper>
|
||||
{/* Pre-Built Shopify Template Modal */}
|
||||
<ShopifyTemplateModal
|
||||
isOpen={shopifyModalOpen}
|
||||
onClose={() => setShopifyModalOpen(false)}
|
||||
onSuccess={fetchItems}
|
||||
/>
|
||||
|
||||
{/* Credentials Configuration Modal */}
|
||||
{credentialModal.isOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-xs flex items-center justify-center p-4">
|
||||
<div className="relative w-full max-w-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCredentialModal({ isOpen: false, integrationId: "", name: "" })}
|
||||
className="absolute top-3 right-3 p-1 text-muted-foreground hover:text-foreground cursor-pointer z-10"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<ShopifyCredentialCard
|
||||
integrationId={credentialModal.integrationId}
|
||||
onSaved={() => setCredentialModal({ isOpen: false, integrationId: "", name: "" })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={deleteModal.isOpen}
|
||||
title="Delete Integration"
|
||||
description="Are you sure you want to delete this integration? This action cannot be undone."
|
||||
itemName={deleteModal.name}
|
||||
loading={isDeleting}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
|
||||
/>
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -258,6 +258,39 @@ export default function NewIntegration() {
|
||||
|
||||
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
|
||||
|
||||
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit || isView) {
|
||||
setHighestVisitedStep(STEPS.length);
|
||||
}
|
||||
}, [isEdit, isView]);
|
||||
|
||||
const isGeneralValid = Boolean(formik.values.name?.trim() && formik.values.channel && formik.values.integrationType && !formik.errors.name && !formik.errors.channel && !formik.errors.integrationType);
|
||||
|
||||
const isStepAccessible = useCallback((stepNum: number) => {
|
||||
if (isEdit || isView) return true;
|
||||
if (stepNum === 1) return true;
|
||||
if (!isGeneralValid) return false;
|
||||
return stepNum <= highestVisitedStep + 1;
|
||||
}, [isEdit, isView, isGeneralValid, highestVisitedStep]);
|
||||
|
||||
const handleNextStep = useCallback(() => {
|
||||
if (activeStep === 'general') {
|
||||
if (!isGeneralValid) {
|
||||
formik.setFieldTouched('name', true);
|
||||
formik.setFieldTouched('channel', true);
|
||||
formik.setFieldTouched('integrationType', true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (activeIndex < STEPS.length - 1) {
|
||||
const nextStepObj = STEPS[activeIndex + 1];
|
||||
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
|
||||
setActiveStep(nextStepObj.id);
|
||||
}
|
||||
}, [activeStep, isGeneralValid, activeIndex, formik]);
|
||||
|
||||
const selectedType = INTEGRATION_TYPES.find((t) => t.id === formik.values.integrationType);
|
||||
const selectedChanName = channels.find(c => c.code === formik.values.channel || c.id === formik.values.channel)?.name ?? '';
|
||||
|
||||
@@ -318,15 +351,34 @@ export default function NewIntegration() {
|
||||
const isActive = activeStep === s.id;
|
||||
const isDone = idx < activeIndex;
|
||||
const isLast = idx === STEPS.length - 1;
|
||||
const accessible = isStepAccessible(s.step);
|
||||
return (
|
||||
<div key={s.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center" style={{ width: 24 }}>
|
||||
<button type="button" onClick={() => setActiveStep(s.id)} className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${isActive ? 'bg-primary ring-2 ring-primary/20' : isDone ? 'bg-success' : 'bg-surface border-2 border-border hover:border-primary/30'}`}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${isActive ? 'bg-primary ring-2 ring-primary/20' : isDone ? 'bg-success' : 'bg-surface border-2 border-border hover:border-primary/30'} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
{isDone ? <Check className="w-3 h-3 text-white" /> : <span className={`text-[9px] font-bold ${isActive ? 'text-white' : 'text-muted-foreground'}`}>{s.step}</span>}
|
||||
</button>
|
||||
{!isLast && <div className={`w-px flex-1 my-0.5 ${isDone ? 'bg-success/50' : 'bg-surface-muted'}`} style={{ minHeight: 14 }} />}
|
||||
</div>
|
||||
<button type="button" onClick={() => setActiveStep(s.id)} className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span className={`text-xs font-medium leading-tight block ${isActive ? 'text-primary' : isDone ? 'text-muted-foreground' : 'text-muted-foreground hover:text-muted-foreground'}`}>{s.label}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -985,7 +1037,7 @@ export default function NewIntegration() {
|
||||
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm font-medium text-muted-foreground hover:bg-background transition-colors">Back</button>
|
||||
)}
|
||||
{activeIndex < STEPS.length - 1 && (
|
||||
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
|
||||
<button type="button" onClick={handleNextStep} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { Integration, IntegrationCreateRequest, IntegrationUpdateRequest } from '../types/integrations.types';
|
||||
import type {
|
||||
Integration,
|
||||
IntegrationCreateRequest,
|
||||
IntegrationUpdateRequest,
|
||||
TestConnectionResult,
|
||||
SyncJob,
|
||||
SyncItem
|
||||
} from '../types/integrations.types';
|
||||
import apiClient from '../../../api/axiosInstance';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
@@ -10,22 +17,82 @@ interface ApiResponse<T> {
|
||||
export const integrationsService = {
|
||||
getAll: async (): Promise<Integration[]> => {
|
||||
const res = await apiClient.get<ApiResponse<Integration[]>>('/api/v1/integrations');
|
||||
return res.data || [];
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data || []) as Integration[];
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Integration | undefined> => {
|
||||
const res = await apiClient.get<ApiResponse<Integration>>(`/api/v1/integrations/${id}`);
|
||||
return res.data;
|
||||
const raw: any = res.data;
|
||||
return (raw?.data || raw) as Integration;
|
||||
},
|
||||
|
||||
create: async (req: IntegrationCreateRequest): Promise<Integration> => {
|
||||
const res = await apiClient.post<ApiResponse<Integration>>('/api/v1/integrations', req);
|
||||
return res.data;
|
||||
const raw: any = res.data;
|
||||
return (raw?.data || raw) as Integration;
|
||||
},
|
||||
|
||||
update: async (id: string, req: IntegrationUpdateRequest): Promise<Integration> => {
|
||||
const res = await apiClient.put<ApiResponse<Integration>>(`/api/v1/integrations/${id}`, req);
|
||||
return res.data;
|
||||
const raw: any = res.data;
|
||||
return (raw?.data || raw) as Integration;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/integrations/${id}`);
|
||||
return res.success;
|
||||
return res.success || true;
|
||||
},
|
||||
|
||||
getCredentials: async (id: string): Promise<Record<string, string>> => {
|
||||
const res = await apiClient.get<ApiResponse<Record<string, string>>>(`/api/v1/integrations/${id}/credentials`);
|
||||
const raw: any = res.data;
|
||||
return (raw?.data || raw) as Record<string, string>;
|
||||
},
|
||||
|
||||
setCredentials: async (id: string, credentialType: string, secretValue: string, expiresAt?: string): Promise<any> => {
|
||||
const res = await apiClient.post<ApiResponse<any>>(`/api/v1/integrations/${id}/credentials`, {
|
||||
credential_type: credentialType,
|
||||
secret_value: secretValue,
|
||||
expires_at: expiresAt
|
||||
});
|
||||
const raw: any = res.data;
|
||||
return raw?.data || raw;
|
||||
},
|
||||
|
||||
testConnection: async (id: string): Promise<TestConnectionResult> => {
|
||||
const res = await apiClient.post<ApiResponse<TestConnectionResult>>(`/api/v1/integrations/${id}/test-connection`);
|
||||
const raw: any = res.data;
|
||||
return (raw?.data || raw) as TestConnectionResult;
|
||||
},
|
||||
|
||||
triggerSync: async (id: string, options?: { productId?: string; productIds?: string[] }): Promise<any> => {
|
||||
const res = await apiClient.post<ApiResponse<any>>(`/api/v1/integrations/${id}/sync`, options || {});
|
||||
const raw: any = res.data;
|
||||
return raw?.data || raw;
|
||||
},
|
||||
|
||||
getSyncJobs: async (id: string): Promise<SyncJob[]> => {
|
||||
const res = await apiClient.get<ApiResponse<SyncJob[]>>(`/api/v1/integrations/${id}/jobs`);
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data || []) as SyncJob[];
|
||||
},
|
||||
|
||||
getAllSyncJobs: async (): Promise<SyncJob[]> => {
|
||||
const res = await apiClient.get<ApiResponse<SyncJob[]>>(`/api/v1/integrations/jobs/all`);
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data || []) as SyncJob[];
|
||||
},
|
||||
|
||||
getSyncItems: async (jobId: string): Promise<SyncItem[]> => {
|
||||
const res = await apiClient.get<ApiResponse<SyncItem[]>>(`/api/v1/integrations/jobs/${jobId}/items`);
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data || []) as SyncItem[];
|
||||
},
|
||||
|
||||
startShopifyOAuth: async (id: string): Promise<{ authorizationUrl: string; state: string }> => {
|
||||
const res = await apiClient.post<ApiResponse<any>>(`/api/v1/integrations/${id}/shopify/oauth/start`);
|
||||
const raw: any = res.data;
|
||||
return raw?.data || raw;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,62 +2,97 @@ export interface Integration {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
channel: string; // channel code or ID
|
||||
integrationType: string; // e.g. ecommerce, marketplace, erp, wms, pos, b2b_portal, mobile_app, website, custom_api
|
||||
environment: string; // e.g. production, staging, development
|
||||
status: string; // Connected, Pending, Disconnected, active, inactive, pending
|
||||
channel: string; // e.g. shopify, amazon, custom_api
|
||||
integrationType?: string; // e.g. ecommerce, marketplace, erp
|
||||
environment?: string;
|
||||
status: string; // active, inactive, pending, error
|
||||
health_status?: string; // healthy, degraded, error
|
||||
healthStatus?: string;
|
||||
sync_mode?: string; // auto, manual, scheduled
|
||||
syncMode?: string;
|
||||
sync_frequency?: string; // realtime, hourly, daily
|
||||
syncFrequency?: string;
|
||||
last_synced_at?: string;
|
||||
lastSync?: string;
|
||||
|
||||
// E-commerce/Website connection config
|
||||
// E-commerce connection credentials & details
|
||||
storeUrl?: string;
|
||||
accessToken?: string;
|
||||
apiVersion?: string;
|
||||
webhookSecret?: string;
|
||||
shopIdentifier?: string;
|
||||
shopDomain?: string;
|
||||
|
||||
// Marketplace specific fields
|
||||
sellerId?: string;
|
||||
marketplaceId?: string;
|
||||
awsAccessKeyId?: string;
|
||||
awsSecretKey?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
|
||||
// ERP/WMS specific fields
|
||||
authMethod?: string;
|
||||
authToken?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
|
||||
// POS specific fields
|
||||
posTerminalId?: string;
|
||||
posStoreCode?: string;
|
||||
posApiKey?: string;
|
||||
posApiSecret?: string;
|
||||
|
||||
// Mobile App specific fields
|
||||
appId?: string;
|
||||
bundleIdentifier?: string;
|
||||
gatewayUrl?: string;
|
||||
|
||||
// Custom API specific fields
|
||||
customApiUrl?: string;
|
||||
customApiHeaderKey?: string;
|
||||
customApiHeaderValue?: string;
|
||||
|
||||
// Sync settings
|
||||
syncDirection: string; // pim_to_channel, channel_to_pim, bidirectional
|
||||
syncFrequency: string; // manual, hourly, daily, realtime
|
||||
autoRetry: boolean;
|
||||
retryAttempts: number;
|
||||
|
||||
// Listing page read-only / metadata fields
|
||||
lastSync?: string;
|
||||
syncErrors?: number;
|
||||
published?: string | number;
|
||||
author?: string;
|
||||
createdAt: string;
|
||||
createdAt?: string;
|
||||
created_at?: string;
|
||||
updatedAt?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export type IntegrationCreateRequest = Omit<Integration, 'id' | 'createdAt'>;
|
||||
export type IntegrationCreateRequest = Omit<Integration, 'id' | 'createdAt' | 'created_at'>;
|
||||
export type IntegrationUpdateRequest = Partial<IntegrationCreateRequest>;
|
||||
|
||||
export interface TestConnectionResult {
|
||||
connected: boolean;
|
||||
shopName?: string;
|
||||
shopDomain?: string;
|
||||
email?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface SyncJob {
|
||||
id: string;
|
||||
tenant_id: number;
|
||||
integration_id: string;
|
||||
trigger_source: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
total_items: number;
|
||||
success_items: number;
|
||||
failed_items: number;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SyncAttempt {
|
||||
id: string;
|
||||
sync_item_id: string;
|
||||
attempt_number: number;
|
||||
started_at: string;
|
||||
completed_at?: string;
|
||||
status: string;
|
||||
request_method: string;
|
||||
request_url: string;
|
||||
response_status?: number;
|
||||
error_code?: string;
|
||||
error_message?: string;
|
||||
duration_ms?: number;
|
||||
}
|
||||
|
||||
export interface SyncErrorItem {
|
||||
id: string;
|
||||
error_code: string;
|
||||
error_type: string;
|
||||
message: string;
|
||||
provider_message?: string;
|
||||
http_status?: number;
|
||||
retryable: boolean;
|
||||
attempt_number: number;
|
||||
}
|
||||
|
||||
export interface SyncItem {
|
||||
id: string;
|
||||
sync_job_id: string;
|
||||
integration_id: string;
|
||||
product_id: string;
|
||||
variant_id?: string;
|
||||
sku?: string;
|
||||
operation: string;
|
||||
status: 'pending' | 'processing' | 'success' | 'failed' | 'skipped';
|
||||
source_version: number;
|
||||
idempotency_key: string;
|
||||
attempt_count: number;
|
||||
error_code?: string;
|
||||
error_message?: string;
|
||||
attempts?: SyncAttempt[];
|
||||
errors?: SyncErrorItem[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Building2, Users, Package, Image, ShieldCheck, Activity, Play, StopCircle } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { tenantService } from "../../tenants/services/tenant.service";
|
||||
import { useTenant } from "../../tenants/hooks/useTenant";
|
||||
import { notify } from "../../../services/toast";
|
||||
|
||||
export default function PlatformOverview() {
|
||||
const navigate = useNavigate();
|
||||
const { impersonateTenant, stopImpersonation } = useTenant();
|
||||
const [metrics, setMetrics] = useState<any>(null);
|
||||
const [tenants, setTenants] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [impersonatingTenantId, setImpersonatingTenantId] = useState<string | null>(
|
||||
localStorage.getItem("impersonatedTenantId")
|
||||
);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [metricRes, tenantRes] = await Promise.all([
|
||||
tenantService.getPlatformMetrics(),
|
||||
tenantService.getPlatformTenants()
|
||||
]);
|
||||
setMetrics(metricRes);
|
||||
setTenants(tenantRes);
|
||||
} catch (err) {
|
||||
notify.error("Failed to load platform dashboard data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleStartImpersonate = async (tenantId: string) => {
|
||||
try {
|
||||
await impersonateTenant(tenantId);
|
||||
setImpersonatingTenantId(tenantId);
|
||||
navigate("/products");
|
||||
} catch (err) {
|
||||
// Handled in hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopImpersonate = () => {
|
||||
stopImpersonation();
|
||||
setImpersonatingTenantId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<Breadcrumb
|
||||
items={[{ label: "Platform Operator" }, { label: "SaaS Control Center Overview" }]}
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Building2 className="w-4 h-4" />}
|
||||
onClick={() => navigate("/platform/tenants")}
|
||||
>
|
||||
Provision New Tenant
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Support Impersonation Banner */}
|
||||
{impersonatingTenantId && (
|
||||
<div className="mb-6 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-amber-500 text-white font-bold">
|
||||
<ShieldCheck className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-foreground text-sm">Support Impersonation Mode Active</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Currently troubleshooting Tenant ID: <span className="font-mono font-bold text-amber-500">{impersonatingTenantId}</span>. Requests are safely scoped to this tenant context.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<StopCircle className="w-4 h-4 text-danger" />}
|
||||
onClick={handleStopImpersonate}
|
||||
>
|
||||
End Support Mode
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metrics Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5 mb-8">
|
||||
<div className="bg-surface rounded-xl p-5 border border-border shadow-sm flex items-center gap-4">
|
||||
<div className="p-3 rounded-xl bg-primary/10 text-primary">
|
||||
<Building2 className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Total SaaS Tenants</p>
|
||||
<h3 className="text-2xl font-bold text-foreground mt-0.5">{loading ? "..." : metrics?.tenants?.total || 0}</h3>
|
||||
<span className="text-xs text-emerald-500 font-medium">{metrics?.tenants?.active || 0} Active</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-xl p-5 border border-border shadow-sm flex items-center gap-4">
|
||||
<div className="p-3 rounded-xl bg-blue-500/10 text-blue-500">
|
||||
<Users className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Platform Accounts</p>
|
||||
<h3 className="text-2xl font-bold text-foreground mt-0.5">{loading ? "..." : metrics?.users?.total || 0}</h3>
|
||||
<span className="text-xs text-muted-foreground">Across all tenants</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-xl p-5 border border-border shadow-sm flex items-center gap-4">
|
||||
<div className="p-3 rounded-xl bg-violet-500/10 text-violet-500">
|
||||
<Package className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Total Products</p>
|
||||
<h3 className="text-2xl font-bold text-foreground mt-0.5">{loading ? "..." : metrics?.data?.total_products || 0}</h3>
|
||||
<span className="text-xs text-muted-foreground">Catalog items</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-xl p-5 border border-border shadow-sm flex items-center gap-4">
|
||||
<div className="p-3 rounded-xl bg-amber-500/10 text-amber-500">
|
||||
<Image className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Cloudinary DAM Assets</p>
|
||||
<h3 className="text-2xl font-bold text-foreground mt-0.5">{loading ? "..." : metrics?.data?.total_assets || 0}</h3>
|
||||
<span className="text-xs text-muted-foreground">Images & Raw Docs</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tenants Table Preview */}
|
||||
<div className="bg-surface rounded-xl border border-border shadow-sm overflow-hidden mb-8">
|
||||
<div className="px-6 py-4 border-b border-border bg-gradient-to-r from-primary/5 to-surface flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-primary" />
|
||||
<h3 className="font-semibold text-foreground text-sm">Tenant Provisioning Registry</h3>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate("/platform/tenants")}>
|
||||
Manage All Tenants
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-background/50 text-xs text-muted-foreground uppercase tracking-wider">
|
||||
<th className="px-6 py-3">Tenant Code</th>
|
||||
<th className="px-6 py-3">Organization Name</th>
|
||||
<th className="px-6 py-3">Contact Email</th>
|
||||
<th className="px-6 py-3">Products</th>
|
||||
<th className="px-6 py-3">Assets</th>
|
||||
<th className="px-6 py-3">Status</th>
|
||||
<th className="px-6 py-3 text-right">Support Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-8 text-center text-muted-foreground">Loading SaaS platform tenants...</td>
|
||||
</tr>
|
||||
) : tenants.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-8 text-center text-muted-foreground">No tenants provisioned yet.</td>
|
||||
</tr>
|
||||
) : (
|
||||
tenants.map((t) => (
|
||||
<tr key={t.id} className="hover:bg-primary/5 transition-colors">
|
||||
<td className="px-6 py-4 font-mono text-xs font-semibold text-primary">{t.tenant_code}</td>
|
||||
<td className="px-6 py-4 font-medium text-foreground">{t.tenant_name}</td>
|
||||
<td className="px-6 py-4 text-muted-foreground">{t.contact_email || "N/A"}</td>
|
||||
<td className="px-6 py-4 font-semibold text-foreground">{t.total_products || 0}</td>
|
||||
<td className="px-6 py-4 font-semibold text-foreground">{t.total_assets || 0}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-semibold ${t.status ? 'bg-emerald-500/10 text-emerald-500' : 'bg-red-500/10 text-red-500'}`}>
|
||||
{t.status ? "Active" : "Suspended"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
{String(t.id) === String(impersonatingTenantId) ? (
|
||||
<span className="text-xs text-amber-500 font-semibold">Active Session</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleStartImpersonate(String(t.id))}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-primary/10 text-primary hover:bg-primary hover:text-white transition-colors"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" /> Support Assist
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Building2, Search, CheckCircle, XCircle, Copy, Check } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useTenant } from "../../tenants/hooks/useTenant";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { notify } from "../../../services/toast";
|
||||
|
||||
const inputClass = (error?: boolean) =>
|
||||
`w-full border ${error ? 'border-danger focus:ring-danger' : 'border-primary/10 focus:ring-primary-light'} rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 bg-surface text-foreground placeholder-muted-foreground`;
|
||||
|
||||
export default function PlatformTenantsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { tenants, fetchPlatformTenants, provisionTenant, updatePlatformStatus, impersonateTenant, stopImpersonation } = useTenant();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isProvisionModalOpen, setIsProvisionModalOpen] = useState(false);
|
||||
const [impersonatingTenantId, setImpersonatingTenantId] = useState<string | null>(
|
||||
localStorage.getItem("impersonatedTenantId")
|
||||
);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await fetchPlatformTenants();
|
||||
} catch {
|
||||
notify.error("Failed to load platform tenants");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const [provisionedSuccessData, setProvisionedSuccessData] = useState<any | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
tenant_name: "",
|
||||
domain: "",
|
||||
contact_email: "",
|
||||
admin_name: "",
|
||||
admin_email: "",
|
||||
admin_password: ""
|
||||
},
|
||||
validationSchema: Yup.object().shape({
|
||||
tenant_name: Yup.string().required("Organization name is required"),
|
||||
contact_email: Yup.string().email("Invalid email").required("Contact email is required"),
|
||||
admin_email: Yup.string().email("Invalid admin email"),
|
||||
admin_password: Yup.string().min(6, "Password must be at least 6 characters")
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
try {
|
||||
const result = await provisionTenant(values);
|
||||
resetForm();
|
||||
setIsProvisionModalOpen(false);
|
||||
setProvisionedSuccessData({
|
||||
tenant: result.tenant || result.data?.tenant,
|
||||
admin: result.admin || result.data?.admin,
|
||||
rawPassword: values.admin_password
|
||||
});
|
||||
fetchPlatformTenants();
|
||||
} catch (err) {
|
||||
// Error handled in hook
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleToggleStatus = async (id: string, currentStatus: boolean) => {
|
||||
try {
|
||||
await updatePlatformStatus(id, !currentStatus);
|
||||
} catch (err) {
|
||||
// Handled in hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartImpersonate = async (tenantId: string) => {
|
||||
try {
|
||||
await impersonateTenant(tenantId);
|
||||
setImpersonatingTenantId(tenantId);
|
||||
navigate("/products");
|
||||
} catch (err) {
|
||||
// Handled in hook
|
||||
}
|
||||
};
|
||||
|
||||
const filteredTenants = (tenants || []).filter(t =>
|
||||
t.tenant_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
t.tenant_code?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
t.contact_email?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<Breadcrumb
|
||||
items={[{ label: "Platform Operator" }, { label: "Tenant Provisioning & Management" }]}
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
onClick={() => setIsProvisionModalOpen(true)}
|
||||
>
|
||||
Provision New Tenant
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filter & Search Bar */}
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by code, name, or email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 border border-border rounded-lg text-sm bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tenants Table */}
|
||||
<div className="bg-surface rounded-xl border border-border shadow-sm overflow-hidden mb-8">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-background/50 text-xs text-muted-foreground uppercase tracking-wider">
|
||||
<th className="px-6 py-3">Tenant Code</th>
|
||||
<th className="px-6 py-3">Organization</th>
|
||||
<th className="px-6 py-3">Domain</th>
|
||||
<th className="px-6 py-3">Contact Email</th>
|
||||
<th className="px-6 py-3">Products</th>
|
||||
<th className="px-6 py-3">Assets</th>
|
||||
<th className="px-6 py-3">Status</th>
|
||||
<th className="px-6 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-6 py-8 text-center text-muted-foreground">Loading tenants...</td>
|
||||
</tr>
|
||||
) : filteredTenants.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-6 py-8 text-center text-muted-foreground">No matching tenants found.</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredTenants.map((t: any) => (
|
||||
<tr key={t.id} className="hover:bg-primary/5 transition-colors">
|
||||
<td className="px-6 py-4 font-mono text-xs font-semibold text-primary">{t.tenant_code}</td>
|
||||
<td className="px-6 py-4 font-medium text-foreground">{t.tenant_name}</td>
|
||||
<td className="px-6 py-4 text-muted-foreground">{t.domain || "N/A"}</td>
|
||||
<td className="px-6 py-4 text-muted-foreground">{t.contact_email}</td>
|
||||
<td className="px-6 py-4 font-semibold">{t.total_products || 0}</td>
|
||||
<td className="px-6 py-4 font-semibold">{t.total_assets || 0}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-semibold ${t.status ? 'bg-emerald-500/10 text-emerald-500' : 'bg-red-500/10 text-red-500'}`}>
|
||||
{t.status ? "Active" : "Suspended"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleToggleStatus(String(t.id), t.status)}
|
||||
className={`p-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
t.status ? 'bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white' : 'bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500 hover:text-white'
|
||||
}`}
|
||||
title={t.status ? "Suspend Tenant" : "Activate Tenant"}
|
||||
>
|
||||
{t.status ? <XCircle className="w-4 h-4" /> : <CheckCircle className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
{String(t.id) === String(impersonatingTenantId) ? (
|
||||
<button
|
||||
onClick={() => { stopImpersonation(); setImpersonatingTenantId(null); }}
|
||||
className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-amber-500 text-white hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
End Support
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleStartImpersonate(String(t.id))}
|
||||
className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-primary/10 text-primary hover:bg-primary hover:text-white transition-colors"
|
||||
>
|
||||
Support Assist
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provision Tenant Modal */}
|
||||
{isProvisionModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-surface rounded-xl border border-border shadow-xl w-full max-w-lg overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-border bg-gradient-to-r from-primary/5 to-surface flex items-center justify-between">
|
||||
<h3 className="font-semibold text-foreground text-sm flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-primary" /> Provision New Tenant Account
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsProvisionModalOpen(false)}
|
||||
className="text-muted-foreground hover:text-foreground text-sm font-semibold"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={formik.handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">Organization Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="tenant_name"
|
||||
value={formik.values.tenant_name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
className={inputClass(formik.touched.tenant_name && Boolean(formik.errors.tenant_name))}
|
||||
placeholder="e.g. IKEA Global"
|
||||
/>
|
||||
{formik.touched.tenant_name && formik.errors.tenant_name && (
|
||||
<p className="text-xs text-danger mt-1">{formik.errors.tenant_name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">Domain (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
name="domain"
|
||||
value={formik.values.domain}
|
||||
onChange={formik.handleChange}
|
||||
className={inputClass()}
|
||||
placeholder="ikea.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">Contact Email *</label>
|
||||
<input
|
||||
type="email"
|
||||
name="contact_email"
|
||||
value={formik.values.contact_email}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
className={inputClass(formik.touched.contact_email && Boolean(formik.errors.contact_email))}
|
||||
placeholder="support@ikea.com"
|
||||
/>
|
||||
{formik.touched.contact_email && formik.errors.contact_email && (
|
||||
<p className="text-xs text-danger mt-1">{formik.errors.contact_email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-border">
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">Initial Tenant Admin Credentials</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-foreground mb-1">Admin Name</label>
|
||||
<input
|
||||
type="text"
|
||||
name="admin_name"
|
||||
value={formik.values.admin_name}
|
||||
onChange={formik.handleChange}
|
||||
className={inputClass()}
|
||||
placeholder="John Admin"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-foreground mb-1">Admin Email</label>
|
||||
<input
|
||||
type="email"
|
||||
name="admin_email"
|
||||
value={formik.values.admin_email}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
className={inputClass(formik.touched.admin_email && Boolean(formik.errors.admin_email))}
|
||||
placeholder="admin@ikea.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-foreground mb-1">Admin Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="admin_password"
|
||||
value={formik.values.admin_password}
|
||||
onChange={formik.handleChange}
|
||||
className={inputClass(formik.touched.admin_password && Boolean(formik.errors.admin_password))}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex items-center justify-end gap-3 border-t border-border">
|
||||
<Button variant="outline" type="button" onClick={() => setIsProvisionModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" type="submit" loading={formik.isSubmitting}>
|
||||
Provision Tenant
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provisioning Success Modal with Copy Credentials */}
|
||||
{provisionedSuccessData && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||
<div className="w-full max-w-md bg-surface border border-primary/20 rounded-2xl p-6 shadow-2xl space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-success/10 text-success flex items-center justify-center">
|
||||
<CheckCircle className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-foreground">Tenant Provisioned!</h3>
|
||||
<p className="text-xs text-muted-foreground">Share these setup credentials with your client</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-primary/5 border border-primary/10 rounded-xl p-4 space-y-3 font-mono text-xs text-foreground">
|
||||
<div className="flex justify-between border-b border-primary/10 pb-2">
|
||||
<span className="text-muted-foreground font-sans">Organization:</span>
|
||||
<span className="font-semibold text-primary">{provisionedSuccessData.tenant?.tenant_name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-primary/10 pb-2">
|
||||
<span className="text-muted-foreground font-sans">Tenant Code:</span>
|
||||
<span className="font-semibold">{provisionedSuccessData.tenant?.tenant_code}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-primary/10 pb-2">
|
||||
<span className="text-muted-foreground font-sans">Admin Email:</span>
|
||||
<span className="font-semibold">{provisionedSuccessData.admin?.email || provisionedSuccessData.tenant?.contact_email}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground font-sans">Admin Password:</span>
|
||||
<span className="font-semibold text-danger">{provisionedSuccessData.rawPassword || '••••••••'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={() => {
|
||||
const textToCopy = `Organization: ${provisionedSuccessData.tenant?.tenant_name}\nTenant Code: ${provisionedSuccessData.tenant?.tenant_code}\nAdmin Email: ${provisionedSuccessData.admin?.email || provisionedSuccessData.tenant?.contact_email}\nPassword: ${provisionedSuccessData.rawPassword || 'Admin@123'}\nLogin URL: http://localhost:5173/login`;
|
||||
navigator.clipboard.writeText(textToCopy);
|
||||
setCopied(true);
|
||||
notify.success("Provisioning credentials copied to clipboard!");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
{copied ? "Copied to Clipboard!" : "Copy Client Credentials"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setProvisionedSuccessData(null)}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -23,21 +23,53 @@ interface DynamicAttributeRendererProps {
|
||||
attribute: Attribute;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
onBlur?: () => void;
|
||||
error?: string;
|
||||
touched?: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> = ({
|
||||
attribute,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
error,
|
||||
touched,
|
||||
readOnly,
|
||||
}) => {
|
||||
const isRequired = attribute.is_required || attribute.isRequired;
|
||||
const inputClass = "w-full border border-border focus:ring-primary rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-surface text-foreground";
|
||||
const labelClass = "block text-sm font-medium text-foreground mb-1.5";
|
||||
|
||||
const normalizedOptions = React.useMemo(() => {
|
||||
if (Array.isArray(attribute.optionsList) && attribute.optionsList.length > 0) {
|
||||
return attribute.optionsList.map((o: any) => ({
|
||||
id: o.id || o.code || o.value || String(o),
|
||||
code: o.code || o.value || String(o).toLowerCase(),
|
||||
label: o.label || o.value || o.name || String(o)
|
||||
}));
|
||||
}
|
||||
const rawOpts = (attribute as any).options || (attribute as any).attributeOptions || (attribute as any).values || [];
|
||||
if (Array.isArray(rawOpts)) {
|
||||
return rawOpts.map((o: any, idx: number) => {
|
||||
if (typeof o === 'string') {
|
||||
return {
|
||||
id: `${attribute.id || attribute.code}-${idx}`,
|
||||
code: o.toLowerCase().trim(),
|
||||
label: o
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: o.id || o.code || `${attribute.code}-${idx}`,
|
||||
code: o.code || o.value || String(o.label || '').toLowerCase(),
|
||||
label: o.label || o.value || o.name || o.code || String(o)
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}, [attribute]);
|
||||
|
||||
const renderInput = () => {
|
||||
switch (attribute.type) {
|
||||
case 'textarea':
|
||||
@@ -45,9 +77,11 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
|
||||
<textarea
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
placeholder={`Enter ${attribute.name}`}
|
||||
rows={3}
|
||||
className={`${inputClass} resize-none`}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
);
|
||||
case 'number':
|
||||
@@ -61,8 +95,10 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
|
||||
const val = e.target.value;
|
||||
onChange(val === '' ? undefined : Number(val));
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
placeholder={`Enter ${attribute.name}`}
|
||||
className={inputClass}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
);
|
||||
case 'date':
|
||||
@@ -71,7 +107,9 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
|
||||
type="date"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
className={inputClass}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
);
|
||||
case 'boolean':
|
||||
@@ -82,6 +120,8 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
|
||||
const val = e.target.value;
|
||||
onChange(val === 'true' ? true : val === 'false' ? false : undefined);
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<option value="">Select option</option>
|
||||
<option value="true">True</option>
|
||||
@@ -95,9 +135,11 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
|
||||
<Select
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<option value="">Select option</option>
|
||||
{attribute.optionsList?.map((opt) => (
|
||||
{normalizedOptions.map((opt) => (
|
||||
<option key={opt.id} value={opt.code}>{opt.label}</option>
|
||||
))}
|
||||
</Select>
|
||||
@@ -112,24 +154,26 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
|
||||
updated = selectedValues.filter((v) => v !== optCode);
|
||||
}
|
||||
onChange(updated.join(','));
|
||||
onBlur?.(); // trigger validation immediately
|
||||
};
|
||||
return (
|
||||
<div className="space-y-2 border border-border rounded-lg p-3 bg-background/30">
|
||||
{attribute.optionsList?.map((opt) => {
|
||||
const isChecked = selectedValues.includes(opt.code);
|
||||
<div className="space-y-2 border border-border rounded-lg p-3 bg-background/30" onBlur={onBlur}>
|
||||
{normalizedOptions.map((opt) => {
|
||||
const isChecked = selectedValues.includes(opt.code) || selectedValues.includes(opt.label);
|
||||
return (
|
||||
<label key={opt.id} className="flex items-center gap-2 cursor-pointer text-xs font-medium text-foreground">
|
||||
<label key={opt.id} className={`flex items-center gap-2 cursor-pointer text-xs font-medium text-foreground ${readOnly ? 'cursor-not-allowed opacity-75' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(e) => handleCheckboxChange(opt.code, e.target.checked)}
|
||||
className="w-4 h-4 text-primary rounded border-border focus:ring-primary"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{(!attribute.optionsList || attribute.optionsList.length === 0) && (
|
||||
{normalizedOptions.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground">No options configured</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -141,8 +185,10 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
placeholder={`Enter ${attribute.name}`}
|
||||
className={inputClass}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,12 @@ interface DynamicAttributesSectionProps {
|
||||
errors?: Record<string, any>;
|
||||
touched?: Record<string, any>;
|
||||
onAttributeChange: (code: string, value: any) => void;
|
||||
onAttributeBlur?: (code: string) => void;
|
||||
onAddAttributeClick?: (group: any) => void;
|
||||
onRemoveAttribute?: (id: string) => void;
|
||||
onRemoveGroup?: (id: string) => void;
|
||||
customAttributeIds?: Set<string>;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export const DynamicAttributesSection: React.FC<DynamicAttributesSectionProps> = ({
|
||||
@@ -41,6 +47,12 @@ export const DynamicAttributesSection: React.FC<DynamicAttributesSectionProps> =
|
||||
errors = {},
|
||||
touched = {},
|
||||
onAttributeChange,
|
||||
onAttributeBlur,
|
||||
onAddAttributeClick,
|
||||
onRemoveAttribute,
|
||||
onRemoveGroup,
|
||||
customAttributeIds,
|
||||
readOnly,
|
||||
}) => {
|
||||
if (!hasAttributeSet) {
|
||||
return (
|
||||
@@ -87,6 +99,12 @@ export const DynamicAttributesSection: React.FC<DynamicAttributesSectionProps> =
|
||||
errors={errors}
|
||||
touched={touched}
|
||||
onAttributeChange={onAttributeChange}
|
||||
onAttributeBlur={onAttributeBlur}
|
||||
onAddAttributeClick={onAddAttributeClick}
|
||||
onRemoveAttribute={onRemoveAttribute}
|
||||
onRemoveGroup={onRemoveGroup}
|
||||
customAttributeIds={customAttributeIds}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, Plus, X } from 'lucide-react';
|
||||
import { DynamicAttributeRenderer } from './DynamicAttributeRenderer';
|
||||
|
||||
interface AttributeOption {
|
||||
@@ -33,6 +33,12 @@ interface ProductAttributeGroupProps {
|
||||
errors?: Record<string, any>;
|
||||
touched?: Record<string, any>;
|
||||
onAttributeChange: (code: string, value: any) => void;
|
||||
onAttributeBlur?: (code: string) => void;
|
||||
onAddAttributeClick?: (group: AttributeGroup) => void;
|
||||
onRemoveAttribute?: (id: string) => void;
|
||||
onRemoveGroup?: (id: string) => void;
|
||||
customAttributeIds?: Set<string>;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
|
||||
@@ -41,6 +47,12 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
|
||||
errors = {},
|
||||
touched = {},
|
||||
onAttributeChange,
|
||||
onAttributeBlur,
|
||||
onAddAttributeClick,
|
||||
onRemoveAttribute,
|
||||
onRemoveGroup,
|
||||
customAttributeIds,
|
||||
readOnly,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const attributes = group.attributes || [];
|
||||
@@ -48,7 +60,37 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
|
||||
if (attributes.length === 0) {
|
||||
return (
|
||||
<div className="bg-surface rounded-xl border border-border shadow-xs p-6">
|
||||
<h3 className="font-semibold text-foreground mb-4">{group.name}</h3>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-foreground text-xs uppercase tracking-wider">{group.name}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{!readOnly && onRemoveGroup && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveGroup(group.id);
|
||||
}}
|
||||
className="text-xs text-red-500 hover:text-red-650 font-semibold px-2 py-1 rounded hover:bg-red-50 transition-colors"
|
||||
title={`Remove ${group.name} container`}
|
||||
>
|
||||
Remove Group
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && onAddAttributeClick && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddAttributeClick(group);
|
||||
}}
|
||||
className="px-3 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold transition-colors flex items-center justify-center shrink-0 h-[42px] w-[42px]"
|
||||
title={`Add attribute to ${group.name}`}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground italic">No Attributes available.</div>
|
||||
</div>
|
||||
);
|
||||
@@ -56,27 +98,70 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
|
||||
|
||||
return (
|
||||
<div className="bg-surface rounded-xl border border-border shadow-xs overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center justify-between px-6 py-4 bg-background/50 border-b border-border/65 hover:bg-background transition-colors"
|
||||
className="w-full flex items-center justify-between px-6 py-4 bg-background/50 border-b border-border/65 hover:bg-background transition-colors cursor-pointer"
|
||||
>
|
||||
<h3 className="font-semibold text-foreground text-xs uppercase tracking-wider">{group.name}</h3>
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
{!readOnly && onRemoveGroup && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveGroup(group.id);
|
||||
}}
|
||||
className="text-xs text-red-500 hover:text-red-650 font-semibold px-2.5 py-1 rounded hover:bg-red-50/70 transition-colors"
|
||||
title={`Remove ${group.name} container`}
|
||||
>
|
||||
Remove Group
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && onAddAttributeClick && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddAttributeClick(group);
|
||||
}}
|
||||
className="px-3 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold transition-colors flex items-center justify-center shrink-0 h-[42px] w-[42px]"
|
||||
title={`Add attribute to ${group.name}`}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="p-6 grid grid-cols-2 gap-6">
|
||||
{attributes.map((attr) => (
|
||||
<DynamicAttributeRenderer
|
||||
key={attr.id}
|
||||
attribute={attr}
|
||||
value={values[attr.code]}
|
||||
onChange={(val) => onAttributeChange(attr.code, val)}
|
||||
error={errors[attr.code]}
|
||||
touched={touched[attr.code]}
|
||||
/>
|
||||
))}
|
||||
{attributes.map((attr) => {
|
||||
const isCustom = customAttributeIds?.has(attr.id);
|
||||
return (
|
||||
<div key={attr.id} className="relative border border-border/40 rounded-xl p-5 bg-background/15 group hover:border-border/80 transition-all">
|
||||
{isCustom && !readOnly && onRemoveAttribute && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveAttribute(attr.id)}
|
||||
className="absolute top-2 right-2 p-1.5 hover:bg-red-50 text-muted-foreground hover:text-red-500 rounded transition-colors"
|
||||
title="Remove custom attribute"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<DynamicAttributeRenderer
|
||||
attribute={attr}
|
||||
value={values[attr.code]}
|
||||
onChange={(val) => onAttributeChange(attr.code, val)}
|
||||
onBlur={() => onAttributeBlur?.(attr.code)}
|
||||
error={errors[attr.code]}
|
||||
touched={touched[attr.code]}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,18 +7,29 @@ interface VariantAxesSelectorProps {
|
||||
onGenerate: (selected: Record<string, string[]>, skuTemplate: string) => void;
|
||||
generating: boolean;
|
||||
parentSku: string;
|
||||
initialSelectedValues?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export const VariantAxesSelector: React.FC<VariantAxesSelectorProps> = ({
|
||||
axes,
|
||||
onGenerate,
|
||||
generating,
|
||||
parentSku
|
||||
parentSku,
|
||||
initialSelectedValues
|
||||
}) => {
|
||||
const [selectedValues, setSelectedValues] = useState<Record<string, string[]>>({});
|
||||
const [selectedValues, setSelectedValues] = useState<Record<string, string[]>>(initialSelectedValues || {});
|
||||
const [skuTemplate, setSkuTemplate] = useState('{PARENT_SKU}-{COMBO}');
|
||||
const [customInputs, setCustomInputs] = useState<Record<string, string>>({});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (initialSelectedValues) {
|
||||
setSelectedValues(prev => ({
|
||||
...prev,
|
||||
...initialSelectedValues
|
||||
}));
|
||||
}
|
||||
}, [initialSelectedValues]);
|
||||
|
||||
// Calculate combinations preview
|
||||
const activeAxes = axes.filter(axis => (selectedValues[axis.code] || []).length > 0);
|
||||
const totalCombinations = activeAxes.length > 0
|
||||
@@ -90,20 +101,63 @@ export const VariantAxesSelector: React.FC<VariantAxesSelectorProps> = ({
|
||||
|
||||
<div className="p-5 space-y-6">
|
||||
{axes.map(axis => {
|
||||
const options = axis.optionsList || [];
|
||||
let options: any[] = [];
|
||||
if (Array.isArray(axis.optionsList) && axis.optionsList.length > 0) {
|
||||
options = axis.optionsList;
|
||||
} else {
|
||||
const rawOpts = (axis as any).options || (axis as any).attributeOptions || (axis as any).values || [];
|
||||
if (Array.isArray(rawOpts)) {
|
||||
options = rawOpts.map((o: any, idx: number) => {
|
||||
if (typeof o === 'string') {
|
||||
return { id: `${axis.code}-${idx}`, code: o, label: o };
|
||||
}
|
||||
return {
|
||||
id: o.id || `${axis.code}-${idx}`,
|
||||
code: o.code || o.value || String(o.label || ''),
|
||||
label: o.label || o.value || o.name || o.code
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
const selected = selectedValues[axis.code] || [];
|
||||
|
||||
return (
|
||||
<div key={axis.id} className="space-y-2">
|
||||
<label className="block text-xs font-bold text-foreground uppercase tracking-wider">
|
||||
{axis.name} <span className="text-muted-foreground">({axis.code})</span>
|
||||
</label>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-xs font-bold text-foreground uppercase tracking-wider">
|
||||
{axis.name} <span className="text-muted-foreground font-mono text-[10px]">({axis.code})</span>
|
||||
{selected.length > 0 && (
|
||||
<span className="ml-2 text-[10px] text-primary font-semibold bg-primary/10 px-2 py-0.5 rounded-full border border-primary/20">
|
||||
{selected.length} selected
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{options.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedValues(prev => ({ ...prev, [axis.code]: options.map(o => o.code) }))}
|
||||
className="text-primary font-medium hover:underline"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<span className="text-muted-foreground/40">•</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedValues(prev => ({ ...prev, [axis.code]: [] }))}
|
||||
className="text-muted-foreground hover:text-foreground font-medium transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{options.length > 0 ? (
|
||||
// Pre-defined options list checkbox layout
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
{options.map(opt => {
|
||||
const isChecked = selected.includes(opt.code);
|
||||
const isChecked = selected.some(sel => sel.toLowerCase().trim() === opt.code.toLowerCase().trim());
|
||||
return (
|
||||
<label
|
||||
key={opt.id}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { VariantStatus } from '../../types/variant.types';
|
||||
import { Settings, Check, Trash2, Archive, DollarSign, Package } from 'lucide-react';
|
||||
import { Settings, Check, Trash2, Archive, DollarSign } from 'lucide-react';
|
||||
|
||||
interface VariantBulkActionsProps {
|
||||
selectedCount: number;
|
||||
onApplyUpdates: (updates: { price?: number; costPrice?: number; stock?: number; status?: VariantStatus }) => void;
|
||||
onApplyUpdates: (updates: { price?: number; costPrice?: number; status?: VariantStatus }) => void;
|
||||
onDeleteSelected: () => void;
|
||||
onArchiveSelected: () => void;
|
||||
}
|
||||
@@ -16,25 +16,22 @@ export const VariantBulkActions: React.FC<VariantBulkActionsProps> = ({
|
||||
onArchiveSelected
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [actionType, setActionType] = useState<'price' | 'stock' | 'status' | null>(null);
|
||||
const [actionType, setActionType] = useState<'price' | 'status' | null>(null);
|
||||
|
||||
// States for bulk inputs
|
||||
const [bulkPrice, setBulkPrice] = useState('');
|
||||
const [bulkCostPrice, setBulkCostPrice] = useState('');
|
||||
const [bulkStock, setBulkStock] = useState('');
|
||||
const [bulkStatus, setBulkStatus] = useState<VariantStatus>('draft');
|
||||
|
||||
if (selectedCount === 0) return null;
|
||||
|
||||
const handleApply = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const updates: { price?: number; costPrice?: number; stock?: number; status?: VariantStatus } = {};
|
||||
const updates: { price?: number; costPrice?: number; status?: VariantStatus } = {};
|
||||
|
||||
if (actionType === 'price') {
|
||||
if (bulkPrice !== '') updates.price = parseFloat(bulkPrice);
|
||||
if (bulkCostPrice !== '') updates.costPrice = parseFloat(bulkCostPrice);
|
||||
} else if (actionType === 'stock') {
|
||||
if (bulkStock !== '') updates.stock = parseInt(bulkStock, 10);
|
||||
} else if (actionType === 'status') {
|
||||
updates.status = bulkStatus;
|
||||
}
|
||||
@@ -80,13 +77,6 @@ export const VariantBulkActions: React.FC<VariantBulkActionsProps> = ({
|
||||
>
|
||||
<DollarSign className="w-4 h-4 text-muted-foreground" /> Update Price & Cost
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActionType('stock')}
|
||||
className="flex items-center gap-2 w-full text-left px-3 py-2 hover:bg-background rounded-lg text-xs text-foreground font-medium transition-colors"
|
||||
>
|
||||
<Package className="w-4 h-4 text-muted-foreground" /> Update Inventory Stock
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActionType('status')}
|
||||
@@ -124,19 +114,6 @@ export const VariantBulkActions: React.FC<VariantBulkActionsProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionType === 'stock' && (
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-muted-foreground uppercase">Stock Level</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Enter inventory quantity"
|
||||
value={bulkStock}
|
||||
onChange={(e) => setBulkStock(e.target.value)}
|
||||
className="w-full text-xs border border-border rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary bg-surface"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionType === 'status' && (
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-muted-foreground uppercase">Lifecycle Status</label>
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import type { Variant, VariantStatus } from '../../types/variant.types';
|
||||
import {
|
||||
X, Save, Archive, Trash2, Image as ImageIcon,
|
||||
Package, Tag, DollarSign, CheckCircle, AlertCircle, Loader2,
|
||||
ShoppingBag, Hash
|
||||
} from 'lucide-react';
|
||||
|
||||
interface VariantDetailModalProps {
|
||||
variant: Variant | null;
|
||||
onClose: () => void;
|
||||
onUpdate: (id: string, updates: Partial<Variant>) => Promise<any>;
|
||||
onDelete?: (id: string) => void;
|
||||
onArchive?: (id: string) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
|
||||
variant,
|
||||
onClose,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onArchive,
|
||||
readOnly = false
|
||||
}) => {
|
||||
const [sku, setSku] = useState('');
|
||||
const [price, setPrice] = useState('');
|
||||
const [costPrice, setCostPrice] = useState('');
|
||||
const [stock, setStock] = useState('');
|
||||
const [status, setStatus] = useState<VariantStatus>('draft');
|
||||
const [activeImageIdx, setActiveImageIdx] = useState(0);
|
||||
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
|
||||
useEffect(() => {
|
||||
if (variant) {
|
||||
setSku(variant.sku || '');
|
||||
setPrice(String(variant.price ?? ''));
|
||||
setCostPrice(String(variant.costPrice ?? ''));
|
||||
setStock(String(variant.stock ?? ''));
|
||||
setStatus(variant.status || 'draft');
|
||||
setActiveImageIdx(0);
|
||||
setSaveState('idle');
|
||||
}
|
||||
}, [variant]);
|
||||
|
||||
if (!variant) return null;
|
||||
|
||||
const images = variant.images || [];
|
||||
const primaryImage = images.find(i => i.isPrimary) || images[0];
|
||||
const activeImage = images[activeImageIdx] || primaryImage;
|
||||
|
||||
const axisEntries = Object.entries(variant.attributes || {});
|
||||
|
||||
const handleSave = async () => {
|
||||
const pNum = parseFloat(price);
|
||||
const cpNum = parseFloat(costPrice);
|
||||
const sNum = parseInt(stock, 10);
|
||||
|
||||
const hasChanges =
|
||||
sku !== variant.sku ||
|
||||
pNum !== variant.price ||
|
||||
cpNum !== variant.costPrice ||
|
||||
sNum !== variant.stock ||
|
||||
status !== variant.status;
|
||||
|
||||
if (!hasChanges) return;
|
||||
|
||||
setSaveState('saving');
|
||||
try {
|
||||
await onUpdate(variant.id, {
|
||||
sku,
|
||||
price: isNaN(pNum) ? 0 : pNum,
|
||||
costPrice: isNaN(cpNum) ? 0 : cpNum,
|
||||
stock: isNaN(sNum) ? 0 : sNum,
|
||||
status
|
||||
});
|
||||
setSaveState('saved');
|
||||
setTimeout(() => setSaveState('idle'), 2000);
|
||||
} catch {
|
||||
setSaveState('error');
|
||||
setTimeout(() => setSaveState('idle'), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const statusColor: Record<VariantStatus, string> = {
|
||||
active: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||
draft: 'bg-amber-100 text-amber-700 border-amber-200',
|
||||
inactive: 'bg-slate-100 text-slate-600 border-slate-200',
|
||||
archived: 'bg-red-50 text-red-600 border-red-200'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-150">
|
||||
<div className="bg-surface border border-border rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Package className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-bold text-foreground text-sm leading-tight">
|
||||
{variant.name || 'Variant Details'}
|
||||
</h2>
|
||||
<p className="text-[11px] text-muted-foreground font-mono mt-0.5">{variant.sku}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-bold uppercase border ${statusColor[variant.status] || statusColor.draft}`}>
|
||||
{variant.status}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 hover:bg-background rounded-lg text-muted-foreground transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Body ── */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="grid grid-cols-5 gap-0 h-full">
|
||||
|
||||
{/* Left: Image Gallery */}
|
||||
<div className="col-span-2 border-r border-border p-5 flex flex-col gap-4 bg-background/50">
|
||||
{/* Main image */}
|
||||
<div className="aspect-square rounded-xl border border-border overflow-hidden bg-surface flex items-center justify-center">
|
||||
{activeImage?.url || activeImage?.thumbnailUrl ? (
|
||||
<img
|
||||
src={activeImage.thumbnailUrl || activeImage.url!}
|
||||
alt={activeImage.name || variant.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<ImageIcon className="w-10 h-10 opacity-30" />
|
||||
<span className="text-[11px] font-medium">No image</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail strip */}
|
||||
{images.length > 1 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{images.map((img, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => setActiveImageIdx(idx)}
|
||||
className={`flex-shrink-0 w-12 h-12 rounded-lg border-2 overflow-hidden transition-all ${idx === activeImageIdx ? 'border-primary' : 'border-border hover:border-primary/40'}`}
|
||||
>
|
||||
{img.url || img.thumbnailUrl ? (
|
||||
<img src={img.thumbnailUrl || img.url!} alt={img.name || `Image ${idx + 1}`} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-surface-muted flex items-center justify-center">
|
||||
<ImageIcon className="w-3 h-3 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Axis pills */}
|
||||
{axisEntries.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Variant Axes</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{axisEntries.map(([key, val]) => (
|
||||
<span
|
||||
key={key}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/8 border border-primary/20 rounded-full text-[11px] font-semibold text-primary"
|
||||
>
|
||||
<Tag className="w-2.5 h-2.5" />
|
||||
<span className="text-muted-foreground capitalize">{key}:</span>
|
||||
<span>{val}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{images.length > 0 && (
|
||||
<p className="text-[10px] text-muted-foreground text-center">
|
||||
{images.length} asset{images.length !== 1 ? 's' : ''} uploaded
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Edit Fields */}
|
||||
<div className="col-span-3 p-6 space-y-5">
|
||||
|
||||
{/* SKU */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
<Hash className="inline w-3 h-3 mr-1" />SKU Code
|
||||
</label>
|
||||
{readOnly ? (
|
||||
<p className="font-mono text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">{sku || '—'}</p>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={sku}
|
||||
onChange={e => setSku(e.target.value)}
|
||||
placeholder="e.g. PROD-RED-M"
|
||||
className="w-full border border-border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price & Cost */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
<DollarSign className="inline w-3 h-3 mr-1" />Sale Price
|
||||
</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">${price}</p>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm font-semibold">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={price}
|
||||
onChange={e => setPrice(e.target.value)}
|
||||
className="w-full border border-border rounded-lg pl-7 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
<DollarSign className="inline w-3 h-3 mr-1" />Cost Price
|
||||
</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">${costPrice}</p>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm font-semibold">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={costPrice}
|
||||
onChange={e => setCostPrice(e.target.value)}
|
||||
className="w-full border border-border rounded-lg pl-7 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stock & Status */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
<ShoppingBag className="inline w-3 h-3 mr-1" />Stock
|
||||
</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">{stock}</p>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={stock}
|
||||
onChange={e => setStock(e.target.value)}
|
||||
className="w-full border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">Status</label>
|
||||
{readOnly ? (
|
||||
<p className={`inline-flex items-center px-2.5 py-1.5 rounded-lg text-xs font-bold uppercase border ${statusColor[status]}`}>{status}</p>
|
||||
) : (
|
||||
<select
|
||||
value={status}
|
||||
onChange={e => setStatus(e.target.value as VariantStatus)}
|
||||
className="w-full border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground font-medium"
|
||||
>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: 'Available Stock', value: variant.availableStock ?? 0 },
|
||||
{ label: 'Reserved', value: variant.reservedStock ?? 0 },
|
||||
{ label: 'Safety Stock', value: variant.safetyStock ?? 0 },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="bg-surface-muted border border-border rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-foreground">{value}</div>
|
||||
<div className="text-[10px] text-muted-foreground font-medium mt-0.5">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Last updated */}
|
||||
{variant.lastUpdated && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Last updated: {new Date(variant.lastUpdated).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0 bg-background/50">
|
||||
{/* Danger actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{!readOnly && onArchive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onArchive(variant.id); onClose(); }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 border border-amber-200 text-amber-700 bg-amber-50 hover:bg-amber-100 rounded-lg text-xs font-semibold transition-colors"
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5" />
|
||||
Archive
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (window.confirm('Delete this variant?')) { onDelete(variant.id); onClose(); } }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 border border-red-200 text-red-600 bg-red-50 hover:bg-red-100 rounded-lg text-xs font-semibold transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Primary actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border border-border text-muted-foreground hover:bg-background rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{readOnly ? 'Close' : 'Cancel'}
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saveState === 'saving'}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold transition-colors disabled:opacity-60"
|
||||
>
|
||||
{saveState === 'saving' && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
{saveState === 'saved' && <CheckCircle className="w-3.5 h-3.5 text-white" />}
|
||||
{saveState === 'error' && <AlertCircle className="w-3.5 h-3.5 text-white" />}
|
||||
{saveState === 'idle' && <Save className="w-3.5 h-3.5" />}
|
||||
{saveState === 'saving' ? 'Saving…' : saveState === 'saved' ? 'Saved!' : saveState === 'error' ? 'Error' : 'Save Changes'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import type { Variant, VariantStatus } from '../../types/variant.types';
|
||||
import { Trash2, Archive, Loader, Check, CircleAlert } from 'lucide-react';
|
||||
import { Trash2, Archive, Loader, Check, CircleAlert, Image as ImageIcon, Eye } from 'lucide-react';
|
||||
|
||||
interface VariantEditorRowProps {
|
||||
variant: Variant;
|
||||
@@ -10,6 +10,8 @@ interface VariantEditorRowProps {
|
||||
onUpdate: (id: string, updates: Partial<Variant>) => Promise<any>;
|
||||
onDelete: (id: string) => void;
|
||||
onArchive: (id: string) => void;
|
||||
onViewDetail?: () => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
|
||||
@@ -19,7 +21,9 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
|
||||
onSelect,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onArchive
|
||||
onArchive,
|
||||
onViewDetail,
|
||||
readOnly
|
||||
}) => {
|
||||
const [sku, setSku] = useState(variant.sku);
|
||||
const [price, setPrice] = useState(String(variant.price));
|
||||
@@ -29,7 +33,6 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
|
||||
|
||||
const [savingStatus, setSavingStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
|
||||
// Synchronize state if variant updates externally
|
||||
useEffect(() => {
|
||||
setSku(variant.sku);
|
||||
setPrice(String(variant.price));
|
||||
@@ -39,7 +42,6 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
|
||||
}, [variant]);
|
||||
|
||||
const handleFieldSave = async () => {
|
||||
// Basic validation
|
||||
const pNum = parseFloat(price);
|
||||
const cpNum = parseFloat(costPrice);
|
||||
const sNum = parseInt(stock, 10);
|
||||
@@ -77,6 +79,57 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const images = variant.images || [];
|
||||
const primaryImg = images.find(i => i.isPrimary) || images[0];
|
||||
const thumbUrl = primaryImg?.thumbnailUrl || primaryImg?.url;
|
||||
|
||||
// ── Read-only row ──────────────────────────────────────────────────────────
|
||||
if (readOnly) {
|
||||
return (
|
||||
<tr className="hover:bg-background/50 transition-colors border-b border-border">
|
||||
<td className="px-3 py-2 text-center">
|
||||
{thumbUrl ? (
|
||||
<img src={thumbUrl} alt={variant.name} className="w-8 h-8 object-cover rounded border border-border mx-auto" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded border border-border bg-surface-muted flex items-center justify-center mx-auto text-muted-foreground">
|
||||
<ImageIcon className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-foreground">{sku}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-foreground text-xs truncate max-w-[200px]" title={variant.name || ''}>
|
||||
{(variant.name || '').split(' - ')[1] || variant.name || variant.sku}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{axesKeys.map(key => {
|
||||
const val = variant.attributes[key];
|
||||
if (!val) return null;
|
||||
return (
|
||||
<span key={key} className="inline-block bg-surface-muted text-[10px] px-1.5 py-0.5 rounded text-muted-foreground font-mono">
|
||||
{key}: {val}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-xs text-foreground">${price}</td>
|
||||
<td className="px-4 py-3 text-right text-xs text-foreground">${costPrice}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider ${
|
||||
status === 'active' ? 'bg-success/10 text-success' :
|
||||
status === 'draft' ? 'bg-warning/10 text-warning' : 'bg-surface-muted text-muted-foreground'
|
||||
}`}>
|
||||
{status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Editable row ───────────────────────────────────────────────────────────
|
||||
return (
|
||||
<tr className={`hover:bg-background/50 transition-colors border-b border-border ${isSelected ? 'bg-primary/5/10' : ''}`}>
|
||||
{/* Checkbox */}
|
||||
@@ -89,27 +142,27 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* Cartesian Combination specifications */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-foreground text-xs truncate max-w-[200px]" title={variant.name}>
|
||||
{variant.name.split(' - ')[1] || variant.name}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{axesKeys.map(key => {
|
||||
const val = variant.attributes[key];
|
||||
if (!val) return null;
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
className="inline-block bg-surface-muted text-[10px] px-1.5 py-0.5 rounded text-muted-foreground font-mono"
|
||||
>
|
||||
{key}: {val}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Image Thumbnail */}
|
||||
<td className="px-3 py-2 text-center">
|
||||
{onViewDetail ? (
|
||||
<button type="button" onClick={onViewDetail} className="group relative block mx-auto focus:outline-none">
|
||||
{thumbUrl ? (
|
||||
<img src={thumbUrl} alt={variant.name} className="w-9 h-9 object-cover rounded-lg border border-border transition-all group-hover:border-primary" />
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-lg border border-border bg-surface-muted flex items-center justify-center text-muted-foreground transition-all group-hover:border-primary">
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
thumbUrl ? (
|
||||
<img src={thumbUrl} alt={variant.name} className="w-9 h-9 object-cover rounded-lg border border-border mx-auto" />
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-lg border border-border bg-surface-muted flex items-center justify-center mx-auto text-muted-foreground">
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* SKU Input */}
|
||||
@@ -124,6 +177,26 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* Variant Specification */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-foreground text-xs truncate max-w-[200px]" title={variant.name || ''}>
|
||||
{(variant.name || '').split(' - ')[1] || variant.name || variant.sku}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{axesKeys.map(key => {
|
||||
const val = variant.attributes[key];
|
||||
if (!val) return null;
|
||||
return (
|
||||
<span key={key} className="inline-block bg-surface-muted text-[10px] px-1.5 py-0.5 rounded text-muted-foreground font-mono">
|
||||
{key}: {val}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Price Input */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="relative">
|
||||
@@ -156,18 +229,6 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Inventory Stock Input */}
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
value={stock}
|
||||
onChange={(e) => setStock(e.target.value)}
|
||||
onBlur={handleFieldSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full text-xs border border-border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-center"
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* Status dropdown */}
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
@@ -183,7 +244,7 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
|
||||
</select>
|
||||
</td>
|
||||
|
||||
{/* Save Status Spinner/Tick */}
|
||||
{/* Save Status */}
|
||||
<td className="px-4 py-3 text-center">
|
||||
{savingStatus === 'saving' && <Loader className="w-3.5 h-3.5 text-primary animate-spin mx-auto" />}
|
||||
{savingStatus === 'saved' && <Check className="w-3.5 h-3.5 text-emerald-500 mx-auto" />}
|
||||
@@ -193,6 +254,16 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
|
||||
{/* Row Actions */}
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
{onViewDetail && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onViewDetail}
|
||||
title="View & Edit Details"
|
||||
className="p-1 hover:bg-primary/10 text-muted-foreground hover:text-primary rounded transition-colors"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onArchive(variant.id)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import type { Variant } from '../../types/variant.types';
|
||||
import { VariantEditorRow } from './VariantEditorRow';
|
||||
import { VariantDetailModal } from './VariantDetailModal';
|
||||
|
||||
interface VariantListViewProps {
|
||||
variants: Variant[];
|
||||
@@ -11,6 +12,7 @@ interface VariantListViewProps {
|
||||
onUpdate: (id: string, updates: Partial<Variant>) => Promise<any>;
|
||||
onDelete: (id: string) => void;
|
||||
onArchive: (id: string) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export const VariantListView: React.FC<VariantListViewProps> = ({
|
||||
@@ -21,59 +23,83 @@ export const VariantListView: React.FC<VariantListViewProps> = ({
|
||||
onSelectAllChange,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onArchive
|
||||
onArchive,
|
||||
readOnly
|
||||
}) => {
|
||||
const [modalVariant, setModalVariant] = useState<Variant | null>(null);
|
||||
|
||||
const allSelected = variants.length > 0 && variants.every(v => selectedIds.has(v.id));
|
||||
const someSelected = variants.length > 0 && variants.some(v => selectedIds.has(v.id)) && !allSelected;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto border border-border rounded-xl bg-surface shadow-xs">
|
||||
<table className="w-full border-collapse text-left min-w-[800px]">
|
||||
<thead>
|
||||
<tr className="bg-background/70 border-b border-border text-[10px] font-bold text-muted-foreground uppercase tracking-wider">
|
||||
<th className="px-4 py-3 text-center w-12">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected;
|
||||
}}
|
||||
onChange={(e) => onSelectAllChange(e.target.checked)}
|
||||
className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-4 py-3">Variant Specification</th>
|
||||
<th className="px-4 py-3 w-48">SKU Code</th>
|
||||
<th className="px-4 py-3 w-28 text-right">Sale Price</th>
|
||||
<th className="px-4 py-3 w-28 text-right">Cost Price</th>
|
||||
<th className="px-4 py-3 w-24 text-center">Stock</th>
|
||||
<th className="px-4 py-3 w-32">Status</th>
|
||||
<th className="px-4 py-3 w-16 text-center">Save</th>
|
||||
<th className="px-4 py-3 w-24 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{variants.map(variant => (
|
||||
<VariantEditorRow
|
||||
key={variant.id}
|
||||
variant={variant}
|
||||
axesKeys={axesKeys}
|
||||
isSelected={selectedIds.has(variant.id)}
|
||||
onSelect={(checked) => onSelectChange(variant.id, checked)}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
/>
|
||||
))}
|
||||
{variants.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-6 py-10 text-center text-xs text-muted-foreground font-medium">
|
||||
No variants found matching criteria.
|
||||
</td>
|
||||
<>
|
||||
<div className="overflow-x-auto border border-border rounded-xl bg-surface shadow-xs">
|
||||
<table className="w-full border-collapse text-left min-w-[800px]">
|
||||
<thead>
|
||||
<tr className="bg-background/70 border-b border-border text-[10px] font-bold text-muted-foreground uppercase tracking-wider">
|
||||
{!readOnly && (
|
||||
<th className="px-4 py-3 text-center w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected;
|
||||
}}
|
||||
onChange={(e) => onSelectAllChange(e.target.checked)}
|
||||
className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
<th className="px-4 py-3 w-14">Image</th>
|
||||
<th className="px-4 py-3 w-44">SKU Code</th>
|
||||
<th className="px-4 py-3">Variant Specification</th>
|
||||
<th className="px-4 py-3 w-28 text-right">Sale Price</th>
|
||||
<th className="px-4 py-3 w-28 text-right">Cost Price</th>
|
||||
<th className="px-4 py-3 w-28">Status</th>
|
||||
{!readOnly && <th className="px-4 py-3 w-14 text-center">Save</th>}
|
||||
{!readOnly && <th className="px-4 py-3 w-28 text-right">Actions</th>}
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{variants.map(variant => (
|
||||
<VariantEditorRow
|
||||
key={variant.id}
|
||||
variant={variant}
|
||||
axesKeys={axesKeys}
|
||||
isSelected={selectedIds.has(variant.id)}
|
||||
onSelect={(checked) => onSelectChange(variant.id, checked)}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
onViewDetail={() => setModalVariant(variant)}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
))}
|
||||
{variants.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={readOnly ? 6 : 9} className="px-6 py-10 text-center text-xs text-muted-foreground font-medium">
|
||||
No variants found matching criteria.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{modalVariant && (
|
||||
<VariantDetailModal
|
||||
variant={modalVariant}
|
||||
onClose={() => setModalVariant(null)}
|
||||
onUpdate={async (id, updates) => {
|
||||
const updated = await onUpdate(id, updates);
|
||||
if (updated) setModalVariant(prev => prev ? { ...prev, ...updates } : null);
|
||||
return updated;
|
||||
}}
|
||||
onDelete={id => { onDelete(id); setModalVariant(null); }}
|
||||
onArchive={id => { onArchive(id); setModalVariant(null); }}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import type { Variant } from '../../types/variant.types';
|
||||
import { VariantDetailModal } from './VariantDetailModal';
|
||||
import { Image as ImageIcon, Tag, Edit2, Trash2, Archive, CheckSquare, Square } from 'lucide-react';
|
||||
|
||||
interface VariantMatrixViewProps {
|
||||
variants: Variant[];
|
||||
@@ -11,262 +13,233 @@ interface VariantMatrixViewProps {
|
||||
onUpdate: (id: string, updates: Partial<Variant>) => Promise<any>;
|
||||
onDelete: (id: string) => void;
|
||||
onArchive: (id: string) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export const VariantMatrixView: React.FC<VariantMatrixViewProps> = ({
|
||||
variants,
|
||||
axesKeys,
|
||||
axesNames,
|
||||
axesKeys: _axesKeys,
|
||||
axesNames: _axesNames,
|
||||
selectedIds,
|
||||
onSelectChange,
|
||||
onSelectAllChange,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onArchive
|
||||
onArchive,
|
||||
readOnly
|
||||
}) => {
|
||||
const [modalVariant, setModalVariant] = useState<Variant | null>(null);
|
||||
|
||||
const allSelected = variants.length > 0 && variants.every(v => selectedIds.has(v.id));
|
||||
const someSelected = variants.length > 0 && variants.some(v => selectedIds.has(v.id)) && !allSelected;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto border border-border rounded-xl bg-surface shadow-xs">
|
||||
<table className="w-full border-collapse text-left min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="bg-primary/5/30 border-b border-border text-[10px] font-bold text-muted-foreground uppercase tracking-wider">
|
||||
<th className="px-4 py-3.5 text-center w-12">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected;
|
||||
}}
|
||||
onChange={(e) => onSelectAllChange(e.target.checked)}
|
||||
className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
|
||||
/>
|
||||
</th>
|
||||
|
||||
{/* Dynamic columns for each variant axis */}
|
||||
{axesKeys.map(key => (
|
||||
<th key={key} className="px-4 py-3.5 font-bold">
|
||||
{axesNames[key] || key}
|
||||
</th>
|
||||
))}
|
||||
|
||||
<th className="px-4 py-3.5 w-48">SKU Code</th>
|
||||
<th className="px-4 py-3.5 w-28 text-right">Sale Price</th>
|
||||
<th className="px-4 py-3.5 w-28 text-right">Cost Price</th>
|
||||
<th className="px-4 py-3.5 w-24 text-center">Stock</th>
|
||||
<th className="px-4 py-3.5 w-32">Status</th>
|
||||
<th className="px-4 py-3.5 w-16 text-center">Save</th>
|
||||
<th className="px-4 py-3.5 w-24 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{variants.map(variant => (
|
||||
<tr
|
||||
key={variant.id}
|
||||
className={`hover:bg-background/50 transition-colors border-b border-border ${
|
||||
selectedIds.has(variant.id) ? 'bg-primary/5/10' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<td className="px-4 py-3 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(variant.id)}
|
||||
onChange={(e) => onSelectChange(variant.id, e.target.checked)}
|
||||
className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* Dynamic cells for each variant axis */}
|
||||
{axesKeys.map(key => {
|
||||
const val = variant.attributes[key];
|
||||
return (
|
||||
<td key={key} className="px-4 py-3">
|
||||
<span className="inline-block bg-primary-light text-primary-dark font-semibold text-xs px-2 py-0.5 rounded-full font-mono">
|
||||
{val || '—'}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Delegate fields to VariantEditorRow columns via inline styles or matching markup */}
|
||||
{/* Note: Instead of nesting a complete table inside a tr, we just render the editor cells directly in the matrix tr. */}
|
||||
{/* To make it extremely clean and reuse the state, we can let VariantEditorRow handle the cells but structure it to match. */}
|
||||
{/* But since VariantEditorRow expects specific column layouts, we can render the matching tds right here in VariantMatrixView or adapt it. */}
|
||||
{/* Adapting: Since a tr cannot easily contain another tr, let's render the editor cells inline here for the matrix view. */}
|
||||
<InlineEditorCells
|
||||
variant={variant}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
/>
|
||||
</tr>
|
||||
))}
|
||||
{variants.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={axesKeys.length + 8} className="px-6 py-10 text-center text-xs text-muted-foreground font-medium">
|
||||
No variants found matching criteria.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Inline Editor Cells Helper ────────────────────────────────────────────────
|
||||
interface InlineCellsProps {
|
||||
variant: Variant;
|
||||
onUpdate: (id: string, updates: Partial<Variant>) => Promise<any>;
|
||||
onDelete: (id: string) => void;
|
||||
onArchive: (id: string) => void;
|
||||
}
|
||||
|
||||
const InlineEditorCells: React.FC<InlineCellsProps> = ({ variant, onUpdate, onDelete, onArchive }) => {
|
||||
const [sku, setSku] = useState(variant.sku);
|
||||
const [price, setPrice] = useState(String(variant.price));
|
||||
const [costPrice, setCostPrice] = useState(String(variant.costPrice));
|
||||
const [stock, setStock] = useState(String(variant.stock));
|
||||
const [status, setStatus] = useState(variant.status);
|
||||
|
||||
const [savingStatus, setSavingStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
|
||||
useEffect(() => {
|
||||
setSku(variant.sku);
|
||||
setPrice(String(variant.price));
|
||||
setCostPrice(String(variant.costPrice));
|
||||
setStock(String(variant.stock));
|
||||
setStatus(variant.status);
|
||||
}, [variant]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const pNum = parseFloat(price);
|
||||
const cpNum = parseFloat(costPrice);
|
||||
const sNum = parseInt(stock, 10);
|
||||
|
||||
const hasChanges =
|
||||
sku !== variant.sku ||
|
||||
pNum !== variant.price ||
|
||||
cpNum !== variant.costPrice ||
|
||||
sNum !== variant.stock ||
|
||||
status !== variant.status;
|
||||
|
||||
if (!hasChanges) return;
|
||||
|
||||
setSavingStatus('saving');
|
||||
try {
|
||||
await onUpdate(variant.id, {
|
||||
sku,
|
||||
price: isNaN(pNum) ? 0 : pNum,
|
||||
costPrice: isNaN(cpNum) ? 0 : cpNum,
|
||||
stock: isNaN(sNum) ? 0 : sNum,
|
||||
status
|
||||
});
|
||||
setSavingStatus('saved');
|
||||
setTimeout(() => setSavingStatus('idle'), 1500);
|
||||
} catch (err) {
|
||||
setSavingStatus('error');
|
||||
setTimeout(() => setSavingStatus('idle'), 3000);
|
||||
const statusStyle = (s: string) => {
|
||||
switch (s) {
|
||||
case 'active': return 'bg-emerald-100 text-emerald-700 border-emerald-200';
|
||||
case 'draft': return 'bg-amber-100 text-amberald-700 border-amber-200';
|
||||
case 'inactive': return 'bg-slate-100 text-slate-500 border-slate-200';
|
||||
case 'archived': return 'bg-red-50 text-red-500 border-red-200';
|
||||
default: return 'bg-surface-muted text-muted-foreground border-border';
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).blur();
|
||||
}
|
||||
};
|
||||
if (variants.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 bg-surface border border-dashed border-border rounded-xl text-muted-foreground gap-3">
|
||||
<ImageIcon className="w-10 h-10 opacity-20" />
|
||||
<p className="text-sm font-medium">No variants found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="text"
|
||||
value={sku}
|
||||
onChange={(e) => setSku(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full text-xs border border-border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary font-mono bg-surface"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="relative">
|
||||
<span className="absolute left-1.5 top-1/2 -translate-y-1/2 text-muted-foreground text-[10px] font-semibold">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full text-xs border border-border rounded pl-4 pr-1 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-right"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="relative">
|
||||
<span className="absolute left-1.5 top-1/2 -translate-y-1/2 text-muted-foreground text-[10px] font-semibold">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={costPrice}
|
||||
onChange={(e) => setCostPrice(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full text-xs border border-border rounded pl-4 pr-1 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-right"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
value={stock}
|
||||
onChange={(e) => setStock(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full text-xs border border-border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-center"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as any)}
|
||||
onBlur={handleSave}
|
||||
className="text-xs border border-border rounded px-1.5 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface font-medium text-foreground"
|
||||
>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{savingStatus === 'saving' && <span className="inline-block w-3.5 h-3.5 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto" />}
|
||||
{savingStatus === 'saved' && <span className="text-emerald-500 font-bold text-xs">✓</span>}
|
||||
{savingStatus === 'error' && <span className="text-red-500 font-bold text-xs">⚠</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
{/* Select-all toolbar */}
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-3 mb-3 px-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onArchive(variant.id)}
|
||||
title="Archive variant"
|
||||
className="p-1 hover:bg-amber-50 text-muted-foreground hover:text-amber-600 rounded transition-colors"
|
||||
onClick={() => onSelectAllChange(!allSelected)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground font-medium transition-colors"
|
||||
>
|
||||
<span className="text-[11px]">Archive</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(variant.id)}
|
||||
title="Delete variant"
|
||||
className="p-1 hover:bg-red-50 text-muted-foreground hover:text-red-500 rounded transition-colors"
|
||||
>
|
||||
<span className="text-[11px]">Delete</span>
|
||||
{allSelected ? (
|
||||
<CheckSquare className="w-3.5 h-3.5 text-primary" />
|
||||
) : someSelected ? (
|
||||
<CheckSquare className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{allSelected ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
{selectedIds.size > 0 && (
|
||||
<span className="text-xs font-semibold text-primary bg-primary/10 border border-primary/20 px-2 py-0.5 rounded-full">
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
|
||||
{/* Card grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{variants.map(variant => {
|
||||
const images = variant.images || [];
|
||||
const primaryImg = images.find(i => i.isPrimary) || images[0];
|
||||
const thumbUrl = primaryImg?.thumbnailUrl || primaryImg?.url;
|
||||
const isSelected = selectedIds.has(variant.id);
|
||||
const axisEntries = Object.entries(variant.attributes || {});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={variant.id}
|
||||
className={`group relative flex flex-col bg-surface border rounded-2xl overflow-hidden shadow-xs transition-all duration-200 hover:shadow-md hover:-translate-y-0.5 ${
|
||||
isSelected
|
||||
? 'border-primary ring-2 ring-primary/20'
|
||||
: 'border-border hover:border-primary/30'
|
||||
}`}
|
||||
>
|
||||
{/* Selection checkbox overlay */}
|
||||
{!readOnly && (
|
||||
<div className="absolute top-2.5 left-2.5 z-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); onSelectChange(variant.id, !isSelected); }}
|
||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
|
||||
isSelected
|
||||
? 'bg-primary border-primary'
|
||||
: 'bg-white/80 border-border opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 12 12">
|
||||
<path d="M10 3L5 8.5 2 5.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status badge */}
|
||||
<div className="absolute top-2.5 right-2.5 z-10">
|
||||
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold uppercase border ${statusStyle(variant.status)}`}>
|
||||
{variant.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Image area */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalVariant(variant)}
|
||||
className="relative w-full aspect-square bg-background overflow-hidden focus:outline-none"
|
||||
>
|
||||
{thumbUrl ? (
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt={variant.name}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-1.5 text-muted-foreground/40">
|
||||
<ImageIcon className="w-8 h-8" />
|
||||
<span className="text-[10px] font-medium">No image</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image count badge */}
|
||||
{images.length > 1 && (
|
||||
<div className="absolute bottom-2 right-2 bg-black/60 text-white text-[10px] font-semibold px-1.5 py-0.5 rounded-md backdrop-blur-sm">
|
||||
+{images.length - 1}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-200 flex items-center justify-center">
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-white/90 backdrop-blur-sm rounded-full p-2 shadow-lg">
|
||||
<Edit2 className="w-4 h-4 text-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Card body */}
|
||||
<div className="p-3 flex flex-col gap-2 flex-1">
|
||||
{/* Axis pills */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{axisEntries.map(([key, val]) => (
|
||||
<span
|
||||
key={key}
|
||||
className="inline-flex items-center gap-0.5 px-2 py-0.5 bg-primary/8 border border-primary/15 rounded-full text-[10px] font-semibold text-primary"
|
||||
>
|
||||
<Tag className="w-2.5 h-2.5 opacity-60" />
|
||||
{val}
|
||||
</span>
|
||||
))}
|
||||
{axisEntries.length === 0 && (
|
||||
<span className="text-[10px] text-muted-foreground">No axes</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SKU */}
|
||||
<div className="font-mono text-[10px] text-muted-foreground truncate" title={variant.sku}>
|
||||
{variant.sku || '—'}
|
||||
</div>
|
||||
|
||||
{/* Price row */}
|
||||
<div className="flex items-center justify-between mt-auto pt-1 border-t border-border">
|
||||
<span className="text-sm font-bold text-foreground">
|
||||
{variant.price > 0 ? `$${variant.price.toFixed(2)}` : <span className="text-muted-foreground text-xs">No price</span>}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{!readOnly && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); setModalVariant(variant); }}
|
||||
title="Edit"
|
||||
className="p-1 hover:bg-primary/10 text-muted-foreground hover:text-primary rounded transition-colors"
|
||||
>
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); onArchive(variant.id); }}
|
||||
title="Archive"
|
||||
className="p-1 hover:bg-amber-50 text-muted-foreground hover:text-amber-600 rounded transition-colors"
|
||||
>
|
||||
<Archive className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); if (window.confirm('Delete variant?')) onDelete(variant.id); }}
|
||||
title="Delete"
|
||||
className="p-1 hover:bg-red-50 text-muted-foreground hover:text-red-500 rounded transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Variant Detail Modal */}
|
||||
{modalVariant && (
|
||||
<VariantDetailModal
|
||||
variant={modalVariant}
|
||||
onClose={() => setModalVariant(null)}
|
||||
onUpdate={async (id, updates) => {
|
||||
const updated = await onUpdate(id, updates);
|
||||
// Reflect updated data in the modal
|
||||
if (updated) setModalVariant(prev => prev ? { ...prev, ...updates } : null);
|
||||
return updated;
|
||||
}}
|
||||
onDelete={onDelete ? id => { onDelete(id); setModalVariant(null); } : undefined}
|
||||
onArchive={onArchive ? id => { onArchive(id); setModalVariant(null); } : undefined}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,24 +5,36 @@ import { VariantListView } from './VariantListView';
|
||||
import { VariantMatrixView } from './VariantMatrixView';
|
||||
import { VariantBulkActions } from './VariantBulkActions';
|
||||
import type { VariantAxis, VariantStatus, Variant } from '../../types/variant.types';
|
||||
import { Info, LayoutGrid, List, Plus, RefreshCw, Layers } from 'lucide-react';
|
||||
import { Info, LayoutGrid, List, Plus, RefreshCw, Layers, X } from 'lucide-react';
|
||||
import { Loader } from '../../../../components/customs/Loader';
|
||||
import { notify } from '../../../../services/toast';
|
||||
|
||||
interface VariantsTabProps {
|
||||
productId?: string;
|
||||
productType: string;
|
||||
parentSku: string;
|
||||
family: any; // Product Family details
|
||||
readOnly?: boolean;
|
||||
productAttributes?: Record<string, any>;
|
||||
availableAttributes?: any[];
|
||||
onVariantsChange?: (variants: Variant[]) => void;
|
||||
initialVariants?: Variant[];
|
||||
}
|
||||
|
||||
export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
productId,
|
||||
productType,
|
||||
parentSku,
|
||||
family
|
||||
family,
|
||||
readOnly,
|
||||
productAttributes = {},
|
||||
availableAttributes = [],
|
||||
onVariantsChange,
|
||||
initialVariants = []
|
||||
}) => {
|
||||
const {
|
||||
variants,
|
||||
setVariants,
|
||||
loading,
|
||||
generating,
|
||||
fetchByProduct,
|
||||
@@ -31,12 +43,37 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
archiveVariant,
|
||||
generateBatch,
|
||||
bulkUpdate
|
||||
} = useVariant();
|
||||
} = useVariant(initialVariants);
|
||||
|
||||
// Sync to parent when variants change
|
||||
useEffect(() => {
|
||||
if (onVariantsChange) {
|
||||
onVariantsChange(variants);
|
||||
}
|
||||
}, [variants, onVariantsChange]);
|
||||
|
||||
const [viewLayout, setViewLayout] = useState<'list' | 'matrix'>('matrix');
|
||||
const [showGenerator, setShowGenerator] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Local state for dynamically configured variant axes (flows from family, reconstructed from variants, or custom selected)
|
||||
const [localAxes, setLocalAxes] = useState<VariantAxis[]>([]);
|
||||
|
||||
// Filter attributes that are marked as variant eligible OR are of eligible types (including type 'color')
|
||||
const selectableAttributes = useMemo(() => {
|
||||
return availableAttributes.filter(attr =>
|
||||
attr.is_variant_eligible === true ||
|
||||
attr.isVariantEligible === true ||
|
||||
['select', 'enumeration', 'swatch', 'multiselect', 'color'].includes(attr.type || '')
|
||||
);
|
||||
}, [availableAttributes]);
|
||||
|
||||
// Form states for axis addition
|
||||
const [selectedAttrId, setSelectedAttrId] = useState('');
|
||||
const [customAxisName, setCustomAxisName] = useState('');
|
||||
const [customAxisCode, setCustomAxisCode] = useState('');
|
||||
const [isAxesConfigOpen, setIsAxesConfigOpen] = useState(false);
|
||||
|
||||
// Load existing variants if product is created
|
||||
useEffect(() => {
|
||||
if (productId) {
|
||||
@@ -44,34 +81,202 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
}
|
||||
}, [productId, fetchByProduct]);
|
||||
|
||||
// Extract configured axes from the family
|
||||
const variantAxes: VariantAxis[] = useMemo(() => {
|
||||
if (!family || !Array.isArray(family.variantAxes)) return [];
|
||||
return family.variantAxes;
|
||||
}, [family]);
|
||||
// Resolve relevant variant axes for this product (prioritized hierarchy: family axes -> existing variants -> product attributes with values)
|
||||
useEffect(() => {
|
||||
const axesMap = new Map<string, VariantAxis>();
|
||||
|
||||
// Priority 1: Family blueprint variant axes (if explicitly configured)
|
||||
if (family && Array.isArray(family.variantAxes) && family.variantAxes.length > 0) {
|
||||
family.variantAxes.forEach((fa: any) => axesMap.set(fa.code, fa));
|
||||
}
|
||||
|
||||
// Priority 2: Existing variants' actual attribute keys
|
||||
if (variants.length > 0) {
|
||||
variants.forEach(v => {
|
||||
if (v.attributes) {
|
||||
Object.keys(v.attributes).forEach(key => {
|
||||
if (!axesMap.has(key)) {
|
||||
const foundAttr = selectableAttributes.find(a => a.code === key);
|
||||
axesMap.set(key, {
|
||||
id: foundAttr?.id || key,
|
||||
code: key,
|
||||
name: foundAttr?.name || key.toUpperCase().replace(/_VARIANT/g, '').replace(/_/g, ' '),
|
||||
type: foundAttr?.type || 'select',
|
||||
optionsList: foundAttr?.optionsList || []
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Priority 3: Only if no axes found yet, look at product attributes that have values set
|
||||
if (axesMap.size === 0 && productAttributes && selectableAttributes.length > 0) {
|
||||
selectableAttributes.forEach(attr => {
|
||||
const val = productAttributes[attr.code];
|
||||
if (val !== undefined && val !== null && val !== '' && !(Array.isArray(val) && val.length === 0)) {
|
||||
if (!axesMap.has(attr.code)) {
|
||||
axesMap.set(attr.code, {
|
||||
...attr,
|
||||
id: attr.id,
|
||||
code: attr.code,
|
||||
name: attr.name,
|
||||
type: attr.type || 'select',
|
||||
optionsList: attr.optionsList || []
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Preserve manually added local axes
|
||||
localAxes.forEach(la => {
|
||||
if (!axesMap.has(la.code)) {
|
||||
axesMap.set(la.code, la);
|
||||
}
|
||||
});
|
||||
|
||||
const resolved = Array.from(axesMap.values());
|
||||
const currentKeys = localAxes.map(la => la.code).sort().join(',');
|
||||
const newKeys = resolved.map(r => r.code).sort().join(',');
|
||||
if (currentKeys !== newKeys && resolved.length > 0) {
|
||||
setLocalAxes(resolved);
|
||||
}
|
||||
}, [family, variants, selectableAttributes, productAttributes]);
|
||||
|
||||
const handleAddAttributeAxis = () => {
|
||||
if (!selectedAttrId) return;
|
||||
const attr = selectableAttributes.find(a => a.id === selectedAttrId);
|
||||
if (!attr) return;
|
||||
|
||||
if (localAxes.some(la => la.code === attr.code)) {
|
||||
notify.error(`Axis with code "${attr.code}" is already added.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newAxis: VariantAxis = {
|
||||
...attr,
|
||||
id: attr.id,
|
||||
code: attr.code,
|
||||
name: attr.name,
|
||||
type: attr.type || 'select',
|
||||
optionsList: attr.optionsList || []
|
||||
};
|
||||
|
||||
setLocalAxes(prev => [...prev, newAxis]);
|
||||
setSelectedAttrId('');
|
||||
notify.success(`Added axis: ${attr.name}`);
|
||||
};
|
||||
|
||||
const handleAddCustomAxis = () => {
|
||||
const name = customAxisName.trim();
|
||||
let code = customAxisCode.trim().toLowerCase().replace(/[^a-z0-9]/g, '_');
|
||||
|
||||
if (!name) {
|
||||
notify.error('Please enter a name for the custom axis.');
|
||||
return;
|
||||
}
|
||||
if (!code) {
|
||||
code = name.toLowerCase().replace(/[^a-z0-9]/g, '_');
|
||||
}
|
||||
|
||||
if (localAxes.some(la => la.code === code)) {
|
||||
notify.error(`Axis with code "${code}" is already added.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newAxis: VariantAxis = {
|
||||
id: `custom-${Date.now()}`,
|
||||
code,
|
||||
name,
|
||||
type: 'select',
|
||||
optionsList: []
|
||||
};
|
||||
|
||||
setLocalAxes(prev => [...prev, newAxis]);
|
||||
setCustomAxisName('');
|
||||
setCustomAxisCode('');
|
||||
notify.success(`Added custom axis: ${name}`);
|
||||
};
|
||||
|
||||
const handleRemoveAxis = (code: string) => {
|
||||
setLocalAxes(prev => prev.filter(la => la.code !== code));
|
||||
notify.info(`Removed axis: ${code}`);
|
||||
};
|
||||
|
||||
const initialSelectedValues = useMemo(() => {
|
||||
const map: Record<string, string[]> = {};
|
||||
if (!productAttributes) return map;
|
||||
|
||||
localAxes.forEach(axis => {
|
||||
const val = productAttributes[axis.code];
|
||||
if (val !== undefined && val !== null && val !== '') {
|
||||
if (Array.isArray(val)) {
|
||||
map[axis.code] = val.map(String);
|
||||
} else if (typeof val === 'string' && val.includes(',')) {
|
||||
map[axis.code] = val.split(',').map(s => s.trim());
|
||||
} else {
|
||||
map[axis.code] = [String(val)];
|
||||
}
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [localAxes, productAttributes]);
|
||||
|
||||
// Helper: cartesian product of axes
|
||||
const cartesian = (axes: { code: string; name: string; values: string[] }[]): Array<Array<{ code: string; value: string }>> => {
|
||||
if (!axes || axes.length === 0) return [];
|
||||
return axes.reduce<Array<Array<{ code: string; value: string }>>>((acc, axis) => {
|
||||
if (!axis.values || axis.values.length === 0) return acc;
|
||||
if (acc.length === 0) return axis.values.map(v => [{ code: axis.code, value: v }]);
|
||||
return acc.flatMap(combo => axis.values.map(v => [...combo, { code: axis.code, value: v }]));
|
||||
}, []);
|
||||
};
|
||||
|
||||
// Helper: build SKU from template
|
||||
const buildSku = (template: string | undefined, pSku: string, combo: Array<{ code: string; value: string }>): string => {
|
||||
let sku = template || '{PARENT_SKU}-{COMBO}';
|
||||
sku = sku.replace('{PARENT_SKU}', pSku || 'SKU');
|
||||
const comboStr = combo.map(c => c.value.replace(/\s+/g, '')).join('-');
|
||||
sku = sku.replace('{COMBO}', comboStr);
|
||||
for (const { code, value } of combo) {
|
||||
sku = sku.replace(new RegExp(`\\{${code}\\}`, 'gi'), value.replace(/\s+/g, ''));
|
||||
}
|
||||
return sku.toUpperCase();
|
||||
};
|
||||
|
||||
// Filter out unconfigured simple master variants (0 attributes) and ensure strict parent productId matching when productId is present
|
||||
const configuredVariants = useMemo(() => {
|
||||
if (!Array.isArray(variants)) return [];
|
||||
return variants.filter(v => {
|
||||
if (!v) return false;
|
||||
const vParentId = v.parentProductId || (v as any).product_id || (v as any).productId;
|
||||
if (productId && vParentId && vParentId !== productId) return false;
|
||||
return v.attributes && typeof v.attributes === 'object' && Object.keys(v.attributes).length > 0;
|
||||
});
|
||||
}, [variants, productId]);
|
||||
|
||||
// Derive axesKeys dynamically from actual configured variants if present, or fallback to localAxes
|
||||
const axesKeys = useMemo(() => {
|
||||
const keysSet = new Set<string>();
|
||||
configuredVariants.forEach(v => {
|
||||
if (v.attributes) {
|
||||
Object.keys(v.attributes).forEach(k => keysSet.add(k));
|
||||
}
|
||||
});
|
||||
if (keysSet.size > 0) {
|
||||
return Array.from(keysSet);
|
||||
}
|
||||
return (localAxes || []).map(a => a.code);
|
||||
}, [configuredVariants, localAxes]);
|
||||
|
||||
const axesKeys = useMemo(() => variantAxes.map(a => a.code), [variantAxes]);
|
||||
|
||||
const axesNames = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
variantAxes.forEach(a => {
|
||||
(localAxes || []).forEach(a => {
|
||||
map[a.code] = a.name;
|
||||
});
|
||||
return map;
|
||||
}, [variantAxes]);
|
||||
|
||||
// If the family does not support variants
|
||||
if (variantAxes.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center bg-surface rounded-xl border border-border shadow-sm">
|
||||
<Layers className="w-10 h-10 text-muted-foreground mx-auto mb-3" />
|
||||
<h3 className="font-semibold text-foreground mb-1">This Product Family does not support variants.</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||
The assigned Product Family ({family?.name || 'Selected Family'}) has no variant axes configured.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [localAxes]);
|
||||
|
||||
// If the product is not Configurable (type !== 'variant')
|
||||
if (productType !== 'variant') {
|
||||
@@ -86,22 +291,9 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// If parent product has not been created/saved yet
|
||||
if (!productId) {
|
||||
return (
|
||||
<div className="p-8 text-center bg-surface rounded-xl border border-border shadow-sm">
|
||||
<Info className="w-10 h-10 text-primary mx-auto mb-3 animate-bounce" />
|
||||
<h3 className="font-semibold text-foreground mb-1">Save Product to Configure Variants</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||
You must create and save the basic product information first before you can configure and generate variants. Please fill in the required fields in the General step and click **Create Draft** on the header bar.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleGenerate = async (selected: Record<string, string[]>, skuTemplate: string) => {
|
||||
const formattedAxes = Object.entries(selected).map(([code, values]) => {
|
||||
const axisInfo = variantAxes.find(a => a.code === code);
|
||||
const axisInfo = localAxes.find(a => a.code === code);
|
||||
return {
|
||||
code,
|
||||
name: axisInfo?.name || code,
|
||||
@@ -109,15 +301,82 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await generateBatch({
|
||||
productId,
|
||||
axes: formattedAxes,
|
||||
skuTemplate
|
||||
});
|
||||
setShowGenerator(false);
|
||||
} catch (err) {
|
||||
// toast notification is done inside the hook
|
||||
if (productId) {
|
||||
try {
|
||||
await generateBatch({
|
||||
productId,
|
||||
axes: formattedAxes,
|
||||
skuTemplate
|
||||
});
|
||||
setShowGenerator(false);
|
||||
} catch (err) {
|
||||
// handled in hook
|
||||
}
|
||||
} else {
|
||||
// Local client-side generation before product is persisted
|
||||
const combinations = cartesian(formattedAxes);
|
||||
if (combinations.length === 0) {
|
||||
notify.error('No combinations could be generated from the selected values');
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect duplicates
|
||||
const existingSignatures = new Set(
|
||||
(variants || []).map(v => {
|
||||
return JSON.stringify(Object.fromEntries(Object.entries(v.attributes || {}).sort()));
|
||||
})
|
||||
);
|
||||
|
||||
const parentSkuVal = parentSku || 'SKU';
|
||||
const newVariants: Variant[] = [];
|
||||
let createdCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const combo of combinations) {
|
||||
const attrMap: Record<string, string> = {};
|
||||
for (const { code, value } of combo) {
|
||||
attrMap[code] = value;
|
||||
}
|
||||
const sig = JSON.stringify(Object.fromEntries(Object.entries(attrMap).sort()));
|
||||
|
||||
if (existingSignatures.has(sig)) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const generatedSku = buildSku(skuTemplate, parentSkuVal, combo);
|
||||
const variantName = combo.map(c => c.value).join(' / ');
|
||||
|
||||
const newVariant: Variant = {
|
||||
id: `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
sku: generatedSku,
|
||||
name: variantName,
|
||||
parentProductId: '',
|
||||
attributes: attrMap,
|
||||
status: 'draft',
|
||||
price: 0,
|
||||
costPrice: 0,
|
||||
currency: 'USD',
|
||||
stock: 0,
|
||||
availableStock: 0,
|
||||
reservedStock: 0,
|
||||
safetyStock: 0,
|
||||
images: []
|
||||
};
|
||||
|
||||
existingSignatures.add(sig);
|
||||
newVariants.push(newVariant);
|
||||
createdCount++;
|
||||
}
|
||||
|
||||
if (createdCount > 0) {
|
||||
setVariants(prev => [...prev, ...newVariants]);
|
||||
notify.success(`Generated ${createdCount} variant(s)${skippedCount > 0 ? `, skipped ${skippedCount} duplicates` : ''}`);
|
||||
setShowGenerator(false);
|
||||
} else {
|
||||
notify.info('All combinations already exist — no new variants created');
|
||||
setShowGenerator(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -141,17 +400,16 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkUpdates = async (updates: { price?: number; costPrice?: number; stock?: number; status?: VariantStatus }) => {
|
||||
const handleBulkUpdates = async (updates: { price?: number; costPrice?: number; status?: VariantStatus }) => {
|
||||
try {
|
||||
const ids = Array.from(selectedIds);
|
||||
await bulkUpdate({
|
||||
ids,
|
||||
updates
|
||||
});
|
||||
// Refresh items
|
||||
fetchByProduct(productId);
|
||||
if (productId) fetchByProduct(productId);
|
||||
setSelectedIds(new Set());
|
||||
} catch (err) {}
|
||||
} catch (err) { }
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
@@ -159,7 +417,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
try {
|
||||
await Promise.all(Array.from(selectedIds).map(id => deleteVariant(id)));
|
||||
setSelectedIds(new Set());
|
||||
} catch (err) {}
|
||||
} catch (err) { }
|
||||
};
|
||||
|
||||
const handleBulkArchive = async () => {
|
||||
@@ -167,13 +425,108 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
try {
|
||||
await Promise.all(Array.from(selectedIds).map(id => archiveVariant(id)));
|
||||
setSelectedIds(new Set());
|
||||
} catch (err) {}
|
||||
} catch (err) { }
|
||||
};
|
||||
|
||||
const handleSingleUpdate = async (id: string, updates: Partial<Variant>) => {
|
||||
return updateVariant(id, updates);
|
||||
};
|
||||
|
||||
const renderAxisCreatorControls = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-2">
|
||||
{/* Option A: Choose from Attribute Set */}
|
||||
{selectableAttributes.length > 0 && (
|
||||
<div className="border border-border/80 rounded-xl p-5 bg-background/25 space-y-4">
|
||||
<div>
|
||||
<h4 className="font-semibold text-foreground text-xs uppercase tracking-wider">A. Choose from Attribute Set</h4>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">Designate a dropdown/select attribute from your assigned Attribute Set.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={selectedAttrId}
|
||||
onChange={(e) => setSelectedAttrId(e.target.value)}
|
||||
className="w-full text-xs border border-border rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary bg-surface font-medium text-foreground"
|
||||
>
|
||||
<option value="">Select Attribute...</option>
|
||||
{selectableAttributes.map(attr => (
|
||||
<option key={attr.id} value={attr.id}>
|
||||
{attr.name} ({attr.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddAttributeAxis}
|
||||
disabled={!selectedAttrId}
|
||||
className="px-3 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Add Axis
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Option B: Create Custom Axis */}
|
||||
<div className="border border-border/80 rounded-xl p-5 bg-background/25 space-y-4">
|
||||
<div>
|
||||
<h4 className="font-semibold text-foreground text-xs uppercase tracking-wider">B. Create Custom Axis</h4>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">Create a custom variant axis not present in the attribute set (e.g. Size, Color).</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Axis Name (e.g. Size)"
|
||||
value={customAxisName}
|
||||
onChange={(e) => setCustomAxisName(e.target.value)}
|
||||
className="w-full text-xs border border-border rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-foreground"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Axis Code (e.g. size)"
|
||||
value={customAxisCode}
|
||||
onChange={(e) => setCustomAxisCode(e.target.value)}
|
||||
className="w-full text-xs border border-border rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddCustomAxis}
|
||||
className="px-3 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold"
|
||||
>
|
||||
Add Custom Axis
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List of currently added local axes */}
|
||||
{localAxes.length > 0 && (
|
||||
<div className="border border-border rounded-xl p-5 bg-background/10 space-y-3">
|
||||
<h4 className="font-semibold text-foreground text-xs uppercase tracking-wider">Designated Variant Axes ({localAxes.length})</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{localAxes.map(axis => (
|
||||
<span key={axis.code} className="inline-flex items-center gap-1.5 px-3 py-1 bg-surface border border-border rounded-lg text-xs font-semibold text-foreground">
|
||||
{axis.name} <span className="text-muted-foreground font-mono">({axis.code})</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveAxis(axis.code)}
|
||||
className="p-0.5 hover:bg-red-50 hover:text-red-500 rounded transition-colors text-muted-foreground"
|
||||
title="Remove axis"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Loading spinner for variant fetching
|
||||
if (loading && variants.length === 0) {
|
||||
return (
|
||||
@@ -183,26 +536,64 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Show generator UI if variants list is empty or generator toggled on
|
||||
if (variants.length === 0 || showGenerator) {
|
||||
// If local variant axes list is empty, they must add at least one axis
|
||||
if (localAxes.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4 bg-surface rounded-xl border border-border p-6 shadow-sm">
|
||||
<div className="border-b border-border pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="w-5 h-5 text-primary" />
|
||||
<h3 className="font-semibold text-foreground text-sm">Configure Variant Axes</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
This product has no variant axes defined. Designate attributes from your Attribute Set or add custom ones to enable variant generation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{renderAxisCreatorControls()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show generator UI if configured variants list is empty or generator toggled on
|
||||
if (configuredVariants.length === 0 || showGenerator) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{variants.length > 0 && (
|
||||
<div className="flex justify-start">
|
||||
<div className="flex justify-between items-center gap-4 flex-wrap">
|
||||
<div className="flex gap-2">
|
||||
{configuredVariants.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGenerator(false)}
|
||||
className="px-3 py-1.5 border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold"
|
||||
>
|
||||
Cancel and view variants
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGenerator(false)}
|
||||
className="px-3 py-1.5 border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold"
|
||||
onClick={() => setIsAxesConfigOpen(!isAxesConfigOpen)}
|
||||
className="px-3 py-1.5 bg-surface hover:bg-background border border-border text-foreground rounded-lg text-xs font-semibold flex items-center gap-1.5"
|
||||
>
|
||||
Cancel and view variants
|
||||
<Layers className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{isAxesConfigOpen ? 'Hide Axes Config' : 'Configure Variant Axes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAxesConfigOpen && (
|
||||
<div className="bg-surface rounded-xl border border-border p-5 shadow-xs space-y-4">
|
||||
<h4 className="font-semibold text-foreground text-xs uppercase tracking-wider">Configure Variant Axes</h4>
|
||||
{renderAxisCreatorControls()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<VariantAxesSelector
|
||||
axes={variantAxes}
|
||||
axes={localAxes}
|
||||
onGenerate={handleGenerate}
|
||||
generating={generating}
|
||||
parentSku={parentSku}
|
||||
initialSelectedValues={initialSelectedValues}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -213,17 +604,21 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
{/* View settings panel */}
|
||||
<div className="flex items-center justify-between bg-surface rounded-xl border border-border p-4 shadow-xs">
|
||||
<div className="flex items-center gap-4">
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGenerator(true)}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add / Generate Variants
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGenerator(true)}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add / Generate Variants
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchByProduct(productId)}
|
||||
onClick={() => {
|
||||
if (productId) fetchByProduct(productId);
|
||||
}}
|
||||
className="p-1.5 border border-border hover:bg-background text-muted-foreground rounded-lg"
|
||||
title="Refresh variants list"
|
||||
>
|
||||
@@ -236,11 +631,10 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewLayout('matrix')}
|
||||
className={`inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium transition-all ${
|
||||
viewLayout === 'matrix'
|
||||
? 'bg-surface text-foreground shadow-xs'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
className={`inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium transition-all ${viewLayout === 'matrix'
|
||||
? 'bg-surface text-foreground shadow-xs'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid className="w-3.5 h-3.5" />
|
||||
Matrix Grid
|
||||
@@ -248,11 +642,10 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewLayout('list')}
|
||||
className={`inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium transition-all ${
|
||||
viewLayout === 'list'
|
||||
? 'bg-surface text-foreground shadow-xs'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
className={`inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium transition-all ${viewLayout === 'list'
|
||||
? 'bg-surface text-foreground shadow-xs'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<List className="w-3.5 h-3.5" />
|
||||
List Table
|
||||
@@ -271,7 +664,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
{/* Primary variants view switcher */}
|
||||
{viewLayout === 'matrix' ? (
|
||||
<VariantMatrixView
|
||||
variants={variants}
|
||||
variants={configuredVariants}
|
||||
axesKeys={axesKeys}
|
||||
axesNames={axesNames}
|
||||
selectedIds={selectedIds}
|
||||
@@ -280,10 +673,11 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
onUpdate={handleSingleUpdate}
|
||||
onDelete={deleteVariant}
|
||||
onArchive={archiveVariant}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
) : (
|
||||
<VariantListView
|
||||
variants={variants}
|
||||
variants={configuredVariants}
|
||||
axesKeys={axesKeys}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={handleSelectChange}
|
||||
@@ -291,6 +685,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
onUpdate={handleSingleUpdate}
|
||||
onDelete={deleteVariant}
|
||||
onArchive={archiveVariant}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { familyService } from '../../family/services/family.service';
|
||||
import { attributeSetsService } from '../../attribute-sets/services/attribute-sets.service';
|
||||
|
||||
export const useProductFamilyConfiguration = (familyId?: string) => {
|
||||
const [family, setFamily] = useState<any | null>(null);
|
||||
@@ -30,9 +31,18 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
|
||||
setFamily(blueprint);
|
||||
setAllowedBrands(blueprint.allowedBrands || []);
|
||||
setCategory(blueprint.category || null);
|
||||
setAttributeSet(blueprint.attributeSet || null);
|
||||
|
||||
const groupsData = blueprint.groups || blueprint.attributeGroups || [];
|
||||
let setObj = blueprint.attributeSet || blueprint.attribute_set;
|
||||
const setId = blueprint.attribute_set_id || blueprint.attributeSetId || (setObj ? setObj.id : null);
|
||||
if ((!setObj || !setObj.groups || setObj.groups.length === 0) && setId) {
|
||||
const fetchedSet = await attributeSetsService.getById(setId).catch(() => null);
|
||||
if (fetchedSet) setObj = fetchedSet;
|
||||
}
|
||||
setAttributeSet(setObj || null);
|
||||
|
||||
const groupsData = (setObj && Array.isArray(setObj.groups) && setObj.groups.length > 0)
|
||||
? setObj.groups
|
||||
: (blueprint.groups || blueprint.attributeGroups || []);
|
||||
let flatAttrs: any[] = [];
|
||||
if (Array.isArray(groupsData) && groupsData.length > 0) {
|
||||
setGroups(groupsData);
|
||||
@@ -64,8 +74,20 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
|
||||
? blueprint.variantAxes.map((va: any) => typeof va === 'string' ? (allAttrsMap.get(va) || { id: va, code: va, name: va }) : va)
|
||||
: [];
|
||||
|
||||
const resolvedProductType =
|
||||
blueprint.productType ||
|
||||
blueprint.product_type ||
|
||||
(blueprint.completenessRules ? blueprint.completenessRules.productType : null) ||
|
||||
null;
|
||||
|
||||
const normalizedBlueprint = {
|
||||
...blueprint,
|
||||
attributeSet: setObj || null,
|
||||
attributeSetId: setId || null,
|
||||
attribute_set_id: setId || null,
|
||||
groups: groupsData,
|
||||
attributes: flatAttrs,
|
||||
productType: resolvedProductType,
|
||||
variantAxes: resolvedVariantAxes,
|
||||
variantEnabled: resolvedVariantAxes.length > 0 || Boolean(blueprint.variantEnabled)
|
||||
};
|
||||
@@ -73,7 +95,7 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
|
||||
setFamily(normalizedBlueprint);
|
||||
setAllowedBrands(blueprint.allowedBrands || []);
|
||||
setCategory(blueprint.category || null);
|
||||
setAttributeSet(blueprint.attributeSet || null);
|
||||
setAttributeSet(setObj || null);
|
||||
setWorkflow(blueprint.workflow || (blueprint.workflowCode ? { code: blueprint.workflowCode } : null));
|
||||
setAssetFamily(blueprint.assetRequirements || blueprint.assetFamily || null);
|
||||
|
||||
|
||||
@@ -8,12 +8,15 @@ import type {
|
||||
} from '../types/variant.types';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const useVariant = () => {
|
||||
const [variants, setVariants] = useState<Variant[]>([]);
|
||||
export const useVariant = (initialVariants: Variant[] = []) => {
|
||||
const [variants, setVariants] = useState<Variant[]>(initialVariants);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const fetchByProduct = useCallback(async (productId: string) => {
|
||||
if (!productId || productId === 'new' || productId === 'null' || productId === 'undefined') {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await variantService.getByProduct(productId);
|
||||
@@ -26,6 +29,10 @@ export const useVariant = () => {
|
||||
}, []);
|
||||
|
||||
const updateVariant = useCallback(async (id: string, updates: VariantUpdateRequest) => {
|
||||
if (id.startsWith('temp-')) {
|
||||
setVariants(prev => prev.map(v => v.id === id ? { ...v, ...updates } : v));
|
||||
return { id, ...updates } as Variant;
|
||||
}
|
||||
try {
|
||||
const updated = await variantService.update(id, updates);
|
||||
setVariants(prev => prev.map(v => v.id === id ? { ...v, ...updated } : v));
|
||||
@@ -37,6 +44,11 @@ export const useVariant = () => {
|
||||
}, []);
|
||||
|
||||
const deleteVariant = useCallback(async (id: string) => {
|
||||
if (id.startsWith('temp-')) {
|
||||
setVariants(prev => prev.filter(v => v.id !== id));
|
||||
toast.success('Variant deleted');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await variantService.delete(id);
|
||||
setVariants(prev => prev.filter(v => v.id !== id));
|
||||
@@ -48,6 +60,11 @@ export const useVariant = () => {
|
||||
}, []);
|
||||
|
||||
const archiveVariant = useCallback(async (id: string) => {
|
||||
if (id.startsWith('temp-')) {
|
||||
setVariants(prev => prev.map(v => v.id === id ? { ...v, status: 'archived' } : v));
|
||||
toast.success('Variant archived');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await variantService.archive(id);
|
||||
setVariants(prev => prev.map(v => v.id === id ? { ...v, status: 'archived' } : v));
|
||||
@@ -78,18 +95,29 @@ export const useVariant = () => {
|
||||
}, []);
|
||||
|
||||
const bulkUpdate = useCallback(async (req: BulkUpdateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const results = await variantService.bulkUpdate(req);
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
// Refresh variants after bulk
|
||||
toast.success(`Updated ${successCount}/${req.ids.length} variants`);
|
||||
return results;
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Bulk update failed');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const tempIds = req.ids.filter(id => id.startsWith('temp-'));
|
||||
const realIds = req.ids.filter(id => !id.startsWith('temp-'));
|
||||
|
||||
if (tempIds.length > 0) {
|
||||
setVariants(prev => prev.map(v => tempIds.includes(v.id) ? { ...v, ...req.updates } : v));
|
||||
}
|
||||
|
||||
if (realIds.length > 0) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const results = await variantService.bulkUpdate({ ...req, ids: realIds });
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
toast.success(`Updated ${successCount + tempIds.length}/${req.ids.length} variants`);
|
||||
return results;
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Bulk update failed');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
toast.success(`Updated ${tempIds.length}/${req.ids.length} variants`);
|
||||
return tempIds.map(id => ({ id, success: true }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,7 @@ function ProductThumb({ name: _name }: { name: string }) {
|
||||
// ── Main page ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ProductList() {
|
||||
const { canImport, canExport } = usePermissions("products.items");
|
||||
const { canEdit, canDelete, canImport, canExport } = usePermissions("products.items");
|
||||
const navigate = useNavigate();
|
||||
const { products, fetchProducts, deleteProduct, loading } = useProduct();
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
@@ -184,8 +184,8 @@ export default function ProductList() {
|
||||
pageSizeOptions={[5, 10, 25, 50]}
|
||||
actionConfig={{
|
||||
onView: (row) => navigate(`/products/${row.id}/edit`),
|
||||
onEdit: (row) => navigate(`/products/${row.id}/edit`),
|
||||
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
|
||||
onEdit: canEdit ? ((row) => navigate(`/products/${row.id}/edit`)) : undefined,
|
||||
onDelete: canDelete ? ((row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })) : undefined
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -47,16 +47,19 @@ export interface Variant {
|
||||
barcode?: string;
|
||||
weight?: number;
|
||||
dimensions?: { length?: number; width?: number; height?: number };
|
||||
images?: VariantImageSlot[];
|
||||
images: VariantImageSlot[];
|
||||
}
|
||||
|
||||
// ─── Image slot (prepared for Phase 3 DAM integration) ────────────────────
|
||||
export interface VariantImageSlot {
|
||||
assetId?: string;
|
||||
url?: string;
|
||||
thumbnailUrl?: string;
|
||||
name?: string;
|
||||
role: 'primary' | 'gallery' | 'swatch';
|
||||
isPrimary: boolean;
|
||||
displayOrder: number;
|
||||
assetType?: { id: string; code: string; name: string } | null;
|
||||
}
|
||||
|
||||
// ─── Axis configuration for batch generation ──────────────────────────────
|
||||
|
||||
@@ -44,11 +44,17 @@ function CardHeader({ title, subtitle }: { title: string; subtitle?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
import { useSelector } from "react-redux";
|
||||
import type { RootState } from "../../../store";
|
||||
|
||||
export default function NewRoleForm() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEdit = Boolean(id);
|
||||
|
||||
const user = useSelector((state: RootState) => state.auth.user);
|
||||
const isPlatformAdmin = user?.user_type === 'platform' || user?.type === 'platform';
|
||||
|
||||
const { nodes, fetchNodes, createRole, updateRole, nodesLoading, nodesError } = useRole();
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [permissions, setPermissions] = useState<Record<string, RolePermission>>({});
|
||||
@@ -85,7 +91,7 @@ export default function NewRoleForm() {
|
||||
const setValuesRef = useRef<((values: any) => void) | null>(null);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: { role_name: "", description: "", tenant_id: "" },
|
||||
initialValues: { role_name: "", description: "", tenant_id: isPlatformAdmin ? "" : String(user?.tenant_id || user?.tenant?.id || "") },
|
||||
validationSchema: roleSchema,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
const permList = Object.values(permissions).filter(
|
||||
@@ -456,21 +462,23 @@ export default function NewRoleForm() {
|
||||
<p className={errorClass}>{formik.errors.role_name}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Assign to Tenant</label>
|
||||
<select
|
||||
name="tenant_id"
|
||||
value={formik.values.tenant_id}
|
||||
onChange={formik.handleChange}
|
||||
className={inputClass()}
|
||||
>
|
||||
<option value="">No Tenant (Platform Role)</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.tenant_name}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-subtle-foreground">Leave empty for a global platform role.</p>
|
||||
</div>
|
||||
{isPlatformAdmin && (
|
||||
<div>
|
||||
<label className={labelClass}>Assign to Tenant</label>
|
||||
<select
|
||||
name="tenant_id"
|
||||
value={formik.values.tenant_id}
|
||||
onChange={formik.handleChange}
|
||||
className={inputClass()}
|
||||
>
|
||||
<option value="">No Tenant (Platform Role)</option>
|
||||
{tenants.map((t: any) => (
|
||||
<option key={t.id} value={t.id}>{t.name || t.tenant_name || `Tenant #${t.id}`}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-subtle-foreground">Leave empty for a global platform role.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Description</label>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import RoleList from '../pages/RoleList';
|
||||
import NewRoleForm from '../pages/NewRoleForm';
|
||||
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
|
||||
|
||||
export const RoleRoutes = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<RoleList />} />
|
||||
<Route path="/new" element={<NewRoleForm />} />
|
||||
<Route path="/:id/edit" element={<NewRoleForm />} />
|
||||
</Routes>
|
||||
<ProtectedRoute node="settings.roles">
|
||||
<Routes>
|
||||
<Route path="/" element={<RoleList />} />
|
||||
<Route path="/new" element={<NewRoleForm />} />
|
||||
<Route path="/:id/edit" element={<NewRoleForm />} />
|
||||
</Routes>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,17 +1,142 @@
|
||||
import { useState } from "react";
|
||||
import { User, Bell, Shield, Plug, Key, Palette, Globe, Database, MessageSquare, Webhook, Box, ChevronRight } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { User, Bell, Shield, Plug, Key, Palette, Globe, Database, MessageSquare, Webhook, Box, ChevronRight, Save } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useSelector } from "react-redux";
|
||||
import type { RootState } from "../../../store";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { Radio } from "../../../components/customs/Radio";
|
||||
import { usePermissions } from "../../../hooks/usePermission";
|
||||
import { fileServerService, type FileServerConfig } from "../services/fileServer.service";
|
||||
|
||||
import { settingsService } from "../services/settings.service";
|
||||
import { notify } from "../../../services/toast";
|
||||
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
|
||||
import { Can } from "../../../components/customs/Can";
|
||||
|
||||
export default function SettingList() {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState("Integrations");
|
||||
const { canView: canViewFileServer } = usePermissions('settings.file_server');
|
||||
|
||||
const user = useSelector((state: RootState) => state.auth.user);
|
||||
|
||||
const [activeTab, setActiveTab] = useState("General");
|
||||
const [requireApproval, setRequireApproval] = useState(true);
|
||||
const [autoPublish, setAutoPublish] = useState(false);
|
||||
|
||||
// File Server States
|
||||
const [fileServer, setFileServer] = useState<FileServerConfig>({
|
||||
provider: 's3',
|
||||
endpoint: '',
|
||||
access_key: '',
|
||||
secret_key: '',
|
||||
bucket_name: '',
|
||||
region: '',
|
||||
status: 'active'
|
||||
});
|
||||
const [loadingServer, setLoadingServer] = useState(false);
|
||||
const [testingServer, setTestingServer] = useState(false);
|
||||
const [testStatus, setTestStatus] = useState<{ type: 'success' | 'error' | null; message: string }>({ type: null, message: '' });
|
||||
const [saveStatus, setSaveStatus] = useState<{ type: 'success' | 'error' | null; message: string }>({ type: null, message: '' });
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "File Server" && canViewFileServer) {
|
||||
setLoadingServer(true);
|
||||
fileServerService.get()
|
||||
.then(res => {
|
||||
if (res.success && res.data) {
|
||||
setFileServer(res.data);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error(err))
|
||||
.finally(() => setLoadingServer(false));
|
||||
}
|
||||
}, [activeTab, canViewFileServer]);
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setTestingServer(true);
|
||||
setTestStatus({ type: null, message: '' });
|
||||
try {
|
||||
const res = await fileServerService.test(fileServer);
|
||||
if (res.success) {
|
||||
setTestStatus({ type: 'success', message: res.message || 'Connection test succeeded!' });
|
||||
} else {
|
||||
setTestStatus({ type: 'error', message: res.message || 'Connection test failed.' });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setTestStatus({ type: 'error', message: err.response?.data?.message || err.message || 'Connection test failed.' });
|
||||
} finally {
|
||||
setTestingServer(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
setSaveStatus({ type: null, message: '' });
|
||||
try {
|
||||
const res = await fileServerService.save(fileServer);
|
||||
if (res.success) {
|
||||
setSaveStatus({ type: 'success', message: 'Settings saved successfully!' });
|
||||
if (res.data) {
|
||||
setFileServer(res.data);
|
||||
}
|
||||
} else {
|
||||
setSaveStatus({ type: 'error', message: res.message || 'Failed to save settings.' });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setSaveStatus({ type: 'error', message: err.response?.data?.message || err.message || 'Failed to save settings.' });
|
||||
}
|
||||
};
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [orgName, setOrgName] = useState(user?.tenant?.name || "Organization");
|
||||
const [subdomain, setSubdomain] = useState(user?.tenant?.tenant_code || user?.tenant?.domain || "org");
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.tenant?.name) {
|
||||
setOrgName(user.tenant.name);
|
||||
}
|
||||
if (user?.tenant?.tenant_code || user?.tenant?.domain) {
|
||||
setSubdomain(user.tenant.tenant_code || user.tenant.domain || "");
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Load category settings from API when tab changes
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const cat = activeTab.toLowerCase();
|
||||
const data = await settingsService.getCategorySettings(cat);
|
||||
if (data) {
|
||||
if (data.orgName) setOrgName(data.orgName);
|
||||
if (data.subdomain) setSubdomain(data.subdomain);
|
||||
if (data.requireApproval !== undefined) setRequireApproval(data.requireApproval);
|
||||
if (data.autoPublish !== undefined) setAutoPublish(data.autoPublish);
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently fallback to defaults
|
||||
}
|
||||
};
|
||||
loadSettings();
|
||||
}, [activeTab]);
|
||||
|
||||
const handleSaveGeneral = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await settingsService.updateCategorySettings("general", {
|
||||
orgName,
|
||||
subdomain,
|
||||
requireApproval,
|
||||
autoPublish
|
||||
});
|
||||
notify.success("General settings saved successfully!");
|
||||
} catch (err: any) {
|
||||
notify.error(err?.message || "Failed to save settings");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const horizontalTabs = [
|
||||
{ id: "General", icon: User },
|
||||
{ id: "Notifications", icon: Bell },
|
||||
@@ -19,11 +144,14 @@ export default function SettingList() {
|
||||
{ id: "Integrations", icon: Plug },
|
||||
{ id: "API Keys", icon: Key },
|
||||
{ id: "Appearance", icon: Palette },
|
||||
...(canViewFileServer ? [{ id: "File Server", icon: Database }] : [])
|
||||
];
|
||||
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<Breadcrumb items={[{ label: "Home" }, { label: "Settings" }]} />
|
||||
<ProtectedRoute node="settings.general">
|
||||
<PageWrapper>
|
||||
<Breadcrumb items={[{ label: "Home" }, { label: "Settings" }]} />
|
||||
|
||||
{/* Horizontal Tabs Header */}
|
||||
<div className="flex items-center gap-2 border-b border-primary/10 mb-6 overflow-x-auto pb-px">
|
||||
@@ -63,12 +191,22 @@ export default function SettingList() {
|
||||
<div className="p-6 space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Organization Name</label>
|
||||
<input type="text" defaultValue="Acme Corporation" className="w-full px-3 py-2.5 text-sm border border-primary/10 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-light focus:border-transparent bg-surface" />
|
||||
<input
|
||||
type="text"
|
||||
value={orgName}
|
||||
onChange={(e) => setOrgName(e.target.value)}
|
||||
className="w-full px-3 py-2.5 text-sm border border-primary/10 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-light focus:border-transparent bg-surface"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Subdomain</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Subdomain / Tenant Code</label>
|
||||
<div className="flex items-stretch">
|
||||
<input type="text" defaultValue="acme" className="flex-1 px-3 py-2.5 text-sm border border-primary/10 rounded-l-lg border-r-0 focus:outline-none focus:ring-2 focus:ring-primary-light focus:border-transparent bg-surface z-10" />
|
||||
<input
|
||||
type="text"
|
||||
value={subdomain}
|
||||
onChange={(e) => setSubdomain(e.target.value)}
|
||||
className="flex-1 px-3 py-2.5 text-sm border border-primary/10 rounded-l-lg border-r-0 focus:outline-none focus:ring-2 focus:ring-primary-light focus:border-transparent bg-surface z-10"
|
||||
/>
|
||||
<div className="px-4 py-2.5 bg-primary/5/50 border border-primary/10 rounded-r-lg text-sm text-muted-foreground flex items-center">.pim-platform.com</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,8 +263,13 @@ export default function SettingList() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 px-6 py-4 border-t border-primary/5 bg-gradient-to-r from-surface to-primary/5/30">
|
||||
<Button variant="ghost" className="text-muted-foreground hover:text-foreground hover:bg-background">Cancel</Button>
|
||||
<Button className="bg-primary hover:bg-primary-hover text-white">Save Changes</Button>
|
||||
<Button variant="ghost" className="text-muted-foreground hover:text-foreground hover:bg-background" onClick={() => window.location.reload()}>Cancel</Button>
|
||||
<Can node="settings.general" action="edit">
|
||||
<Button className="bg-primary hover:bg-primary-hover text-white flex items-center gap-2" loading={saving} onClick={handleSaveGeneral}>
|
||||
<Save className="w-4 h-4" />
|
||||
Save Changes
|
||||
</Button>
|
||||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -322,12 +465,162 @@ export default function SettingList() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!["General", "Integrations", "API Keys", "Appearance"].includes(activeTab) && (
|
||||
{activeTab === "File Server" && (
|
||||
<div className="max-w-3xl">
|
||||
<div className="px-6 py-4 border-b border-primary/5 bg-gradient-to-r from-primary/5/70 to-surface flex items-center gap-3">
|
||||
<div className="w-1 h-5 bg-primary-light rounded-full shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground text-sm">File Server Configuration</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Configure an S3-compatible bucket (AWS S3, MinIO) for cloud asset uploads</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingServer ? (
|
||||
<div className="p-10 text-center text-sm text-muted-foreground">Loading configuration...</div>
|
||||
) : (
|
||||
<div className="p-6 space-y-5">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Storage Provider</label>
|
||||
<select
|
||||
value={fileServer.provider}
|
||||
onChange={(e) => setFileServer({ ...fileServer, provider: e.target.value as 's3' | 'minio' })}
|
||||
className="w-full px-3 py-2.5 text-sm border border-primary/10 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-light focus:border-transparent text-foreground bg-surface"
|
||||
>
|
||||
<option value="s3">Amazon S3</option>
|
||||
<option value="minio">MinIO (S3-Compatible)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Bucket Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fileServer.bucket_name}
|
||||
onChange={(e) => setFileServer({ ...fileServer, bucket_name: e.target.value })}
|
||||
placeholder="e.g. pc-bucket"
|
||||
className="w-full px-3 py-2.5 text-sm border border-primary/10 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-light focus:border-transparent bg-surface text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Server Endpoint URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fileServer.endpoint}
|
||||
onChange={(e) => setFileServer({ ...fileServer, endpoint: e.target.value })}
|
||||
placeholder="e.g. https://play.min.io or https://s3.amazonaws.com"
|
||||
className="w-full px-3 py-2.5 text-sm border border-primary/10 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-light focus:border-transparent bg-surface text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Access Key</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fileServer.access_key}
|
||||
onChange={(e) => setFileServer({ ...fileServer, access_key: e.target.value })}
|
||||
placeholder="Access Key"
|
||||
className="w-full px-3 py-2.5 text-sm border border-primary/10 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-light focus:border-transparent bg-surface text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Secret Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={fileServer.secret_key}
|
||||
onChange={(e) => setFileServer({ ...fileServer, secret_key: e.target.value })}
|
||||
placeholder="Secret Key"
|
||||
className="w-full px-3 py-2.5 text-sm border border-primary/10 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-light focus:border-transparent bg-surface text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Region (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fileServer.region || ''}
|
||||
onChange={(e) => setFileServer({ ...fileServer, region: e.target.value })}
|
||||
placeholder="e.g. us-east-1"
|
||||
className="w-full px-3 py-2.5 text-sm border border-primary/10 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-light focus:border-transparent bg-surface text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Status</label>
|
||||
<select
|
||||
value={fileServer.status}
|
||||
onChange={(e) => setFileServer({ ...fileServer, status: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm border border-primary/10 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-light focus:border-transparent text-foreground bg-surface"
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testStatus.message && (
|
||||
<div className={`p-3.5 rounded-lg border text-sm flex items-center gap-2 ${
|
||||
testStatus.type === 'success'
|
||||
? 'bg-success/10 border-success/20 text-success'
|
||||
: 'bg-danger/10 border-danger/20 text-danger'
|
||||
}`}>
|
||||
<span>{testStatus.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveStatus.message && (
|
||||
<div className={`p-3.5 rounded-lg border text-sm flex items-center gap-2 ${
|
||||
saveStatus.type === 'success'
|
||||
? 'bg-success/10 border-success/20 text-success'
|
||||
: 'bg-danger/10 border-danger/20 text-danger'
|
||||
}`}>
|
||||
<span>{saveStatus.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between gap-3 px-6 py-4 border-t border-primary/5 bg-gradient-to-r from-surface to-primary/5/30">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testingServer || loadingServer}
|
||||
className="border-primary/20 hover:bg-primary/5 text-foreground disabled:opacity-50"
|
||||
>
|
||||
{testingServer ? 'Testing...' : 'Test Connection'}
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate("/settings")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-background"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveConfig}
|
||||
disabled={loadingServer}
|
||||
className="bg-primary hover:bg-primary-hover text-white disabled:opacity-50"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!["General", "Integrations", "API Keys", "Appearance", "File Server"].includes(activeTab) && (
|
||||
<div className="px-6 py-10 text-center text-sm text-muted-foreground">
|
||||
Settings for <span className="font-medium text-muted-foreground">{activeTab}</span> will appear here.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import apiClient from '../../../api/axiosInstance';
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface FileServerConfig {
|
||||
id?: string;
|
||||
tenant_id?: number;
|
||||
provider: 's3' | 'minio';
|
||||
endpoint: string;
|
||||
access_key: string;
|
||||
secret_key: string;
|
||||
bucket_name: string;
|
||||
region?: string;
|
||||
status?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export const fileServerService = {
|
||||
get: async (): Promise<ApiResponse<FileServerConfig | null>> => {
|
||||
const res = await apiClient.get<ApiResponse<FileServerConfig | null>>('/api/v1/settings/file-server');
|
||||
return res;
|
||||
},
|
||||
|
||||
save: async (config: FileServerConfig): Promise<ApiResponse<FileServerConfig>> => {
|
||||
const res = await apiClient.post<ApiResponse<FileServerConfig>>('/api/v1/settings/file-server', config);
|
||||
return res;
|
||||
},
|
||||
|
||||
test: async (config: FileServerConfig): Promise<{ success: boolean; message: string }> => {
|
||||
const res = await apiClient.post<{ success: boolean; message: string }>('/api/v1/settings/file-server/test', config);
|
||||
return res;
|
||||
},
|
||||
|
||||
delete: async (): Promise<ApiResponse<boolean>> => {
|
||||
const res = await apiClient.delete<ApiResponse<boolean>>('/api/v1/settings/file-server');
|
||||
return res;
|
||||
}
|
||||
};
|
||||
@@ -1,55 +1,32 @@
|
||||
import type { Setting, SettingCreateRequest, SettingUpdateRequest } from '../types/settings.types';
|
||||
|
||||
const STORAGE_KEY = 'pim_settings';
|
||||
|
||||
const getStored = (): Setting[] => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return [];
|
||||
return JSON.parse(stored);
|
||||
};
|
||||
|
||||
const setStored = (items: Setting[]) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||
};
|
||||
import axiosInstance from '../../../api/axiosInstance';
|
||||
|
||||
export const settingsService = {
|
||||
getAll: async (): Promise<Setting[]> => {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(getStored()), 300));
|
||||
getCategorySettings: async (category: string) => {
|
||||
const response: any = await axiosInstance.get(`/settings/by-category/${category}`);
|
||||
return response.data?.data;
|
||||
},
|
||||
getById: async (id: string): Promise<Setting | undefined> => {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(getStored().find(p => p.id === id)), 200));
|
||||
updateCategorySettings: async (category: string, data: Record<string, any>) => {
|
||||
const response: any = await axiosInstance.put(`/settings/by-category/${category}`, data);
|
||||
return response.data;
|
||||
},
|
||||
create: async (req: SettingCreateRequest): Promise<Setting> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored();
|
||||
const newItem: Setting = { ...req, id: String(Date.now()), createdAt: new Date().toISOString() };
|
||||
list.push(newItem);
|
||||
setStored(list);
|
||||
resolve(newItem);
|
||||
}, 300);
|
||||
});
|
||||
getAll: async (): Promise<any[]> => {
|
||||
const response: any = await axiosInstance.get('/settings');
|
||||
return response.data?.data || response.data || [];
|
||||
},
|
||||
update: async (id: string, req: SettingUpdateRequest): Promise<Setting> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored();
|
||||
const index = list.findIndex(p => p.id === id);
|
||||
if (index === -1) { reject(new Error('Not found')); return; }
|
||||
const updated = { ...list[index], ...req };
|
||||
list[index] = updated;
|
||||
setStored(list);
|
||||
resolve(updated);
|
||||
}, 300);
|
||||
});
|
||||
getById: async (id: string): Promise<any> => {
|
||||
const response: any = await axiosInstance.get(`/settings/${id}`);
|
||||
return response.data?.data || response.data;
|
||||
},
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored().filter(p => p.id !== id);
|
||||
setStored(list);
|
||||
resolve(true);
|
||||
}, 300);
|
||||
});
|
||||
create: async (data: any): Promise<any> => {
|
||||
const response: any = await axiosInstance.post('/settings', data);
|
||||
return response.data?.data || response.data;
|
||||
},
|
||||
update: async (id: string, data: any): Promise<any> => {
|
||||
const response: any = await axiosInstance.put(`/settings/${id}`, data);
|
||||
return response.data?.data || response.data;
|
||||
},
|
||||
delete: async (id: string): Promise<any> => {
|
||||
const response: any = await axiosInstance.delete(`/settings/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -70,14 +70,80 @@ export function useTenant() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPlatformTenants = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await tenantService.getPlatformTenants();
|
||||
setTenants(data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch platform tenants');
|
||||
notify.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const provisionTenant = async (data: any) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await tenantService.provisionTenant(data);
|
||||
notify.success('Tenant provisioned successfully with Admin credentials');
|
||||
return result;
|
||||
} catch (err) {
|
||||
notify.error(err);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updatePlatformStatus = async (id: string, status: boolean) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const updated = await tenantService.updatePlatformStatus(id, status);
|
||||
setTenants(prev => prev.map(t => t.id === id ? { ...t, status: updated.status } : t));
|
||||
notify.success(`Tenant status updated to ${updated.status ? 'Active' : 'Suspended'}`);
|
||||
return updated;
|
||||
} catch (err) {
|
||||
notify.error(err);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const impersonateTenant = async (tenantId: string) => {
|
||||
try {
|
||||
const result = await tenantService.impersonateTenant(tenantId);
|
||||
localStorage.setItem('impersonatedTenantId', tenantId);
|
||||
notify.success(result.message || 'Support impersonation active');
|
||||
return result;
|
||||
} catch (err) {
|
||||
notify.error(err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const stopImpersonation = () => {
|
||||
localStorage.removeItem('impersonatedTenantId');
|
||||
notify.info('Support impersonation ended');
|
||||
};
|
||||
|
||||
return {
|
||||
tenants,
|
||||
loading,
|
||||
error,
|
||||
fetchTenants,
|
||||
fetchPlatformTenants,
|
||||
getTenant,
|
||||
createTenant,
|
||||
provisionTenant,
|
||||
updateTenant,
|
||||
deleteTenant
|
||||
updatePlatformStatus,
|
||||
deleteTenant,
|
||||
impersonateTenant,
|
||||
stopImpersonation
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,5 +25,31 @@ export const tenantService = {
|
||||
delete: async (id: string) => {
|
||||
const response = await api.delete(`/api/v1/tenants/${id}`);
|
||||
return (response as any).data;
|
||||
},
|
||||
|
||||
// Platform Admin Endpoints
|
||||
getPlatformTenants: async () => {
|
||||
const response = await api.get('/api/v1/platform/tenants');
|
||||
return (response as any).data;
|
||||
},
|
||||
|
||||
provisionTenant: async (data: any) => {
|
||||
const response = await api.post('/api/v1/platform/tenants', data);
|
||||
return (response as any).data;
|
||||
},
|
||||
|
||||
updatePlatformStatus: async (id: string, status: boolean) => {
|
||||
const response = await api.patch(`/api/v1/platform/tenants/${id}/status`, { status });
|
||||
return (response as any).data;
|
||||
},
|
||||
|
||||
getPlatformMetrics: async () => {
|
||||
const response = await api.get('/api/v1/platform/metrics');
|
||||
return (response as any).data;
|
||||
},
|
||||
|
||||
impersonateTenant: async (tenantId: string) => {
|
||||
const response = await api.post(`/api/v1/platform/impersonate/${tenantId}`);
|
||||
return (response as any).data;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -25,3 +25,18 @@ export interface UpdateTenantDTO {
|
||||
mobile?: string;
|
||||
status?: boolean;
|
||||
}
|
||||
|
||||
export interface PlatformTenant extends Tenant {
|
||||
total_products?: number;
|
||||
total_assets?: number;
|
||||
total_users?: number;
|
||||
}
|
||||
|
||||
export interface ProvisionTenantDTO {
|
||||
tenant_name: string;
|
||||
domain?: string;
|
||||
contact_email: string;
|
||||
admin_name?: string;
|
||||
admin_email?: string;
|
||||
admin_password?: string;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export const UnitTable: React.FC<UnitTableProps> = ({
|
||||
key: "conversionFactor",
|
||||
label: "CONVERSION",
|
||||
render: (value: any, row: Unit) =>
|
||||
value !== undefined ? `${value} ${row.baseUnit || ''}` : "—"
|
||||
(value !== undefined && value !== null && value !== '') ? `${value} ${row.baseUnit || ''}`.trim() : "—"
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
|
||||
@@ -26,8 +26,10 @@ export const useUnit = () => {
|
||||
setUnits((prev) => [...prev, created]);
|
||||
notify.success('Unit created successfully!');
|
||||
return created;
|
||||
} catch (err) {
|
||||
notify.error(err);
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status !== 409 && err?.status !== 409) {
|
||||
notify.error(err);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -14,6 +14,8 @@ import { tenantService } from "../../tenants/services/tenant.service";
|
||||
import { notify } from "../../../services/toast";
|
||||
import type { DBRole, PermissionNode } from "../services/roles.service";
|
||||
import type { Tenant } from "../../tenants/types/tenant.types";
|
||||
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
|
||||
import { Can } from "../../../components/customs/Can";
|
||||
|
||||
export default function UserList() {
|
||||
const navigate = useNavigate();
|
||||
@@ -79,21 +81,18 @@ export default function UserList() {
|
||||
const [newRoleStatus, setNewRoleStatus] = useState(true);
|
||||
|
||||
const isSuperAdminUser = (user: any) => {
|
||||
if (!user) return false;
|
||||
if (user.email === 'admin@admin.com') return true;
|
||||
const userRoles = user.roles || [];
|
||||
if (user?.user_type === 'platform' || user?.type === 'platform') return true;
|
||||
const userRoles = user?.roles || [];
|
||||
return userRoles.some((r: any) =>
|
||||
r.role_code === 'SUPER_ADMIN' ||
|
||||
r.role_code === 'SUPERADMIN' ||
|
||||
r.role_name?.toLowerCase().includes('super admin') ||
|
||||
r.is_system_role
|
||||
r.role_name?.toLowerCase().includes('super admin')
|
||||
);
|
||||
};
|
||||
|
||||
const isSuperAdminRole = (role: any) => {
|
||||
if (!role) return false;
|
||||
return Boolean(
|
||||
role.is_system_role ||
|
||||
role.role_code === 'SUPER_ADMIN' ||
|
||||
role.role_code === 'SUPERADMIN' ||
|
||||
role.role_name?.toLowerCase().includes('super admin')
|
||||
@@ -378,16 +377,19 @@ export default function UserList() {
|
||||
];
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<Breadcrumb
|
||||
items={[{ label: "Home" }, { label: "Users & Roles" }]}
|
||||
actions={
|
||||
<Button onClick={() => navigate("new")} className="bg-primary hover:bg-primary-hover text-white">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Invite Member
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<ProtectedRoute node="settings.users">
|
||||
<PageWrapper>
|
||||
<Breadcrumb
|
||||
items={[{ label: "Home" }, { label: "Users & Roles" }]}
|
||||
actions={
|
||||
<Can node="settings.users" action="create">
|
||||
<Button onClick={() => navigate("new")} className="bg-primary hover:bg-primary-hover text-white">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Invite Member
|
||||
</Button>
|
||||
</Can>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
@@ -907,6 +909,7 @@ export default function UserList() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageWrapper>
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -181,6 +181,39 @@ export default function NewVariant() {
|
||||
const activeProducts = products.filter(p => p.status === 'active');
|
||||
const isLoading = productsLoading || variantLoading || parentLoading;
|
||||
|
||||
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit || isView) {
|
||||
setHighestVisitedStep(STEPS.length);
|
||||
}
|
||||
}, [isEdit, isView]);
|
||||
|
||||
const isBasicValid = Boolean(formik.values.sku?.trim() && formik.values.name?.trim() && formik.values.productId && !formik.errors.sku && !formik.errors.name && !formik.errors.productId);
|
||||
|
||||
const isStepAccessible = useCallback((stepNum: number) => {
|
||||
if (isEdit || isView) return true;
|
||||
if (stepNum === 1) return true;
|
||||
if (!isBasicValid) return false;
|
||||
return stepNum <= highestVisitedStep + 1;
|
||||
}, [isEdit, isView, isBasicValid, highestVisitedStep]);
|
||||
|
||||
const handleNextStep = useCallback(() => {
|
||||
if (activeStep === 'basic') {
|
||||
if (!isBasicValid) {
|
||||
formik.setFieldTouched('sku', true);
|
||||
formik.setFieldTouched('name', true);
|
||||
formik.setFieldTouched('productId', true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (activeIndex < STEPS.length - 1) {
|
||||
const nextStepObj = STEPS[activeIndex + 1];
|
||||
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
|
||||
setActiveStep(nextStepObj.id);
|
||||
}
|
||||
}, [activeStep, isBasicValid, activeIndex, formik]);
|
||||
|
||||
return (
|
||||
<ProtectedRoute node="products.variants">
|
||||
<PageWrapper>
|
||||
@@ -235,17 +268,23 @@ export default function NewVariant() {
|
||||
const isActive = activeStep === s.id;
|
||||
const isDone = idx < activeIndex;
|
||||
const isLast = idx === STEPS.length - 1;
|
||||
const accessible = isStepAccessible(s.step);
|
||||
return (
|
||||
<div key={s.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center" style={{ width: 24 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${
|
||||
isActive ? 'bg-primary ring-2 ring-primary/20' :
|
||||
isDone ? 'bg-success' :
|
||||
'bg-surface border-2 border-border hover:border-primary/30'
|
||||
}`}
|
||||
} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
{isDone
|
||||
? <Check className="w-3 h-3 text-white" />
|
||||
@@ -258,8 +297,13 @@ export default function NewVariant() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''}`}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span className={`text-xs font-medium leading-tight block ${
|
||||
isActive ? 'text-primary' : isDone ? 'text-muted-foreground' : 'text-muted-foreground hover:text-muted-foreground'
|
||||
@@ -586,7 +630,7 @@ export default function NewVariant() {
|
||||
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm font-medium text-muted-foreground hover:bg-background transition-colors">Back</button>
|
||||
)}
|
||||
{activeIndex < STEPS.length - 1 && (
|
||||
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
|
||||
<button type="button" onClick={handleNextStep} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,16 +8,17 @@ import { hasPermission } from '../utils/permissionUtils';
|
||||
* Usage: const { canView, canCreate } = usePermissions('products.items');
|
||||
*/
|
||||
export const usePermissions = (nodeCode: PermissionNodes | string) => {
|
||||
// Extract permissions from the Redux store
|
||||
// Extract permissions and user from the Redux store
|
||||
const permissions = useSelector((state: RootState) => state.auth.permissions);
|
||||
const user = useSelector((state: RootState) => state.auth.user);
|
||||
|
||||
return {
|
||||
canView: hasPermission(permissions, nodeCode, 'view'),
|
||||
canCreate: hasPermission(permissions, nodeCode, 'create'),
|
||||
canEdit: hasPermission(permissions, nodeCode, 'edit'),
|
||||
canDelete: hasPermission(permissions, nodeCode, 'delete'),
|
||||
canAlter: hasPermission(permissions, nodeCode, 'alter'),
|
||||
canImport: hasPermission(permissions, nodeCode, 'import'),
|
||||
canExport: hasPermission(permissions, nodeCode, 'export'),
|
||||
canView: hasPermission(permissions, nodeCode, 'view', user),
|
||||
canCreate: hasPermission(permissions, nodeCode, 'create', user),
|
||||
canEdit: hasPermission(permissions, nodeCode, 'edit', user),
|
||||
canDelete: hasPermission(permissions, nodeCode, 'delete', user),
|
||||
canAlter: hasPermission(permissions, nodeCode, 'alter', user),
|
||||
canImport: hasPermission(permissions, nodeCode, 'import', user),
|
||||
canExport: hasPermission(permissions, nodeCode, 'export', user),
|
||||
};
|
||||
};
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ export function getAssetUrl(url?: string): string {
|
||||
return url;
|
||||
}
|
||||
const cleanUrl = url.startsWith('/') ? url : `/${url}`;
|
||||
const baseUrl = (import.meta as any).env?.VITE_API_BASE_URL || 'http://localhost:5000';
|
||||
const baseUrl = (import.meta as any).env?.VITE_API_BASE_URL || (import.meta as any).env?.VITE_API_URL || 'http://localhost:5002';
|
||||
return `${baseUrl}${cleanUrl}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// src/routes/AppRoutes.tsx
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthGuard } from '../authentication/components/ProtectedRoute';
|
||||
import { AuthGuard, PlatformGuard } from '../authentication/components/ProtectedRoute';
|
||||
import { ProtectedRoute } from '../components/layouts/ProtectedRoute';
|
||||
import MainLayout from '../components/layouts/MainLayout';
|
||||
import { Login, SignUp, ForgotPassword, ResetPassword, AcceptInvite } from '../authentication/routes';
|
||||
|
||||
@@ -31,6 +32,8 @@ import { SettingRoutes } from '../features/settings/routes/settings.routes';
|
||||
import { TenantRoutes } from '../features/tenants/routes/tenant.routes';
|
||||
import { RoleRoutes } from '../features/roles/routes/role.routes';
|
||||
import { NotificationRoutes } from '../features/notifications/routes/notifications.routes';
|
||||
import PlatformOverview from '../features/platform/pages/PlatformOverview';
|
||||
import PlatformTenantsPage from '../features/platform/pages/PlatformTenantsPage';
|
||||
|
||||
const AppRoutes = () => {
|
||||
return (
|
||||
@@ -47,7 +50,12 @@ const AppRoutes = () => {
|
||||
|
||||
{/* Protected Routes with Layout */}
|
||||
<Route element={<AuthGuard><MainLayout /></AuthGuard>}>
|
||||
<Route path="/dashboard" element={<DashboardRoutes />} />
|
||||
{/* Platform Control Center (Platform Super Admins Only) */}
|
||||
<Route path="/platform" element={<PlatformGuard><Navigate to="/platform/overview" replace /></PlatformGuard>} />
|
||||
<Route path="/platform/overview" element={<PlatformGuard><PlatformOverview /></PlatformGuard>} />
|
||||
<Route path="/platform/tenants" element={<PlatformGuard><PlatformTenantsPage /></PlatformGuard>} />
|
||||
|
||||
<Route path="/dashboard/*" element={<DashboardRoutes />} />
|
||||
|
||||
{/* Catalog */}
|
||||
<Route path="/products/*" element={<ProductRoutes />} />
|
||||
@@ -75,8 +83,8 @@ const AppRoutes = () => {
|
||||
|
||||
{/* Users */}
|
||||
<Route path="/users/tenants/*" element={<TenantRoutes />} />
|
||||
<Route path="/users/roles/*" element={<RoleRoutes />} />
|
||||
<Route path="/users/*" element={<UserRoutes />} />
|
||||
<Route path="/users/roles/*" element={<ProtectedRoute node="settings.roles"><RoleRoutes /></ProtectedRoute>} />
|
||||
<Route path="/users/*" element={<ProtectedRoute node="settings.users"><UserRoutes /></ProtectedRoute>} />
|
||||
|
||||
{/* Notifications */}
|
||||
<Route path="/notifications/*" element={<NotificationRoutes />} />
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Grid3x3,
|
||||
Tag,
|
||||
Layers,
|
||||
Database,
|
||||
Ruler,
|
||||
Award,
|
||||
Image,
|
||||
@@ -19,7 +18,10 @@ import {
|
||||
Settings,
|
||||
List,
|
||||
Layers2,
|
||||
Bell
|
||||
Bell,
|
||||
ShieldCheck,
|
||||
Activity,
|
||||
Building2
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
@@ -36,10 +38,21 @@ export interface SidebarItem {
|
||||
href: string;
|
||||
icon: React.ElementType;
|
||||
permission?: string;
|
||||
platformOnly?: boolean;
|
||||
children?: SidebarSubItem[];
|
||||
}
|
||||
|
||||
export const sidebarConfig: SidebarItem[] = [
|
||||
{
|
||||
label: 'Platform Control',
|
||||
href: '/platform',
|
||||
icon: ShieldCheck,
|
||||
platformOnly: true,
|
||||
children: [
|
||||
{ label: 'SaaS Overview & Metrics', href: '/platform/overview', icon: Activity },
|
||||
{ label: 'Tenant Provisioning', href: '/platform/tenants', icon: Building2 },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Dashboard',
|
||||
href: '/dashboard',
|
||||
@@ -95,24 +108,27 @@ export const sidebarConfig: SidebarItem[] = [
|
||||
label: 'Asset Management',
|
||||
href: '/assets',
|
||||
icon: Image,
|
||||
permission: 'media.assets',
|
||||
children: [
|
||||
{ label: 'Asset Manager', href: '/assets', icon: Folder },
|
||||
{ label: 'Asset Types', href: '/asset-types', icon: LayoutGrid },
|
||||
{ label: 'Asset Families', href: '/asset-families', icon: Layers },
|
||||
{ label: 'Asset Manager', href: '/assets', icon: Folder, permission: 'media.assets' },
|
||||
{ label: 'Asset Types', href: '/asset-types', icon: LayoutGrid, permission: 'media.taxonomy' },
|
||||
{ label: 'Asset Families', href: '/asset-families', icon: Layers, permission: 'media.taxonomy' },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Workflow & Approvals',
|
||||
href: '/workflow',
|
||||
icon: Workflow
|
||||
icon: Workflow,
|
||||
permission: 'products.items'
|
||||
},
|
||||
{
|
||||
label: 'Channels & Integration',
|
||||
href: '/channels',
|
||||
icon: Radio,
|
||||
permission: 'channels.syndication',
|
||||
children: [
|
||||
{ label: 'Channel Registry', href: '/channels', icon: Radio },
|
||||
{ label: 'Channel Types', href: '/channel-types', icon: Layers2, permission: 'channels.types' },
|
||||
{ label: 'Channel Registry', href: '/channels', icon: Radio, permission: 'channels.syndication' },
|
||||
{ label: 'Channel Types', href: '/channel-types', icon: Layers2, permission: 'channels.syndication' },
|
||||
{ label: 'Integration Hub', href: '/integrations', icon: Plug, permission: 'settings.integrations' },
|
||||
]
|
||||
},
|
||||
@@ -122,7 +138,6 @@ export const sidebarConfig: SidebarItem[] = [
|
||||
icon: Users,
|
||||
children: [
|
||||
{ label: 'Users', href: '/users', icon: Users, permission: 'settings.users' },
|
||||
{ label: 'Tenants', href: '/users/tenants', icon: Database, permission: 'settings.tenants' },
|
||||
{ label: 'Roles', href: '/users/roles', icon: Users, permission: 'settings.roles' }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
|
||||
const API_BASE_URL = 'http://localhost:5000';
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5002';
|
||||
|
||||
class SocketServiceClass {
|
||||
private socket: Socket | null = null;
|
||||
|
||||
@@ -27,6 +27,7 @@ export type PermissionNodes =
|
||||
| 'settings.users'
|
||||
| 'settings.roles'
|
||||
| 'settings.integrations'
|
||||
| 'settings.file_server'
|
||||
| 'reports';
|
||||
|
||||
export type PermissionAction = keyof NodePermissions;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user