Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
11b30cab97 | ||
|
|
dca896f0e8 |
@@ -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}</>;
|
||||
};
|
||||
@@ -77,10 +77,11 @@ export function Select({
|
||||
dropdownRef.current?.contains(e.target as Node)
|
||||
) return;
|
||||
setIsOpen(false);
|
||||
onBlur?.({ target: { name } } as any);
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isOpen]);
|
||||
}, [isOpen, name, onBlur]);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (disabled) return;
|
||||
@@ -100,6 +101,7 @@ export function Select({
|
||||
<button
|
||||
ref={buttonRef}
|
||||
id={id}
|
||||
name={name}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={handleToggle}
|
||||
|
||||
@@ -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, ArrowRight } 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>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,8 @@ export interface AssetTypeValidation {
|
||||
maxFileSize: number;
|
||||
minUploadCount: number;
|
||||
maxUploadCount: number;
|
||||
allowed_extensions?: string[];
|
||||
max_file_size?: number;
|
||||
}
|
||||
|
||||
export interface AssetType {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 { canCreate, 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) {
|
||||
@@ -305,6 +324,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">
|
||||
|
||||
@@ -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`)) : undefined}
|
||||
onDelete={canDelete ? ((row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })) : undefined}
|
||||
/>
|
||||
</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) => sum + (Number(c.productCount) || 0), 0),
|
||||
families: categories.reduce((sum, c) => 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)" },
|
||||
];
|
||||
|
||||
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({
|
||||
@@ -330,7 +335,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" />
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Building2, Users, Package, Image, ShieldCheck, Activity, UserCheck, 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, Play, StopCircle, 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,9 @@ interface DynamicAttributesSectionProps {
|
||||
errors?: Record<string, any>;
|
||||
touched?: Record<string, any>;
|
||||
onAttributeChange: (code: string, value: any) => void;
|
||||
onAttributeBlur?: (code: string) => void;
|
||||
onAddAttributeClick?: (group: any) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export const DynamicAttributesSection: React.FC<DynamicAttributesSectionProps> = ({
|
||||
@@ -41,6 +44,9 @@ export const DynamicAttributesSection: React.FC<DynamicAttributesSectionProps> =
|
||||
errors = {},
|
||||
touched = {},
|
||||
onAttributeChange,
|
||||
onAttributeBlur,
|
||||
onAddAttributeClick,
|
||||
readOnly,
|
||||
}) => {
|
||||
if (!hasAttributeSet) {
|
||||
return (
|
||||
@@ -87,6 +93,9 @@ export const DynamicAttributesSection: React.FC<DynamicAttributesSectionProps> =
|
||||
errors={errors}
|
||||
touched={touched}
|
||||
onAttributeChange={onAttributeChange}
|
||||
onAttributeBlur={onAttributeBlur}
|
||||
onAddAttributeClick={onAddAttributeClick}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { assetsService, type AssetMapping } from '../../assets/services/assets.service';
|
||||
import type { Asset } from '../../assets/types/assets.types';
|
||||
import { useAssetType } from '../../asset-types/hook/useAssetType';
|
||||
import { assetFamiliesService } from '../../asset-families/services/asset-families.service';
|
||||
import {
|
||||
Upload, Image as ImageIcon, Video, FileText, Trash2,
|
||||
Check, Loader2, Search, Info, Shield, ArrowUp, ArrowDown
|
||||
Check, Loader2, Search, Info, Shield, ArrowUp, ArrowDown, Plus, Box
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Loader } from '../../../components/customs/Loader';
|
||||
import { getAssetUrl, isImageFile } from '../../../lib/utils';
|
||||
import { Select } from '../../../components/customs/Select';
|
||||
|
||||
interface ProductAssetsTabProps {
|
||||
productId?: string;
|
||||
family: any;
|
||||
readOnly?: boolean;
|
||||
refreshProductData?: () => void;
|
||||
}
|
||||
|
||||
export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
productId,
|
||||
family
|
||||
family,
|
||||
readOnly,
|
||||
refreshProductData
|
||||
}) => {
|
||||
const [assignedAssets, setAssignedAssets] = useState<AssetMapping[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -32,6 +39,85 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Asset type and family states
|
||||
const { items: allAssetTypes, fetchItems: fetchAssetTypes } = useAssetType();
|
||||
const [selectedAssetTypeId, setSelectedAssetTypeId] = useState<string>('');
|
||||
const [allAssetFamilies, setAllAssetFamilies] = useState<any[]>([]);
|
||||
const [selectedAssetFamilyId, setSelectedAssetFamilyId] = useState<string>('');
|
||||
|
||||
// Fetch asset types and families
|
||||
useEffect(() => {
|
||||
fetchAssetTypes();
|
||||
const fetchFamilies = async () => {
|
||||
try {
|
||||
const data = await assetFamiliesService.getAll();
|
||||
setAllAssetFamilies(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load asset families:", err);
|
||||
}
|
||||
};
|
||||
fetchFamilies();
|
||||
}, [fetchAssetTypes]);
|
||||
|
||||
// Filter asset types based on selected asset family
|
||||
const filteredAssetTypes = useMemo(() => {
|
||||
if (!selectedAssetFamilyId) {
|
||||
return allAssetTypes;
|
||||
}
|
||||
const matchedFamily = allAssetFamilies.find(af => af.id === selectedAssetFamilyId);
|
||||
if (!matchedFamily) {
|
||||
return [];
|
||||
}
|
||||
const allowedTypeIds =
|
||||
Array.isArray(matchedFamily.assetTypeIds) && matchedFamily.assetTypeIds.length > 0
|
||||
? matchedFamily.assetTypeIds
|
||||
: (matchedFamily.assetTypes || [])
|
||||
.map((at: any) => at.id || at.assetTypeId)
|
||||
.filter(Boolean);
|
||||
|
||||
return allAssetTypes.filter(at => allowedTypeIds.includes(at.id));
|
||||
}, [allAssetTypes, allAssetFamilies, selectedAssetFamilyId]);
|
||||
|
||||
// Reset selected asset type if it is no longer allowed by the newly selected family
|
||||
useEffect(() => {
|
||||
if (selectedAssetTypeId && selectedAssetFamilyId) {
|
||||
const isStillAllowed = filteredAssetTypes.some(at => at.id === selectedAssetTypeId);
|
||||
if (!isStillAllowed) {
|
||||
setSelectedAssetTypeId('');
|
||||
}
|
||||
}
|
||||
}, [selectedAssetFamilyId, filteredAssetTypes, selectedAssetTypeId]);
|
||||
|
||||
// Resolve selected asset type details
|
||||
const selectedAssetType = useMemo(() => {
|
||||
return allAssetTypes.find(at => at.id === selectedAssetTypeId);
|
||||
}, [allAssetTypes, selectedAssetTypeId]);
|
||||
|
||||
// Resolve required asset type ids from family requirements
|
||||
const requiredAssetTypeIds = useMemo(() => {
|
||||
const ids: string[] = [];
|
||||
const familyReqs = family?.assetRequirements || [];
|
||||
familyReqs.forEach((req: any) => {
|
||||
const matchedFamily = allAssetFamilies.find(af => af.id === req.id);
|
||||
if (matchedFamily) {
|
||||
const allowedTypeIds =
|
||||
Array.isArray(matchedFamily.assetTypeIds) && matchedFamily.assetTypeIds.length > 0
|
||||
? matchedFamily.assetTypeIds
|
||||
: (matchedFamily.assetTypes || [])
|
||||
.map((at: any) => at.id || at.assetTypeId)
|
||||
.filter(Boolean);
|
||||
ids.push(...allowedTypeIds);
|
||||
}
|
||||
});
|
||||
return [...new Set(ids)];
|
||||
}, [family, allAssetFamilies]);
|
||||
|
||||
const isAssetTypeRequiredByFamily = (code: string) => {
|
||||
const matchingType = allAssetTypes.find(at => at.code === code);
|
||||
if (!matchingType) return false;
|
||||
return requiredAssetTypeIds.includes(matchingType.id);
|
||||
};
|
||||
|
||||
// Load product assets
|
||||
const loadProductAssets = async () => {
|
||||
if (!productId) return;
|
||||
@@ -83,50 +169,97 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Handle new file upload
|
||||
const handleFileUpload = async (file: File) => {
|
||||
// Handle multiple files upload sequentially
|
||||
const handleMultipleFilesUpload = async (files: FileList | File[]) => {
|
||||
if (!selectedAssetType) {
|
||||
toast.error('Please select an Asset Type first.');
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
// 1. Upload to PIM media server
|
||||
const uploadedData = await assetsService.upload(file);
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
// 2. Create the asset registry record
|
||||
const newAsset = await assetsService.create({
|
||||
name: file.name.replace(/\.[^/.]+$/, ""),
|
||||
file_url: uploadedData.file_url,
|
||||
file_size: uploadedData.file_size,
|
||||
mime_type: uploadedData.mime_type,
|
||||
status: 'active'
|
||||
});
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
|
||||
// Determine default role based on mime-type
|
||||
let role = 'gallery_image';
|
||||
if (uploadedData.mime_type.startsWith('video/')) {
|
||||
role = 'video';
|
||||
} else if (uploadedData.mime_type === 'application/pdf') {
|
||||
role = 'document';
|
||||
// 1. Validate format/extension
|
||||
const allowedFileTypes =
|
||||
selectedAssetType.validation?.allowedFileTypes ??
|
||||
selectedAssetType.validation?.allowed_extensions ??
|
||||
[];
|
||||
const allowedExts = allowedFileTypes.map((e: string) => e.toLowerCase().replace('.', ''));
|
||||
const fileExtension = file.name.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
if (allowedExts.length > 0 && !allowedExts.includes(fileExtension)) {
|
||||
toast.error(`File "${file.name}" rejected: Unsupported format.`);
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Map this asset to current product
|
||||
await assetsService.assignProductAsset(productId, {
|
||||
asset_id: newAsset.id,
|
||||
role,
|
||||
is_primary: assignedAssets.length === 0, // Set primary if it's the first asset
|
||||
display_order: assignedAssets.length
|
||||
});
|
||||
// 2. Validate size
|
||||
const maxFileSize =
|
||||
selectedAssetType.validation?.maxFileSize ??
|
||||
selectedAssetType.validation?.max_file_size;
|
||||
if (maxFileSize && file.size > maxFileSize) {
|
||||
const sizeMb = Math.round(maxFileSize / (1024 * 1024));
|
||||
toast.error(`File "${file.name}" rejected: Exceeds the ${sizeMb}MB limit.`);
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
toast.success('Asset uploaded and assigned successfully');
|
||||
loadProductAssets();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Failed to upload asset');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
try {
|
||||
// Upload to PIM media server
|
||||
const uploadedData = await assetsService.upload(file);
|
||||
|
||||
// Create the asset registry record
|
||||
const newAsset = await assetsService.create({
|
||||
name: file.name.replace(/\.[^/.]+$/, ""),
|
||||
file_url: uploadedData.file_url,
|
||||
file_size: uploadedData.file_size,
|
||||
mime_type: uploadedData.mime_type,
|
||||
asset_type_id: selectedAssetType.id,
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
// Determine role based on selected Asset Type code
|
||||
let role = selectedAssetType?.code || 'hero_image';
|
||||
if (role === 'thumbnail' || role === 'image' || role === 'gallery_image') {
|
||||
role = 'hero_image';
|
||||
}
|
||||
if (!selectedAssetType) {
|
||||
if (uploadedData.mime_type.startsWith('video/')) {
|
||||
role = 'video';
|
||||
} else if (uploadedData.mime_type === 'application/pdf') {
|
||||
role = 'document';
|
||||
}
|
||||
}
|
||||
|
||||
// Map this asset to current product
|
||||
await assetsService.assignProductAsset(productId, {
|
||||
asset_id: newAsset.id,
|
||||
role,
|
||||
is_primary: assignedAssets.length === 0 && successCount === 0,
|
||||
display_order: assignedAssets.length + successCount
|
||||
});
|
||||
|
||||
successCount++;
|
||||
} catch (err: any) {
|
||||
toast.error(`Failed to upload "${file.name}": ${err?.message || 'Unknown error'}`);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(`Successfully uploaded and assigned ${successCount} assets.`);
|
||||
loadProductAssets();
|
||||
refreshProductData?.();
|
||||
}
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
handleFileUpload(e.target.files[0]);
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handleMultipleFilesUpload(e.target.files);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -143,8 +276,12 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
handleFileUpload(e.dataTransfer.files[0]);
|
||||
if (!selectedAssetType) {
|
||||
toast.error('Please select an Asset Type first.');
|
||||
return;
|
||||
}
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
handleMultipleFilesUpload(e.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -158,11 +295,22 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
let role = 'gallery_image';
|
||||
if (asset.mime_type?.startsWith('video/')) {
|
||||
role = 'video';
|
||||
} else if (asset.mime_type === 'application/pdf') {
|
||||
role = 'document';
|
||||
// Determine role from the actual library Asset's Asset Type
|
||||
let role = selectedAssetType?.code || 'hero_image';
|
||||
if (asset.asset_type_id) {
|
||||
const matchingType = allAssetTypes.find(at => at.id === asset.asset_type_id);
|
||||
if (matchingType && matchingType.code) {
|
||||
role = matchingType.code;
|
||||
}
|
||||
}
|
||||
if (!role || role === 'thumbnail' || role === 'image' || role === 'gallery_image') {
|
||||
if (asset.mime_type?.startsWith('video/')) {
|
||||
role = 'video';
|
||||
} else if (asset.mime_type === 'application/pdf') {
|
||||
role = 'document';
|
||||
} else {
|
||||
role = 'hero_image';
|
||||
}
|
||||
}
|
||||
|
||||
await assetsService.assignProductAsset(productId, {
|
||||
@@ -174,6 +322,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
|
||||
toast.success('Asset assigned from library');
|
||||
loadProductAssets();
|
||||
refreshProductData?.();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Failed to assign asset');
|
||||
}
|
||||
@@ -186,21 +335,13 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
await assetsService.unassignProductAsset(productId, assetId);
|
||||
toast.success('Asset unassigned');
|
||||
loadProductAssets();
|
||||
refreshProductData?.();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Failed to unassign asset');
|
||||
}
|
||||
};
|
||||
|
||||
// Update asset role mapping
|
||||
const handleRoleChange = async (assetId: string, role: string) => {
|
||||
try {
|
||||
await assetsService.updateProductAsset(productId, assetId, { role });
|
||||
setAssignedAssets(prev => prev.map(a => a.asset_id === assetId ? { ...a, role } : a));
|
||||
toast.success('Asset role updated');
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Failed to update asset role');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Set selected asset as the primary display image
|
||||
const handleSetPrimary = async (assetId: string) => {
|
||||
@@ -208,6 +349,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
await assetsService.updateProductAsset(productId, assetId, { is_primary: true });
|
||||
toast.success('Primary image updated');
|
||||
loadProductAssets();
|
||||
refreshProductData?.();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Failed to set primary image');
|
||||
}
|
||||
@@ -236,6 +378,8 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const filteredLibrary = libraryAssets.filter(asset =>
|
||||
asset.name.toLowerCase().includes(pickerSearch.toLowerCase())
|
||||
);
|
||||
@@ -270,56 +414,181 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Zone & Picker Trigger */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`md:col-span-2 border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all flex flex-col items-center justify-center ${dragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/20 hover:bg-background/50 bg-surface'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
accept="image/*,video/*,application/pdf"
|
||||
/>
|
||||
{uploading ? (
|
||||
<div className="space-y-2">
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin mx-auto" />
|
||||
<p className="font-semibold text-xs text-foreground">Uploading new media file...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Upload className="w-8 h-8 text-muted-foreground mx-auto" />
|
||||
<div>
|
||||
<h4 className="text-foreground font-semibold text-xs">Drag & Drop Files Here</h4>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">or click to browse media files</p>
|
||||
{/* Asset Family & Asset Type Selectors */}
|
||||
{!readOnly && (
|
||||
<div className="bg-surface border border-border rounded-xl p-5 shadow-2xs space-y-4">
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{/* Asset Family Selector */}
|
||||
<div className="flex-1 min-w-[240px]">
|
||||
<label className="text-xs font-bold text-foreground block mb-2">Asset Family</label>
|
||||
<div className="relative">
|
||||
<Select
|
||||
id="asset-family-select"
|
||||
value={selectedAssetFamilyId}
|
||||
onChange={(e) => setSelectedAssetFamilyId(e.target.value)}
|
||||
placeholder="Select Asset Family..."
|
||||
className="w-full text-xs font-semibold"
|
||||
>
|
||||
{allAssetFamilies.filter(af => af && af.status === 'active').map((af) => (
|
||||
<option key={af.id} value={af.id}>
|
||||
{af.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-surface border border-border rounded-xl p-6 flex flex-col justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold text-foreground text-xs">Asset Library</h4>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
Select existing images, catalogs or spec documents from the central Asset library.
|
||||
</p>
|
||||
{/* Asset Type Selector */}
|
||||
<div className="flex-1 min-w-[240px]">
|
||||
<label className="text-xs font-bold text-foreground block mb-2">Asset Type Classification</label>
|
||||
<div className="relative">
|
||||
<Select
|
||||
id="asset-type-select"
|
||||
value={selectedAssetTypeId}
|
||||
onChange={(e) => setSelectedAssetTypeId(e.target.value)}
|
||||
placeholder={selectedAssetFamilyId && filteredAssetTypes.length === 0 ? "No asset types available for this family" : "Select Asset Type classification..."}
|
||||
className="w-full text-xs font-semibold"
|
||||
disabled={!!(selectedAssetFamilyId && filteredAssetTypes.length === 0)}
|
||||
>
|
||||
{filteredAssetTypes.filter(at => at && at.status === 'active').map((at) => {
|
||||
const isRequired = isAssetTypeRequiredByFamily(at.code);
|
||||
return (
|
||||
<option key={at.id} value={at.id}>
|
||||
{at.name} ({at.category || 'Other'}) {isRequired ? '★ Required' : ''}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPicker(true)}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-2xs"
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
Browse Asset Library
|
||||
</button>
|
||||
|
||||
{selectedAssetType && (() => {
|
||||
const allowedFileTypes =
|
||||
selectedAssetType.validation?.allowedFileTypes ??
|
||||
selectedAssetType.validation?.allowed_extensions ??
|
||||
[];
|
||||
const maxFileSize =
|
||||
selectedAssetType.validation?.maxFileSize ??
|
||||
selectedAssetType.validation?.max_file_size;
|
||||
|
||||
return (
|
||||
<div className="bg-background border border-border rounded-lg p-3.5 flex items-start gap-3">
|
||||
<Info className="w-4 h-4 text-primary shrink-0 mt-0.5" />
|
||||
<div className="text-xs space-y-1">
|
||||
<div>
|
||||
<span className="font-bold text-foreground">Allowed formats: </span>
|
||||
<span className="font-mono text-primary-dark font-semibold">
|
||||
{allowedFileTypes.length > 0 ? allowedFileTypes.map((t: string) => t.toUpperCase()).join(', ') : 'Any'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-foreground">Max File Size: </span>
|
||||
<span className="font-semibold text-muted-foreground">
|
||||
{maxFileSize
|
||||
? `${Math.round(maxFileSize / (1024 * 1024))} MB`
|
||||
: '10 MB'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Zone & Picker Trigger */}
|
||||
{!readOnly && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => {
|
||||
if (!selectedAssetType) {
|
||||
toast.error('Please select an Asset Type first.');
|
||||
return;
|
||||
}
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
className={`md:col-span-2 border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all flex flex-col items-center justify-center ${dragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/20 hover:bg-background/50 bg-surface'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
multiple
|
||||
accept={(() => {
|
||||
const allowedFileTypes =
|
||||
selectedAssetType?.validation?.allowedFileTypes ??
|
||||
selectedAssetType?.validation?.allowed_extensions ??
|
||||
[];
|
||||
return allowedFileTypes.length > 0
|
||||
? allowedFileTypes.map((t: string) => '.' + t.replace('.', '')).join(',')
|
||||
: '*';
|
||||
})()}
|
||||
/>
|
||||
{uploading ? (
|
||||
<div className="space-y-2">
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin mx-auto" />
|
||||
<p className="font-semibold text-xs text-foreground">Uploading files...</p>
|
||||
</div>
|
||||
) : !selectedAssetType ? (
|
||||
<div className="space-y-2 text-muted-foreground">
|
||||
<Info className="w-8 h-8 mx-auto animate-pulse" />
|
||||
<div>
|
||||
<h4 className="font-semibold text-xs text-foreground">Select Asset Type to Upload</h4>
|
||||
<p className="text-[10px] mt-1">Classification is required before mapping files to product registry</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Upload className="w-8 h-8 text-muted-foreground mx-auto" />
|
||||
<div>
|
||||
<h4 className="text-foreground font-semibold text-xs">Drag & Drop Files Here</h4>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">or click to browse {selectedAssetType.name} files</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-surface border border-border rounded-xl p-6 flex flex-col justify-between gap-4">
|
||||
<div>
|
||||
<h4 className="font-semibold text-foreground text-xs">Asset Actions</h4>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
Upload new files directly or select existing files from the central Asset library.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!selectedAssetType) {
|
||||
toast.error('Please select an Asset Type first.');
|
||||
return;
|
||||
}
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold shadow-2xs cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!selectedAssetType}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
+ Add Asset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPicker(true)}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-2xs cursor-pointer"
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
Browse Library
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid of assigned assets */}
|
||||
{loading && assignedAssets.length === 0 ? (
|
||||
@@ -331,12 +600,11 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
<table className="w-full border-collapse text-left text-xs text-foreground">
|
||||
<thead>
|
||||
<tr className="bg-background border-b border-border text-[10px] font-bold text-muted-foreground uppercase tracking-wider">
|
||||
<th className="px-4 py-3 w-16 text-center">Order</th>
|
||||
{!readOnly && <th className="px-4 py-3 w-16 text-center">Order</th>}
|
||||
<th className="px-4 py-3 w-20">Preview</th>
|
||||
<th className="px-4 py-3">Asset Details</th>
|
||||
<th className="px-4 py-3 w-40">Role Classification</th>
|
||||
<th className="px-4 py-3 w-32 text-center">Primary</th>
|
||||
<th className="px-4 py-3 w-20 text-right">Actions</th>
|
||||
{!readOnly && <th className="px-4 py-3 w-20 text-right">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
@@ -347,30 +615,33 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
const isImage = isImageFile(asset.mime_type, asset.file_url);
|
||||
const isVideo = asset.mime_type?.startsWith('video/');
|
||||
const isPdf = asset.mime_type === 'application/pdf';
|
||||
const is3DModel = asset.mime_type?.startsWith('model/') || ['glb', 'gltf', 'usdz'].includes(asset.extension || '');
|
||||
|
||||
return (
|
||||
<tr key={mapping.id} className="hover:bg-background/50 transition-colors">
|
||||
{/* Display Order sorting */}
|
||||
<td className="px-4 py-3 text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={idx === 0}
|
||||
onClick={() => handleMoveOrder(idx, 'up')}
|
||||
className="p-1 hover:bg-surface-muted rounded text-muted-foreground disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<ArrowUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={idx === assignedAssets.length - 1}
|
||||
onClick={() => handleMoveOrder(idx, 'down')}
|
||||
className="p-1 hover:bg-surface-muted rounded text-muted-foreground disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
{!readOnly && (
|
||||
<td className="px-4 py-3 text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={idx === 0}
|
||||
onClick={() => handleMoveOrder(idx, 'up')}
|
||||
className="p-1 hover:bg-surface-muted rounded text-muted-foreground disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<ArrowUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={idx === assignedAssets.length - 1}
|
||||
onClick={() => handleMoveOrder(idx, 'down')}
|
||||
className="p-1 hover:bg-surface-muted rounded text-muted-foreground disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
|
||||
{/* Preview Thumbnail */}
|
||||
<td className="px-4 py-3">
|
||||
@@ -381,6 +652,8 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
<Video className="w-5 h-5 text-primary" />
|
||||
) : isPdf ? (
|
||||
<FileText className="w-5 h-5 text-emerald-500" />
|
||||
) : is3DModel ? (
|
||||
<Box className="w-5 h-5 text-purple-500" />
|
||||
) : (
|
||||
<FileText className="w-5 h-5 text-blue-500" />
|
||||
)}
|
||||
@@ -389,26 +662,23 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
|
||||
{/* Meta info */}
|
||||
<td className="px-4 py-3 font-medium">
|
||||
<div className="text-foreground font-semibold">{asset.name}</div>
|
||||
<div className="text-foreground font-semibold flex items-center gap-2">
|
||||
{asset.name}
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-primary/10 text-primary border border-primary/20 uppercase">
|
||||
{mapping.role ? mapping.role.replace('_', ' ') : 'HERO IMAGE'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono mt-1 flex items-center gap-2 flex-wrap">
|
||||
<span>Size: {asset.file_size ? `${(asset.file_size / 1024).toFixed(1)} KB` : '—'}</span>
|
||||
{asset.width && asset.height && <span>• Dim: {asset.width}×{asset.height}px</span>}
|
||||
<span>• MIME: {asset.mime_type || asset.extension || 'bin'}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono mt-0.5 max-w-[300px] truncate" title={asset.file_url}>
|
||||
{asset.file_url}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Role dropdown selection */}
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={mapping.role}
|
||||
onChange={(e) => handleRoleChange(asset.id, e.target.value)}
|
||||
className="text-xs border border-border focus:ring-primary rounded-lg px-2.5 py-1 focus:outline-none bg-surface font-medium text-foreground"
|
||||
>
|
||||
<option value="primary_image">Primary Image</option>
|
||||
<option value="gallery_image">Gallery Image</option>
|
||||
<option value="thumbnail">Thumbnail</option>
|
||||
<option value="video">Video</option>
|
||||
<option value="document">Documentation PDF</option>
|
||||
</select>
|
||||
</td>
|
||||
|
||||
|
||||
{/* Primary Badge toggle button */}
|
||||
<td className="px-4 py-3 text-center">
|
||||
@@ -417,6 +687,8 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
<Shield className="w-3 h-3" />
|
||||
Primary
|
||||
</span>
|
||||
) : readOnly ? (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
@@ -429,15 +701,17 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
</td>
|
||||
|
||||
{/* Unassign */}
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUnassign(asset.id)}
|
||||
className="p-1.5 hover:bg-red-50 text-muted-foreground hover:text-red-600 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
{!readOnly && (
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUnassign(asset.id)}
|
||||
className="p-1.5 hover:bg-red-50 text-muted-foreground hover:text-red-600 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, Plus } from 'lucide-react';
|
||||
import { DynamicAttributeRenderer } from './DynamicAttributeRenderer';
|
||||
|
||||
interface AttributeOption {
|
||||
@@ -33,6 +33,9 @@ interface ProductAttributeGroupProps {
|
||||
errors?: Record<string, any>;
|
||||
touched?: Record<string, any>;
|
||||
onAttributeChange: (code: string, value: any) => void;
|
||||
onAttributeBlur?: (code: string) => void;
|
||||
onAddAttributeClick?: (group: AttributeGroup) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
|
||||
@@ -41,6 +44,9 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
|
||||
errors = {},
|
||||
touched = {},
|
||||
onAttributeChange,
|
||||
onAttributeBlur,
|
||||
onAddAttributeClick,
|
||||
readOnly,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const attributes = group.attributes || [];
|
||||
@@ -48,7 +54,22 @@ 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>
|
||||
{!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 className="text-xs text-muted-foreground italic">No Attributes available.</div>
|
||||
</div>
|
||||
);
|
||||
@@ -56,14 +77,28 @@ 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 && 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">
|
||||
@@ -73,8 +108,10 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
|
||||
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>
|
||||
|
||||
@@ -90,7 +90,24 @@ 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 (
|
||||
|
||||
@@ -10,6 +10,7 @@ interface VariantEditorRowProps {
|
||||
onUpdate: (id: string, updates: Partial<Variant>) => Promise<any>;
|
||||
onDelete: (id: string) => void;
|
||||
onArchive: (id: string) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
|
||||
@@ -19,7 +20,8 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
|
||||
onSelect,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onArchive
|
||||
onArchive,
|
||||
readOnly
|
||||
}) => {
|
||||
const [sku, setSku] = useState(variant.sku);
|
||||
const [price, setPrice] = useState(String(variant.price));
|
||||
@@ -29,7 +31,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 +40,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 +77,45 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// ── Read-only row ──────────────────────────────────────────────────────────
|
||||
if (readOnly) {
|
||||
return (
|
||||
<tr className="hover:bg-background/50 transition-colors border-b border-border">
|
||||
<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>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-foreground">{sku}</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 text-center text-xs text-foreground">{stock}</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,7 +128,7 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* Cartesian Combination specifications */}
|
||||
{/* 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}>
|
||||
@@ -100,10 +139,7 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
@@ -183,7 +219,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" />}
|
||||
|
||||
@@ -11,6 +11,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,7 +22,8 @@ export const VariantListView: React.FC<VariantListViewProps> = ({
|
||||
onSelectAllChange,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onArchive
|
||||
onArchive,
|
||||
readOnly
|
||||
}) => {
|
||||
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;
|
||||
@@ -31,25 +33,27 @@ export const VariantListView: React.FC<VariantListViewProps> = ({
|
||||
<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>
|
||||
{!readOnly && (
|
||||
<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>
|
||||
{!readOnly && <th className="px-4 py-3 w-16 text-center">Save</th>}
|
||||
{!readOnly && <th className="px-4 py-3 w-24 text-right">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
@@ -63,11 +67,12 @@ export const VariantListView: React.FC<VariantListViewProps> = ({
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
))}
|
||||
{variants.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-6 py-10 text-center text-xs text-muted-foreground font-medium">
|
||||
<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>
|
||||
|
||||
@@ -11,6 +11,7 @@ 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> = ({
|
||||
@@ -22,7 +23,8 @@ export const VariantMatrixView: React.FC<VariantMatrixViewProps> = ({
|
||||
onSelectAllChange,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onArchive
|
||||
onArchive,
|
||||
readOnly
|
||||
}) => {
|
||||
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;
|
||||
@@ -32,17 +34,19 @@ export const VariantMatrixView: React.FC<VariantMatrixViewProps> = ({
|
||||
<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>
|
||||
{!readOnly && (
|
||||
<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 => (
|
||||
@@ -56,8 +60,8 @@ export const VariantMatrixView: React.FC<VariantMatrixViewProps> = ({
|
||||
<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>
|
||||
{!readOnly && <th className="px-4 py-3.5 w-16 text-center">Save</th>}
|
||||
{!readOnly && <th className="px-4 py-3.5 w-24 text-right">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
@@ -69,14 +73,16 @@ export const VariantMatrixView: React.FC<VariantMatrixViewProps> = ({
|
||||
}`}
|
||||
>
|
||||
{/* 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>
|
||||
{!readOnly && (
|
||||
<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 => {
|
||||
@@ -100,12 +106,13 @@ export const VariantMatrixView: React.FC<VariantMatrixViewProps> = ({
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</tr>
|
||||
))}
|
||||
{variants.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={axesKeys.length + 8} className="px-6 py-10 text-center text-xs text-muted-foreground font-medium">
|
||||
<td colSpan={axesKeys.length + (readOnly ? 5 : 8)} className="px-6 py-10 text-center text-xs text-muted-foreground font-medium">
|
||||
No variants found matching criteria.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -122,9 +129,10 @@ interface InlineCellsProps {
|
||||
onUpdate: (id: string, updates: Partial<Variant>) => Promise<any>;
|
||||
onDelete: (id: string) => void;
|
||||
onArchive: (id: string) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const InlineEditorCells: React.FC<InlineCellsProps> = ({ variant, onUpdate, onDelete, onArchive }) => {
|
||||
const InlineEditorCells: React.FC<InlineCellsProps> = ({ variant, onUpdate, onDelete, onArchive, readOnly }) => {
|
||||
const [sku, setSku] = useState(variant.sku);
|
||||
const [price, setPrice] = useState(String(variant.price));
|
||||
const [costPrice, setCostPrice] = useState(String(variant.costPrice));
|
||||
@@ -179,6 +187,25 @@ const InlineEditorCells: React.FC<InlineCellsProps> = ({ variant, onUpdate, onDe
|
||||
}
|
||||
};
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<>
|
||||
<td className="px-4 py-3 font-mono text-xs text-foreground">{sku}</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 text-center text-xs text-foreground">{stock}</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<td className="px-4 py-3">
|
||||
|
||||
@@ -13,13 +13,15 @@ interface VariantsTabProps {
|
||||
productType: string;
|
||||
parentSku: string;
|
||||
family: any; // Product Family details
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
productId,
|
||||
productType,
|
||||
parentSku,
|
||||
family
|
||||
family,
|
||||
readOnly
|
||||
}) => {
|
||||
const {
|
||||
variants,
|
||||
@@ -213,14 +215,16 @@ 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">
|
||||
<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>
|
||||
{!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={() => fetchByProduct(productId)}
|
||||
@@ -280,6 +284,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
onUpdate={handleSingleUpdate}
|
||||
onDelete={deleteVariant}
|
||||
onArchive={archiveVariant}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
) : (
|
||||
<VariantListView
|
||||
@@ -291,6 +296,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,7 +31,14 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
|
||||
setFamily(blueprint);
|
||||
setAllowedBrands(blueprint.allowedBrands || []);
|
||||
setCategory(blueprint.category || null);
|
||||
setAttributeSet(blueprint.attributeSet || null);
|
||||
|
||||
let setObj = blueprint.attributeSet || blueprint.attribute_set;
|
||||
const setId = blueprint.attribute_set_id || blueprint.attributeSetId || (setObj ? setObj.id : null);
|
||||
if ((!setObj || !setObj.groups) && setId) {
|
||||
const fetchedSet = await attributeSetsService.getById(setId).catch(() => null);
|
||||
if (fetchedSet) setObj = fetchedSet;
|
||||
}
|
||||
setAttributeSet(setObj || null);
|
||||
|
||||
const groupsData = blueprint.groups || blueprint.attributeGroups || [];
|
||||
let flatAttrs: any[] = [];
|
||||
|
||||
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
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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,12 @@
|
||||
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));
|
||||
},
|
||||
getById: async (id: string): Promise<Setting | undefined> => {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(getStored().find(p => p.id === id)), 200));
|
||||
},
|
||||
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);
|
||||
});
|
||||
},
|
||||
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);
|
||||
});
|
||||
},
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored().filter(p => p.id !== id);
|
||||
setStored(list);
|
||||
resolve(true);
|
||||
}, 300);
|
||||
});
|
||||
getCategorySettings: async (category: string) => {
|
||||
const response = await axiosInstance.get(`/settings/by-category/${category}`);
|
||||
return response.data.data;
|
||||
},
|
||||
updateCategorySettings: async (category: string, data: Record<string, any>) => {
|
||||
const response = await axiosInstance.put(`/settings/by-category/${category}`, data);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -19,7 +19,10 @@ import {
|
||||
Settings,
|
||||
List,
|
||||
Layers2,
|
||||
Bell
|
||||
Bell,
|
||||
ShieldCheck,
|
||||
Activity,
|
||||
Building2
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
@@ -36,10 +39,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 +109,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 +139,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;
|
||||
|
||||
@@ -4,80 +4,89 @@
|
||||
* Single Responsibility: Orchestrate theme business logic.
|
||||
*
|
||||
* This service is the ONLY layer that knows about both storage and DOM.
|
||||
* It delegates:
|
||||
* - Persistence → theme.storage.ts
|
||||
* - DOM mutation → applyTheme.ts (utils)
|
||||
*
|
||||
* FUTURE API INTEGRATION:
|
||||
* When a backend API is introduced, only this file changes.
|
||||
* The storage layer, DOM utility, context, and hook remain untouched.
|
||||
* Pattern: replace loadTheme() / saveTheme() calls with API calls,
|
||||
* keeping the same method signatures.
|
||||
*
|
||||
* RULES:
|
||||
* - No React imports.
|
||||
* - No JSX.
|
||||
* - No component access.
|
||||
* It delegates persistence to the backend database via REST API.
|
||||
*/
|
||||
|
||||
import type { Theme } from "../types/theme.types";
|
||||
import {
|
||||
loadTheme as storageLoad,
|
||||
saveTheme as storageSave,
|
||||
clearTheme as storageClear,
|
||||
} from "../storage/theme.storage";
|
||||
import apiClient from "../api/axiosInstance";
|
||||
import { applyTheme as applyThemeToDom } from "../utils/applyTheme";
|
||||
import { DEFAULT_THEME, AVAILABLE_THEMES } from "../constants/themes";
|
||||
|
||||
export { DEFAULT_THEME, AVAILABLE_THEMES };
|
||||
|
||||
let currentTheme: Theme = DEFAULT_THEME;
|
||||
|
||||
// Load user theme preference asynchronously during application initialization (module import)
|
||||
try {
|
||||
const token = localStorage.getItem("accessToken");
|
||||
if (token) {
|
||||
const response = await apiClient.get<{ success: boolean; data: { themeCode: string } }>("/api/v1/settings/theme");
|
||||
if (response && response.success && response.data?.themeCode) {
|
||||
const themeCode = response.data.themeCode;
|
||||
const found = AVAILABLE_THEMES.find((t) => t.id === themeCode);
|
||||
if (found) {
|
||||
currentTheme = found;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load user theme preference asynchronously at startup:", error);
|
||||
}
|
||||
|
||||
// Apply the resolved theme to the DOM immediately prior to React tree mount
|
||||
applyThemeToDom(currentTheme.palette);
|
||||
if (typeof document !== "undefined") {
|
||||
if (currentTheme.mode === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PUBLIC SERVICE METHODS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the currently persisted theme from storage.
|
||||
* Falls back to DEFAULT_THEME if nothing is saved or storage fails.
|
||||
* Returns the loaded theme.
|
||||
*/
|
||||
export function getCurrentTheme(): Theme {
|
||||
const saved = storageLoad();
|
||||
if (!saved) return DEFAULT_THEME;
|
||||
// If the saved theme id still exists in the registry, use it.
|
||||
// Otherwise (e.g. default changed), fall back to DEFAULT_THEME.
|
||||
const exists = AVAILABLE_THEMES.some((t) => t.id === saved.id);
|
||||
return exists ? saved : DEFAULT_THEME;
|
||||
return currentTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all themes available for selection.
|
||||
* Future: merge with API-fetched tenant themes.
|
||||
*/
|
||||
export function getAvailableThemes(): Theme[] {
|
||||
return AVAILABLE_THEMES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a theme to storage.
|
||||
* Does NOT apply it to the DOM — call applyTheme() separately if needed.
|
||||
* Persists a theme to the backend database.
|
||||
*
|
||||
* @param theme - The theme to persist.
|
||||
*/
|
||||
export function saveTheme(theme: Theme): void {
|
||||
storageSave(theme);
|
||||
currentTheme = theme;
|
||||
const themeCode = (theme as any).code || theme.id;
|
||||
apiClient.put("/api/v1/settings/theme", { themeCode })
|
||||
.catch((err) => {
|
||||
console.error("Failed to save theme preference to DB:", err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets to the default theme:
|
||||
* 1. Clears persisted theme from storage.
|
||||
* 2. Removes all runtime CSS variable overrides from the DOM.
|
||||
* The static @theme values in index.css take effect again.
|
||||
* Resets to the default theme.
|
||||
*
|
||||
* @returns The default theme, so the caller can update its state.
|
||||
*/
|
||||
export function resetTheme(): Theme {
|
||||
storageClear();
|
||||
// Apply explicitly so the DOM reflects DEFAULT_THEME (Royal Purple),
|
||||
// matching the static @theme values in index.css.
|
||||
currentTheme = DEFAULT_THEME;
|
||||
apiClient.put("/api/v1/settings/theme", { themeCode: DEFAULT_THEME.id })
|
||||
.catch((err) => {
|
||||
console.error("Failed to reset theme preference on DB:", err);
|
||||
});
|
||||
|
||||
applyThemeToDom(DEFAULT_THEME.palette);
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.classList.remove("dark");
|
||||
|
||||
@@ -27,6 +27,7 @@ export type PermissionNodes =
|
||||
| 'settings.users'
|
||||
| 'settings.roles'
|
||||
| 'settings.integrations'
|
||||
| 'settings.file_server'
|
||||
| 'reports';
|
||||
|
||||
export type PermissionAction = keyof NodePermissions;
|
||||
|
||||
@@ -12,8 +12,14 @@ import type { PermissionPayload, PermissionNodes, PermissionAction } from '../ty
|
||||
export const hasPermission = (
|
||||
permissions: PermissionPayload | undefined | null,
|
||||
nodeCode: PermissionNodes | string,
|
||||
action: PermissionAction
|
||||
action: PermissionAction,
|
||||
user?: any
|
||||
): boolean => {
|
||||
// Platform superadmin or tenant admin role bypass
|
||||
if (user?.user_type === 'platform' || user?.role_code === 'TENANT_ADMIN' || user?.roles?.some((r: any) => r.role_code === 'SUPER_ADMIN' || r.role_code === 'TENANT_ADMIN')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!permissions) return false;
|
||||
|
||||
// Superadmin wildcard check
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/uploads': {
|
||||
target: 'http://localhost:5000',
|
||||
target: 'http://localhost:5002',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user