56 Commits
Author SHA1 Message Date
Inamul-hasan-tec 1556a67a83 fix(headers): perfectly balance JSX tree structure and closing tags 2026-08-20 12:16:54 +05:30
Inamul-hasan-tec d0a0f30e85 fix(header): correct JSX tag nesting and closing structure 2026-08-20 12:15:24 +05:30
Inamul-hasan-tec 8302093c6e feat(superadmin): add Workspace Switcher, Support Impersonation Banner, and default /platform/overview routing 2026-08-20 12:14:13 +05:30
Inamul-hasan-tec 3b6563c2b4 fix(variants): normalize variant axes options and support custom tags in VariantAxesSelector 2026-08-20 11:16:06 +05:30
Inamul-hasan-tec 5f64b3c0d7 docs: add Master System Credentials & Demo Test Accounts Reference 2026-08-19 18:22:46 +05:30
Inamul-hasan-tec b17e22cdc2 docs: add complete visual Tenant Creation and RBAC guide 2026-08-19 18:19:09 +05:30
Inamul-hasan-tec 46550e4f6e docs: add Core SaaS, Multi-Tenancy, RBAC, and Audit Logging master architectural documentation 2026-08-19 17:46:03 +05:30
Inamul-hasan-tec 5f6b9dc11f docs: add comprehensive product engine architecture and database dictionary 2026-08-19 16:13:13 +05:30
Inamul-hasan-tec ccfe105320 fix(products): remove extraneous asset type label beside HERO IMAGE badge on review card 2026-08-19 16:07:24 +05:30
Inamul-hasan-tec 7658e6de08 fix(products): resolve Color Spec options rendering, dynamic completeness scaling, Hero Image review badge, and wizard navigation labeling 2026-08-19 15:43:03 +05:30
Inamul-hasan-tec 3f3bf38999 fix(products): resolve QA retest findings across Electronics attribute set DB linkage, connectivity attribute, 409 toast suppression, library hero image role, and step progress clarity 2026-08-19 15:32:09 +05:30
Inamul-hasan-tec 3222ee7ae6 fix(products): fix JSX div tag nesting in NewProduct.tsx Identification section 2026-08-19 15:21:41 +05:30
Inamul-hasan-tec c48b753446 fix(products): resolve 11 QA defects across Price/Stock, SKU preservation, AttributeSet sync, Unit 409 handling, DAM hero role/metadata, and Review cards 2026-08-19 15:12:53 +05:30
Inamul-hasan-tec cedd26653b fix(settings): remove duplicate useEffect import in SettingList.tsx 2026-08-19 14:46:01 +05:30
Mohammed-Fardeen-02 fb99c991a3 resolved merge conflict 2026-08-19 12:47:05 +05:30
Mohammed-Fardeen-02 4e1fec0059 configured file server 2026-08-19 12:45:34 +05:30
Inamul-hasan-tec a912331eab Merge remote-tracking branch 'origin/dev' into feature/inam-platform-core-setup 2026-08-19 12:45:30 +05:30
fardeen 8edef647dc Merge pull request 'mahir_frontend' (#21) from mahir_frontend into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/productcatalogue_frontend/pulls/21
2026-08-17 06:18:13 +00:00
Mahir-Mohamed e81d5c58a2 Merge branch 'dev' into mahir_frontend 2026-08-17 10:53:42 +05:30
Mahir-Mohamed e3d275d4dc feat(products): improve product configuration review step 2026-08-14 19:17:51 +05:30
Mahir-Mohamed abb135b5e0 feat(products): implement Asset Family filtering and automatic role mapping in Assets step 2026-08-13 14:55:18 +05:30
Inamul-hasan-tec 2fb18391cf fix(categories): add safe null guards on allowedParentOptions and enable parent selection in NewCategory.tsx 2026-08-12 17:07:21 +05:30
Inamul-hasan-tec 6b9b700a25 fix(ui): pass row.groups to ATTRIBUTES column using unique column id in AttributeSetList.tsx resolving inherited attributes count display 2026-08-12 17:04:12 +05:30
Inamul-hasan-tec 3e96ee5f16 fix(ui): change key to attributesCount in AttributeSetList.tsx resolving duplicate React key warning 2026-08-12 17:00:52 +05:30
Inamul-hasan-tec 95ae534619 fix(ui): add Selectable Options manager card and payload integration for select/multiselect data types in NewAttribute.tsx 2026-08-12 16:51:30 +05:30
Inamul-hasan-tec ac167539fa fix(ui): handle null conversion values as - in UnitTable and pass name prop to button in Select to resolve Formik handleBlur warnings 2026-08-12 16:42:56 +05:30
Inamul-hasan-tec 6565b7fa50 fix(routes): import ProtectedRoute in AppRoutes.tsx resolving ReferenceError 2026-08-12 16:23:41 +05:30
Inamul-hasan-tec 9ce467820a fix(ui): wrap /users/roles/* in ProtectedRoute node=settings.roles, fix tenant label in NewRoleForm, update Dashboard title, and add minWidth/minHeight to Recharts ResponsiveContainer 2026-08-12 16:16:17 +05:30
Inamul-hasan-tec 1ceed926fc fix(dashboard): correct import path for productApi in Dashboard.tsx 2026-08-12 16:07:39 +05:30
Inamul-hasan-tec 00b5b93253 fix(ui): wrap RoleRoutes in ProtectedRoute settings.roles, add trailing star to dashboard route, and use dynamic tenant counts in Dashboard and CategoryList 2026-08-12 16:04:37 +05:30
Inamul-hasan-tec be1d12072f fix(ui): gracefully handle default initial baseline mappings and job runs in ChannelMappingTab and SyndicationHistoryTab 2026-08-12 15:40:14 +05:30
Inamul-hasan-tec 3c3dab9d26 feat(ui): add Syndicate All Channels bulk toolbar action button in ChannelList 2026-08-12 15:09:08 +05:30
Inamul-hasan-tec bcbf2cc112 feat(channels): implement PayloadPreviewModal side-by-side JSON inspector and Preview Transformed Payload button 2026-08-12 15:02:23 +05:30
Inamul-hasan-tec ed97796480 feat(ui): add inline Trigger Sync button in ChannelList table and update PIM attribute selector 2026-08-12 13:25:49 +05:30
Inamul-hasan-tec e75efac2dc feat(channels): implement ChannelMappingTab, SyndicationHistoryTab and integrate multi-step syndication wizard 2026-08-12 13:23:08 +05:30
Inamul-hasan-tec b560fc650b fix(ui): remove false SUPER ADMIN badge on Tenant Administrator in UserList and clarify Platform Super Admin role in Header 2026-08-11 18:42:32 +05:30
Inamul-hasan-tec 3b89c34c87 fix(rbac): hide Assign to Tenant selector in NewRoleForm for non-platform users and lock tenant_id 2026-08-11 18:36:38 +05:30
Mahir-Mohamed 7cd9ab5e0c fix: improve product form validation and navigation guards 2026-08-11 18:19:41 +05:30
Inamul-hasan-tec 44e245b9dc Merge remote-tracking branch 'origin/dev' into feature/inam-platform-core-setup 2026-08-11 18:11:43 +05:30
Inamul-hasan-tec 27724a2328 feat(rbac): wire component-level RBAC guards across Sidebar, Categories, Families, Brands, Media Assets, Channels, Users, and Settings 2026-08-11 11:33:54 +05:30
fardeen b11dc35915 Merge pull request 'mahir_frontend' (#20) from mahir_frontend into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/productcatalogue_frontend/pulls/20
2026-08-11 05:46:06 +00:00
Mahir-Mohamed f012aa37e9 Added functionality in Attribute step of product creation configuration 2026-08-11 11:01:34 +05:30
Mahir-Mohamed d04610f1ac Implement product attribute enhancements 2026-08-10 12:03:00 +05:30
Inamul-hasan-tec a15e5d9139 feat(rbac): implement PermissionGuard, enhance permissionUtils & bind button-level guards in Product and Attribute lists 2026-08-10 11:53:10 +05:30
Inamul-hasan-tec e8edab84b9 feat(settings): connect General Settings to real backend category endpoints with live persistence 2026-08-08 16:48:01 +05:30
Inamul-hasan-tec b76227e674 fix(settings): bind General Settings Organization Name and Subdomain inputs to logged-in user tenant state 2026-08-08 16:05:40 +05:30
Inamul-hasan-tec 6b3c0c93cd fix(sidebar): remove Tenants sub-item from Users & Roles menu for tenant workspaces 2026-08-08 16:00:47 +05:30
Inamul-hasan-tec cfc35f3fbc security(rbac): add PlatformGuard to protect /platform/* routes and restrict Platform Control sidebar to platform super admins 2026-08-08 15:25:51 +05:30
Inamul-hasan-tec 1c0595d8b2 feat(platform): add Provisioning Success Modal with 1-click Copy Credentials button for tenant onboarding 2026-08-08 15:19:01 +05:30
Inamul-hasan-tec 9fc4b34e60 feat(platform): auto navigate to /products when Support Assist impersonation mode is activated 2026-08-08 15:15:36 +05:30
Inamul-hasan-tec 6b82bfea72 feat(platform): build Platform Control Center UI, tenant provisioning modal, support impersonation bar & fix 401 login reload 2026-08-08 10:55:01 +05:30
Inamul-hasan-tec cb711f767e feat(platform): add platform tenant management, provisioning DTOs, metrics & support impersonation headers 2026-08-07 18:39:29 +05:30
Inamul-hasan-tec 0434385059 feat(core): align API base URL, socket service, asset preview fallback to port 5001 2026-08-07 18:31:45 +05:30
Hasan 07df2ccdd3 Merge pull request 'this Application is unfinshed and pending works in (UI,Logical Workflows)last git push by Hasan (28/7/2026)' (#19) from hasan_frontend into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/productcatalogue_frontend/pulls/19
2026-07-28 05:37:55 +00:00
MohamedHasan07 11b30cab97 this Application is unfinshed and pending works in (UI,Logical Workflows)last git push by Hasan (28/7/2026) 2026-07-28 10:59:07 +05:30
liyaqath dca896f0e8 Merge pull request 'ali' (#18) from ali into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/productcatalogue_frontend/pulls/18
2026-07-25 11:17:16 +00:00
76 changed files with 7046 additions and 1442 deletions
@@ -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
```
+35
View File
@@ -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.
+28
View File
@@ -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
```
+58 -42
View File
@@ -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"
}
+11 -4
View File
@@ -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;
+8 -2
View File
@@ -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);
}
+20 -23
View File
@@ -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 */}
+5 -2
View File
@@ -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}</>;
};
+3 -1
View File
@@ -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}
+177 -67
View File
@@ -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>
);
}
+20 -9
View File
@@ -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 {
+19 -15
View File
@@ -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">
+11 -5
View File
@@ -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>
+15 -11
View File
@@ -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>
+35
View File
@@ -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>
);
}
+57 -8
View File
@@ -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>
+23 -4
View File
@@ -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" />
+1 -1
View File
@@ -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>
+37 -15
View File
@@ -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>
+7 -3
View File
@@ -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
+3 -3
View File
@@ -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
}}
/>
+24 -16
View File
@@ -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>
+8 -5
View File
@@ -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>
);
};
+304 -11
View File
@@ -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;
}
};
+67 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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",
+4 -2
View File
@@ -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);
+20 -17
View File
@@ -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>
);
}
+9 -8
View File
@@ -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
View File
@@ -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}`;
}
+12 -4
View File
@@ -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 />} />
+24 -8
View File
@@ -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 -1
View File
@@ -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;
+47 -38
View File
@@ -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");
+1
View File
@@ -27,6 +27,7 @@ export type PermissionNodes =
| 'settings.users'
| 'settings.roles'
| 'settings.integrations'
| 'settings.file_server'
| 'reports';
export type PermissionAction = keyof NodePermissions;
+7 -1
View File
@@ -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
View File
@@ -7,7 +7,7 @@ export default defineConfig({
server: {
proxy: {
'/uploads': {
target: 'http://localhost:5000',
target: 'http://localhost:5002',
changeOrigin: true,
},
},