69 Commits
Author SHA1 Message Date
Mahir-Mohamed 4a5aa9599a Merge remote-tracking branch 'origin/dev' into dev 2026-09-08 15:20:48 +05:30
Mahir-Mohamed 452896be0c feat: enhance product and family configuration 2026-09-08 14:15:16 +05:30
mahir 3da12a9e49 Merge pull request 'Fix product and product family configuration' (#26) from mahir-fixes into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/productcatalogue_frontend/pulls/26
2026-09-01 10:12:24 +00:00
Mahir-Mohamed 1d9f7840cd Fix product and product family configuration 2026-09-01 14:59:28 +05:30
fardeen 65d2f48dce Merge pull request 'fardeen-dev' (#25) from fardeen-dev into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/productcatalogue_frontend/pulls/25
2026-08-31 14:04:37 +00:00
Mohammed-Fardeen-02 9980a67261 Merge branch 'dev' of https://gitea.maskantech.in/gitea_admin/productcatalogue_frontend into fardeen-dev 2026-08-31 19:33:22 +05:30
Mohammed-Fardeen-02 9e05f162a9 implemented shopify integration 2026-08-31 19:33:00 +05:30
fardeen 064b1726da Merge pull request 'fardeen-dev' (#24) from fardeen-dev into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/productcatalogue_frontend/pulls/24
2026-08-29 11:29:01 +00:00
Mohammed-Fardeen-02 2bb10e43dc Merge branch 'dev' of https://gitea.maskantech.in/gitea_admin/productcatalogue_frontend into fardeen-dev 2026-08-29 16:58:25 +05:30
Mohammed-Fardeen-02 2f1524d0fa resolved the assets filter 2026-08-29 16:57:53 +05:30
fardeen 4b70e8dbf8 Merge pull request 'fardeen-dev' (#23) from fardeen-dev into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/productcatalogue_frontend/pulls/23
2026-08-29 10:02:29 +00:00
Mohammed-Fardeen-02 eb3a613cf2 Merge branch 'dev' of https://gitea.maskantech.in/gitea_admin/productcatalogue_frontend into fardeen-dev 2026-08-29 15:31:49 +05:30
Mohammed-Fardeen-02 ed816920e2 resolved the variant and the assets issue 2026-08-29 15:30:58 +05:30
fardeen 3c15c4313f Merge pull request 'implemented product and variant creation' (#22) from fardeen-dev into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/productcatalogue_frontend/pulls/22
2026-08-27 11:16:10 +00:00
Mohammed-Fardeen-02 06bafc288c implemented product and variant creation 2026-08-27 16:44:20 +05:30
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
102 changed files with 14901 additions and 3123 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}</>;
};
+88 -31
View File
@@ -1,5 +1,6 @@
import * as React from "react";
import { createPortal } from "react-dom";
import { Search, X, Check, ChevronDown } from "lucide-react";
import { cn } from "../../lib/utils";
interface Option {
@@ -18,6 +19,7 @@ interface SelectProps {
placeholder?: string;
children?: React.ReactNode;
disabled?: boolean;
searchable?: boolean;
}
export function Select({
@@ -31,11 +33,14 @@ export function Select({
placeholder = "Select...",
children,
disabled,
searchable,
}: SelectProps) {
const [isOpen, setIsOpen] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState("");
const [rect, setRect] = React.useState<DOMRect | null>(null);
const buttonRef = React.useRef<HTMLButtonElement>(null);
const dropdownRef = React.useRef<HTMLDivElement>(null);
const searchInputRef = React.useRef<HTMLInputElement>(null);
const options = React.useMemo(() => {
const opts: Option[] = [];
@@ -53,6 +58,19 @@ export function Select({
const selectedOption = options.find((opt) => opt.value === value);
// By default, enable search if there are more than 5 options or if explicitly requested
const isSearchEnabled = searchable !== undefined ? searchable : options.length > 5;
const filteredOptions = React.useMemo(() => {
if (!isSearchEnabled || !searchQuery.trim()) {
return options;
}
const q = searchQuery.toLowerCase().trim();
return options.filter((opt) =>
opt.label.toLowerCase().includes(q) || opt.value.toLowerCase().includes(q)
);
}, [options, isSearchEnabled, searchQuery]);
// Recalculate position on scroll/resize while open
React.useEffect(() => {
if (!isOpen) return;
@@ -68,6 +86,17 @@ export function Select({
};
}, [isOpen]);
// Auto-focus search input when opened
React.useEffect(() => {
if (isOpen && isSearchEnabled) {
setTimeout(() => {
searchInputRef.current?.focus();
}, 50);
} else if (!isOpen) {
setSearchQuery("");
}
}, [isOpen, isSearchEnabled]);
// Close on outside click
React.useEffect(() => {
if (!isOpen) return;
@@ -77,10 +106,12 @@ export function Select({
dropdownRef.current?.contains(e.target as Node)
) return;
setIsOpen(false);
setSearchQuery("");
onBlur?.({ target: { name } } as any);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
}, [isOpen, name, onBlur]);
const handleToggle = () => {
if (disabled) return;
@@ -93,13 +124,15 @@ export function Select({
const handleSelect = (val: string) => {
onChange?.({ target: { name, value: val } });
setIsOpen(false);
setSearchQuery("");
};
return (
<div className="relative w-full">
<div className="relative w-full font-sans">
<button
ref={buttonRef}
id={id}
name={name}
type="button"
disabled={disabled}
onClick={handleToggle}
@@ -113,18 +146,12 @@ export function Select({
className
)}
>
<span className={cn(!selectedOption && "text-muted-foreground")}>
<span className={cn(!selectedOption && "text-muted-foreground", "truncate pr-2")}>
{selectedOption ? selectedOption.label : placeholder}
</span>
<svg
className={cn("h-4 w-4 text-muted-foreground transition-transform", isOpen && "rotate-180")}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="m19 9-7 7-7-7" />
</svg>
<ChevronDown
className={cn("h-4 w-4 text-muted-foreground transition-transform shrink-0", isOpen && "rotate-180")}
/>
</button>
{isOpen && rect && createPortal(
@@ -134,30 +161,60 @@ export function Select({
position: "fixed",
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
width: Math.max(rect.width, 220),
zIndex: 9999,
}}
className="bg-surface text-foreground border border-border shadow-lg rounded-xl p-1 max-h-60 overflow-y-auto"
className="bg-surface text-foreground border border-border shadow-xl rounded-xl overflow-hidden flex flex-col max-h-64 font-sans animate-in fade-in zoom-in-95 duration-100"
>
{options.map((opt) => (
<div
key={opt.value}
onMouseDown={(e) => { e.preventDefault(); handleSelect(opt.value); }}
className={cn(
"cursor-pointer rounded-lg px-3 py-2 text-sm select-none transition-colors flex items-center justify-between",
opt.value === value
? "bg-primary/10 text-primary font-medium"
: "hover:bg-primary/5 hover:text-primary text-foreground"
)}
>
{opt.label}
{opt.value === value && (
<svg className="h-4 w-4 fill-current text-primary shrink-0" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
{isSearchEnabled && (
<div className="p-2 border-b border-border bg-surface-muted flex items-center gap-2 shrink-0">
<Search className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..."
className="w-full px-2 py-1 text-xs border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary bg-background text-foreground font-sans"
onKeyDown={(e) => e.stopPropagation()}
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="p-0.5 text-muted-foreground hover:text-foreground shrink-0 cursor-pointer"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
))}
)}
<div className="p-1 overflow-y-auto flex-1 space-y-0.5">
{filteredOptions.length > 0 ? (
filteredOptions.map((opt) => (
<div
key={opt.value}
onMouseDown={(e) => { e.preventDefault(); handleSelect(opt.value); }}
className={cn(
"cursor-pointer rounded-lg px-3 py-2 text-xs select-none transition-colors flex items-center justify-between",
opt.value === value
? "bg-primary/10 text-primary font-medium"
: "hover:bg-background text-foreground"
)}
>
<span className="truncate pr-2">{opt.label}</span>
{opt.value === value && (
<Check className="h-3.5 w-3.5 text-primary shrink-0" />
)}
</div>
))
) : (
<div className="p-4 text-center text-xs text-muted-foreground">
No options found.
</div>
)}
</div>
</div>,
document.body
)}
+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 } 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>
)}
@@ -0,0 +1,605 @@
import { useState, useEffect } from "react";
import { useFormik } from "formik";
import {
X, Image as ImageIcon, Video, FileText, Award, Megaphone,
HelpCircle, Plus, AlertCircle, Check, Loader2, Save
} from "lucide-react";
import { Input } from "../../../components/customs/Input";
import { TextArea } from "../../../components/customs/TextArea";
import { Select } from "../../../components/customs/Select";
import { Button } from "../../../components/customs/Button";
import { useAssetType } from "../hook/useAssetType";
import { assetTypeSchema } from "../validation/asset-types.schema";
import { notify } from "../../../services/toast";
import type { AssetType, AssetTypeCreateRequest } from "../types/asset-types.types";
const CATEGORIES = [
{
id: 'image',
label: 'Image',
icon: ImageIcon,
desc: 'jpg, jpeg, png...',
color: 'text-blue-600',
bg: 'bg-blue-50',
bgSelected: 'bg-blue-600',
borderSelected: 'border-blue-500',
ringSelected: 'ring-blue-500',
bgSelectedCard: 'bg-blue-50/70',
hoverBorder: 'hover:border-blue-300',
hoverBg: 'hover:bg-blue-50/50'
},
{
id: 'video',
label: 'Video',
icon: Video,
desc: 'mp4, mov, avi...',
color: 'text-orange-600',
bg: 'bg-orange-50',
bgSelected: 'bg-orange-600',
borderSelected: 'border-orange-500',
ringSelected: 'ring-orange-500',
bgSelectedCard: 'bg-orange-50/70',
hoverBorder: 'hover:border-orange-300',
hoverBg: 'hover:bg-orange-50/50'
},
{
id: 'document',
label: 'Document',
icon: FileText,
desc: 'pdf, docx, doc...',
color: 'text-red-600',
bg: 'bg-red-50',
bgSelected: 'bg-red-600',
borderSelected: 'border-red-500',
ringSelected: 'ring-red-500',
bgSelectedCard: 'bg-red-50/70',
hoverBorder: 'hover:border-red-300',
hoverBg: 'hover:bg-red-50/50'
},
{
id: 'certificate',
label: 'Certificate',
icon: Award,
desc: 'pdf, jpg, png',
color: 'text-emerald-600',
bg: 'bg-emerald-50',
bgSelected: 'bg-emerald-600',
borderSelected: 'border-emerald-500',
ringSelected: 'ring-emerald-500',
bgSelectedCard: 'bg-emerald-50/70',
hoverBorder: 'hover:border-emerald-300',
hoverBg: 'hover:bg-emerald-50/50'
},
{
id: 'marketing',
label: 'Marketing',
icon: Megaphone,
desc: 'jpg, png, svg...',
color: 'text-purple-600',
bg: 'bg-purple-50',
bgSelected: 'bg-purple-600',
borderSelected: 'border-purple-500',
ringSelected: 'ring-purple-500',
bgSelectedCard: 'bg-purple-50/70',
hoverBorder: 'hover:border-purple-300',
hoverBg: 'hover:bg-purple-50/50'
},
{
id: 'other',
label: 'Other',
icon: HelpCircle,
desc: 'pdf, zip, csv...',
color: 'text-muted-foreground',
bg: 'bg-background',
bgSelected: 'bg-surface-active',
borderSelected: 'border-border',
ringSelected: 'ring-ring',
bgSelectedCard: 'bg-background',
hoverBorder: 'hover:border-border',
hoverBg: 'hover:bg-background/50'
},
] as const;
const POPULAR_EXTENSIONS = [
// Images
{ ext: 'jpg', category: 'image', label: 'JPG' },
{ ext: 'jpeg', category: 'image', label: 'JPEG' },
{ ext: 'png', category: 'image', label: 'PNG' },
{ ext: 'webp', category: 'image', label: 'WEBP' },
{ ext: 'gif', category: 'image', label: 'GIF' },
{ ext: 'svg', category: 'image', label: 'SVG' },
// Videos
{ ext: 'mp4', category: 'video', label: 'MP4' },
{ ext: 'mov', category: 'video', label: 'MOV' },
{ ext: 'avi', category: 'video', label: 'AVI' },
{ ext: 'webm', category: 'video', label: 'WEBM' },
// Documents
{ ext: 'pdf', category: 'document', label: 'PDF' },
{ ext: 'doc', category: 'document', label: 'DOC' },
{ ext: 'docx', category: 'document', label: 'DOCX' },
{ ext: 'xls', category: 'document', label: 'XLS' },
{ ext: 'xlsx', category: 'document', label: 'XLSX' },
{ ext: 'ppt', category: 'document', label: 'PPT' },
{ ext: 'pptx', category: 'document', label: 'PPTX' },
{ ext: 'txt', category: 'document', label: 'TXT' },
// Other
{ ext: 'zip', category: 'other', label: 'ZIP' },
{ ext: 'rar', category: 'other', label: 'RAR' },
{ ext: 'csv', category: 'other', label: 'CSV' },
{ ext: 'json', category: 'other', label: 'JSON' },
];
const STEPS = [
{ id: 'basic', label: 'Basic Info', step: 1 },
{ id: 'category', label: 'Asset Category', step: 2 },
{ id: 'validation', label: 'Validation Rules', step: 3 },
{ id: 'preview', label: 'Preview', step: 4 },
];
const labelClass = 'block text-xs font-semibold text-foreground mb-1.5';
const errorClass = 'text-xs text-red-500 mt-1';
interface CreateAssetTypeModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: (created: AssetType) => void;
}
export function CreateAssetTypeModal({ isOpen, onClose, onSuccess }: CreateAssetTypeModalProps) {
const { createItem, loading } = useAssetType();
const [newFileType, setNewFileType] = useState('');
const [activeStep, setActiveStep] = useState('basic');
const formik = useFormik({
initialValues: {
name: "",
code: "",
description: "",
status: "active" as "active" | "inactive",
isRequired: false,
category: "" as typeof CATEGORIES[number]['id'] | "",
validation: {
allowedFileTypes: [] as string[],
maxFileSize: 10,
minUploadCount: 0,
maxUploadCount: 1,
}
},
validationSchema: assetTypeSchema,
onSubmit: async (values, { setSubmitting }) => {
try {
const created = await createItem(values as AssetTypeCreateRequest);
if (created) {
onSuccess(created);
onClose();
}
} catch (err: any) {
// Handled in hook notify
} finally {
setSubmitting(false);
}
},
});
// Sync validation errors and switch steps if submit is attempted with errors
useEffect(() => {
if (formik.submitCount > 0 && !formik.isSubmitting) {
const errors = formik.errors;
const errorKeys = Object.keys(errors);
if (errorKeys.length > 0) {
const messages: string[] = [];
if (errors.name) messages.push(errors.name);
if (errors.code) messages.push(errors.code);
if (errors.category) {
messages.push(errors.category);
setActiveStep('category');
} else if (errors.name || errors.code) {
setActiveStep('basic');
} else if (errors.validation) {
setActiveStep('validation');
const valErrors = errors.validation as any;
if (valErrors?.maxFileSize) messages.push(valErrors.maxFileSize);
if (valErrors?.allowedFileTypes) messages.push(valErrors.allowedFileTypes);
}
notify.error(`Please resolve validation errors: ${messages.join('; ')}`);
formik.setSubmitting(false);
}
}
}, [formik.submitCount, formik.isSubmitting, formik.errors]);
if (!isOpen) return null;
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
formik.handleChange(e);
if (!formik.touched.code) {
const generatedCode = e.target.value.toLowerCase().replace(/[^a-z0-9]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
formik.setFieldValue('code', generatedCode);
}
};
const handleAddFileType = () => {
const trimmed = newFileType.trim().toLowerCase().replace(/^\./, '');
if (trimmed && !formik.values.validation.allowedFileTypes.includes(trimmed)) {
formik.setFieldValue('validation.allowedFileTypes', [...formik.values.validation.allowedFileTypes, trimmed]);
setNewFileType('');
}
};
const removeFileType = (type: string) => {
formik.setFieldValue('validation.allowedFileTypes', formik.values.validation.allowedFileTypes.filter(t => t !== type));
};
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-xs animate-in fade-in duration-150 p-4 font-sans">
<div className="bg-surface rounded-2xl border border-border shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200">
{/* Modal Header */}
<div className="px-6 py-4 border-b border-border flex items-center justify-between bg-surface-muted/30">
<div>
<h3 className="font-bold text-foreground text-base">Create New Asset Type</h3>
<p className="text-xs text-muted-foreground mt-0.5">Configure media classifications and validation rules</p>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 hover:bg-background rounded-lg text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Step Indicator Bar */}
<div className="px-6 py-3 border-b border-border bg-surface flex items-center justify-between gap-2 overflow-x-auto">
{STEPS.map((s, idx) => {
const isActive = activeStep === s.id;
const isDone = idx < activeIndex;
return (
<button
key={s.id}
type="button"
onClick={() => setActiveStep(s.id)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-all shrink-0 cursor-pointer ${
isActive
? 'bg-primary/10 text-primary font-bold border border-primary/20'
: isDone
? 'text-foreground hover:bg-surface-muted'
: 'text-muted-foreground hover:bg-surface-muted/50'
}`}
>
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold ${
isActive
? 'bg-primary text-white'
: isDone
? 'bg-emerald-500 text-white'
: 'bg-surface-muted text-muted-foreground border border-border'
}`}>
{isDone ? <Check className="w-3 h-3" /> : s.step}
</span>
<span>{s.label}</span>
</button>
);
})}
</div>
{/* Modal Body */}
<form id="inline-asset-type-form" onSubmit={formik.handleSubmit} className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Step 1 — Basic Information */}
{activeStep === 'basic' && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Asset Type Name <span className="text-red-500">*</span></label>
<Input
name="name"
value={formik.values.name}
onChange={handleNameChange}
onBlur={formik.handleBlur}
placeholder="e.g. Primary Image"
aria-invalid={formik.touched.name && Boolean(formik.errors.name)}
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div>
<label className={labelClass}>Asset Code <span className="text-red-500">*</span></label>
<Input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. primary_image"
aria-invalid={formik.touched.code && Boolean(formik.errors.code)}
/>
<p className="text-[10px] text-muted-foreground mt-1">Unique identifier (slug)</p>
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
</div>
</div>
<div>
<label className={labelClass}>Description</label>
<TextArea
name="description"
value={formik.values.description}
onChange={formik.handleChange}
rows={2}
placeholder="Describe the purpose and usage guidelines for this asset type..."
className="resize-none text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Status</label>
<Select
name="status"
value={formik.values.status}
onChange={formik.handleChange}
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</Select>
</div>
<div>
<label className={labelClass}>Required Asset</label>
<label className="flex items-center gap-3 p-2 border border-border rounded-lg cursor-pointer hover:bg-background transition-colors h-9">
<input
type="checkbox"
name="isRequired"
checked={formik.values.isRequired}
onChange={formik.handleChange}
className="w-4 h-4 text-primary rounded border-border focus:ring-primary"
/>
<div className="text-xs font-medium text-foreground">Mark as required by default</div>
</label>
</div>
</div>
</div>
)}
{/* Step 2 — Asset Category */}
{activeStep === 'category' && (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">Select the media category. This determines default validation rules and file type presets.</p>
<div className="grid grid-cols-3 gap-3">
{CATEGORIES.map(cat => (
<div
key={cat.id}
onClick={() => formik.setFieldValue('category', cat.id)}
className={`
cursor-pointer p-3.5 rounded-xl border transition-all flex flex-col items-center justify-center gap-1.5 text-center
${formik.values.category === cat.id
? `${cat.bgSelectedCard} ${cat.borderSelected} shadow-xs ring-1 ${cat.ringSelected}`
: `bg-surface border-border ${cat.hoverBorder} ${cat.hoverBg}`
}
`}
>
<div className={`w-9 h-9 rounded-lg flex items-center justify-center transition-all ${formik.values.category === cat.id ? `${cat.bgSelected} text-white` : `${cat.bg} ${cat.color}`}`}>
<cat.icon className="w-4 h-4" />
</div>
<div>
<div className={`font-semibold text-xs ${formik.values.category === cat.id ? 'text-foreground font-bold' : 'text-foreground'}`}>{cat.label}</div>
<div className="text-[10px] text-muted-foreground mt-0.5">{cat.desc}</div>
</div>
</div>
))}
</div>
{formik.touched.category && formik.errors.category && (
<p className={errorClass}>{formik.errors.category}</p>
)}
</div>
)}
{/* Step 3 — Validation Rules */}
{activeStep === 'validation' && (
<div className="space-y-4">
{/* Selected formats badges list */}
<div>
<label className={labelClass}>Allowed File Types</label>
<div className="min-h-[38px] p-2.5 border border-border rounded-lg flex flex-wrap gap-1.5 bg-background">
{formik.values.validation.allowedFileTypes.length === 0 ? (
<span className="text-xs text-muted-foreground py-0.5 px-1">No file types selected yet. Check boxes below or add custom formats.</span>
) : (
formik.values.validation.allowedFileTypes.map(type => (
<span key={type} className="inline-flex items-center gap-1 px-2 py-0.5 bg-surface border border-border rounded-md text-xs font-semibold text-foreground shadow-2xs">
.{type}
<button type="button" onClick={() => removeFileType(type)} className="text-muted-foreground hover:text-red-500 ml-1 transition-colors cursor-pointer"><X className="w-3 h-3" /></button>
</span>
))
)}
</div>
</div>
{/* Format selection */}
<div>
<label className={labelClass}>Select Formats</label>
<div className="bg-background border border-border rounded-xl p-3 space-y-3 max-h-48 overflow-y-auto">
{['image', 'video', 'document', 'other'].map(group => {
const exts = POPULAR_EXTENSIONS.filter(e => e.category === group);
const groupLabel = group === 'image' ? 'Image' : group === 'video' ? 'Video' : group === 'document' ? 'Document' : 'Data & Other';
return (
<div key={group} className="space-y-1.5">
<div className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider">{groupLabel}</div>
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2">
{exts.map(item => {
const isChecked = formik.values.validation.allowedFileTypes.includes(item.ext);
return (
<label
key={item.ext}
className={`
flex items-center gap-1.5 px-2 py-1 border rounded-md cursor-pointer transition-all select-none text-xs
${isChecked
? 'bg-primary/10 border-primary text-primary font-bold shadow-2xs'
: 'bg-surface border-border text-foreground hover:border-primary/20'
}
`}
>
<input
type="checkbox"
checked={isChecked}
onChange={(e) => {
const current = formik.values.validation.allowedFileTypes || [];
if (e.target.checked) {
formik.setFieldValue('validation.allowedFileTypes', [...current, item.ext]);
} else {
formik.setFieldValue('validation.allowedFileTypes', current.filter(t => t !== item.ext));
}
}}
className="w-3 h-3 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
/>
<span className="text-[11px]">.{item.ext}</span>
</label>
);
})}
</div>
</div>
);
})}
</div>
</div>
{/* Custom Extension Input */}
<div>
<label className="block text-[11px] font-semibold text-muted-foreground mb-1">Add Custom Extension</label>
<div className="flex gap-2 max-w-xs">
<Input
value={newFileType}
onChange={(e) => setNewFileType(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddFileType(); } }}
placeholder="e.g. psd"
className="text-xs h-8"
/>
<button
type="button"
onClick={handleAddFileType}
className="px-3 py-1 bg-surface hover:bg-background border border-border rounded-md text-xs font-semibold text-foreground flex items-center justify-center transition-colors cursor-pointer"
>
<Plus className="w-3.5 h-3.5 mr-1" />
Add
</button>
</div>
</div>
{/* Constraints */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className={labelClass}>Max Size (MB)</label>
<Input
type="number"
name="validation.maxFileSize"
value={formik.values.validation.maxFileSize}
onChange={formik.handleChange}
className="h-8 text-xs"
/>
</div>
<div>
<label className={labelClass}>Min Uploads</label>
<Input
type="number"
name="validation.minUploadCount"
value={formik.values.validation.minUploadCount}
onChange={formik.handleChange}
className="h-8 text-xs"
/>
</div>
<div>
<label className={labelClass}>Max Uploads</label>
<Input
type="number"
name="validation.maxUploadCount"
value={formik.values.validation.maxUploadCount}
onChange={formik.handleChange}
className="h-8 text-xs"
/>
</div>
</div>
</div>
)}
{/* Step 4 — Preview */}
{activeStep === 'preview' && (
<div className="space-y-4">
<div className="border border-border rounded-xl overflow-hidden shadow-xs">
<div className="bg-primary p-3.5 flex items-center justify-between text-white">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-surface/20 rounded-lg flex items-center justify-center backdrop-blur-sm">
<ImageIcon className="w-4 h-4" />
</div>
<div>
<div className="font-bold text-sm">{formik.values.name || 'Asset Type Name'}</div>
<div className="text-[11px] text-primary-light font-mono">{formik.values.code || 'asset_code'}</div>
</div>
</div>
<div className="px-2 py-0.5 bg-surface/20 rounded-full text-xs font-medium flex items-center gap-1.5 backdrop-blur-sm">
<div className={`w-1.5 h-1.5 rounded-full ${formik.values.status === 'active' ? 'bg-green-400' : 'bg-muted-foreground'}`} />
{formik.values.status === 'active' ? 'Active' : 'Inactive'}
</div>
</div>
<div className="bg-background p-4 grid grid-cols-2 gap-4 text-xs">
<div>
<div className="font-semibold text-muted-foreground uppercase mb-1.5 text-[10px]">Category & Formats</div>
<div className="mb-2 font-medium capitalize text-foreground">{formik.values.category || 'Not specified'}</div>
<div className="flex flex-wrap gap-1">
{formik.values.validation.allowedFileTypes.length === 0 ? (
<span className="text-muted-foreground text-[11px]">All file types permitted</span>
) : (
formik.values.validation.allowedFileTypes.map(type => (
<span key={type} className="px-1.5 py-0.5 bg-surface-muted text-foreground rounded text-[10px] font-mono">.{type}</span>
))
)}
</div>
</div>
<div>
<div className="font-semibold text-muted-foreground uppercase mb-1.5 text-[10px]">Constraints</div>
<div className="space-y-1 text-[11px] text-muted-foreground">
<div className="flex justify-between"><span>Max Size:</span> <span className="font-medium text-foreground">{formik.values.validation.maxFileSize} MB</span></div>
<div className="flex justify-between"><span>Uploads:</span> <span className="font-medium text-foreground">Min {formik.values.validation.minUploadCount}, Max {formik.values.validation.maxUploadCount}</span></div>
<div className="flex justify-between"><span>Required:</span> <span className="font-medium text-foreground">{formik.values.isRequired ? 'Yes' : 'No (Optional)'}</span></div>
</div>
</div>
</div>
</div>
</div>
)}
</form>
{/* Modal Footer */}
<div className="px-6 py-3.5 border-t border-border bg-surface-muted/30 flex items-center justify-between">
<Button variant="outline" size="sm" type="button" onClick={onClose} disabled={formik.isSubmitting}>
Cancel
</Button>
<div className="flex items-center gap-2">
{activeIndex > 0 && (
<Button variant="outline" size="sm" type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)}>
Back
</Button>
)}
{activeIndex < STEPS.length - 1 ? (
<Button variant="primary" size="sm" type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)}>
Next
</Button>
) : (
<Button
variant="primary"
size="sm"
type="submit"
form="inline-asset-type-form"
icon={<Save className="w-3.5 h-3.5" />}
loading={formik.isSubmitting || loading}
>
Create Asset Type
</Button>
)}
</div>
</div>
</div>
</div>
);
}
+2
View File
@@ -2,3 +2,5 @@ export * from './types/asset-types.types';
export * from './services/asset-types.service';
export * from './hook/useAssetType';
export * from './routes/asset-types.routes';
export * from './components/CreateAssetTypeModal';
+147 -13
View File
@@ -10,6 +10,7 @@ import { TextArea } from "../../../components/customs/TextArea";
import { Select } from "../../../components/customs/Select";
import { useAssetType } from "../hook/useAssetType";
import { assetTypeSchema } from "../validation/asset-types.schema";
import { notify } from "../../../services/toast";
import type { AssetTypeCreateRequest } from "../types/asset-types.types";
const CATEGORIES = [
@@ -99,6 +100,35 @@ const CATEGORIES = [
},
] as const;
const POPULAR_EXTENSIONS = [
// Images
{ ext: 'jpg', category: 'image', label: 'JPG' },
{ ext: 'jpeg', category: 'image', label: 'JPEG' },
{ ext: 'png', category: 'image', label: 'PNG' },
{ ext: 'webp', category: 'image', label: 'WEBP' },
{ ext: 'gif', category: 'image', label: 'GIF' },
{ ext: 'svg', category: 'image', label: 'SVG' },
// Videos
{ ext: 'mp4', category: 'video', label: 'MP4' },
{ ext: 'mov', category: 'video', label: 'MOV' },
{ ext: 'avi', category: 'video', label: 'AVI' },
{ ext: 'webm', category: 'video', label: 'WEBM' },
// Documents
{ ext: 'pdf', category: 'document', label: 'PDF' },
{ ext: 'doc', category: 'document', label: 'DOC' },
{ ext: 'docx', category: 'document', label: 'DOCX' },
{ ext: 'xls', category: 'document', label: 'XLS' },
{ ext: 'xlsx', category: 'document', label: 'XLSX' },
{ ext: 'ppt', category: 'document', label: 'PPT' },
{ ext: 'pptx', category: 'document', label: 'PPTX' },
{ ext: 'txt', category: 'document', label: 'TXT' },
// Other
{ ext: 'zip', category: 'other', label: 'ZIP' },
{ ext: 'rar', category: 'other', label: 'RAR' },
{ ext: 'csv', category: 'other', label: 'CSV' },
{ ext: 'json', category: 'other', label: 'JSON' },
];
const STEPS = [
{ id: 'basic', label: 'Basic Information', step: 1 },
{ id: 'category', label: 'Asset Category', step: 2 },
@@ -190,6 +220,36 @@ export default function NewAssetType() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id, items]);
// Sync validation errors and switch steps if submit is attempted with errors
useEffect(() => {
if (formik.submitCount > 0 && !formik.isSubmitting) {
const errors = formik.errors;
const errorKeys = Object.keys(errors);
if (errorKeys.length > 0) {
const messages: string[] = [];
if (errors.name) messages.push(errors.name);
if (errors.code) messages.push(errors.code);
if (errors.category) {
messages.push(errors.category);
setActiveStep('category');
} else if (errors.name || errors.code) {
setActiveStep('basic');
} else if (errors.validation) {
setActiveStep('validation');
const valErrors = errors.validation as any;
if (valErrors.maxFileSize) messages.push(valErrors.maxFileSize);
if (valErrors.allowedFileTypes) messages.push(valErrors.allowedFileTypes);
}
notify.error(`Please resolve validation errors: ${messages.join('; ')}`);
formik.setSubmitting(false);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formik.submitCount, formik.isSubmitting]);
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
formik.handleChange(e);
if (!isEdit && !formik.touched.code) {
@@ -199,8 +259,9 @@ export default function NewAssetType() {
};
const handleAddFileType = () => {
if (newFileType.trim() && !formik.values.validation.allowedFileTypes.includes(newFileType.trim().toLowerCase())) {
formik.setFieldValue('validation.allowedFileTypes', [...formik.values.validation.allowedFileTypes, newFileType.trim().toLowerCase()]);
const trimmed = newFileType.trim().toLowerCase().replace(/^\./, '');
if (trimmed && !formik.values.validation.allowedFileTypes.includes(trimmed)) {
formik.setFieldValue('validation.allowedFileTypes', [...formik.values.validation.allowedFileTypes, trimmed]);
setNewFileType('');
}
};
@@ -404,29 +465,92 @@ export default function NewAssetType() {
<div className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader title="Validation Rules" subtitle="Enforced when assets are uploaded" />
<div className="p-6 space-y-6">
{/* Selected formats badges list */}
<div>
<label className={labelClass}>Allowed File Types <span className="text-red-500">*</span></label>
<div className="min-h-[42px] p-2 border border-primary/10 rounded-lg mb-2 flex flex-wrap gap-2 bg-background">
<label className={labelClass}>Allowed File Types Summary <span className="text-red-500">*</span></label>
<div className="min-h-[42px] p-3 border border-primary/10 rounded-lg mb-4 flex flex-wrap gap-2 bg-background">
{formik.values.validation.allowedFileTypes.length === 0 ? (
<span className="text-sm text-muted-foreground py-1 px-2">No file types added yet</span>
<span className="text-xs text-muted-foreground py-1 px-1">No file types selected yet. Check the boxes below to allow extensions.</span>
) : (
formik.values.validation.allowedFileTypes.map(type => (
<span key={type} className="inline-flex items-center gap-1 px-2 py-1 bg-surface border border-border rounded text-xs font-medium text-foreground">
<span key={type} className="inline-flex items-center gap-1 px-2.5 py-1 bg-surface border border-border rounded-lg text-xs font-semibold text-foreground animate-fade-in shadow-2xs">
.{type}
<button type="button" onClick={() => removeFileType(type)} className="text-muted-foreground hover:text-red-500"><X className="w-3 h-3" /></button>
<button type="button" onClick={() => removeFileType(type)} className="text-muted-foreground hover:text-red-500 ml-1 transition-colors"><X className="w-3 h-3" /></button>
</span>
))
)}
</div>
<div className="flex gap-2">
</div>
{/* Multiselect checkboxes for popular formats */}
<div>
<label className={labelClass}>Select Allowed Formats</label>
<div className="bg-background border border-primary/10 rounded-xl p-5 space-y-5">
{['image', 'video', 'document', 'other'].map(group => {
const exts = POPULAR_EXTENSIONS.filter(e => e.category === group);
const groupLabel = group === 'image' ? 'Image Formats' : group === 'video' ? 'Video Formats' : group === 'document' ? 'Document Formats' : 'Data & Archive Formats';
return (
<div key={group} className="space-y-2">
<div className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider">{groupLabel}</div>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-3">
{exts.map(item => {
const isChecked = formik.values.validation.allowedFileTypes.includes(item.ext);
return (
<label
key={item.ext}
className={`
flex items-center gap-2 px-3 py-2 border rounded-lg cursor-pointer transition-all select-none
${isChecked
? 'bg-primary/5 border-primary text-primary font-bold shadow-2xs'
: 'bg-surface border-border text-foreground hover:border-primary/20 hover:bg-background/20'
}
`}
>
<input
type="checkbox"
checked={isChecked}
onChange={(e) => {
const current = formik.values.validation.allowedFileTypes || [];
if (e.target.checked) {
formik.setFieldValue('validation.allowedFileTypes', [...current, item.ext]);
} else {
formik.setFieldValue('validation.allowedFileTypes', current.filter(t => t !== item.ext));
}
}}
className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
/>
<span className="text-xs">.{item.ext}</span>
</label>
);
})}
</div>
</div>
);
})}
</div>
</div>
{/* Custom Extension Input */}
<div className="pt-2">
<label className="block text-[11px] font-semibold text-muted-foreground mb-1">Add Custom Extension (Optional)</label>
<div className="flex gap-2 max-w-sm">
<Input
value={newFileType}
onChange={(e) => setNewFileType(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddFileType(); } }}
placeholder="Type extension and press Enter (e.g. jpg)"
placeholder="e.g. psd"
className="text-xs"
/>
<button type="button" onClick={handleAddFileType} className="px-3 py-2 border border-primary/10 rounded-lg hover:bg-background">
<Plus className="w-4 h-4 text-muted-foreground" />
<button
type="button"
onClick={handleAddFileType}
className="px-4 py-2 bg-surface hover:bg-background border border-border rounded-lg text-xs font-semibold text-foreground flex items-center justify-center transition-colors"
title="Add custom format"
>
<Plus className="w-4 h-4 text-muted-foreground mr-1" />
Add
</button>
</div>
</div>
@@ -528,12 +652,22 @@ export default function NewAssetType() {
</div>
{/* Bottom navigation */}
<div className="shrink-0 pt-2 flex justify-end gap-2">
<div className="shrink-0 pt-4 flex justify-end gap-2">
{activeIndex > 0 && (
<Button variant="outline" type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)}>Back</Button>
)}
{activeIndex < STEPS.length - 1 && (
{activeIndex < STEPS.length - 1 ? (
<Button variant="primary" type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)}>Next</Button>
) : (
<Button
variant="primary"
type="submit"
form="asset-type-form"
icon={<Save className="w-4 h-4" />}
loading={formik.isSubmitting}
>
{isEdit ? 'Save Changes' : 'Create Asset Type'}
</Button>
)}
</div>
</div>
@@ -3,6 +3,8 @@ export interface AssetTypeValidation {
maxFileSize: number;
minUploadCount: number;
maxUploadCount: number;
allowed_extensions?: string[];
max_file_size?: number;
}
export interface AssetType {
@@ -12,6 +14,8 @@ export interface AssetType {
description?: string;
status: 'active' | 'inactive';
isRequired: boolean;
isVariantEligible?: boolean;
is_variant_eligible?: boolean;
category: 'image' | 'video' | 'document' | 'certificate' | 'marketing' | 'other' | '';
validation: AssetTypeValidation;
createdAt: string;
+28 -9
View File
@@ -86,49 +86,68 @@ export const assetsApi = {
// Product Assets Assignment
getProductAssets: async (productId: string): Promise<AssetMapping[]> => {
const res = await apiClient.get<ApiResponse<AssetMapping[]>>(`/api/v1/products/${productId}/assets`);
return res.data || [];
const raw: any = res.data;
return (Array.isArray(raw) ? raw : raw?.data) || [];
},
getAllVariantAssets: async (productId: string): Promise<any[]> => {
const res = await apiClient.get<ApiResponse<any[]>>(`/api/v1/products/${productId}/all-variant-assets`);
const raw: any = res.data;
return (Array.isArray(raw) ? raw : raw?.data) || [];
},
assignProductAsset: async (productId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
const res = await apiClient.post<ApiResponse<AssetMapping>>(`/api/v1/products/${productId}/assets`, body);
return res.data;
const raw: any = res.data;
return raw?.data ? raw.data : raw;
},
updateProductAsset: async (productId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
const res = await apiClient.put<ApiResponse<AssetMapping>>(`/api/v1/products/${productId}/assets/${assetId}`, body);
return res.data;
const raw: any = res.data;
return raw?.data ? raw.data : raw;
},
unassignProductAsset: async (productId: string, assetId: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/products/${productId}/assets/${assetId}`);
return res.success;
return res.data?.success ?? true;
},
bulkAssignVariantAsset: async (productId: string, body: { asset_id: string; role: string; variant_ids: string[]; is_primary?: boolean }): Promise<any[]> => {
const res = await apiClient.post<ApiResponse<any[]>>(`/api/v1/products/${productId}/assets/bulk-assign`, body);
const raw: any = res.data;
return (Array.isArray(raw) ? raw : raw?.data) || [];
},
// Variant Assets Assignment
getVariantAssets: async (variantId: string): Promise<AssetMapping[]> => {
const res = await apiClient.get<ApiResponse<AssetMapping[]>>(`/api/v1/variants/${variantId}/assets`);
return res.data || [];
const raw: any = res.data;
return (Array.isArray(raw) ? raw : raw?.data) || [];
},
assignVariantAsset: async (variantId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
const res = await apiClient.post<ApiResponse<AssetMapping>>(`/api/v1/variants/${variantId}/assets`, body);
return res.data;
const raw: any = res.data;
return raw?.data ? raw.data : raw;
},
updateVariantAsset: async (variantId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
const res = await apiClient.put<ApiResponse<AssetMapping>>(`/api/v1/variants/${variantId}/assets/${assetId}`, body);
return res.data;
const raw: any = res.data;
return raw?.data ? raw.data : raw;
},
unassignVariantAsset: async (variantId: string, assetId: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/variants/${variantId}/assets/${assetId}`);
return res.success;
return res.data?.success ?? true;
},
// Product variants list for the variant select dropdown
getProductVariants: async (productId: string): Promise<any[]> => {
const res = await apiClient.get<ApiResponse<any[]>>('/api/v1/variants', { params: { parentProductId: productId } });
return res.data || [];
const raw: any = res.data;
return (Array.isArray(raw) ? raw : raw?.data) || [];
}
};
+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>
);
}
@@ -49,9 +49,11 @@ export const assetsService = {
getFolders: () => assetsApi.getFolders(),
getTags: () => assetsApi.getTags(),
getProductAssets: (productId: string) => assetsApi.getProductAssets(productId),
getAllVariantAssets: (productId: string) => assetsApi.getAllVariantAssets(productId),
assignProductAsset: (productId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }) => assetsApi.assignProductAsset(productId, body),
updateProductAsset: (productId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }) => assetsApi.updateProductAsset(productId, assetId, body),
unassignProductAsset: (productId: string, assetId: string) => assetsApi.unassignProductAsset(productId, assetId),
bulkAssignVariantAsset: (productId: string, body: { asset_id: string; role: string; variant_ids: string[]; is_primary?: boolean }) => assetsApi.bulkAssignVariantAsset(productId, body),
getVariantAssets: (variantId: string) => assetsApi.getVariantAssets(variantId),
assignVariantAsset: (variantId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }) => assetsApi.assignVariantAsset(variantId, body),
updateVariantAsset: (variantId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }) => assetsApi.updateVariantAsset(variantId, assetId, body),
@@ -197,20 +197,6 @@ export default function NewAttributeGroup() {
<CardHeader title="Basic Information" subtitle="Define the group's identity and metadata" />
<div className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>Group Code <span className="text-red-400">*</span></label>
<input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
disabled={isEdit}
placeholder="e.g., general_info"
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-background disabled:text-muted-foreground`}
/>
<p className="text-xs text-muted-foreground mt-1">Unique identifier (snake_case)</p>
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
</div>
<div>
<label className={labelClass}>Group Name <span className="text-red-400">*</span></label>
<input
@@ -230,6 +216,20 @@ export default function NewAttributeGroup() {
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div>
<label className={labelClass}>Group Code <span className="text-red-400">*</span></label>
<input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
disabled={isEdit}
placeholder="e.g., general_info"
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-background disabled:text-muted-foreground`}
/>
<p className="text-xs text-muted-foreground mt-1">Unique identifier (snake_case)</p>
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
</div>
</div>
<div>
@@ -53,7 +53,9 @@ export default function AttributeSetList() {
render: (val: string) => <span className="text-sm text-muted-foreground">{val || '—'}</span>,
},
{
key: 'groups', label: 'GROUPS',
id: 'col_groups_count',
key: 'groups',
label: 'GROUPS',
render: (val: any) => (
<span className="px-2.5 py-1 text-xs font-medium rounded bg-blue-50 text-blue-700">
{Array.isArray(val) ? val.length : 0} groups
@@ -61,7 +63,9 @@ export default function AttributeSetList() {
),
},
{
key: 'groups', label: 'ATTRIBUTES',
id: 'col_attributes_count',
key: 'groups',
label: 'ATTRIBUTES',
render: (groups: any) => {
let count = 0;
if (Array.isArray(groups)) {
@@ -139,6 +139,38 @@ export default function NewAttributeSet() {
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
useEffect(() => {
if (isEdit || isView) {
setHighestVisitedStep(STEPS.length);
}
}, [isEdit, isView]);
const isBasicValid = Boolean(formik.values.name?.trim() && formik.values.code?.trim() && !formik.errors.name && !formik.errors.code);
const isStepAccessible = useCallback((stepNum: number) => {
if (isEdit || isView) return true;
if (stepNum === 1) return true;
if (!isBasicValid) return false;
return stepNum <= highestVisitedStep + 1;
}, [isEdit, isView, isBasicValid, highestVisitedStep]);
const handleNextStep = useCallback(() => {
if (activeStep === 'basic') {
if (!isBasicValid) {
formik.setFieldTouched('name', true);
formik.setFieldTouched('code', true);
return;
}
}
if (activeIndex < STEPS.length - 1) {
const nextStepObj = STEPS[activeIndex + 1];
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
setActiveStep(nextStepObj.id);
}
}, [activeStep, isBasicValid, activeIndex, formik]);
return (
<ProtectedRoute node="products.attributes">
<PageWrapper>
@@ -181,17 +213,23 @@ export default function NewAttributeSet() {
const isActive = activeStep === s.id;
const isDone = idx < activeIndex;
const isLast = idx === STEPS.length - 1;
const accessible = isStepAccessible(s.step);
return (
<div key={s.id} className="flex gap-3">
<div className="flex flex-col items-center" style={{ width: 24 }}>
<button
type="button"
onClick={() => setActiveStep(s.id)}
disabled={!accessible}
onClick={() => {
if (accessible) {
setActiveStep(s.id);
}
}}
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${
isActive ? "bg-primary ring-2 ring-primary/20" :
isDone ? "bg-emerald-500" :
"bg-surface border-2 border-border hover:border-primary/30"
}`}
} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
>
{isDone
? <Check className="w-3 h-3 text-white" />
@@ -204,8 +242,13 @@ export default function NewAttributeSet() {
</div>
<button
type="button"
onClick={() => setActiveStep(s.id)}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""}`}
disabled={!accessible}
onClick={() => {
if (accessible) {
setActiveStep(s.id);
}
}}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
>
<span className={`text-xs font-medium leading-tight block ${
isActive ? "text-primary-dark" : isDone ? "text-muted-foreground" : "text-muted-foreground hover:text-muted-foreground"
@@ -228,20 +271,6 @@ export default function NewAttributeSet() {
<CardHeader title="Basic Information" subtitle="Define the set's identity and metadata" />
<div className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>Set Code <span className="text-red-400">*</span></label>
<input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
disabled={isEdit}
placeholder="e.g., electronic_accessories"
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-background disabled:text-muted-foreground`}
/>
<p className="text-xs text-muted-foreground mt-1">Unique identifier (snake_case/kebab-case)</p>
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
</div>
<div>
<label className={labelClass}>Set Name <span className="text-red-400">*</span></label>
<input
@@ -259,6 +288,20 @@ export default function NewAttributeSet() {
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div>
<label className={labelClass}>Set Code <span className="text-red-400">*</span></label>
<input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
disabled={isEdit}
placeholder="e.g., electronic_accessories"
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-background disabled:text-muted-foreground`}
/>
<p className="text-xs text-muted-foreground mt-1">Unique identifier (snake_case/kebab-case)</p>
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
</div>
</div>
<div>
@@ -452,7 +495,7 @@ export default function NewAttributeSet() {
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm font-medium text-muted-foreground hover:bg-background transition-colors">Back</button>
)}
{activeIndex < STEPS.length - 1 && (
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
<button type="button" onClick={handleNextStep} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
)}
</div>
</div>
@@ -13,7 +13,11 @@ import { StatsCard } from "../../../components/customs/StatsCard";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { useState } from "react";
import { usePermissions } from "../../../hooks/usePermission";
import { Can } from "../../../components/customs/Can";
export default function AttributeList() {
const { canEdit, canDelete } = usePermissions("products.attributes");
const navigate = useNavigate();
const { attributes, fetchAttributes, deleteAttribute } = useAttribute();
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" });
@@ -183,9 +187,11 @@ export default function AttributeList() {
<Breadcrumb
items={[{ label: "Home" }, { label: "Attributes" }]}
actions={
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("/attributes/new")}>
Create Attribute
</Button>
<Can node="products.attributes" action="create">
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("/attributes/new")}>
Create Attribute
</Button>
</Can>
}
/>
@@ -235,8 +241,8 @@ export default function AttributeList() {
}
actionConfig={{
onView: (row) => navigate(`/attributes/${row.id}/view`),
onEdit: (row) => navigate(`/attributes/${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
onEdit: canEdit ? ((row) => navigate(`/attributes/${row.id}/edit`)) : undefined,
onDelete: canDelete ? ((row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })) : undefined
}}
/>
</div>
+123 -18
View File
@@ -45,6 +45,22 @@ export default function NewAttribute() {
const { createAttribute, updateAttribute, fetchAttributes, getAttributeById } = useAttribute();
const [optionsList, setOptionsList] = useState<Array<{ code: string; label: string }>>([]);
const [newOptionInput, setNewOptionInput] = useState("");
const handleAddOption = () => {
if (!newOptionInput.trim()) return;
const label = newOptionInput.trim();
const code = label.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
if (optionsList.some((o) => o.code === code)) return;
setOptionsList((prev) => [...prev, { code, label }]);
setNewOptionInput("");
};
const handleRemoveOption = (index: number) => {
setOptionsList((prev) => prev.filter((_, i) => i !== index));
};
const formik = useFormik({
initialValues: {
code: "",
@@ -97,6 +113,9 @@ export default function NewAttribute() {
apiVisible: values.apiVisible,
isRequiredForCompleteness: values.isRequiredForCompleteness,
};
if (values.dataType === "select" || values.dataType === "multiselect") {
payload.options = optionsList;
}
if (values.description?.trim()) payload.description = values.description.trim();
try {
if (isEdit && id) {
@@ -157,6 +176,14 @@ export default function NewAttribute() {
apiVisible: (match as any).apiVisible ?? true,
isRequiredForCompleteness: (match as any).isRequiredForCompleteness ?? false,
});
if ((match as any).optionsList && Array.isArray((match as any).optionsList)) {
setOptionsList((match as any).optionsList.map((o: any) => ({ code: o.code, label: o.label })));
} else if ((match as any).options && Array.isArray((match as any).options)) {
setOptionsList((match as any).options.map((o: any) => typeof o === 'string' ? { code: o.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, ''), label: o } : o));
} else {
setOptionsList([]);
}
}
});
}
@@ -166,6 +193,39 @@ export default function NewAttribute() {
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
useEffect(() => {
if (isEdit || isView) {
setHighestVisitedStep(STEPS.length);
}
}, [isEdit, isView]);
const isGeneralValid = Boolean(formik.values.name?.trim() && formik.values.code?.trim() && formik.values.type && !formik.errors.name && !formik.errors.code && !formik.errors.type);
const isStepAccessible = useCallback((stepNum: number) => {
if (isEdit || isView) return true;
if (stepNum === 1) return true;
if (!isGeneralValid) return false;
return stepNum <= highestVisitedStep + 1;
}, [isEdit, isView, isGeneralValid, highestVisitedStep]);
const handleNextStep = useCallback(() => {
if (activeStep === 'general') {
if (!isGeneralValid) {
formik.setFieldTouched('name', true);
formik.setFieldTouched('code', true);
formik.setFieldTouched('type', true);
return;
}
}
if (activeIndex < STEPS.length - 1) {
const nextStepObj = STEPS[activeIndex + 1];
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
setActiveStep(nextStepObj.id);
}
}, [activeStep, isGeneralValid, activeIndex, formik]);
return (
<ProtectedRoute node="products.attributes">
<div className="h-screen flex flex-col overflow-hidden bg-background/50">
@@ -220,16 +280,22 @@ export default function NewAttribute() {
const isActive = activeStep === s.id;
const isDone = s.step < activeIndex + 1;
const isLast = idx === STEPS.length - 1;
const accessible = isStepAccessible(s.step);
return (
<div key={s.id} className="flex gap-3">
<div className="flex flex-col items-center" style={{ width: 24 }}>
<button
type="button"
onClick={() => setActiveStep(s.id)}
disabled={!accessible}
onClick={() => {
if (accessible) {
setActiveStep(s.id);
}
}}
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${isActive ? "bg-primary ring-2 ring-primary/20" :
isDone ? "bg-emerald-500" :
"bg-surface border-2 border-border hover:border-primary/30"
}`}
} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
>
{isDone
? <Check className="w-3 h-3 text-white" />
@@ -242,8 +308,13 @@ export default function NewAttribute() {
</div>
<button
type="button"
onClick={() => setActiveStep(s.id)}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""}`}
disabled={!accessible}
onClick={() => {
if (accessible) {
setActiveStep(s.id);
}
}}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
>
<span className={`text-xs font-medium leading-tight block ${isActive ? "text-primary-dark" : isDone ? "text-muted-foreground" : "text-muted-foreground hover:text-muted-foreground"
}`}>{s.label}</span>
@@ -264,19 +335,6 @@ export default function NewAttribute() {
<CardHeader title="General Information" subtitle="Basic details about the attribute" />
<div className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>Attribute Code <span className="text-red-400">*</span></label>
<Input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
disabled={isEdit || isView}
placeholder="e.g., product_weight"
aria-invalid={formik.touched.code && Boolean(formik.errors.code)}
/>
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
</div>
<div>
<label className={labelClass}>Attribute Name <span className="text-red-400">*</span></label>
<Input
@@ -290,6 +348,19 @@ export default function NewAttribute() {
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div>
<label className={labelClass}>Attribute Code <span className="text-red-400">*</span></label>
<Input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
disabled={isEdit || isView}
placeholder="e.g., product_weight"
aria-invalid={formik.touched.code && Boolean(formik.errors.code)}
/>
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
</div>
</div>
<div>
@@ -305,6 +376,40 @@ export default function NewAttribute() {
</Select>
</div>
{(formik.values.dataType === "select" || formik.values.dataType === "multiselect") && (
<div className="bg-primary/5 p-4 rounded-lg border border-primary/20 space-y-3">
<label className={labelClass}>Selectable Options <span className="text-red-400">*</span></label>
<div className="flex gap-2">
<Input
value={newOptionInput}
onChange={(e: any) => setNewOptionInput(e.target.value)}
onKeyDown={(e: any) => { if (e.key === "Enter") { e.preventDefault(); handleAddOption(); } }}
placeholder="Type option label (e.g. Red, Blue, Black) and press Enter"
disabled={isView}
/>
<Button type="button" variant="secondary" onClick={handleAddOption} disabled={isView || !newOptionInput.trim()}>
Add Option
</Button>
</div>
{optionsList.length > 0 ? (
<div className="flex flex-wrap gap-2 pt-1">
{optionsList.map((opt, idx) => (
<span key={opt.code} className="inline-flex items-center gap-1.5 px-3 py-1 bg-surface border border-border rounded-full text-xs font-medium text-foreground">
{opt.label} <code className="text-[10px] text-muted-foreground">({opt.code})</code>
{!isView && (
<button type="button" onClick={() => handleRemoveOption(idx)} className="text-muted-foreground hover:text-red-500 font-bold ml-1">
×
</button>
)}
</span>
))}
</div>
) : (
<p className="text-xs text-amber-600 italic">No options added yet. Type an option label above and click Add Option.</p>
)}
</div>
)}
<div>
<label className={labelClass}>Status</label>
<RadioGroup className="mt-1">
@@ -517,7 +622,7 @@ export default function NewAttribute() {
<Button variant="outline" type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} icon={<ChevronLeft className="w-4 h-4" />}>Back</Button>
)}
{activeIndex < STEPS.length - 1 && (
<Button variant="primary" type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)}>Next <ChevronRight className="w-4 h-4" /></Button>
<Button variant="primary" type="button" onClick={handleNextStep}>Next <ChevronRight className="w-4 h-4" /></Button>
)}
</div>
</form>
+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`)) : () => {}}
onDelete={canDelete ? ((row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })) : () => {}}
/>
</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: any) => sum + (Number(c.productCount) || 0), 0),
families: categories.reduce((sum, c: any) => sum + (Number(c.familyCount) || 0), 0),
};
const handleDeleteConfirm = async () => {
@@ -56,13 +58,15 @@ export default function CategoryList() {
<Breadcrumb
items={[{ label: "Home" }, { label: "Categories" }]}
actions={
<Button
variant="primary"
icon={<Plus className="w-4 h-4" />}
onClick={() => navigate("/categories/new")}
>
Add Root Category
</Button>
<Can node="products.categories" action="create">
<Button
variant="primary"
icon={<Plus className="w-4 h-4" />}
onClick={() => navigate("/categories/new")}
>
Add Root Category
</Button>
</Can>
}
/>
@@ -113,8 +113,11 @@ export default function NewCategory() {
}, [isEdit, id, categories, parentIdParam]);
const allowedParentOptions = useMemo(() => {
if (!isEdit) return categories;
return categories.filter((cat) => cat.id !== id && !cat.path?.startsWith(categories.find(c => c.id === id)?.path + "/"));
const validCategories = Array.isArray(categories) ? categories.filter((cat) => cat && cat.id) : [];
if (!isEdit) return validCategories;
const currentCat = validCategories.find((c) => c.id === id);
const currentPath = currentCat?.path || "";
return validCategories.filter((cat) => cat.id !== id && (!currentPath || !cat.path?.startsWith(currentPath + "/")));
}, [categories, isEdit, id]);
return (
@@ -204,16 +207,16 @@ export default function NewCategory() {
name="parentId"
value={formik.values.parentId}
onChange={formik.handleChange}
disabled={!isEdit} // Disabled (Read-only) during creation, enabled during Edit
disabled={Boolean(parentIdParam)}
>
<option value="">None (Root Level)</option>
{allowedParentOptions.map((cat) => (
{allowedParentOptions.filter(Boolean).map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name} ({cat.code})
</option>
))}
</Select>
{!isEdit && (
{!isEdit && Boolean(parentIdParam) && (
<p className="text-[10px] text-muted-foreground mt-1">
Locked to parent context. Click inline tree actions to create subcategories.
</p>
+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)" },
];
export const COMMON_CHANNEL_FIELDS = [
{ code: "title", label: "Storefront Title (title)" },
{ code: "body_html", label: "HTML Body Description (body_html)" },
{ code: "variant_sku", label: "Variant SKU (variant_sku)" },
{ code: "price", label: "Variant Price (price)" },
{ code: "vendor", label: "Brand / Vendor (vendor)" },
{ code: "product_type", label: "Product Category / Type (product_type)" },
];
const TRANSFORMATION_RULES = [
{ value: "none", label: "Direct Pass-through" },
{ value: "uppercase", label: "UPPERCASE" },
{ value: "lowercase", label: "lowercase" },
{ value: "currency_format", label: "Currency Format (0.00)" },
{ value: "strip_html", label: "Strip HTML Tags" },
{ value: "default_if_null", label: "Fallback Default Value" },
];
export function ChannelMappingTab({ channelId }: { channelId: string }) {
const [mappings, setMappings] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [previewing, setPreviewing] = useState(false);
const [previewData, setPreviewData] = useState<any>(null);
useEffect(() => {
loadMappings();
}, [channelId]);
const loadMappings = async () => {
setLoading(true);
const defaultBaseline = [
{ pim_attribute_code: "name", channel_field_code: "title", transformation_rule: "none", default_value: "", is_required: true },
{ pim_attribute_code: "code", channel_field_code: "variant_sku", transformation_rule: "uppercase", default_value: "", is_required: true },
{ pim_attribute_code: "status", channel_field_code: "published_status", transformation_rule: "lowercase", default_value: "published", is_required: false },
];
if (!channelId || channelId === "demo-channel-id") {
setMappings(defaultBaseline);
setLoading(false);
return;
}
try {
const data = await channelsApi.getMappings(channelId);
if (data && data.length > 0) {
setMappings(data);
} else {
setMappings(defaultBaseline);
}
} catch {
setMappings(defaultBaseline);
} finally {
setLoading(false);
}
};
const handleAddRule = () => {
setMappings((prev) => [
...prev,
{ pim_attribute_code: "name", channel_field_code: "custom_field", transformation_rule: "none", default_value: "", is_required: false },
]);
};
const handleRemoveRule = (index: number) => {
setMappings((prev) => prev.filter((_, i) => i !== index));
};
const handleChange = (index: number, field: string, value: any) => {
setMappings((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], [field]: value };
return updated;
});
};
const handleSave = async () => {
setSaving(true);
try {
await channelsApi.updateMappings(channelId, mappings);
notify.success("Attribute mapping rules saved successfully!");
await loadMappings();
} catch {
notify.error("Failed to save mapping rules");
} finally {
setSaving(false);
}
};
const handlePreview = async () => {
setPreviewing(true);
try {
const data = await channelsApi.previewPayload(channelId);
setPreviewData(data);
} catch {
notify.error("Failed to generate transformation preview");
} finally {
setPreviewing(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-48">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-semibold text-foreground">Channel Field Mapping Matrix</h3>
<p className="text-xs text-muted-foreground mt-0.5">Map central PIM attributes to target storefront fields and apply transformation pipelines.</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" loading={previewing} onClick={handlePreview}>
<Eye className="w-4 h-4 mr-2" /> Preview Transformed Payload
</Button>
<Button variant="outline" onClick={handleAddRule}>
<Plus className="w-4 h-4 mr-2" /> Add Rule
</Button>
<Button variant="primary" loading={saving} onClick={handleSave}>
<Save className="w-4 h-4 mr-2" /> Save Mappings
</Button>
</div>
</div>
<PayloadPreviewModal
isOpen={Boolean(previewData)}
onClose={() => setPreviewData(null)}
previewData={previewData}
/>
<div className="border border-border rounded-lg overflow-hidden bg-surface shadow-sm">
<table className="w-full text-left text-sm">
<thead className="bg-surface-muted border-b border-border text-xs text-muted-foreground uppercase">
<tr>
<th className="px-4 py-3">PIM Central Attribute</th>
<th className="px-4 py-3 text-center">Pipeline</th>
<th className="px-4 py-3">Target Storefront Field</th>
<th className="px-4 py-3">Transformation Rule</th>
<th className="px-4 py-3 text-center">Required</th>
<th className="px-4 py-3 text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{mappings.map((rule, idx) => (
<tr key={idx} className="hover:bg-surface-muted/50">
<td className="px-4 py-3">
<select
value={rule.pim_attribute_code}
onChange={(e) => handleChange(idx, "pim_attribute_code", e.target.value)}
className="w-full px-3 py-1.5 border border-border rounded-md bg-surface text-foreground text-xs focus:ring-1 focus:ring-primary"
>
{COMMON_PIM_ATTRIBUTES.map((attr) => (
<option key={attr.code} value={attr.code}>
{attr.label} ({attr.code})
</option>
))}
</select>
</td>
<td className="px-4 py-3 text-center">
<ArrowRight className="w-4 h-4 mx-auto text-muted-foreground" />
</td>
<td className="px-4 py-3">
<input
type="text"
value={rule.channel_field_code}
onChange={(e) => handleChange(idx, "channel_field_code", e.target.value)}
placeholder="e.g. title or body_html"
className="w-full px-3 py-1.5 border border-border rounded-md bg-surface text-foreground text-xs font-mono focus:ring-1 focus:ring-primary"
/>
</td>
<td className="px-4 py-3">
<select
value={rule.transformation_rule}
onChange={(e) => handleChange(idx, "transformation_rule", e.target.value)}
className="w-full px-3 py-1.5 border border-border rounded-md bg-surface text-foreground text-xs focus:ring-1 focus:ring-primary"
>
{TRANSFORMATION_RULES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</td>
<td className="px-4 py-3 text-center">
<input
type="checkbox"
checked={Boolean(rule.is_required)}
onChange={(e) => handleChange(idx, "is_required", e.target.checked)}
className="rounded border-border text-primary focus:ring-primary h-4 w-4"
/>
</td>
<td className="px-4 py-3 text-right">
<button
type="button"
onClick={() => handleRemoveRule(idx)}
className="p-1.5 text-danger hover:bg-danger/10 rounded transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,84 @@
import { Button } from "../../../components/customs/Button";
import { Code, Check, Copy } from "lucide-react";
import { useState } from "react";
interface PayloadPreviewModalProps {
isOpen: boolean;
onClose: () => void;
previewData: any;
}
export function PayloadPreviewModal({ isOpen, onClose, previewData }: PayloadPreviewModalProps) {
const [copied, setCopied] = useState(false);
if (!isOpen || !previewData) return null;
const handleCopy = () => {
navigator.clipboard.writeText(JSON.stringify(previewData.adapterOutput, null, 2));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="bg-surface border border-border rounded-xl shadow-2xl max-w-4xl w-full flex flex-col max-h-[85vh] overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-border bg-gradient-to-r from-primary/10 to-surface flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
<Code className="w-4 h-4" />
</div>
<div>
<h3 className="font-semibold text-foreground text-sm">Transformed Payload Preview</h3>
<p className="text-xs text-muted-foreground">Real-time adapter transformation preview for channel: <span className="font-semibold text-primary">{previewData.channel?.name}</span></p>
</div>
</div>
<button
onClick={onClose}
className="text-muted-foreground hover:text-foreground text-sm font-bold p-1 rounded"
>
</button>
</div>
{/* Content */}
<div className="p-6 grid grid-cols-2 gap-4 flex-1 overflow-y-auto">
{/* Left Column: PIM Raw Data */}
<div className="flex flex-col border border-border rounded-lg bg-surface-muted overflow-hidden">
<div className="px-3 py-2 bg-border/40 text-xs font-semibold text-muted-foreground uppercase tracking-wider border-b border-border">
PIM Central Product (Raw JSON)
</div>
<pre className="p-4 text-xs font-mono text-foreground overflow-auto flex-1 max-h-96">
{JSON.stringify(previewData.pimProductRaw, null, 2)}
</pre>
</div>
{/* Right Column: Adapter Storefront Output */}
<div className="flex flex-col border border-primary/20 rounded-lg bg-primary/5/30 overflow-hidden">
<div className="px-3 py-2 bg-primary/10 text-xs font-semibold text-primary uppercase tracking-wider border-b border-primary/20 flex justify-between items-center">
<span>Adapter Storefront Payload ({previewData.channel?.code})</span>
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center gap-1 text-[11px] text-primary hover:underline"
>
{copied ? <Check className="w-3 h-3 text-success" /> : <Copy className="w-3 h-3" />}
{copied ? "Copied!" : "Copy Payload"}
</button>
</div>
<pre className="p-4 text-xs font-mono text-primary-dark overflow-auto flex-1 max-h-96">
{JSON.stringify(previewData.adapterOutput, null, 2)}
</pre>
</div>
</div>
{/* Footer */}
<div className="px-6 py-3 border-t border-border bg-surface-muted flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
Close Inspector
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,159 @@
import { useState, useEffect } from "react";
import { Play, RefreshCw, AlertCircle, CheckCircle2, Clock, Eye } from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { channelsApi } from "../api/channels.api";
import { notify } from "../../../services/toast";
export function SyndicationHistoryTab({ channelId }: { channelId: string }) {
const [jobs, setJobs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [selectedErrorLog, setSelectedErrorLog] = useState<any[] | null>(null);
useEffect(() => {
loadJobs();
}, [channelId]);
const loadJobs = async () => {
if (!channelId || channelId === "demo-channel-id") {
setJobs([]);
setLoading(false);
return;
}
setLoading(true);
try {
const data = await channelsApi.getJobs(channelId);
setJobs(data || []);
} catch {
setJobs([]);
} finally {
setLoading(false);
}
};
const handleTriggerSync = async () => {
setSyncing(true);
try {
await channelsApi.triggerSyndication(channelId);
notify.success("Syndication job triggered and processed successfully!");
await loadJobs();
} catch {
notify.error("Failed to trigger syndication job");
} finally {
setSyncing(false);
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'completed':
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-success/10 text-success border border-success/20"><CheckCircle2 className="w-3.5 h-3.5" /> Completed</span>;
case 'failed':
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-danger/10 text-danger border border-danger/20"><AlertCircle className="w-3.5 h-3.5" /> Failed</span>;
case 'running':
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-warning/10 text-warning border border-warning/20 animate-pulse"><Clock className="w-3.5 h-3.5" /> Running</span>;
default:
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-muted text-muted-foreground">Pending</span>;
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-semibold text-foreground">Syndication Execution History</h3>
<p className="text-xs text-muted-foreground mt-0.5">Real-time execution runs, success metrics, and error log inspection for this channel.</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={loadJobs} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} /> Refresh
</Button>
<Button variant="primary" loading={syncing} onClick={handleTriggerSync}>
<Play className="w-4 h-4 mr-2" /> Trigger Instant Sync
</Button>
</div>
</div>
<div className="border border-border rounded-lg overflow-hidden bg-surface shadow-sm">
<table className="w-full text-left text-sm">
<thead className="bg-surface-muted border-b border-border text-xs text-muted-foreground uppercase">
<tr>
<th className="px-4 py-3">Job ID</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3 text-center">Total Products</th>
<th className="px-4 py-3 text-center">Success</th>
<th className="px-4 py-3 text-center">Failed</th>
<th className="px-4 py-3">Started At</th>
<th className="px-4 py-3 text-right">Log Details</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{jobs.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground text-xs">
No syndication runs recorded yet. Click "Trigger Instant Sync" to start your first job run.
</td>
</tr>
) : (
jobs.map((job) => (
<tr key={job.id} className="hover:bg-surface-muted/50">
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{job.id.slice(0, 8)}...</td>
<td className="px-4 py-3">{getStatusBadge(job.status)}</td>
<td className="px-4 py-3 text-center font-medium">{job.total_products}</td>
<td className="px-4 py-3 text-center text-success font-semibold">{job.success_count}</td>
<td className="px-4 py-3 text-center text-danger font-semibold">{job.failed_count}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{new Date(job.started_at || job.created_at).toLocaleString()}</td>
<td className="px-4 py-3 text-right">
{job.error_log && job.error_log.length > 0 ? (
<button
type="button"
onClick={() => setSelectedErrorLog(job.error_log)}
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-danger bg-danger/10 hover:bg-danger/20 rounded transition-colors"
>
<Eye className="w-3.5 h-3.5" /> View Errors ({job.error_log.length})
</button>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Error Log Inspection Modal */}
{selectedErrorLog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-surface border border-border rounded-xl shadow-xl max-w-2xl w-full p-6 space-y-4">
<div className="flex items-center justify-between border-b border-border pb-3">
<h4 className="text-base font-semibold text-danger flex items-center gap-2">
<AlertCircle className="w-5 h-5" /> Syndication Error Logs
</h4>
<button
onClick={() => setSelectedErrorLog(null)}
className="text-muted-foreground hover:text-foreground text-sm font-bold"
>
</button>
</div>
<div className="max-h-80 overflow-y-auto space-y-2">
{selectedErrorLog.map((err, idx) => (
<div key={idx} className="p-3 bg-danger/5 border border-danger/20 rounded-lg text-xs font-mono">
<div className="font-semibold text-danger">Product SKU: {err.sku || 'N/A'} (ID: {err.productId})</div>
<div className="text-muted-foreground mt-1">{err.error}</div>
</div>
))}
</div>
<div className="flex justify-end pt-2">
<Button variant="outline" onClick={() => setSelectedErrorLog(null)}>
Close Inspector
</Button>
</div>
</div>
</div>
)}
</div>
);
}
+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>
+72 -9
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({
@@ -120,6 +125,39 @@ export default function NewChannel() {
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
useEffect(() => {
if (isEdit || isView) {
setHighestVisitedStep(STEPS.length);
}
}, [isEdit, isView]);
const isBasicValid = Boolean(formik.values.name?.trim() && formik.values.code?.trim() && formik.values.channelType && !formik.errors.name && !formik.errors.code && !formik.errors.channelType);
const isStepAccessible = useCallback((stepNum: number) => {
if (isEdit || isView) return true;
if (stepNum === 1) return true;
if (!isBasicValid) return false;
return stepNum <= highestVisitedStep + 1;
}, [isEdit, isView, isBasicValid, highestVisitedStep]);
const handleNextStep = useCallback(() => {
if (activeStep === 'basic') {
if (!isBasicValid) {
formik.setFieldTouched('name', true);
formik.setFieldTouched('code', true);
formik.setFieldTouched('channelType', true);
return;
}
}
if (activeIndex < STEPS.length - 1) {
const nextStepObj = STEPS[activeIndex + 1];
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
setActiveStep(nextStepObj.id);
}
}, [activeStep, isBasicValid, activeIndex, formik]);
return (
<ProtectedRoute node="settings.integrations">
<PageWrapper>
@@ -164,17 +202,23 @@ export default function NewChannel() {
const isActive = activeStep === s.id;
const isDone = idx < activeIndex;
const isLast = idx === STEPS.length - 1;
const accessible = isStepAccessible(s.step);
return (
<div key={s.id} className="flex gap-3">
<div className="flex flex-col items-center" style={{ width: 24 }}>
<button
type="button"
onClick={() => setActiveStep(s.id)}
disabled={!accessible}
onClick={() => {
if (accessible) {
setActiveStep(s.id);
}
}}
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${
isActive ? "bg-primary ring-2 ring-primary/20" :
isDone ? "bg-success" :
"bg-surface border-2 border-border hover:border-primary/30"
}`}
} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
>
{isDone
? <Check className="w-3 h-3 text-white" />
@@ -187,8 +231,13 @@ export default function NewChannel() {
</div>
<button
type="button"
onClick={() => setActiveStep(s.id)}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""}`}
disabled={!accessible}
onClick={() => {
if (accessible) {
setActiveStep(s.id);
}
}}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
>
<span className={`text-xs font-medium leading-tight block ${
isActive ? "text-primary" : isDone ? "text-muted-foreground" : "text-muted-foreground hover:text-muted-foreground"
@@ -330,7 +379,21 @@ export default function NewChannel() {
</div>
)}
{/* Step 3 — Summary */}
{/* Step 3 — Field Mapping Matrix */}
{activeStep === "mapping" && (
<div className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden p-6">
<ChannelMappingTab channelId={id || "demo-channel-id"} />
</div>
)}
{/* Step 4 — Syndication History */}
{activeStep === "syndication" && (
<div className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden p-6">
<SyndicationHistoryTab channelId={id || "demo-channel-id"} />
</div>
)}
{/* Step 5 — Summary */}
{activeStep === "summary" && (
<div className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader title="Summary" subtitle="Review your channel configuration before saving" />
@@ -407,7 +470,7 @@ export default function NewChannel() {
<Button variant="outline" type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)}>Back</Button>
)}
{activeIndex < STEPS.length - 1 && (
<Button variant="primary" type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)}>Next</Button>
<Button variant="primary" type="button" onClick={handleNextStep}>Next</Button>
)}
</div>
</div>
+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>
}
/>
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,7 @@ export interface Family {
description?: string;
category?: string;
categoryId?: string | null;
productType?: 'simple' | 'variant' | string;
attributes: string[]; // List of attribute codes
attributeGroups?: number;
variantAxes: string[]; // List of attribute codes used as variant axes
@@ -17,6 +18,7 @@ export interface Family {
createdBy: string;
channels?: string[];
assetRequirements?: string[];
directAssetTypes?: string[];
completenessRules?: Record<string, number>;
workflowCode?: string;
attributeSetId?: string;
@@ -20,6 +20,8 @@ export const familySchema = Yup.object().shape({
})
),
status: Yup.string().oneOf(['active', 'inactive', 'draft']),
productType: Yup.string().oneOf(['simple', 'variant']).optional(),
allowedBrands: Yup.array().of(Yup.string()).optional(),
allowedUnits: Yup.array().of(Yup.string()).optional(),
directAssetTypes: Yup.array().of(Yup.string()).optional(),
});
@@ -0,0 +1,160 @@
import React, { useState } from 'react';
import { Layers, RefreshCw, Save, Check, Plus, ArrowRight } from 'lucide-react';
import { notify } from '../../../services/toast/index';
interface MappingRow {
id: string;
sourcePath: string;
targetPath: string;
transformationType: string;
defaultValue: string;
required: boolean;
}
const DEFAULT_SHOPIFY_MAPPINGS: MappingRow[] = [
{ id: 'm1', sourcePath: 'content.name', targetPath: 'title', transformationType: 'string', defaultValue: '', required: true },
{ id: 'm2', sourcePath: 'content.description', targetPath: 'bodyHtml', transformationType: 'string', defaultValue: '', required: false },
{ id: 'm3', sourcePath: 'content.status', targetPath: 'status', transformationType: 'uppercase', defaultValue: 'DRAFT', required: true },
{ id: 'm4', sourcePath: 'taxonomy.brand.name', targetPath: 'vendor', transformationType: 'string', defaultValue: 'Generic', required: false },
{ id: 'm5', sourcePath: 'taxonomy.category.name', targetPath: 'productType', transformationType: 'string', defaultValue: 'General', required: false },
{ id: 'm6', sourcePath: 'variants.sku', targetPath: 'variants.sku', transformationType: 'string', defaultValue: '', required: true },
{ id: 'm7', sourcePath: 'variants.price', targetPath: 'variants.price', transformationType: 'currency_format', defaultValue: '0.00', required: true }
];
export default function FieldMappingsTab() {
const [mappings, setMappings] = useState<MappingRow[]>(DEFAULT_SHOPIFY_MAPPINGS);
const [saving, setSaving] = useState(false);
const handleSave = () => {
setSaving(true);
setTimeout(() => {
setSaving(false);
notify.success('Field mappings updated successfully!');
}, 400);
};
const handleAddMapping = () => {
const newId = `m_${Date.now()}`;
setMappings([
...mappings,
{ id: newId, sourcePath: 'attributes.', targetPath: 'metafields.', transformationType: 'string', defaultValue: '', required: false }
]);
};
return (
<div className="space-y-5">
<div className="flex items-center justify-between bg-background p-4 border border-border rounded-xl">
<div className="flex items-center gap-2">
<Layers className="w-5 h-5 text-primary" />
<div>
<h3 className="font-bold text-foreground text-sm">Canonical PIM Attribute Mapping Schema</h3>
<p className="text-xs text-muted-foreground">Map canonical product attributes to target channel GraphQL/REST properties</p>
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={handleAddMapping}
className="px-3 py-1.5 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-2xs flex items-center gap-1 cursor-pointer"
>
<Plus className="w-3.5 h-3.5" /> Add Mapping
</button>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="px-4 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold shadow-2xs flex items-center gap-1 cursor-pointer disabled:opacity-50"
>
{saving ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />} Save Schema
</button>
</div>
</div>
<div className="border border-border rounded-xl overflow-hidden bg-surface shadow-2xs">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-background border-b border-border text-muted-foreground font-semibold">
<th className="py-3 px-4">Canonical Source Path (PIM)</th>
<th className="py-3 px-2 text-center">Transform</th>
<th className="py-3 px-4">Channel Target Path (Shopify)</th>
<th className="py-3 px-4">Transformation Type</th>
<th className="py-3 px-4">Default Value</th>
<th className="py-3 px-4 text-center">Required</th>
</tr>
</thead>
<tbody className="divide-y divide-border font-medium">
{mappings.map((m) => (
<tr key={m.id} className="hover:bg-background/50 transition-colors">
<td className="py-2.5 px-4">
<input
type="text"
value={m.sourcePath}
onChange={(e) => {
const val = e.target.value;
setMappings(prev => prev.map(p => p.id === m.id ? { ...p, sourcePath: val } : p));
}}
className="w-full font-mono text-[11px] bg-background border border-border rounded px-2.5 py-1 text-foreground focus:ring-1 focus:ring-primary"
/>
</td>
<td className="py-2.5 px-2 text-center text-muted-foreground">
<ArrowRight className="w-4 h-4 mx-auto text-primary" />
</td>
<td className="py-2.5 px-4">
<input
type="text"
value={m.targetPath}
onChange={(e) => {
const val = e.target.value;
setMappings(prev => prev.map(p => p.id === m.id ? { ...p, targetPath: val } : p));
}}
className="w-full font-mono text-[11px] bg-background border border-border rounded px-2.5 py-1 text-foreground focus:ring-1 focus:ring-primary"
/>
</td>
<td className="py-2.5 px-4">
<select
value={m.transformationType}
onChange={(e) => {
const val = e.target.value;
setMappings(prev => prev.map(p => p.id === m.id ? { ...p, transformationType: val } : p));
}}
className="w-full bg-background border border-border rounded px-2 py-1 text-xs text-foreground focus:ring-1 focus:ring-primary"
>
<option value="string">string (direct)</option>
<option value="uppercase">uppercase</option>
<option value="lowercase">lowercase</option>
<option value="currency_format">currency_format</option>
<option value="json_stringify">json_stringify</option>
</select>
</td>
<td className="py-2.5 px-4">
<input
type="text"
value={m.defaultValue}
placeholder="—"
onChange={(e) => {
const val = e.target.value;
setMappings(prev => prev.map(p => p.id === m.id ? { ...p, defaultValue: val } : p));
}}
className="w-full font-mono text-[11px] bg-background border border-border rounded px-2 py-1 text-foreground focus:ring-1 focus:ring-primary"
/>
</td>
<td className="py-2.5 px-4 text-center">
<input
type="checkbox"
checked={m.required}
onChange={(e) => {
const checked = e.target.checked;
setMappings(prev => prev.map(p => p.id === m.id ? { ...p, required: checked } : p));
}}
className="rounded border-border text-primary focus:ring-primary"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,45 @@
import React from 'react';
import { CheckCircle2, AlertTriangle, XCircle, RefreshCw } from 'lucide-react';
interface IntegrationHealthBadgeProps {
status?: string;
healthStatus?: string;
}
export const IntegrationHealthBadge: React.FC<IntegrationHealthBadgeProps> = ({ status, healthStatus }) => {
const normalizedStatus = (status || healthStatus || 'active').toLowerCase();
if (normalizedStatus === 'healthy' || normalizedStatus === 'active' || normalizedStatus === 'connected') {
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-600" />
Healthy
</span>
);
}
if (normalizedStatus === 'syncing' || normalizedStatus === 'processing') {
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-50 text-blue-700 border border-blue-200">
<RefreshCw className="w-3.5 h-3.5 text-blue-600 animate-spin" />
Syncing
</span>
);
}
if (normalizedStatus === 'degraded' || normalizedStatus === 'rate_limited' || normalizedStatus === 'pending') {
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-50 text-amber-700 border border-amber-200">
<AlertTriangle className="w-3.5 h-3.5 text-amber-600" />
{normalizedStatus === 'rate_limited' ? 'Rate Limited' : 'Pending'}
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-red-50 text-red-700 border border-red-200">
<XCircle className="w-3.5 h-3.5 text-red-600" />
Error
</span>
);
};
@@ -0,0 +1,130 @@
import React from 'react';
import { ShoppingCart, ShoppingBag, Globe, Code2, Plus, CheckCircle2, ArrowRight } from 'lucide-react';
interface IntegrationTemplateGalleryProps {
onSelectShopify: () => void;
onSelectCustomApi: () => void;
}
export const IntegrationTemplateGallery: React.FC<IntegrationTemplateGalleryProps> = ({
onSelectShopify,
onSelectCustomApi
}) => {
return (
<div className="bg-gradient-to-r from-primary/5 via-surface to-emerald-500/5 border border-border rounded-xl p-6 mb-8 shadow-2xs">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-base font-bold text-foreground flex items-center gap-2">
Pre-Built Channel Templates
<span className="text-[10px] font-extrabold uppercase bg-primary text-white px-2 py-0.5 rounded-full">
Zero Config
</span>
</h2>
<p className="text-xs text-muted-foreground">Select a channel template to connect in 1 click using native GraphQL/REST capability adapters</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Shopify Template Card */}
<div className="bg-surface border-2 border-emerald-500/30 hover:border-emerald-500 rounded-xl p-4 transition-all shadow-2xs group relative flex flex-col justify-between">
<div>
<div className="flex items-center justify-between mb-3">
<div className="w-10 h-10 rounded-xl bg-emerald-50 border border-emerald-200 flex items-center justify-center text-emerald-600 font-bold">
<ShoppingCart className="w-5 h-5" />
</div>
<span className="text-[10px] font-bold text-emerald-700 bg-emerald-100 px-2 py-0.5 rounded-full flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" /> Ready
</span>
</div>
<h3 className="font-bold text-foreground text-sm group-hover:text-emerald-700 transition-colors">Shopify GraphQL</h3>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
Sync products, variants, assets & inventory via Shopify Admin API v2025-01 with cost bucket management.
</p>
</div>
<button
type="button"
onClick={onSelectShopify}
className="mt-4 w-full py-2 px-3 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-semibold shadow-2xs flex items-center justify-center gap-1.5 cursor-pointer transition-colors"
>
<Plus className="w-3.5 h-3.5" /> Setup Shopify
</button>
</div>
{/* Amazon Template Card */}
<div className="bg-surface/60 border border-border rounded-xl p-4 transition-all shadow-2xs opacity-80 flex flex-col justify-between">
<div>
<div className="flex items-center justify-between mb-3">
<div className="w-10 h-10 rounded-xl bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-600 font-bold">
<ShoppingBag className="w-5 h-5" />
</div>
<span className="text-[10px] font-medium text-muted-foreground bg-surface border border-border px-2 py-0.5 rounded-full">
Coming Soon
</span>
</div>
<h3 className="font-bold text-foreground text-sm">Amazon SP-API</h3>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
Syndicate ASIN listings, FBA inventory and pricing updates via Amazon Selling Partner API.
</p>
</div>
<button
type="button"
disabled
className="mt-4 w-full py-2 px-3 bg-surface border border-border text-muted-foreground rounded-lg text-xs font-semibold cursor-not-allowed opacity-60 flex items-center justify-center gap-1"
>
Coming Soon
</button>
</div>
{/* WooCommerce Template Card */}
<div className="bg-surface/60 border border-border rounded-xl p-4 transition-all shadow-2xs opacity-80 flex flex-col justify-between">
<div>
<div className="flex items-center justify-between mb-3">
<div className="w-10 h-10 rounded-xl bg-purple-50 border border-purple-200 flex items-center justify-center text-purple-600 font-bold">
<Globe className="w-5 h-5" />
</div>
<span className="text-[10px] font-medium text-muted-foreground bg-surface border border-border px-2 py-0.5 rounded-full">
Coming Soon
</span>
</div>
<h3 className="font-bold text-foreground text-sm">WooCommerce REST</h3>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
Push PIM canonical catalog to WordPress WooCommerce stores via REST API v3.
</p>
</div>
<button
type="button"
disabled
className="mt-4 w-full py-2 px-3 bg-surface border border-border text-muted-foreground rounded-lg text-xs font-semibold cursor-not-allowed opacity-60 flex items-center justify-center gap-1"
>
Coming Soon
</button>
</div>
{/* Custom API Card */}
<div className="bg-surface border border-border hover:border-primary/50 rounded-xl p-4 transition-all shadow-2xs group flex flex-col justify-between">
<div>
<div className="flex items-center justify-between mb-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary font-bold">
<Code2 className="w-5 h-5" />
</div>
<span className="text-[10px] font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded-full">
Custom Wizard
</span>
</div>
<h3 className="font-bold text-foreground text-sm group-hover:text-primary transition-colors">Custom API / Webhook</h3>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
Configure multi-step generic REST/GraphQL endpoints with custom headers and transformations.
</p>
</div>
<button
type="button"
onClick={onSelectCustomApi}
className="mt-4 w-full py-2 px-3 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-2xs flex items-center justify-center gap-1.5 cursor-pointer transition-colors"
>
Custom Wizard <ArrowRight className="w-3.5 h-3.5 text-muted-foreground" />
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,248 @@
import React, { useState, useEffect } from 'react';
import { Eye, EyeOff, Key, Globe, Zap, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
import { useIntegration } from '../hook/useIntegration';
import { integrationsService } from '../services/integrations.service';
interface ShopifyCredentialCardProps {
integrationId: string;
onSaved?: () => void;
}
export const ShopifyCredentialCard: React.FC<ShopifyCredentialCardProps> = ({ integrationId, onSaved }) => {
const [authMode, setAuthMode] = useState<'private_app' | 'custom_app'>('private_app');
const [shopDomain, setShopDomain] = useState('');
// Private app fields
const [apiKey, setApiKey] = useState('');
const [apiSecret, setApiSecret] = useState('');
const [storefrontToken, setStorefrontToken] = useState('');
// Custom app token
const [accessToken, setAccessToken] = useState('');
const [showSecret, setShowSecret] = useState(false);
const [testResult, setTestResult] = useState<any>(null);
const [testError, setTestError] = useState<string | null>(null);
const [fetchingCreds, setFetchingCreds] = useState(false);
const { setCredentials, testConnection, loading, testingConnection } = useIntegration();
// Pre-fill existing credentials for this specific integration
useEffect(() => {
if (!integrationId) return;
setFetchingCreds(true);
integrationsService.getCredentials(integrationId)
.then(creds => {
if (creds.shop_domain) setShopDomain(creds.shop_domain);
if (creds.api_key) setApiKey(creds.api_key);
if (creds.api_secret_key) setApiSecret(creds.api_secret_key);
if (creds.access_token) {
setAccessToken(creds.access_token);
if (creds.access_token.startsWith('shpat_')) {
setAuthMode('custom_app');
}
}
})
.catch(console.error)
.finally(() => setFetchingCreds(false));
}, [integrationId]);
const handleSaveCredentials = async (e: React.FormEvent) => {
e.preventDefault();
if (!shopDomain) return;
try {
await setCredentials(integrationId, 'shop_domain', shopDomain.trim());
if (authMode === 'private_app') {
if (apiKey) await setCredentials(integrationId, 'api_key', apiKey.trim());
if (apiSecret) await setCredentials(integrationId, 'api_secret_key', apiSecret.trim());
if (storefrontToken) await setCredentials(integrationId, 'access_token', storefrontToken.trim());
} else {
if (accessToken) await setCredentials(integrationId, 'access_token', accessToken.trim());
}
if (onSaved) onSaved();
} catch (err) {
console.error(err);
}
};
const handleTestConnection = async () => {
setTestResult(null);
setTestError(null);
try {
const res = await testConnection(integrationId);
setTestResult(res);
} catch (err: any) {
setTestError(err?.response?.data?.message || err?.message || 'Connection failed');
}
};
return (
<div className="bg-surface border border-border rounded-xl p-6 shadow-sm space-y-5">
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-2">
<div className="p-2 bg-emerald-50 rounded-lg text-emerald-600">
<Globe className="w-5 h-5" />
</div>
<div>
<h3 className="font-bold text-foreground text-sm flex items-center gap-2">
Shopify Admin API Credentials
{fetchingCreds && <Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />}
</h3>
<p className="text-xs text-muted-foreground">Configure credentials for GraphQL product syndication</p>
</div>
</div>
<span className="px-2 py-0.5 text-[10px] font-bold bg-primary/10 text-primary rounded">GraphQL Admin 2025-01</span>
</div>
{/* Auth Mode Toggle */}
<div>
<label className="block text-xs font-semibold text-foreground mb-2">Authentication Mode</label>
<div className="grid grid-cols-2 gap-2">
<label className={`p-2.5 border rounded-lg cursor-pointer text-xs transition-all ${authMode === 'private_app' ? 'border-primary bg-primary/5 text-primary font-bold' : 'border-border bg-background text-muted-foreground'}`}>
<input type="radio" name="credAuthMode" className="sr-only" checked={authMode === 'private_app'} onChange={() => setAuthMode('private_app')} />
Private App (API Key + Secret)
</label>
<label className={`p-2.5 border rounded-lg cursor-pointer text-xs transition-all ${authMode === 'custom_app' ? 'border-primary bg-primary/5 text-primary font-bold' : 'border-border bg-background text-muted-foreground'}`}>
<input type="radio" name="credAuthMode" className="sr-only" checked={authMode === 'custom_app'} onChange={() => setAuthMode('custom_app')} />
Custom App (shpat_ Token)
</label>
</div>
</div>
<form onSubmit={handleSaveCredentials} className="space-y-4">
{/* Shop Domain */}
<div>
<label className="block text-xs font-semibold text-foreground mb-1">
Store Domain <span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type="text"
placeholder="9xarg3-gj.myshopify.com"
value={shopDomain}
onChange={(e) => setShopDomain(e.target.value.replace(/^https?:\/\//, '').replace(/\/$/, ''))}
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 pl-8 focus:outline-none focus:ring-1 focus:ring-primary"
required
/>
<Globe className="w-4 h-4 text-muted-foreground absolute left-2.5 top-1/2 -translate-y-1/2" />
</div>
</div>
{/* Private App Fields */}
{authMode === 'private_app' && (
<>
<div>
<label className="block text-xs font-semibold text-foreground mb-1">
API Key <span className="text-red-500">*</span>
</label>
<input
type="text"
placeholder="6ed762d39bbeb6eef41669057976b331"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label className="block text-xs font-semibold text-foreground mb-1">
API Secret Key (Password) <span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type={showSecret ? 'text' : 'password'}
placeholder="API Secret / shpss_ token as password"
value={apiSecret}
onChange={(e) => setApiSecret(e.target.value)}
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 pl-8 pr-10 focus:outline-none focus:ring-1 focus:ring-primary"
/>
<Key className="w-4 h-4 text-muted-foreground absolute left-2.5 top-1/2 -translate-y-1/2" />
<button type="button" onClick={() => setShowSecret(v => !v)} className="absolute right-2.5 top-1/2 -translate-y-1/2 cursor-pointer text-muted-foreground hover:text-foreground">
{showSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-[10px] text-muted-foreground mt-1">
For private apps: use the Shopify <strong>API secret key</strong> or <strong>shpss_ storefront token</strong> as password for Basic Auth
</p>
</div>
<div>
<label className="block text-xs font-semibold text-foreground mb-1">
Storefront Token (Optional, shpss_...)
</label>
<input
type="password"
placeholder="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"
value={storefrontToken}
onChange={(e) => setStorefrontToken(e.target.value)}
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</>
)}
{/* Custom App Token */}
{authMode === 'custom_app' && (
<div>
<label className="block text-xs font-semibold text-foreground mb-1">
Admin API Access Token <span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type={showSecret ? 'text' : 'password'}
placeholder="shpat_xxxxxxxxxxxxxxxxxxxxxxxx"
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 pl-8 pr-10 focus:outline-none focus:ring-1 focus:ring-primary"
/>
<Key className="w-4 h-4 text-muted-foreground absolute left-2.5 top-1/2 -translate-y-1/2" />
<button type="button" onClick={() => setShowSecret(v => !v)} className="absolute right-2.5 top-1/2 -translate-y-1/2 cursor-pointer text-muted-foreground hover:text-foreground">
{showSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-[10px] text-muted-foreground mt-1">Stored using AES-256-GCM encryption</p>
</div>
)}
{/* Test Result */}
{testResult?.connected && (
<div className="p-3 bg-emerald-50 border border-emerald-200 rounded-lg flex items-start gap-2 text-xs text-emerald-800 font-medium">
<CheckCircle2 className="w-4 h-4 text-emerald-600 shrink-0 mt-0.5" />
<div>
<span>Connected to <strong>{testResult.shopName}</strong></span>
{testResult.plan && <span className="ml-1 text-emerald-700">· {testResult.plan}</span>}
{testResult.email && <div className="text-[11px] mt-0.5">{testResult.email}</div>}
</div>
</div>
)}
{testError && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg flex items-start gap-2 text-xs text-red-800">
<AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
<span>{testError}</span>
</div>
)}
<div className="flex gap-2 pt-2">
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold shadow-sm flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
>
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
Save Credentials
</button>
<button
type="button"
onClick={handleTestConnection}
disabled={testingConnection}
className="px-4 py-2 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-sm flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
>
{testingConnection ? <Loader2 className="w-3.5 h-3.5 animate-spin text-primary" /> : <Zap className="w-3.5 h-3.5 text-amber-500" />}
Test Connection
</button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,370 @@
import React, { useState, useEffect } from 'react';
import { X, ShoppingCart, Zap, Key, Globe, Loader2, CheckCircle2, AlertCircle, Eye, EyeOff, ExternalLink, ArrowLeft } from 'lucide-react';
import { useIntegration } from '../hook/useIntegration';
import { integrationsService } from '../services/integrations.service';
import { useSearchParams } from 'react-router-dom';
interface ShopifyTemplateModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
}
export const ShopifyTemplateModal: React.FC<ShopifyTemplateModalProps> = ({
isOpen,
onClose,
onSuccess
}) => {
const [searchParams] = useSearchParams();
const [name, setName] = useState('Shopify Main Store');
const [shopDomain, setShopDomain] = useState('');
const [apiKey, setApiKey] = useState('');
const [apiSecret, setApiSecret] = useState('');
const [syncMode, setSyncMode] = useState<'auto' | 'manual'>('manual');
const [showSecret, setShowSecret] = useState(false);
const [savedIntegrationId, setSavedIntegrationId] = useState<string | null>(null);
const [credentialsSaved, setCredentialsSaved] = useState(false);
const [oauthConnecting, setOauthConnecting] = useState(false);
const [oauthSuccess, setOauthSuccess] = useState(false);
const [testResult, setTestResult] = useState<any>(null);
const [testError, setTestError] = useState<string | null>(null);
const [savingCreds, setSavingCreds] = useState(false);
const { createItem, setCredentials, testConnection, testingConnection } = useIntegration();
// Handle OAuth callback redirect back from Shopify
useEffect(() => {
const oauthStatus = searchParams.get('oauth');
const intId = searchParams.get('integrationId');
const shop = searchParams.get('shop');
if (oauthStatus === 'success' && intId) {
setCredentialsSaved(true);
setOauthSuccess(true);
setSavedIntegrationId(intId);
if (shop) setShopDomain(shop);
onSuccess?.();
}
}, [searchParams]);
if (!isOpen) return null;
const handleSaveCredentials = async () => {
if (!name || !shopDomain || !apiKey || !apiSecret) return;
setSavingCreds(true);
try {
let cleanDomain = shopDomain.trim().replace(/^https?:\/\//, '').replace(/\/$/, '');
let cleanKey = apiKey.trim();
let cleanSecret = apiSecret.trim();
// Auto-correct if user accidentally swapped shop domain and API key
if (cleanKey.includes('.myshopify.com') && !cleanDomain.includes('.myshopify.com')) {
const temp = cleanDomain;
cleanDomain = cleanKey;
cleanKey = temp;
setShopDomain(cleanDomain);
setApiKey(cleanKey);
}
if (cleanDomain && !cleanDomain.includes('.')) {
cleanDomain = `${cleanDomain}.myshopify.com`;
setShopDomain(cleanDomain);
}
// Step 1: Create or reuse integration record
let integrationId = savedIntegrationId;
if (!integrationId) {
const created = await createItem({
name,
channel: 'shopify',
integration_type: 'ecommerce',
sync_mode: syncMode,
sync_frequency: syncMode === 'auto' ? 'realtime' : 'manual',
status: 'pending'
});
integrationId = created.id;
setSavedIntegrationId(integrationId);
}
// Step 2: Store credentials encrypted
await setCredentials(integrationId!, 'shop_domain', cleanDomain);
await setCredentials(integrationId!, 'api_key', cleanKey);
await setCredentials(integrationId!, 'api_secret_key', cleanSecret);
setCredentialsSaved(true);
} catch (err) {
console.error(err);
} finally {
setSavingCreds(false);
}
};
const handleStartOAuth = async () => {
if (!savedIntegrationId) return;
setOauthConnecting(true);
try {
const result = await integrationsService.startShopifyOAuth(savedIntegrationId);
// Open Shopify auth page in new tab
window.open(result.authorizationUrl, '_blank', 'width=1000,height=700,scrollbars=yes');
} catch (err: any) {
setTestError(err?.response?.data?.message || err?.message || 'Failed to start OAuth');
} finally {
setOauthConnecting(false);
}
};
const handleTestConnection = async () => {
if (!savedIntegrationId) return;
setTestResult(null);
setTestError(null);
try {
const res = await testConnection(savedIntegrationId);
setTestResult(res);
} catch (err: any) {
setTestError(err?.response?.data?.message || err?.message || 'Connection test failed');
}
};
const resetForm = () => {
setName('Shopify Main Store');
setShopDomain('');
setApiKey('');
setApiSecret('');
setSavedIntegrationId(null);
setCredentialsSaved(false);
setOauthSuccess(false);
setTestResult(null);
setTestError(null);
};
const step = !credentialsSaved ? 1 : !oauthSuccess ? 2 : 3;
return (
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-surface border border-border rounded-xl shadow-2xl w-full max-w-xl overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-emerald-600 to-teal-700 p-5 text-white relative">
<button type="button" onClick={() => { onClose(); resetForm(); }} className="absolute top-4 right-4 text-white/80 hover:text-white p-1 rounded-lg hover:bg-white/10 cursor-pointer">
<X className="w-5 h-5" />
</button>
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-xl bg-white/10 border border-white/20 flex items-center justify-center">
<ShoppingCart className="w-6 h-6" />
</div>
<div>
<h2 className="text-base font-bold">Shopify Integration Setup</h2>
<p className="text-xs text-white/70">Partners Dashboard OAuth 2.0 · Admin API 2025-01</p>
</div>
</div>
</div>
{/* Progress Steps */}
<div className="flex items-center border-b border-border px-6 pt-4 pb-3 gap-0">
{[
{ n: 1, label: 'Store Details & Keys' },
{ n: 2, label: 'Authorize via OAuth' },
{ n: 3, label: 'Test & Activate' }
].map((s, i) => (
<React.Fragment key={s.n}>
<div className={`flex items-center gap-1.5 ${step >= s.n ? 'text-primary' : 'text-muted-foreground'}`}>
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-bold border-2 ${step > s.n ? 'bg-primary border-primary text-white' : step === s.n ? 'border-primary text-primary' : 'border-border text-muted-foreground'}`}>
{step > s.n ? <CheckCircle2 className="w-3.5 h-3.5" /> : s.n}
</div>
<span className="text-xs font-medium hidden sm:block">{s.label}</span>
</div>
{i < 2 && <div className={`flex-1 h-px mx-3 ${step > s.n ? 'bg-primary' : 'bg-border'}`} />}
</React.Fragment>
))}
</div>
<div className="p-6 space-y-4 overflow-y-auto max-h-[65vh]">
{/* Step 1: Store Details */}
{step === 1 && (
<>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-xs text-blue-900">
<p className="font-semibold mb-1">📍 Finding your Client ID & Client Secret:</p>
<p>Go to <a href="https://partners.shopify.com" target="_blank" rel="noreferrer" className="underline font-bold">partners.shopify.com</a> <strong>Apps</strong> Select your app (<strong>PIM Integration</strong>) <strong>App setup</strong> Copy the <strong>Client ID</strong> and <strong>Client secret</strong> under <i>API credentials</i>.</p>
</div>
<div>
<label className="block text-xs font-semibold text-foreground mb-1">Integration Name <span className="text-red-500">*</span></label>
<input type="text" value={name} onChange={e => setName(e.target.value)} className="w-full text-sm bg-background border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary" placeholder="Shopify Main Store" required />
</div>
<div>
<label className="block text-xs font-semibold text-foreground mb-1">Shop Domain <span className="text-red-500">*</span></label>
<div className="relative">
<Globe className="w-4 h-4 text-muted-foreground absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="maskcomerce.myshopify.com"
value={shopDomain}
onChange={e => setShopDomain(e.target.value)}
className="w-full text-sm font-mono bg-background border border-border rounded-lg px-3 py-2 pl-9 focus:outline-none focus:ring-1 focus:ring-primary"
required
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-foreground mb-1">API Key (Client ID) <span className="text-red-500">*</span></label>
<input
type="text"
placeholder="6ed762d39bbeb6eef416..."
value={apiKey}
onChange={e => setApiKey(e.target.value)}
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary"
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-foreground mb-1">API Secret (Client Secret) <span className="text-red-500">*</span></label>
<div className="relative">
<input
type={showSecret ? 'text' : 'password'}
placeholder="shpss_9dc647b3cd13de8..."
value={apiSecret}
onChange={e => setApiSecret(e.target.value)}
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 pr-9 focus:outline-none focus:ring-1 focus:ring-primary"
required
/>
<button type="button" onClick={() => setShowSecret(v => !v)} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer">
{showSecret ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
</button>
</div>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-foreground mb-2">Sync Mode</label>
<div className="grid grid-cols-2 gap-3">
{(['manual', 'auto'] as const).map(mode => (
<label key={mode} className={`p-3 border rounded-lg cursor-pointer text-xs transition-all ${syncMode === mode ? 'border-primary bg-primary/5 text-primary font-bold' : 'border-border bg-background text-muted-foreground'}`}>
<input type="radio" name="syncMode" className="sr-only" checked={syncMode === mode} onChange={() => setSyncMode(mode)} />
{mode === 'manual' ? 'Manual Trigger' : 'Automatic (Outbox)'}
</label>
))}
</div>
</div>
<div className="pt-3 border-t border-border">
<button
type="button"
onClick={handleSaveCredentials}
disabled={savingCreds || !shopDomain || !apiKey || !apiSecret || !name}
className="w-full py-2.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold shadow-sm flex items-center justify-center gap-2 cursor-pointer disabled:opacity-50"
>
{savingCreds && <Loader2 className="w-4 h-4 animate-spin" />}
Save & Continue to Authorize
</button>
</div>
</>
)}
{/* Step 2: OAuth Authorization */}
{step === 2 && (
<div className="space-y-4">
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 text-sm text-amber-900">
<p className="font-bold mb-2 flex items-center gap-2">
<ExternalLink className="w-4 h-4" /> Allowed Redirection URLs in Partners Dashboard:
</p>
<ol className="list-decimal list-inside space-y-1.5 text-xs">
<li>Go to your <strong>Shopify Partners Dashboard</strong> Apps <strong>PIM Integration</strong> App setup</li>
<li>Under <strong>"Allowed redirection URL(s)"</strong>, add this URL:
<code className="bg-amber-100 rounded px-1 py-0.5 text-[11px] font-mono block mt-1 font-bold">http://localhost:5002/api/v1/integrations/shopify/oauth/callback</code>
<span className="text-[11px] text-amber-800 block mt-0.5">(If using port 5000, add <code>http://localhost:5000/api/v1/integrations/shopify/oauth/callback</code> as well)</span>
</li>
<li>Click <strong>Save</strong> in Partners Dashboard, then click Authorize below.</li>
</ol>
</div>
<div className="bg-background border border-border rounded-xl p-4 space-y-2 text-xs">
<div className="flex justify-between"><span className="text-muted-foreground">Shop:</span><span className="font-mono font-semibold">{shopDomain}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">API Key:</span><span className="font-mono">{apiKey.slice(0, 12)}...</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Scopes:</span><span className="text-emerald-700">read/write_product_feeds, read/write_product_listings, read/write_products</span></div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setCredentialsSaved(false)}
className="px-4 py-3 bg-surface border border-border hover:bg-background text-foreground rounded-xl text-xs font-semibold flex items-center justify-center gap-1.5 cursor-pointer"
>
<ArrowLeft className="w-4 h-4" /> Edit Details & Keys
</button>
<button
type="button"
onClick={handleStartOAuth}
disabled={oauthConnecting}
className="flex-1 py-3 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl text-sm font-bold shadow-sm flex items-center justify-center gap-2 cursor-pointer disabled:opacity-50"
>
{oauthConnecting ? <Loader2 className="w-4 h-4 animate-spin" /> : <ExternalLink className="w-4 h-4" />}
Authorize Shopify Access
</button>
</div>
<p className="text-center text-xs text-muted-foreground">
After authorizing in the new tab, this modal will automatically update.
</p>
</div>
)}
{/* Step 3: Connected — Test + Done */}
{step === 3 && (
<div className="space-y-4">
<div className="p-4 bg-emerald-50 border border-emerald-200 rounded-xl flex items-center gap-3 text-emerald-800">
<CheckCircle2 className="w-7 h-7 text-emerald-600 shrink-0" />
<div>
<p className="font-bold text-sm">Shopify Access Authorized!</p>
<p className="text-xs mt-0.5">Access token saved securely. Your store is ready for product syndication.</p>
</div>
</div>
{(testResult || testError) && (
<div className={`p-3 rounded-lg flex items-start gap-2 text-xs font-medium border ${testResult?.connected ? 'bg-emerald-50 border-emerald-200 text-emerald-800' : 'bg-red-50 border-red-200 text-red-800'}`}>
{testResult?.connected
? <CheckCircle2 className="w-4 h-4 text-emerald-600 shrink-0 mt-0.5" />
: <AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
}
<div>
{testResult?.connected
? <><strong>{testResult.shopName}</strong>{testResult.plan && ` · ${testResult.plan}`}{testResult.email && <div className="text-[11px] mt-0.5">{testResult.email}</div>}</>
: testError
}
</div>
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => { setOauthSuccess(false); setCredentialsSaved(false); }}
className="px-3 py-2.5 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold flex items-center gap-1.5 cursor-pointer"
>
<ArrowLeft className="w-3.5 h-3.5" /> Re-configure
</button>
<button
type="button"
onClick={handleTestConnection}
disabled={testingConnection}
className="flex-1 py-2.5 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold flex items-center justify-center gap-1.5 cursor-pointer disabled:opacity-50"
>
{testingConnection ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Zap className="w-3.5 h-3.5 text-amber-500" />}
Test Connection
</button>
<button
type="button"
onClick={() => { onSuccess?.(); onClose(); resetForm(); }}
className="flex-1 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold flex items-center justify-center gap-1.5 cursor-pointer"
>
<CheckCircle2 className="w-3.5 h-3.5" /> Done View Integrations
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,157 @@
import React, { useEffect, useState } from 'react';
import { X, CheckCircle2, AlertTriangle, Clock, RefreshCw, Layers } from 'lucide-react';
import { useIntegration } from '../hook/useIntegration';
import type { SyncItem } from '../types/integrations.types';
interface SyncJobStatusModalProps {
isOpen: boolean;
onClose: () => void;
jobId: string;
integrationName?: string;
}
export const SyncJobStatusModal: React.FC<SyncJobStatusModalProps> = ({
isOpen,
onClose,
jobId,
integrationName
}) => {
const [items, setItems] = useState<SyncItem[]>([]);
const [loading, setLoading] = useState(false);
const { getSyncItems } = useIntegration();
useEffect(() => {
if (isOpen && jobId) {
setLoading(true);
getSyncItems(jobId)
.then(res => setItems(res))
.finally(() => setLoading(false));
}
}, [isOpen, jobId, getSyncItems]);
if (!isOpen) return null;
const total = items.length;
const successCount = items.filter(i => i.status === 'success').length;
const failedCount = items.filter(i => i.status === 'failed').length;
const pendingCount = items.filter(i => i.status === 'pending' || i.status === 'processing').length;
return (
<div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-xs flex items-center justify-center p-4">
<div className="bg-surface border border-border rounded-xl shadow-xl w-full max-w-3xl overflow-hidden flex flex-col max-h-[85vh] animate-scale-in">
{/* Header */}
<div className="px-6 py-4 border-b border-border flex items-center justify-between bg-background/50">
<div className="flex items-center gap-2">
<Layers className="w-5 h-5 text-primary" />
<div>
<h3 className="font-bold text-foreground text-sm">Sync Execution Telemetry</h3>
<p className="text-xs text-muted-foreground">{integrationName || 'Integration Sync Run'} Job #{jobId.slice(0, 8)}</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-1 hover:bg-background rounded-lg text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Stats summary */}
<div className="grid grid-cols-4 gap-3 p-4 bg-background border-b border-border text-center text-xs">
<div className="p-2.5 bg-surface border border-border rounded-lg">
<span className="text-muted-foreground font-semibold block">Total Scope</span>
<span className="text-sm font-bold text-foreground">{total}</span>
</div>
<div className="p-2.5 bg-emerald-50 border border-emerald-200 rounded-lg">
<span className="text-emerald-700 font-semibold block">Successful</span>
<span className="text-sm font-bold text-emerald-800">{successCount}</span>
</div>
<div className="p-2.5 bg-red-50 border border-red-200 rounded-lg">
<span className="text-red-700 font-semibold block">Failed</span>
<span className="text-sm font-bold text-red-800">{failedCount}</span>
</div>
<div className="p-2.5 bg-blue-50 border border-blue-200 rounded-lg">
<span className="text-blue-700 font-semibold block">In Progress</span>
<span className="text-sm font-bold text-blue-800">{pendingCount}</span>
</div>
</div>
{/* Item table */}
<div className="p-6 overflow-y-auto flex-1">
{loading ? (
<div className="flex items-center justify-center py-10 text-xs text-muted-foreground gap-2">
<RefreshCw className="w-4 h-4 animate-spin text-primary" />
<span>Fetching telemetry items...</span>
</div>
) : items.length === 0 ? (
<div className="text-center py-10 text-xs text-muted-foreground">
No sync items logged for this job run yet.
</div>
) : (
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-border text-muted-foreground font-semibold">
<th className="py-2 px-3">Product Name & SKU</th>
<th className="py-2 px-3">Operation</th>
<th className="py-2 px-3">Status</th>
<th className="py-2 px-3 text-center">Attempts</th>
<th className="py-2 px-3 text-right">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-border font-medium">
{items.map((item: any) => (
<tr key={item.id} className="hover:bg-background/50 transition-colors">
<td className="py-2.5 px-3">
<div className="font-bold text-foreground">{item.product?.name || `Product #${item.product_id.slice(0, 8)}`}</div>
<div className="text-[11px] font-mono text-muted-foreground">{item.sku || item.product?.sku || item.product_id}</div>
</td>
<td className="py-2.5 px-3">
<span className="px-2 py-0.5 rounded text-[10px] font-mono font-bold bg-surface border border-border text-foreground">
{item.operation}
</span>
</td>
<td className="py-2.5 px-3">
{item.status === 'success' ? (
<span className="inline-flex items-center gap-1 text-emerald-600 font-bold text-[11px]">
<CheckCircle2 className="w-3.5 h-3.5" /> Success
</span>
) : item.status === 'failed' ? (
<span className="inline-flex items-center gap-1 text-red-600 font-bold text-[11px]">
<AlertTriangle className="w-3.5 h-3.5" /> Failed
</span>
) : item.status === 'skipped' ? (
<span className="inline-flex items-center gap-1 text-amber-600 font-bold text-[11px]">
<Clock className="w-3.5 h-3.5" /> Skipped
</span>
) : (
<span className="inline-flex items-center gap-1 text-blue-600 font-bold text-[11px]">
<Clock className="w-3.5 h-3.5 animate-spin" /> {item.status}
</span>
)}
</td>
<td className="py-2.5 px-3 font-mono text-center">{item.attempt_count}</td>
<td className="py-2.5 px-3 text-right text-muted-foreground truncate max-w-[220px]" title={item.error_message || 'Synced'}>
{item.error_message ? <span className="text-red-500 font-semibold">{item.error_message}</span> : <span className="text-emerald-600 font-semibold">Synced to Store</span>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Footer */}
<div className="px-6 py-3 border-t border-border bg-background/50 flex justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-1.5 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-2xs cursor-pointer"
>
Close
</button>
</div>
</div>
</div>
);
};
@@ -1,159 +1,126 @@
import { useNavigate } from "react-router-dom";
import { RefreshCw, Download } from "lucide-react";
import { useEffect, useState } from "react";
import { RefreshCw, Download, Layers } from "lucide-react";
import { DataTable } from "../../../components/customs/DataTable";
import { StatusBadge, type BadgeVariant } from "../../../components/customs/StatusBadge";
import { Button } from "../../../components/customs/Button";
import { useIntegration } from "../hook/useIntegration";
import { SyncJobStatusModal } from "./SyncJobStatusModal";
import type { SyncJob } from "../types/integrations.types";
const MOCK_JOBS = [
{
id: "JOB-2891",
integration: "Amazon India",
type: "Full Sync",
records: "4,240",
success: "4,237",
failed: "3",
status: "Completed",
started: "2025-06-09 14:00",
completed: "2025-06-09 14:32",
duration: "32m 14s",
triggered: "Scheduled"
},
{
id: "JOB-2892",
integration: "Shopify Main Store",
type: "Delta Sync",
records: "128",
success: "128",
failed: "0",
status: "Running",
started: "2025-06-09 14:30",
completed: "",
duration: "In progress",
triggered: "Realtime trigger"
},
{
id: "JOB-2890",
integration: "Amazon UAE",
type: "Full Sync",
records: "1,840",
success: "1,840",
failed: "0",
status: "Completed",
started: "2025-06-09 13:00",
completed: "2025-06-09 13:15",
duration: "15m 02s",
triggered: "Scheduled"
},
{
id: "JOB-2889",
integration: "Warehouse WMS",
type: "Inventory Pull",
records: "284",
success: "0",
failed: "284",
status: "Failed",
started: "2025-06-08 08:00",
completed: "2025-06-08 08:03",
duration: "3m 12s",
triggered: "Scheduled"
},
{
id: "JOB-2888",
integration: "Retail POS Network",
type: "Catalogue Sync",
records: "3,240",
success: "3,240",
failed: "0",
status: "Completed",
started: "2025-06-09 12:00",
completed: "2025-06-09 12:18",
duration: "18m 40s",
triggered: "Scheduled"
},
{
id: "JOB-2887",
integration: "Amazon India",
type: "Price Update",
records: "521",
success: "521",
failed: "0",
status: "Cancelled",
started: "2025-06-08 18:00",
completed: "2025-06-08 18:01",
duration: "1m 04s",
triggered: "Manual"
},
{
id: "JOB-2892",
integration: "Shopify Main Store",
type: "Realtime Sync",
records: "128",
success: "128",
failed: "0",
status: "Completed",
started: "2026-08-30 04:30",
completed: "2026-08-30 04:31",
duration: "1m 02s",
triggered: "Outbox Trigger"
}
];
export default function SyncJobsList() {
const navigate = useNavigate();
const [jobs, setJobs] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const columns = [
{ key: "id", label: "JOB ID", render: (val: string) => <span className="font-mono text-primary font-medium">{val}</span> },
{ key: "integration", label: "INTEGRATION" },
{ key: "type", label: "JOB TYPE" },
{
key: "records",
label: "RECORDS",
render: (_: any, row: any) => (
<div className="text-sm">
<span className="font-semibold text-foreground">{row.records}</span>
{row.failed && parseInt(row.failed) > 0 && (
<span className="text-red-600 text-xs ml-1">({row.failed} failed)</span>
)}
</div>
),
},
{
key: "status",
label: "STATUS",
render: (val: string) => {
let variant: BadgeVariant = "neutral";
if (val === "Completed") variant = "success";
if (val === "Running") variant = "warning";
if (val === "Failed") variant = "error";
if (val === "Cancelled") variant = "neutral";
const { getAllSyncJobs } = useIntegration();
return <StatusBadge status={variant} label={val} />;
},
},
{ key: "started", label: "STARTED AT" },
{ key: "completed", label: "COMPLETED AT" },
{ key: "duration", label: "DURATION" },
{ key: "triggered", label: "TRIGGERED BY" },
];
const fetchJobs = async () => {
setLoading(true);
try {
const list = await getAllSyncJobs();
const mapped = list.map((j: any) => ({
id: j.id,
integration: j.integration?.name || 'Shopify Store',
type: j.trigger_source === 'outbox' ? 'Realtime Outbox' : 'Manual Trigger',
records: String(j.total_items || 0),
success: String(j.success_items || 0),
failed: String(j.failed_items || 0),
status: j.status === 'completed' ? 'Completed' : j.status === 'failed' ? 'Failed' : 'Running',
started: j.started_at ? new Date(j.started_at).toLocaleString() : '—',
completed: j.completed_at ? new Date(j.completed_at).toLocaleString() : '—',
duration: j.completed_at && j.started_at
? `${Math.round((new Date(j.completed_at).getTime() - new Date(j.started_at).getTime()) / 1000)}s`
: 'In progress',
triggered: j.trigger_source || 'manual'
}));
setJobs(mapped);
} catch (err) {
console.error('Failed to load sync jobs:', err);
} finally {
setLoading(false);
}
};
return (
<div className="space-y-6">
useEffect(() => {
fetchJobs();
}, []);
<DataTable
columns={columns}
data={MOCK_JOBS}
actionConfig={{
onView: (row) => navigate(`${row.id}/view`),
}}
searchPlaceholder="Search jobs..."
toolbarLeft={
<div className="flex gap-2">
<select className="h-9 px-3 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary bg-surface">
<option>All Statuses</option>
<option>Completed</option>
<option>Running</option>
<option>Failed</option>
</select>
<Button variant="outline" className="flex items-center gap-2">
<RefreshCw className="w-4 h-4" />
Refresh Jobs
</Button>
</div>
}
toolbarRight={
<Button variant="outline">
<Download className="w-4 h-4 mr-2" />
Export Log
</Button>
}
/>
const columns = [
{ key: "id", label: "JOB ID", render: (val: string) => <span className="font-mono text-primary font-medium">{val.slice(0, 8)}</span> },
{ key: "integration", label: "INTEGRATION" },
{ key: "type", label: "JOB TYPE" },
{
key: "records",
label: "RECORDS",
render: (_: any, row: any) => (
<div className="text-sm">
<span className="font-semibold text-foreground">{row.records}</span>
{row.failed && parseInt(row.failed) > 0 && (
<span className="text-red-600 text-xs ml-1">({row.failed} failed)</span>
)}
</div>
);
),
},
{
key: "status",
label: "STATUS",
render: (val: string) => {
let variant: BadgeVariant = "neutral";
if (val === "Completed" || val === "completed") variant = "success";
if (val === "Running" || val === "pending" || val === "processing") variant = "warning";
if (val === "Failed" || val === "failed") variant = "error";
return <StatusBadge status={variant} label={val} />;
},
},
{ key: "started", label: "STARTED AT" },
{ key: "completed", label: "COMPLETED AT" },
{ key: "duration", label: "DURATION" },
{ key: "triggered", label: "TRIGGERED BY" },
];
return (
<div className="space-y-6">
<DataTable
columns={columns}
data={jobs}
actionConfig={{
onView: (row) => setSelectedJobId(row.id),
}}
searchPlaceholder="Search jobs..."
toolbarLeft={
<div className="flex gap-2">
<Button variant="outline" onClick={fetchJobs} className="flex items-center gap-2">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh Jobs
</Button>
</div>
}
/>
{selectedJobId && (
<SyncJobStatusModal
isOpen={!!selectedJobId}
onClose={() => setSelectedJobId(null)}
jobId={selectedJobId}
/>
)}
</div>
);
}
@@ -1,11 +1,20 @@
import { useState, useCallback } from 'react';
import { integrationsService } from '../services/integrations.service';
import type { Integration, IntegrationCreateRequest, IntegrationUpdateRequest } from '../types/integrations.types';
import type {
Integration,
IntegrationCreateRequest,
IntegrationUpdateRequest,
TestConnectionResult,
SyncJob,
SyncItem
} from '../types/integrations.types';
import { notify } from '../../../services/toast';
export const useIntegration = () => {
const [items, setItems] = useState<Integration[]>([]);
const [loading, setLoading] = useState(false);
const [testingConnection, setTestingConnection] = useState(false);
const [triggeringSync, setTriggeringSync] = useState(false);
const fetchItems = useCallback(async () => {
setLoading(true);
@@ -63,5 +72,91 @@ export const useIntegration = () => {
}
}, []);
return { items, loading, fetchItems, createItem, updateItem, deleteItem };
const testConnection = useCallback(async (id: string): Promise<TestConnectionResult> => {
setTestingConnection(true);
try {
const res = await integrationsService.testConnection(id);
if (res.connected) {
notify.success(`Connected to Shopify store: ${res.shopName || res.shopDomain}`);
}
return res;
} catch (err: any) {
notify.error(err?.message || 'Failed to connect to Shopify store');
throw err;
} finally {
setTestingConnection(false);
}
}, []);
const setCredentials = useCallback(async (id: string, type: string, value: string, expiresAt?: string) => {
setLoading(true);
try {
const res = await integrationsService.setCredentials(id, type, value, expiresAt);
notify.success('Credentials configured securely!');
return res;
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
}
}, []);
const triggerSync = useCallback(async (id: string, options?: { productId?: string; productIds?: string[] }) => {
setTriggeringSync(true);
try {
const res = await integrationsService.triggerSync(id, options);
notify.success(`Sync initialized for ${res?.totalItems || 1} product(s)!`);
return res;
} catch (err) {
notify.error(err);
throw err;
} finally {
setTriggeringSync(false);
}
}, []);
const getSyncJobs = useCallback(async (id: string): Promise<SyncJob[]> => {
try {
return await integrationsService.getSyncJobs(id);
} catch (err) {
notify.error(err);
return [];
}
}, []);
const getAllSyncJobs = useCallback(async (): Promise<SyncJob[]> => {
try {
return await integrationsService.getAllSyncJobs();
} catch (err) {
notify.error(err);
return [];
}
}, []);
const getSyncItems = useCallback(async (jobId: string): Promise<SyncItem[]> => {
try {
return await integrationsService.getSyncItems(jobId);
} catch (err) {
notify.error(err);
return [];
}
}, []);
return {
items,
loading,
testingConnection,
triggeringSync,
fetchItems,
createItem,
updateItem,
deleteItem,
testConnection,
setCredentials,
triggerSync,
getSyncJobs,
getAllSyncJobs,
getSyncItems
};
};
@@ -9,14 +9,18 @@ import {
Code2,
Monitor,
Warehouse,
Globe
Globe,
Zap,
Play,
Key,
X,
Trash2
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useNavigate, useSearchParams } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { Button } from "../../../components/customs/Button";
import { DataTable } from "../../../components/customs/DataTable";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { useIntegration } from "../hook/useIntegration";
import { useChannel } from "../../channels/hook/useChannel";
@@ -24,15 +28,17 @@ import type { Integration } from "../types/integrations.types";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { StatsCard } from "../../../components/customs/StatsCard";
// Import Tab Components
// Import Custom Feature Components & Pre-Built Templates
import { IntegrationHealthBadge } from "../components/IntegrationHealthBadge";
import { ShopifyCredentialCard } from "../components/ShopifyCredentialCard";
import { IntegrationTemplateGallery } from "../components/IntegrationTemplateGallery";
import { ShopifyTemplateModal } from "../components/ShopifyTemplateModal";
import FieldMappingsList from "../components/FieldMappingsTab";
import PublishingRulesList from "../components/PublishingRulesTab";
import SyncJobsList from "../components/SyncJobsTab";
import ErrorCenterList from "../components/ErrorCenterTab";
import AuditLogsList from "../components/AuditLogsTab";
// ──────────────────────────────────────────────
const INTEGRATION_META: Record<string, { label: string; icon: any; color: string; bg: string }> = {
ecommerce: { label: "E-Commerce", icon: ShoppingCart, color: "text-blue-600", bg: "bg-blue-50" },
marketplace: { label: "Marketplace", icon: ShoppingBag, color: "text-orange-600", bg: "bg-orange-50" },
@@ -47,14 +53,23 @@ const INTEGRATION_META: Record<string, { label: string; icon: any; color: string
export default function IntegrationList() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<"Connections" | "Publishing Rules" | "Sync Jobs" | "Error Center" | "Audit & Logs">("Connections");
const [searchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState<"Connections" | "Field Mappings" | "Publishing Rules" | "Sync Jobs" | "Error Center" | "Audit & Logs">("Connections");
const [statusFilter, setStatusFilter] = useState("All Status");
const [typeFilter, setTypeFilter] = useState("All Types");
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: "", name: "" });
const [shopifyModalOpen, setShopifyModalOpen] = useState(false);
const [credentialModal, setCredentialModal] = useState<{ isOpen: boolean; integrationId: string; name: string }>({
isOpen: false,
integrationId: "",
name: ""
});
const [isDeleting, setIsDeleting] = useState(false);
const [testingId, setTestingId] = useState<string | null>(null);
const [syncingId, setSyncingId] = useState<string | null>(null);
const { items, fetchItems, loading, deleteItem } = useIntegration();
const { items, fetchItems, loading, deleteItem, testConnection, triggerSync } = useIntegration();
const { items: channels, fetchItems: fetchChannels } = useChannel();
useEffect(() => {
@@ -62,6 +77,13 @@ export default function IntegrationList() {
fetchChannels();
}, [fetchItems, fetchChannels]);
// Automatically open Shopify modal into Step 3 when returning from OAuth redirect
useEffect(() => {
if (searchParams.get('oauth') === 'success') {
setShopifyModalOpen(true);
}
}, [searchParams]);
const handleDeleteConfirm = async () => {
if (!deleteModal.id) return;
setIsDeleting(true);
@@ -75,13 +97,37 @@ export default function IntegrationList() {
}
};
const handleTestConnectionClick = async (id: string) => {
setTestingId(id);
try {
await testConnection(id);
} catch (err) {
console.error(err);
} finally {
setTestingId(null);
}
};
const handleTriggerSyncClick = async (id: string) => {
setSyncingId(id);
try {
await triggerSync(id);
setActiveTab("Sync Jobs");
} catch (err) {
console.error(err);
} finally {
setSyncingId(null);
}
};
const columns = [
{
key: "name",
label: "Integration Name",
sortable: true,
render: (_: any, row: Integration) => {
const meta = INTEGRATION_META[row.integrationType] || INTEGRATION_META.custom_api;
const metaType = row.integrationType || 'ecommerce';
const meta = INTEGRATION_META[metaType] || INTEGRATION_META.ecommerce;
const Icon = meta.icon;
return (
<div className="flex items-center gap-3">
@@ -90,7 +136,7 @@ export default function IntegrationList() {
</div>
<div>
<div className="font-medium text-foreground">{row.name}</div>
<div className="text-xs text-muted-foreground mt-0.5">{row.description || "No description provided"}</div>
<div className="text-xs text-muted-foreground mt-0.5">{row.channel ? `Channel: ${row.channel.toUpperCase()}` : "No description provided"}</div>
</div>
</div>
);
@@ -101,61 +147,77 @@ export default function IntegrationList() {
label: "Channel",
render: (val: string) => {
const chan = channels.find(c => c.code === val || c.id === val);
return <span className="font-medium">{chan ? chan.name : val}</span>;
return <span className="font-semibold text-xs uppercase px-2 py-0.5 rounded bg-surface border border-border">{chan ? chan.name : val || 'Shopify'}</span>;
}
},
{
key: "integrationType",
label: "Type",
render: (val: string) => {
const meta = INTEGRATION_META[val] || INTEGRATION_META.custom_api;
return <span className="text-xs font-medium text-muted-foreground capitalize">{meta.label}</span>;
}
},
{
key: "environment",
label: "Environment",
render: (val: string) => <span className="text-blue-600 text-sm font-medium capitalize">{val}</span>,
key: "sync_mode",
label: "Sync Mode",
render: (_: any, row: Integration) => (
<span className="text-xs font-mono capitalize text-muted-foreground">{row.sync_mode || row.syncMode || 'auto'}</span>
)
},
{
key: "status",
label: "Connection Status",
render: (val: string) => {
const isSuccess = val === "Connected" || val === "active";
const isWarning = val === "Pending" || val === "pending";
label: "Health Status",
render: (_: any, row: Integration) => (
<IntegrationHealthBadge status={row.status} healthStatus={row.health_status} />
),
},
{
key: "last_synced_at",
label: "Last Synced",
render: (_: any, row: Integration) => {
const ts = row.last_synced_at || row.lastSync;
return (
<StatusBadge
status={isSuccess ? "success" : isWarning ? "warning" : "neutral"}
label={val === "active" ? "Connected" : val === "pending" ? "Pending" : val}
/>
<div className="text-xs text-foreground font-medium">
{ts ? new Date(ts).toLocaleString() : 'Not synced yet'}
</div>
);
},
},
{
key: "lastSync",
label: "Last Sync",
key: "actions",
label: "Actions",
render: (_: any, row: Integration) => (
<div>
<div className="text-foreground font-medium text-sm">{row.lastSync || "—"}</div>
{row.syncErrors && row.syncErrors > 0 && (
<div className="text-red-600 text-xs mt-0.5">{row.syncErrors} failed</div>
)}
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => handleTestConnectionClick(row.id)}
disabled={testingId === row.id}
title="Test Connection"
className="p-1.5 rounded-lg border border-border bg-surface hover:bg-background text-amber-600 cursor-pointer disabled:opacity-50"
>
<Zap className={`w-3.5 h-3.5 ${testingId === row.id ? 'animate-spin' : ''}`} />
</button>
<button
type="button"
onClick={() => handleTriggerSyncClick(row.id)}
disabled={syncingId === row.id}
title="Trigger Manual Sync"
className="p-1.5 rounded-lg border border-border bg-surface hover:bg-background text-primary cursor-pointer disabled:opacity-50"
>
<Play className={`w-3.5 h-3.5 ${syncingId === row.id ? 'animate-spin' : ''}`} />
</button>
<button
type="button"
onClick={() => setCredentialModal({ isOpen: true, integrationId: row.id, name: row.name })}
title="Configure Credentials"
className="p-1.5 rounded-lg border border-border bg-surface hover:bg-background text-foreground cursor-pointer"
>
<Key className="w-3.5 h-3.5 text-muted-foreground" />
</button>
<button
type="button"
onClick={() => setDeleteModal({ isOpen: true, id: row.id, name: row.name })}
title="Delete Integration"
className="p-1.5 rounded-lg border border-border bg-surface hover:bg-red-50 hover:border-red-200 text-red-600 cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
),
},
{ key: "published", label: "Published", render: (val: any) => val || 0 },
{
key: "createdAt",
label: "Created By",
render: (_: any, row: Integration) => (
<div>
<div className="text-foreground text-sm font-medium">{row.author || "Admin"}</div>
<div className="text-muted-foreground text-xs mt-0.5">
{row.createdAt ? new Date(row.createdAt).toLocaleDateString() : "—"}
</div>
</div>
),
},
)
}
];
const filteredItems = items.filter(item => {
@@ -165,7 +227,7 @@ export default function IntegrationList() {
(statusFilter === "Disconnected" && (item.status === "Disconnected" || item.status === "inactive"));
const matchesType = typeFilter === "All Types" ||
typeFilter.toLowerCase() === item.integrationType.toLowerCase();
(item.integrationType && typeFilter.toLowerCase() === item.integrationType.toLowerCase());
return matchesStatus && matchesType;
});
@@ -173,142 +235,165 @@ export default function IntegrationList() {
return (
<ProtectedRoute node="settings.integrations">
<PageWrapper>
<Breadcrumb
items={[{ label: "Home" }, { label: "Integration Hub" }]}
actions={
<>
<Button variant="outline" className="bg-surface border-border text-foreground hover:bg-background" onClick={fetchItems}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button onClick={() => navigate("new")} className="bg-primary hover:bg-primary-hover text-white">
<Plus className="w-4 h-4 mr-2" />New Integration
</Button>
</>
}
/>
<Breadcrumb
items={[{ label: "Home" }, { label: "Integration Hub" }]}
actions={
<>
<Button variant="outline" className="bg-surface border-border text-foreground hover:bg-background" onClick={fetchItems}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button onClick={() => setShopifyModalOpen(true)} className="bg-emerald-600 hover:bg-emerald-700 text-white">
<Plus className="w-4 h-4 mr-2" />Setup Shopify
</Button>
</>
}
/>
{/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatsCard
title="Total Integrations"
value={items.length}
subtitle="All integrations"
icon={<Plug className="w-5 h-5" />}
color="purple"
{/* Quick Pre-Built Template Gallery */}
<IntegrationTemplateGallery
onSelectShopify={() => setShopifyModalOpen(true)}
onSelectCustomApi={() => navigate("new")}
/>
<StatsCard
title="Connected Systems"
value={items.filter(i => i.status === "Connected" || i.status === "active").length}
subtitle="Healthy"
icon={<CheckCircle className="w-5 h-5" />}
color="green"
/>
<StatsCard
title="Failed Sync Jobs"
value={items.filter(i => i.syncErrors && i.syncErrors > 0).length}
subtitle="Need attention"
icon={<AlertCircle className="w-5 h-5" />}
color="red"
/>
<StatsCard
title="Last Synchronised"
value="14:32"
subtitle="Today"
icon={<Clock className="w-5 h-5" />}
color="slate"
/>
</div>
<div className="bg-surface rounded-xl shadow-sm border border-border mt-6">
{/* Tabs */}
<div className="flex border-b border-border px-6 pt-2">
{[
{ id: "Connections", count: items.length },
{ id: "Publishing Rules", count: 5 },
{ id: "Sync Jobs", count: 6 },
{ id: "Error Center", count: items.filter(i => i.syncErrors && i.syncErrors > 0).length },
{ id: "Audit & Logs", count: null }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
{tab.id}
{tab.count !== null && (
<span className={`px-2 py-0.5 rounded-full text-xs ${
activeTab === tab.id ? 'bg-primary-light text-primary-dark' : 'bg-surface-muted text-muted-foreground'
}`}>
{tab.count}
</span>
)}
</button>
))}
{/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatsCard
title="Total Integrations"
value={items.length}
subtitle="All active channels"
icon={<Plug className="w-5 h-5" />}
color="purple"
/>
<StatsCard
title="Healthy Systems"
value={items.filter(i => i.status === "Connected" || i.status === "active" || i.health_status === 'healthy').length}
subtitle="Operational"
icon={<CheckCircle className="w-5 h-5" />}
color="green"
/>
<StatsCard
title="Outbox Events"
value="Active"
subtitle="Realtime Outbox Queue"
icon={<AlertCircle className="w-5 h-5" />}
color="blue"
/>
<StatsCard
title="Engine Health"
value="BullMQ"
subtitle="Redis Workers Running"
icon={<Clock className="w-5 h-5" />}
color="slate"
/>
</div>
{/* Tab Content */}
<div className="p-6">
{activeTab === "Connections" && (
<DataTable
columns={columns}
data={filteredItems}
rowIdKey="id"
resultLabel="integrations"
statusKey="status"
actionConfig={{
onView: (row) => navigate(`${row.id}/view`),
onEdit: (row) => navigate(`${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
}}
searchPlaceholder="Search integrations..."
toolbarLeft={
<div className="flex gap-2">
<select
className="h-9 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-foreground bg-surface"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option>All Status</option>
<option>Connected</option>
<option>Pending</option>
<option>Disconnected</option>
</select>
<select
className="h-9 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-foreground bg-surface"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
<option>All Types</option>
<option>Marketplace</option>
<option>E-Commerce</option>
</select>
</div>
}
/>
)}
<div className="bg-surface rounded-xl shadow-sm border border-border mt-6">
{/* Tabs */}
<div className="flex border-b border-border px-6 pt-2 overflow-x-auto">
{[
{ id: "Connections", count: items.length },
{ id: "Field Mappings", count: 7 },
{ id: "Publishing Rules", count: 1 },
{ id: "Sync Jobs", count: null },
{ id: "Error Center", count: 0 },
{ id: "Audit & Logs", count: null }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium border-b-2 transition-colors cursor-pointer shrink-0 ${
activeTab === tab.id
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
{tab.id}
{tab.count !== null && (
<span className={`px-2 py-0.5 rounded-full text-xs ${
activeTab === tab.id ? 'bg-primary-light text-primary-dark' : 'bg-surface-muted text-muted-foreground'
}`}>
{tab.count}
</span>
)}
</button>
))}
</div>
{activeTab === "Publishing Rules" && <PublishingRulesList />}
{activeTab === "Sync Jobs" && <SyncJobsList />}
{activeTab === "Error Center" && <ErrorCenterList />}
{activeTab === "Audit & Logs" && <AuditLogsList />}
{/* Tab Content */}
<div className="p-6">
{activeTab === "Connections" && (
<DataTable
columns={columns}
data={filteredItems}
rowIdKey="id"
resultLabel="integrations"
actionConfig={{
onEdit: (row) => navigate(`${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
}}
searchPlaceholder="Search integrations..."
toolbarLeft={
<div className="flex gap-2">
<select
className="h-9 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-foreground bg-surface"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option>All Status</option>
<option>Connected</option>
<option>Pending</option>
<option>Disconnected</option>
</select>
</div>
}
/>
)}
{activeTab === "Field Mappings" && <FieldMappingsList />}
{activeTab === "Publishing Rules" && <PublishingRulesList />}
{activeTab === "Sync Jobs" && <SyncJobsList />}
{activeTab === "Error Center" && <ErrorCenterList />}
{activeTab === "Audit & Logs" && <AuditLogsList />}
</div>
</div>
</div>
<ConfirmationModal
isOpen={deleteModal.isOpen}
title="Delete Integration"
description="Are you sure you want to delete this integration? This action cannot be undone."
itemName={deleteModal.name}
loading={isDeleting}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</PageWrapper>
{/* Pre-Built Shopify Template Modal */}
<ShopifyTemplateModal
isOpen={shopifyModalOpen}
onClose={() => setShopifyModalOpen(false)}
onSuccess={fetchItems}
/>
{/* Credentials Configuration Modal */}
{credentialModal.isOpen && (
<div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-xs flex items-center justify-center p-4">
<div className="relative w-full max-w-lg">
<button
type="button"
onClick={() => setCredentialModal({ isOpen: false, integrationId: "", name: "" })}
className="absolute top-3 right-3 p-1 text-muted-foreground hover:text-foreground cursor-pointer z-10"
>
<X className="w-5 h-5" />
</button>
<ShopifyCredentialCard
integrationId={credentialModal.integrationId}
onSaved={() => setCredentialModal({ isOpen: false, integrationId: "", name: "" })}
/>
</div>
</div>
)}
<ConfirmationModal
isOpen={deleteModal.isOpen}
title="Delete Integration"
description="Are you sure you want to delete this integration? This action cannot be undone."
itemName={deleteModal.name}
loading={isDeleting}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</PageWrapper>
</ProtectedRoute>
);
}
@@ -258,6 +258,39 @@ export default function NewIntegration() {
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
useEffect(() => {
if (isEdit || isView) {
setHighestVisitedStep(STEPS.length);
}
}, [isEdit, isView]);
const isGeneralValid = Boolean(formik.values.name?.trim() && formik.values.channel && formik.values.integrationType && !formik.errors.name && !formik.errors.channel && !formik.errors.integrationType);
const isStepAccessible = useCallback((stepNum: number) => {
if (isEdit || isView) return true;
if (stepNum === 1) return true;
if (!isGeneralValid) return false;
return stepNum <= highestVisitedStep + 1;
}, [isEdit, isView, isGeneralValid, highestVisitedStep]);
const handleNextStep = useCallback(() => {
if (activeStep === 'general') {
if (!isGeneralValid) {
formik.setFieldTouched('name', true);
formik.setFieldTouched('channel', true);
formik.setFieldTouched('integrationType', true);
return;
}
}
if (activeIndex < STEPS.length - 1) {
const nextStepObj = STEPS[activeIndex + 1];
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
setActiveStep(nextStepObj.id);
}
}, [activeStep, isGeneralValid, activeIndex, formik]);
const selectedType = INTEGRATION_TYPES.find((t) => t.id === formik.values.integrationType);
const selectedChanName = channels.find(c => c.code === formik.values.channel || c.id === formik.values.channel)?.name ?? '';
@@ -318,15 +351,34 @@ export default function NewIntegration() {
const isActive = activeStep === s.id;
const isDone = idx < activeIndex;
const isLast = idx === STEPS.length - 1;
const accessible = isStepAccessible(s.step);
return (
<div key={s.id} className="flex gap-3">
<div className="flex flex-col items-center" style={{ width: 24 }}>
<button type="button" onClick={() => setActiveStep(s.id)} className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${isActive ? 'bg-primary ring-2 ring-primary/20' : isDone ? 'bg-success' : 'bg-surface border-2 border-border hover:border-primary/30'}`}>
<button
type="button"
disabled={!accessible}
onClick={() => {
if (accessible) {
setActiveStep(s.id);
}
}}
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${isActive ? 'bg-primary ring-2 ring-primary/20' : isDone ? 'bg-success' : 'bg-surface border-2 border-border hover:border-primary/30'} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
>
{isDone ? <Check className="w-3 h-3 text-white" /> : <span className={`text-[9px] font-bold ${isActive ? 'text-white' : 'text-muted-foreground'}`}>{s.step}</span>}
</button>
{!isLast && <div className={`w-px flex-1 my-0.5 ${isDone ? 'bg-success/50' : 'bg-surface-muted'}`} style={{ minHeight: 14 }} />}
</div>
<button type="button" onClick={() => setActiveStep(s.id)} className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''}`}>
<button
type="button"
disabled={!accessible}
onClick={() => {
if (accessible) {
setActiveStep(s.id);
}
}}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
>
<span className={`text-xs font-medium leading-tight block ${isActive ? 'text-primary' : isDone ? 'text-muted-foreground' : 'text-muted-foreground hover:text-muted-foreground'}`}>{s.label}</span>
</button>
</div>
@@ -985,7 +1037,7 @@ export default function NewIntegration() {
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm font-medium text-muted-foreground hover:bg-background transition-colors">Back</button>
)}
{activeIndex < STEPS.length - 1 && (
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
<button type="button" onClick={handleNextStep} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
)}
</div>
</div>
@@ -1,4 +1,11 @@
import type { Integration, IntegrationCreateRequest, IntegrationUpdateRequest } from '../types/integrations.types';
import type {
Integration,
IntegrationCreateRequest,
IntegrationUpdateRequest,
TestConnectionResult,
SyncJob,
SyncItem
} from '../types/integrations.types';
import apiClient from '../../../api/axiosInstance';
interface ApiResponse<T> {
@@ -10,22 +17,82 @@ interface ApiResponse<T> {
export const integrationsService = {
getAll: async (): Promise<Integration[]> => {
const res = await apiClient.get<ApiResponse<Integration[]>>('/api/v1/integrations');
return res.data || [];
const raw: any = res.data;
return (Array.isArray(raw) ? raw : raw?.data || []) as Integration[];
},
getById: async (id: string): Promise<Integration | undefined> => {
const res = await apiClient.get<ApiResponse<Integration>>(`/api/v1/integrations/${id}`);
return res.data;
const raw: any = res.data;
return (raw?.data || raw) as Integration;
},
create: async (req: IntegrationCreateRequest): Promise<Integration> => {
const res = await apiClient.post<ApiResponse<Integration>>('/api/v1/integrations', req);
return res.data;
const raw: any = res.data;
return (raw?.data || raw) as Integration;
},
update: async (id: string, req: IntegrationUpdateRequest): Promise<Integration> => {
const res = await apiClient.put<ApiResponse<Integration>>(`/api/v1/integrations/${id}`, req);
return res.data;
const raw: any = res.data;
return (raw?.data || raw) as Integration;
},
delete: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/integrations/${id}`);
return res.success;
return res.success || true;
},
getCredentials: async (id: string): Promise<Record<string, string>> => {
const res = await apiClient.get<ApiResponse<Record<string, string>>>(`/api/v1/integrations/${id}/credentials`);
const raw: any = res.data;
return (raw?.data || raw) as Record<string, string>;
},
setCredentials: async (id: string, credentialType: string, secretValue: string, expiresAt?: string): Promise<any> => {
const res = await apiClient.post<ApiResponse<any>>(`/api/v1/integrations/${id}/credentials`, {
credential_type: credentialType,
secret_value: secretValue,
expires_at: expiresAt
});
const raw: any = res.data;
return raw?.data || raw;
},
testConnection: async (id: string): Promise<TestConnectionResult> => {
const res = await apiClient.post<ApiResponse<TestConnectionResult>>(`/api/v1/integrations/${id}/test-connection`);
const raw: any = res.data;
return (raw?.data || raw) as TestConnectionResult;
},
triggerSync: async (id: string, options?: { productId?: string; productIds?: string[] }): Promise<any> => {
const res = await apiClient.post<ApiResponse<any>>(`/api/v1/integrations/${id}/sync`, options || {});
const raw: any = res.data;
return raw?.data || raw;
},
getSyncJobs: async (id: string): Promise<SyncJob[]> => {
const res = await apiClient.get<ApiResponse<SyncJob[]>>(`/api/v1/integrations/${id}/jobs`);
const raw: any = res.data;
return (Array.isArray(raw) ? raw : raw?.data || []) as SyncJob[];
},
getAllSyncJobs: async (): Promise<SyncJob[]> => {
const res = await apiClient.get<ApiResponse<SyncJob[]>>(`/api/v1/integrations/jobs/all`);
const raw: any = res.data;
return (Array.isArray(raw) ? raw : raw?.data || []) as SyncJob[];
},
getSyncItems: async (jobId: string): Promise<SyncItem[]> => {
const res = await apiClient.get<ApiResponse<SyncItem[]>>(`/api/v1/integrations/jobs/${jobId}/items`);
const raw: any = res.data;
return (Array.isArray(raw) ? raw : raw?.data || []) as SyncItem[];
},
startShopifyOAuth: async (id: string): Promise<{ authorizationUrl: string; state: string }> => {
const res = await apiClient.post<ApiResponse<any>>(`/api/v1/integrations/${id}/shopify/oauth/start`);
const raw: any = res.data;
return raw?.data || raw;
}
};
@@ -2,62 +2,97 @@ export interface Integration {
id: string;
name: string;
description?: string;
channel: string; // channel code or ID
integrationType: string; // e.g. ecommerce, marketplace, erp, wms, pos, b2b_portal, mobile_app, website, custom_api
environment: string; // e.g. production, staging, development
status: string; // Connected, Pending, Disconnected, active, inactive, pending
channel: string; // e.g. shopify, amazon, custom_api
integrationType?: string; // e.g. ecommerce, marketplace, erp
environment?: string;
status: string; // active, inactive, pending, error
health_status?: string; // healthy, degraded, error
healthStatus?: string;
sync_mode?: string; // auto, manual, scheduled
syncMode?: string;
sync_frequency?: string; // realtime, hourly, daily
syncFrequency?: string;
last_synced_at?: string;
lastSync?: string;
// E-commerce/Website connection config
// E-commerce connection credentials & details
storeUrl?: string;
accessToken?: string;
apiVersion?: string;
webhookSecret?: string;
shopIdentifier?: string;
shopDomain?: string;
// Marketplace specific fields
sellerId?: string;
marketplaceId?: string;
awsAccessKeyId?: string;
awsSecretKey?: string;
clientId?: string;
clientSecret?: string;
// ERP/WMS specific fields
authMethod?: string;
authToken?: string;
username?: string;
password?: string;
// POS specific fields
posTerminalId?: string;
posStoreCode?: string;
posApiKey?: string;
posApiSecret?: string;
// Mobile App specific fields
appId?: string;
bundleIdentifier?: string;
gatewayUrl?: string;
// Custom API specific fields
customApiUrl?: string;
customApiHeaderKey?: string;
customApiHeaderValue?: string;
// Sync settings
syncDirection: string; // pim_to_channel, channel_to_pim, bidirectional
syncFrequency: string; // manual, hourly, daily, realtime
autoRetry: boolean;
retryAttempts: number;
// Listing page read-only / metadata fields
lastSync?: string;
syncErrors?: number;
published?: string | number;
author?: string;
createdAt: string;
createdAt?: string;
created_at?: string;
updatedAt?: string;
updated_at?: string;
}
export type IntegrationCreateRequest = Omit<Integration, 'id' | 'createdAt'>;
export type IntegrationCreateRequest = Omit<Integration, 'id' | 'createdAt' | 'created_at'>;
export type IntegrationUpdateRequest = Partial<IntegrationCreateRequest>;
export interface TestConnectionResult {
connected: boolean;
shopName?: string;
shopDomain?: string;
email?: string;
message?: string;
}
export interface SyncJob {
id: string;
tenant_id: number;
integration_id: string;
trigger_source: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
total_items: number;
success_items: number;
failed_items: number;
started_at?: string;
completed_at?: string;
created_at: string;
}
export interface SyncAttempt {
id: string;
sync_item_id: string;
attempt_number: number;
started_at: string;
completed_at?: string;
status: string;
request_method: string;
request_url: string;
response_status?: number;
error_code?: string;
error_message?: string;
duration_ms?: number;
}
export interface SyncErrorItem {
id: string;
error_code: string;
error_type: string;
message: string;
provider_message?: string;
http_status?: number;
retryable: boolean;
attempt_number: number;
}
export interface SyncItem {
id: string;
sync_job_id: string;
integration_id: string;
product_id: string;
variant_id?: string;
sku?: string;
operation: string;
status: 'pending' | 'processing' | 'success' | 'failed' | 'skipped';
source_version: number;
idempotency_key: string;
attempt_count: number;
error_code?: string;
error_message?: string;
attempts?: SyncAttempt[];
errors?: SyncErrorItem[];
created_at: string;
}
@@ -0,0 +1,211 @@
import { useEffect, useState } from "react";
import { Building2, Users, Package, Image, ShieldCheck, Activity, Play, StopCircle } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { Button } from "../../../components/customs/Button";
import { tenantService } from "../../tenants/services/tenant.service";
import { useTenant } from "../../tenants/hooks/useTenant";
import { notify } from "../../../services/toast";
export default function PlatformOverview() {
const navigate = useNavigate();
const { impersonateTenant, stopImpersonation } = useTenant();
const [metrics, setMetrics] = useState<any>(null);
const [tenants, setTenants] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [impersonatingTenantId, setImpersonatingTenantId] = useState<string | null>(
localStorage.getItem("impersonatedTenantId")
);
const loadData = async () => {
try {
setLoading(true);
const [metricRes, tenantRes] = await Promise.all([
tenantService.getPlatformMetrics(),
tenantService.getPlatformTenants()
]);
setMetrics(metricRes);
setTenants(tenantRes);
} catch (err) {
notify.error("Failed to load platform dashboard data");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleStartImpersonate = async (tenantId: string) => {
try {
await impersonateTenant(tenantId);
setImpersonatingTenantId(tenantId);
navigate("/products");
} catch (err) {
// Handled in hook
}
};
const handleStopImpersonate = () => {
stopImpersonation();
setImpersonatingTenantId(null);
};
return (
<PageWrapper>
<Breadcrumb
items={[{ label: "Platform Operator" }, { label: "SaaS Control Center Overview" }]}
actions={
<Button
variant="primary"
icon={<Building2 className="w-4 h-4" />}
onClick={() => navigate("/platform/tenants")}
>
Provision New Tenant
</Button>
}
/>
{/* Support Impersonation Banner */}
{impersonatingTenantId && (
<div className="mb-6 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-amber-500 text-white font-bold">
<ShieldCheck className="w-5 h-5" />
</div>
<div>
<h4 className="font-semibold text-foreground text-sm">Support Impersonation Mode Active</h4>
<p className="text-xs text-muted-foreground">
Currently troubleshooting Tenant ID: <span className="font-mono font-bold text-amber-500">{impersonatingTenantId}</span>. Requests are safely scoped to this tenant context.
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
icon={<StopCircle className="w-4 h-4 text-danger" />}
onClick={handleStopImpersonate}
>
End Support Mode
</Button>
</div>
)}
{/* Metrics Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5 mb-8">
<div className="bg-surface rounded-xl p-5 border border-border shadow-sm flex items-center gap-4">
<div className="p-3 rounded-xl bg-primary/10 text-primary">
<Building2 className="w-6 h-6" />
</div>
<div>
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Total SaaS Tenants</p>
<h3 className="text-2xl font-bold text-foreground mt-0.5">{loading ? "..." : metrics?.tenants?.total || 0}</h3>
<span className="text-xs text-emerald-500 font-medium">{metrics?.tenants?.active || 0} Active</span>
</div>
</div>
<div className="bg-surface rounded-xl p-5 border border-border shadow-sm flex items-center gap-4">
<div className="p-3 rounded-xl bg-blue-500/10 text-blue-500">
<Users className="w-6 h-6" />
</div>
<div>
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Platform Accounts</p>
<h3 className="text-2xl font-bold text-foreground mt-0.5">{loading ? "..." : metrics?.users?.total || 0}</h3>
<span className="text-xs text-muted-foreground">Across all tenants</span>
</div>
</div>
<div className="bg-surface rounded-xl p-5 border border-border shadow-sm flex items-center gap-4">
<div className="p-3 rounded-xl bg-violet-500/10 text-violet-500">
<Package className="w-6 h-6" />
</div>
<div>
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Total Products</p>
<h3 className="text-2xl font-bold text-foreground mt-0.5">{loading ? "..." : metrics?.data?.total_products || 0}</h3>
<span className="text-xs text-muted-foreground">Catalog items</span>
</div>
</div>
<div className="bg-surface rounded-xl p-5 border border-border shadow-sm flex items-center gap-4">
<div className="p-3 rounded-xl bg-amber-500/10 text-amber-500">
<Image className="w-6 h-6" />
</div>
<div>
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Cloudinary DAM Assets</p>
<h3 className="text-2xl font-bold text-foreground mt-0.5">{loading ? "..." : metrics?.data?.total_assets || 0}</h3>
<span className="text-xs text-muted-foreground">Images & Raw Docs</span>
</div>
</div>
</div>
{/* Tenants Table Preview */}
<div className="bg-surface rounded-xl border border-border shadow-sm overflow-hidden mb-8">
<div className="px-6 py-4 border-b border-border bg-gradient-to-r from-primary/5 to-surface flex items-center justify-between">
<div className="flex items-center gap-2">
<Activity className="w-4 h-4 text-primary" />
<h3 className="font-semibold text-foreground text-sm">Tenant Provisioning Registry</h3>
</div>
<Button variant="ghost" size="sm" onClick={() => navigate("/platform/tenants")}>
Manage All Tenants
</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse text-sm">
<thead>
<tr className="border-b border-border bg-background/50 text-xs text-muted-foreground uppercase tracking-wider">
<th className="px-6 py-3">Tenant Code</th>
<th className="px-6 py-3">Organization Name</th>
<th className="px-6 py-3">Contact Email</th>
<th className="px-6 py-3">Products</th>
<th className="px-6 py-3">Assets</th>
<th className="px-6 py-3">Status</th>
<th className="px-6 py-3 text-right">Support Action</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-muted-foreground">Loading SaaS platform tenants...</td>
</tr>
) : tenants.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-muted-foreground">No tenants provisioned yet.</td>
</tr>
) : (
tenants.map((t) => (
<tr key={t.id} className="hover:bg-primary/5 transition-colors">
<td className="px-6 py-4 font-mono text-xs font-semibold text-primary">{t.tenant_code}</td>
<td className="px-6 py-4 font-medium text-foreground">{t.tenant_name}</td>
<td className="px-6 py-4 text-muted-foreground">{t.contact_email || "N/A"}</td>
<td className="px-6 py-4 font-semibold text-foreground">{t.total_products || 0}</td>
<td className="px-6 py-4 font-semibold text-foreground">{t.total_assets || 0}</td>
<td className="px-6 py-4">
<span className={`px-2.5 py-1 rounded-full text-xs font-semibold ${t.status ? 'bg-emerald-500/10 text-emerald-500' : 'bg-red-500/10 text-red-500'}`}>
{t.status ? "Active" : "Suspended"}
</span>
</td>
<td className="px-6 py-4 text-right">
{String(t.id) === String(impersonatingTenantId) ? (
<span className="text-xs text-amber-500 font-semibold">Active Session</span>
) : (
<button
onClick={() => handleStartImpersonate(String(t.id))}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-primary/10 text-primary hover:bg-primary hover:text-white transition-colors"
>
<Play className="w-3.5 h-3.5" /> Support Assist
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</PageWrapper>
);
}
@@ -0,0 +1,377 @@
import { useEffect, useState } from "react";
import { Plus, Building2, Search, CheckCircle, XCircle, Copy, Check } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { Button } from "../../../components/customs/Button";
import { useTenant } from "../../tenants/hooks/useTenant";
import { useFormik } from "formik";
import * as Yup from "yup";
import { notify } from "../../../services/toast";
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-danger focus:ring-danger' : 'border-primary/10 focus:ring-primary-light'} rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 bg-surface text-foreground placeholder-muted-foreground`;
export default function PlatformTenantsPage() {
const navigate = useNavigate();
const { tenants, fetchPlatformTenants, provisionTenant, updatePlatformStatus, impersonateTenant, stopImpersonation } = useTenant();
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [isProvisionModalOpen, setIsProvisionModalOpen] = useState(false);
const [impersonatingTenantId, setImpersonatingTenantId] = useState<string | null>(
localStorage.getItem("impersonatedTenantId")
);
const loadData = async () => {
try {
setLoading(true);
await fetchPlatformTenants();
} catch {
notify.error("Failed to load platform tenants");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const [provisionedSuccessData, setProvisionedSuccessData] = useState<any | null>(null);
const [copied, setCopied] = useState(false);
const formik = useFormik({
initialValues: {
tenant_name: "",
domain: "",
contact_email: "",
admin_name: "",
admin_email: "",
admin_password: ""
},
validationSchema: Yup.object().shape({
tenant_name: Yup.string().required("Organization name is required"),
contact_email: Yup.string().email("Invalid email").required("Contact email is required"),
admin_email: Yup.string().email("Invalid admin email"),
admin_password: Yup.string().min(6, "Password must be at least 6 characters")
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
try {
const result = await provisionTenant(values);
resetForm();
setIsProvisionModalOpen(false);
setProvisionedSuccessData({
tenant: result.tenant || result.data?.tenant,
admin: result.admin || result.data?.admin,
rawPassword: values.admin_password
});
fetchPlatformTenants();
} catch (err) {
// Error handled in hook
} finally {
setSubmitting(false);
}
}
});
const handleToggleStatus = async (id: string, currentStatus: boolean) => {
try {
await updatePlatformStatus(id, !currentStatus);
} catch (err) {
// Handled in hook
}
};
const handleStartImpersonate = async (tenantId: string) => {
try {
await impersonateTenant(tenantId);
setImpersonatingTenantId(tenantId);
navigate("/products");
} catch (err) {
// Handled in hook
}
};
const filteredTenants = (tenants || []).filter(t =>
t.tenant_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
t.tenant_code?.toLowerCase().includes(searchTerm.toLowerCase()) ||
t.contact_email?.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<PageWrapper>
<Breadcrumb
items={[{ label: "Platform Operator" }, { label: "Tenant Provisioning & Management" }]}
actions={
<Button
variant="primary"
icon={<Plus className="w-4 h-4" />}
onClick={() => setIsProvisionModalOpen(true)}
>
Provision New Tenant
</Button>
}
/>
{/* Filter & Search Bar */}
<div className="mb-6 flex items-center justify-between gap-4">
<div className="relative flex-1 max-w-md">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Search by code, name, or email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2 border border-border rounded-lg text-sm bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
{/* Tenants Table */}
<div className="bg-surface rounded-xl border border-border shadow-sm overflow-hidden mb-8">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse text-sm">
<thead>
<tr className="border-b border-border bg-background/50 text-xs text-muted-foreground uppercase tracking-wider">
<th className="px-6 py-3">Tenant Code</th>
<th className="px-6 py-3">Organization</th>
<th className="px-6 py-3">Domain</th>
<th className="px-6 py-3">Contact Email</th>
<th className="px-6 py-3">Products</th>
<th className="px-6 py-3">Assets</th>
<th className="px-6 py-3">Status</th>
<th className="px-6 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading ? (
<tr>
<td colSpan={8} className="px-6 py-8 text-center text-muted-foreground">Loading tenants...</td>
</tr>
) : filteredTenants.length === 0 ? (
<tr>
<td colSpan={8} className="px-6 py-8 text-center text-muted-foreground">No matching tenants found.</td>
</tr>
) : (
filteredTenants.map((t: any) => (
<tr key={t.id} className="hover:bg-primary/5 transition-colors">
<td className="px-6 py-4 font-mono text-xs font-semibold text-primary">{t.tenant_code}</td>
<td className="px-6 py-4 font-medium text-foreground">{t.tenant_name}</td>
<td className="px-6 py-4 text-muted-foreground">{t.domain || "N/A"}</td>
<td className="px-6 py-4 text-muted-foreground">{t.contact_email}</td>
<td className="px-6 py-4 font-semibold">{t.total_products || 0}</td>
<td className="px-6 py-4 font-semibold">{t.total_assets || 0}</td>
<td className="px-6 py-4">
<span className={`px-2.5 py-1 rounded-full text-xs font-semibold ${t.status ? 'bg-emerald-500/10 text-emerald-500' : 'bg-red-500/10 text-red-500'}`}>
{t.status ? "Active" : "Suspended"}
</span>
</td>
<td className="px-6 py-4 text-right flex items-center justify-end gap-2">
<button
onClick={() => handleToggleStatus(String(t.id), t.status)}
className={`p-1.5 rounded-lg text-xs font-medium transition-colors ${
t.status ? 'bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white' : 'bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500 hover:text-white'
}`}
title={t.status ? "Suspend Tenant" : "Activate Tenant"}
>
{t.status ? <XCircle className="w-4 h-4" /> : <CheckCircle className="w-4 h-4" />}
</button>
{String(t.id) === String(impersonatingTenantId) ? (
<button
onClick={() => { stopImpersonation(); setImpersonatingTenantId(null); }}
className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-amber-500 text-white hover:bg-amber-600 transition-colors"
>
End Support
</button>
) : (
<button
onClick={() => handleStartImpersonate(String(t.id))}
className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-primary/10 text-primary hover:bg-primary hover:text-white transition-colors"
>
Support Assist
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Provision Tenant Modal */}
{isProvisionModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-surface rounded-xl border border-border shadow-xl w-full max-w-lg overflow-hidden">
<div className="px-6 py-4 border-b border-border bg-gradient-to-r from-primary/5 to-surface flex items-center justify-between">
<h3 className="font-semibold text-foreground text-sm flex items-center gap-2">
<Building2 className="w-4 h-4 text-primary" /> Provision New Tenant Account
</h3>
<button
onClick={() => setIsProvisionModalOpen(false)}
className="text-muted-foreground hover:text-foreground text-sm font-semibold"
>
</button>
</div>
<form onSubmit={formik.handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-xs font-semibold text-foreground mb-1">Organization Name *</label>
<input
type="text"
name="tenant_name"
value={formik.values.tenant_name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
className={inputClass(formik.touched.tenant_name && Boolean(formik.errors.tenant_name))}
placeholder="e.g. IKEA Global"
/>
{formik.touched.tenant_name && formik.errors.tenant_name && (
<p className="text-xs text-danger mt-1">{formik.errors.tenant_name}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-foreground mb-1">Domain (Optional)</label>
<input
type="text"
name="domain"
value={formik.values.domain}
onChange={formik.handleChange}
className={inputClass()}
placeholder="ikea.com"
/>
</div>
<div>
<label className="block text-xs font-semibold text-foreground mb-1">Contact Email *</label>
<input
type="email"
name="contact_email"
value={formik.values.contact_email}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
className={inputClass(formik.touched.contact_email && Boolean(formik.errors.contact_email))}
placeholder="support@ikea.com"
/>
{formik.touched.contact_email && formik.errors.contact_email && (
<p className="text-xs text-danger mt-1">{formik.errors.contact_email}</p>
)}
</div>
</div>
<div className="pt-2 border-t border-border">
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">Initial Tenant Admin Credentials</h4>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-foreground mb-1">Admin Name</label>
<input
type="text"
name="admin_name"
value={formik.values.admin_name}
onChange={formik.handleChange}
className={inputClass()}
placeholder="John Admin"
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1">Admin Email</label>
<input
type="email"
name="admin_email"
value={formik.values.admin_email}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
className={inputClass(formik.touched.admin_email && Boolean(formik.errors.admin_email))}
placeholder="admin@ikea.com"
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1">Admin Password</label>
<input
type="password"
name="admin_password"
value={formik.values.admin_password}
onChange={formik.handleChange}
className={inputClass(formik.touched.admin_password && Boolean(formik.errors.admin_password))}
placeholder="••••••••"
/>
</div>
</div>
</div>
<div className="pt-4 flex items-center justify-end gap-3 border-t border-border">
<Button variant="outline" type="button" onClick={() => setIsProvisionModalOpen(false)}>
Cancel
</Button>
<Button variant="primary" type="submit" loading={formik.isSubmitting}>
Provision Tenant
</Button>
</div>
</form>
</div>
</div>
)}
{/* Provisioning Success Modal with Copy Credentials */}
{provisionedSuccessData && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="w-full max-w-md bg-surface border border-primary/20 rounded-2xl p-6 shadow-2xl space-y-5">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-success/10 text-success flex items-center justify-center">
<CheckCircle className="w-6 h-6" />
</div>
<div>
<h3 className="text-lg font-bold text-foreground">Tenant Provisioned!</h3>
<p className="text-xs text-muted-foreground">Share these setup credentials with your client</p>
</div>
</div>
<div className="bg-primary/5 border border-primary/10 rounded-xl p-4 space-y-3 font-mono text-xs text-foreground">
<div className="flex justify-between border-b border-primary/10 pb-2">
<span className="text-muted-foreground font-sans">Organization:</span>
<span className="font-semibold text-primary">{provisionedSuccessData.tenant?.tenant_name}</span>
</div>
<div className="flex justify-between border-b border-primary/10 pb-2">
<span className="text-muted-foreground font-sans">Tenant Code:</span>
<span className="font-semibold">{provisionedSuccessData.tenant?.tenant_code}</span>
</div>
<div className="flex justify-between border-b border-primary/10 pb-2">
<span className="text-muted-foreground font-sans">Admin Email:</span>
<span className="font-semibold">{provisionedSuccessData.admin?.email || provisionedSuccessData.tenant?.contact_email}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground font-sans">Admin Password:</span>
<span className="font-semibold text-danger">{provisionedSuccessData.rawPassword || '••••••••'}</span>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<Button
variant="primary"
className="w-full flex items-center justify-center gap-2"
onClick={() => {
const textToCopy = `Organization: ${provisionedSuccessData.tenant?.tenant_name}\nTenant Code: ${provisionedSuccessData.tenant?.tenant_code}\nAdmin Email: ${provisionedSuccessData.admin?.email || provisionedSuccessData.tenant?.contact_email}\nPassword: ${provisionedSuccessData.rawPassword || 'Admin@123'}\nLogin URL: http://localhost:5173/login`;
navigator.clipboard.writeText(textToCopy);
setCopied(true);
notify.success("Provisioning credentials copied to clipboard!");
setTimeout(() => setCopied(false), 2000);
}}
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
{copied ? "Copied to Clipboard!" : "Copy Client Credentials"}
</Button>
<Button variant="outline" onClick={() => setProvisionedSuccessData(null)}>
Done
</Button>
</div>
</div>
</div>
)}
</PageWrapper>
);
}
@@ -23,21 +23,53 @@ interface DynamicAttributeRendererProps {
attribute: Attribute;
value: any;
onChange: (value: any) => void;
onBlur?: () => void;
error?: string;
touched?: boolean;
readOnly?: boolean;
}
export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> = ({
attribute,
value,
onChange,
onBlur,
error,
touched,
readOnly,
}) => {
const isRequired = attribute.is_required || attribute.isRequired;
const inputClass = "w-full border border-border focus:ring-primary rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-surface text-foreground";
const labelClass = "block text-sm font-medium text-foreground mb-1.5";
const normalizedOptions = React.useMemo(() => {
if (Array.isArray(attribute.optionsList) && attribute.optionsList.length > 0) {
return attribute.optionsList.map((o: any) => ({
id: o.id || o.code || o.value || String(o),
code: o.code || o.value || String(o).toLowerCase(),
label: o.label || o.value || o.name || String(o)
}));
}
const rawOpts = (attribute as any).options || (attribute as any).attributeOptions || (attribute as any).values || [];
if (Array.isArray(rawOpts)) {
return rawOpts.map((o: any, idx: number) => {
if (typeof o === 'string') {
return {
id: `${attribute.id || attribute.code}-${idx}`,
code: o.toLowerCase().trim(),
label: o
};
}
return {
id: o.id || o.code || `${attribute.code}-${idx}`,
code: o.code || o.value || String(o.label || '').toLowerCase(),
label: o.label || o.value || o.name || o.code || String(o)
};
});
}
return [];
}, [attribute]);
const renderInput = () => {
switch (attribute.type) {
case 'textarea':
@@ -45,9 +77,11 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
<textarea
value={value || ''}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
placeholder={`Enter ${attribute.name}`}
rows={3}
className={`${inputClass} resize-none`}
disabled={readOnly}
/>
);
case 'number':
@@ -61,8 +95,10 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
const val = e.target.value;
onChange(val === '' ? undefined : Number(val));
}}
onBlur={onBlur}
placeholder={`Enter ${attribute.name}`}
className={inputClass}
disabled={readOnly}
/>
);
case 'date':
@@ -71,7 +107,9 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
type="date"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
className={inputClass}
disabled={readOnly}
/>
);
case 'boolean':
@@ -82,6 +120,8 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
const val = e.target.value;
onChange(val === 'true' ? true : val === 'false' ? false : undefined);
}}
onBlur={onBlur}
disabled={readOnly}
>
<option value="">Select option</option>
<option value="true">True</option>
@@ -95,9 +135,11 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
<Select
value={value || ''}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
disabled={readOnly}
>
<option value="">Select option</option>
{attribute.optionsList?.map((opt) => (
{normalizedOptions.map((opt) => (
<option key={opt.id} value={opt.code}>{opt.label}</option>
))}
</Select>
@@ -112,24 +154,26 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
updated = selectedValues.filter((v) => v !== optCode);
}
onChange(updated.join(','));
onBlur?.(); // trigger validation immediately
};
return (
<div className="space-y-2 border border-border rounded-lg p-3 bg-background/30">
{attribute.optionsList?.map((opt) => {
const isChecked = selectedValues.includes(opt.code);
<div className="space-y-2 border border-border rounded-lg p-3 bg-background/30" onBlur={onBlur}>
{normalizedOptions.map((opt) => {
const isChecked = selectedValues.includes(opt.code) || selectedValues.includes(opt.label);
return (
<label key={opt.id} className="flex items-center gap-2 cursor-pointer text-xs font-medium text-foreground">
<label key={opt.id} className={`flex items-center gap-2 cursor-pointer text-xs font-medium text-foreground ${readOnly ? 'cursor-not-allowed opacity-75' : ''}`}>
<input
type="checkbox"
checked={isChecked}
onChange={(e) => handleCheckboxChange(opt.code, e.target.checked)}
className="w-4 h-4 text-primary rounded border-border focus:ring-primary"
disabled={readOnly}
/>
{opt.label}
</label>
);
})}
{(!attribute.optionsList || attribute.optionsList.length === 0) && (
{normalizedOptions.length === 0 && (
<div className="text-xs text-muted-foreground">No options configured</div>
)}
</div>
@@ -141,8 +185,10 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
placeholder={`Enter ${attribute.name}`}
className={inputClass}
disabled={readOnly}
/>
);
}
@@ -32,6 +32,12 @@ interface DynamicAttributesSectionProps {
errors?: Record<string, any>;
touched?: Record<string, any>;
onAttributeChange: (code: string, value: any) => void;
onAttributeBlur?: (code: string) => void;
onAddAttributeClick?: (group: any) => void;
onRemoveAttribute?: (id: string) => void;
onRemoveGroup?: (id: string) => void;
customAttributeIds?: Set<string>;
readOnly?: boolean;
}
export const DynamicAttributesSection: React.FC<DynamicAttributesSectionProps> = ({
@@ -41,6 +47,12 @@ export const DynamicAttributesSection: React.FC<DynamicAttributesSectionProps> =
errors = {},
touched = {},
onAttributeChange,
onAttributeBlur,
onAddAttributeClick,
onRemoveAttribute,
onRemoveGroup,
customAttributeIds,
readOnly,
}) => {
if (!hasAttributeSet) {
return (
@@ -87,6 +99,12 @@ export const DynamicAttributesSection: React.FC<DynamicAttributesSectionProps> =
errors={errors}
touched={touched}
onAttributeChange={onAttributeChange}
onAttributeBlur={onAttributeBlur}
onAddAttributeClick={onAddAttributeClick}
onRemoveAttribute={onRemoveAttribute}
onRemoveGroup={onRemoveGroup}
customAttributeIds={customAttributeIds}
readOnly={readOnly}
/>
))}
</div>
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { ChevronDown, ChevronUp, Plus, X } from 'lucide-react';
import { DynamicAttributeRenderer } from './DynamicAttributeRenderer';
interface AttributeOption {
@@ -33,6 +33,12 @@ interface ProductAttributeGroupProps {
errors?: Record<string, any>;
touched?: Record<string, any>;
onAttributeChange: (code: string, value: any) => void;
onAttributeBlur?: (code: string) => void;
onAddAttributeClick?: (group: AttributeGroup) => void;
onRemoveAttribute?: (id: string) => void;
onRemoveGroup?: (id: string) => void;
customAttributeIds?: Set<string>;
readOnly?: boolean;
}
export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
@@ -41,6 +47,12 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
errors = {},
touched = {},
onAttributeChange,
onAttributeBlur,
onAddAttributeClick,
onRemoveAttribute,
onRemoveGroup,
customAttributeIds,
readOnly,
}) => {
const [isExpanded, setIsExpanded] = useState(true);
const attributes = group.attributes || [];
@@ -48,7 +60,37 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
if (attributes.length === 0) {
return (
<div className="bg-surface rounded-xl border border-border shadow-xs p-6">
<h3 className="font-semibold text-foreground mb-4">{group.name}</h3>
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-foreground text-xs uppercase tracking-wider">{group.name}</h3>
<div className="flex items-center gap-2">
{!readOnly && onRemoveGroup && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemoveGroup(group.id);
}}
className="text-xs text-red-500 hover:text-red-650 font-semibold px-2 py-1 rounded hover:bg-red-50 transition-colors"
title={`Remove ${group.name} container`}
>
Remove Group
</button>
)}
{!readOnly && onAddAttributeClick && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onAddAttributeClick(group);
}}
className="px-3 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold transition-colors flex items-center justify-center shrink-0 h-[42px] w-[42px]"
title={`Add attribute to ${group.name}`}
>
<Plus className="w-4 h-4" />
</button>
)}
</div>
</div>
<div className="text-xs text-muted-foreground italic">No Attributes available.</div>
</div>
);
@@ -56,27 +98,70 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
return (
<div className="bg-surface rounded-xl border border-border shadow-xs overflow-hidden">
<button
type="button"
<div
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-6 py-4 bg-background/50 border-b border-border/65 hover:bg-background transition-colors"
className="w-full flex items-center justify-between px-6 py-4 bg-background/50 border-b border-border/65 hover:bg-background transition-colors cursor-pointer"
>
<h3 className="font-semibold text-foreground text-xs uppercase tracking-wider">{group.name}</h3>
{isExpanded ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
</button>
<div className="flex items-center gap-3">
{!readOnly && onRemoveGroup && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemoveGroup(group.id);
}}
className="text-xs text-red-500 hover:text-red-650 font-semibold px-2.5 py-1 rounded hover:bg-red-50/70 transition-colors"
title={`Remove ${group.name} container`}
>
Remove Group
</button>
)}
{!readOnly && onAddAttributeClick && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onAddAttributeClick(group);
}}
className="px-3 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold transition-colors flex items-center justify-center shrink-0 h-[42px] w-[42px]"
title={`Add attribute to ${group.name}`}
>
<Plus className="w-4 h-4" />
</button>
)}
{isExpanded ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
</div>
</div>
{isExpanded && (
<div className="p-6 grid grid-cols-2 gap-6">
{attributes.map((attr) => (
<DynamicAttributeRenderer
key={attr.id}
attribute={attr}
value={values[attr.code]}
onChange={(val) => onAttributeChange(attr.code, val)}
error={errors[attr.code]}
touched={touched[attr.code]}
/>
))}
{attributes.map((attr) => {
const isCustom = customAttributeIds?.has(attr.id);
return (
<div key={attr.id} className="relative border border-border/40 rounded-xl p-5 bg-background/15 group hover:border-border/80 transition-all">
{isCustom && !readOnly && onRemoveAttribute && (
<button
type="button"
onClick={() => onRemoveAttribute(attr.id)}
className="absolute top-2 right-2 p-1.5 hover:bg-red-50 text-muted-foreground hover:text-red-500 rounded transition-colors"
title="Remove custom attribute"
>
<X className="w-3.5 h-3.5" />
</button>
)}
<DynamicAttributeRenderer
attribute={attr}
value={values[attr.code]}
onChange={(val) => onAttributeChange(attr.code, val)}
onBlur={() => onAttributeBlur?.(attr.code)}
error={errors[attr.code]}
touched={touched[attr.code]}
readOnly={readOnly}
/>
</div>
);
})}
</div>
)}
</div>
@@ -7,18 +7,29 @@ interface VariantAxesSelectorProps {
onGenerate: (selected: Record<string, string[]>, skuTemplate: string) => void;
generating: boolean;
parentSku: string;
initialSelectedValues?: Record<string, string[]>;
}
export const VariantAxesSelector: React.FC<VariantAxesSelectorProps> = ({
axes,
onGenerate,
generating,
parentSku
parentSku,
initialSelectedValues
}) => {
const [selectedValues, setSelectedValues] = useState<Record<string, string[]>>({});
const [selectedValues, setSelectedValues] = useState<Record<string, string[]>>(initialSelectedValues || {});
const [skuTemplate, setSkuTemplate] = useState('{PARENT_SKU}-{COMBO}');
const [customInputs, setCustomInputs] = useState<Record<string, string>>({});
React.useEffect(() => {
if (initialSelectedValues) {
setSelectedValues(prev => ({
...prev,
...initialSelectedValues
}));
}
}, [initialSelectedValues]);
// Calculate combinations preview
const activeAxes = axes.filter(axis => (selectedValues[axis.code] || []).length > 0);
const totalCombinations = activeAxes.length > 0
@@ -90,20 +101,63 @@ export const VariantAxesSelector: React.FC<VariantAxesSelectorProps> = ({
<div className="p-5 space-y-6">
{axes.map(axis => {
const options = axis.optionsList || [];
let options: any[] = [];
if (Array.isArray(axis.optionsList) && axis.optionsList.length > 0) {
options = axis.optionsList;
} else {
const rawOpts = (axis as any).options || (axis as any).attributeOptions || (axis as any).values || [];
if (Array.isArray(rawOpts)) {
options = rawOpts.map((o: any, idx: number) => {
if (typeof o === 'string') {
return { id: `${axis.code}-${idx}`, code: o, label: o };
}
return {
id: o.id || `${axis.code}-${idx}`,
code: o.code || o.value || String(o.label || ''),
label: o.label || o.value || o.name || o.code
};
});
}
}
const selected = selectedValues[axis.code] || [];
return (
<div key={axis.id} className="space-y-2">
<label className="block text-xs font-bold text-foreground uppercase tracking-wider">
{axis.name} <span className="text-muted-foreground">({axis.code})</span>
</label>
<div className="flex items-center justify-between">
<label className="block text-xs font-bold text-foreground uppercase tracking-wider">
{axis.name} <span className="text-muted-foreground font-mono text-[10px]">({axis.code})</span>
{selected.length > 0 && (
<span className="ml-2 text-[10px] text-primary font-semibold bg-primary/10 px-2 py-0.5 rounded-full border border-primary/20">
{selected.length} selected
</span>
)}
</label>
{options.length > 0 && (
<div className="flex items-center gap-2 text-[11px]">
<button
type="button"
onClick={() => setSelectedValues(prev => ({ ...prev, [axis.code]: options.map(o => o.code) }))}
className="text-primary font-medium hover:underline"
>
Select All
</button>
<span className="text-muted-foreground/40"></span>
<button
type="button"
onClick={() => setSelectedValues(prev => ({ ...prev, [axis.code]: [] }))}
className="text-muted-foreground hover:text-foreground font-medium transition-colors"
>
Clear
</button>
</div>
)}
</div>
{options.length > 0 ? (
// Pre-defined options list checkbox layout
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
{options.map(opt => {
const isChecked = selected.includes(opt.code);
const isChecked = selected.some(sel => sel.toLowerCase().trim() === opt.code.toLowerCase().trim());
return (
<label
key={opt.id}
@@ -1,10 +1,10 @@
import React, { useState } from 'react';
import type { VariantStatus } from '../../types/variant.types';
import { Settings, Check, Trash2, Archive, DollarSign, Package } from 'lucide-react';
import { Settings, Check, Trash2, Archive, DollarSign } from 'lucide-react';
interface VariantBulkActionsProps {
selectedCount: number;
onApplyUpdates: (updates: { price?: number; costPrice?: number; stock?: number; status?: VariantStatus }) => void;
onApplyUpdates: (updates: { price?: number; costPrice?: number; status?: VariantStatus }) => void;
onDeleteSelected: () => void;
onArchiveSelected: () => void;
}
@@ -16,25 +16,22 @@ export const VariantBulkActions: React.FC<VariantBulkActionsProps> = ({
onArchiveSelected
}) => {
const [isOpen, setIsOpen] = useState(false);
const [actionType, setActionType] = useState<'price' | 'stock' | 'status' | null>(null);
const [actionType, setActionType] = useState<'price' | 'status' | null>(null);
// States for bulk inputs
const [bulkPrice, setBulkPrice] = useState('');
const [bulkCostPrice, setBulkCostPrice] = useState('');
const [bulkStock, setBulkStock] = useState('');
const [bulkStatus, setBulkStatus] = useState<VariantStatus>('draft');
if (selectedCount === 0) return null;
const handleApply = (e: React.FormEvent) => {
e.preventDefault();
const updates: { price?: number; costPrice?: number; stock?: number; status?: VariantStatus } = {};
const updates: { price?: number; costPrice?: number; status?: VariantStatus } = {};
if (actionType === 'price') {
if (bulkPrice !== '') updates.price = parseFloat(bulkPrice);
if (bulkCostPrice !== '') updates.costPrice = parseFloat(bulkCostPrice);
} else if (actionType === 'stock') {
if (bulkStock !== '') updates.stock = parseInt(bulkStock, 10);
} else if (actionType === 'status') {
updates.status = bulkStatus;
}
@@ -80,13 +77,6 @@ export const VariantBulkActions: React.FC<VariantBulkActionsProps> = ({
>
<DollarSign className="w-4 h-4 text-muted-foreground" /> Update Price & Cost
</button>
<button
type="button"
onClick={() => setActionType('stock')}
className="flex items-center gap-2 w-full text-left px-3 py-2 hover:bg-background rounded-lg text-xs text-foreground font-medium transition-colors"
>
<Package className="w-4 h-4 text-muted-foreground" /> Update Inventory Stock
</button>
<button
type="button"
onClick={() => setActionType('status')}
@@ -124,19 +114,6 @@ export const VariantBulkActions: React.FC<VariantBulkActionsProps> = ({
</div>
)}
{actionType === 'stock' && (
<div>
<label className="block text-[10px] font-bold text-muted-foreground uppercase">Stock Level</label>
<input
type="number"
placeholder="Enter inventory quantity"
value={bulkStock}
onChange={(e) => setBulkStock(e.target.value)}
className="w-full text-xs border border-border rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary bg-surface"
/>
</div>
)}
{actionType === 'status' && (
<div>
<label className="block text-[10px] font-bold text-muted-foreground uppercase">Lifecycle Status</label>
@@ -0,0 +1,373 @@
import React, { useState, useEffect } from 'react';
import type { Variant, VariantStatus } from '../../types/variant.types';
import {
X, Save, Archive, Trash2, Image as ImageIcon,
Package, Tag, DollarSign, CheckCircle, AlertCircle, Loader2,
ShoppingBag, Hash
} from 'lucide-react';
interface VariantDetailModalProps {
variant: Variant | null;
onClose: () => void;
onUpdate: (id: string, updates: Partial<Variant>) => Promise<any>;
onDelete?: (id: string) => void;
onArchive?: (id: string) => void;
readOnly?: boolean;
}
export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
variant,
onClose,
onUpdate,
onDelete,
onArchive,
readOnly = false
}) => {
const [sku, setSku] = useState('');
const [price, setPrice] = useState('');
const [costPrice, setCostPrice] = useState('');
const [stock, setStock] = useState('');
const [status, setStatus] = useState<VariantStatus>('draft');
const [activeImageIdx, setActiveImageIdx] = useState(0);
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
useEffect(() => {
if (variant) {
setSku(variant.sku || '');
setPrice(String(variant.price ?? ''));
setCostPrice(String(variant.costPrice ?? ''));
setStock(String(variant.stock ?? ''));
setStatus(variant.status || 'draft');
setActiveImageIdx(0);
setSaveState('idle');
}
}, [variant]);
if (!variant) return null;
const images = variant.images || [];
const primaryImage = images.find(i => i.isPrimary) || images[0];
const activeImage = images[activeImageIdx] || primaryImage;
const axisEntries = Object.entries(variant.attributes || {});
const handleSave = async () => {
const pNum = parseFloat(price);
const cpNum = parseFloat(costPrice);
const sNum = parseInt(stock, 10);
const hasChanges =
sku !== variant.sku ||
pNum !== variant.price ||
cpNum !== variant.costPrice ||
sNum !== variant.stock ||
status !== variant.status;
if (!hasChanges) return;
setSaveState('saving');
try {
await onUpdate(variant.id, {
sku,
price: isNaN(pNum) ? 0 : pNum,
costPrice: isNaN(cpNum) ? 0 : cpNum,
stock: isNaN(sNum) ? 0 : sNum,
status
});
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 2000);
} catch {
setSaveState('error');
setTimeout(() => setSaveState('idle'), 3000);
}
};
const statusColor: Record<VariantStatus, string> = {
active: 'bg-emerald-100 text-emerald-700 border-emerald-200',
draft: 'bg-amber-100 text-amber-700 border-amber-200',
inactive: 'bg-slate-100 text-slate-600 border-slate-200',
archived: 'bg-red-50 text-red-600 border-red-200'
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-150">
<div className="bg-surface border border-border rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200">
{/* ── Header ── */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Package className="w-4 h-4 text-primary" />
</div>
<div>
<h2 className="font-bold text-foreground text-sm leading-tight">
{variant.name || 'Variant Details'}
</h2>
<p className="text-[11px] text-muted-foreground font-mono mt-0.5">{variant.sku}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-bold uppercase border ${statusColor[variant.status] || statusColor.draft}`}>
{variant.status}
</span>
<button
type="button"
onClick={onClose}
className="p-1.5 hover:bg-background rounded-lg text-muted-foreground transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* ── Body ── */}
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-5 gap-0 h-full">
{/* Left: Image Gallery */}
<div className="col-span-2 border-r border-border p-5 flex flex-col gap-4 bg-background/50">
{/* Main image */}
<div className="aspect-square rounded-xl border border-border overflow-hidden bg-surface flex items-center justify-center">
{activeImage?.url || activeImage?.thumbnailUrl ? (
<img
src={activeImage.thumbnailUrl || activeImage.url!}
alt={activeImage.name || variant.name}
className="w-full h-full object-cover"
/>
) : (
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
<ImageIcon className="w-10 h-10 opacity-30" />
<span className="text-[11px] font-medium">No image</span>
</div>
)}
</div>
{/* Thumbnail strip */}
{images.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-1">
{images.map((img, idx) => (
<button
key={idx}
type="button"
onClick={() => setActiveImageIdx(idx)}
className={`flex-shrink-0 w-12 h-12 rounded-lg border-2 overflow-hidden transition-all ${idx === activeImageIdx ? 'border-primary' : 'border-border hover:border-primary/40'}`}
>
{img.url || img.thumbnailUrl ? (
<img src={img.thumbnailUrl || img.url!} alt={img.name || `Image ${idx + 1}`} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-surface-muted flex items-center justify-center">
<ImageIcon className="w-3 h-3 text-muted-foreground" />
</div>
)}
</button>
))}
</div>
)}
{/* Axis pills */}
{axisEntries.length > 0 && (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Variant Axes</p>
<div className="flex flex-wrap gap-1.5">
{axisEntries.map(([key, val]) => (
<span
key={key}
className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/8 border border-primary/20 rounded-full text-[11px] font-semibold text-primary"
>
<Tag className="w-2.5 h-2.5" />
<span className="text-muted-foreground capitalize">{key}:</span>
<span>{val}</span>
</span>
))}
</div>
</div>
)}
{images.length > 0 && (
<p className="text-[10px] text-muted-foreground text-center">
{images.length} asset{images.length !== 1 ? 's' : ''} uploaded
</p>
)}
</div>
{/* Right: Edit Fields */}
<div className="col-span-3 p-6 space-y-5">
{/* SKU */}
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
<Hash className="inline w-3 h-3 mr-1" />SKU Code
</label>
{readOnly ? (
<p className="font-mono text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">{sku || '—'}</p>
) : (
<input
type="text"
value={sku}
onChange={e => setSku(e.target.value)}
placeholder="e.g. PROD-RED-M"
className="w-full border border-border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
/>
)}
</div>
{/* Price & Cost */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
<DollarSign className="inline w-3 h-3 mr-1" />Sale Price
</label>
{readOnly ? (
<p className="text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">${price}</p>
) : (
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm font-semibold">$</span>
<input
type="number"
step="0.01"
min="0"
value={price}
onChange={e => setPrice(e.target.value)}
className="w-full border border-border rounded-lg pl-7 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
/>
</div>
)}
</div>
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
<DollarSign className="inline w-3 h-3 mr-1" />Cost Price
</label>
{readOnly ? (
<p className="text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">${costPrice}</p>
) : (
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm font-semibold">$</span>
<input
type="number"
step="0.01"
min="0"
value={costPrice}
onChange={e => setCostPrice(e.target.value)}
className="w-full border border-border rounded-lg pl-7 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
/>
</div>
)}
</div>
</div>
{/* Stock & Status */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
<ShoppingBag className="inline w-3 h-3 mr-1" />Stock
</label>
{readOnly ? (
<p className="text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">{stock}</p>
) : (
<input
type="number"
min="0"
value={stock}
onChange={e => setStock(e.target.value)}
className="w-full border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
/>
)}
</div>
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">Status</label>
{readOnly ? (
<p className={`inline-flex items-center px-2.5 py-1.5 rounded-lg text-xs font-bold uppercase border ${statusColor[status]}`}>{status}</p>
) : (
<select
value={status}
onChange={e => setStatus(e.target.value as VariantStatus)}
className="w-full border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground font-medium"
>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="archived">Archived</option>
</select>
)}
</div>
</div>
{/* Stats row */}
<div className="grid grid-cols-3 gap-3">
{[
{ label: 'Available Stock', value: variant.availableStock ?? 0 },
{ label: 'Reserved', value: variant.reservedStock ?? 0 },
{ label: 'Safety Stock', value: variant.safetyStock ?? 0 },
].map(({ label, value }) => (
<div key={label} className="bg-surface-muted border border-border rounded-lg p-3 text-center">
<div className="text-lg font-bold text-foreground">{value}</div>
<div className="text-[10px] text-muted-foreground font-medium mt-0.5">{label}</div>
</div>
))}
</div>
{/* Last updated */}
{variant.lastUpdated && (
<p className="text-[10px] text-muted-foreground">
Last updated: {new Date(variant.lastUpdated).toLocaleString()}
</p>
)}
</div>
</div>
</div>
{/* ── Footer ── */}
<div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0 bg-background/50">
{/* Danger actions */}
<div className="flex items-center gap-2">
{!readOnly && onArchive && (
<button
type="button"
onClick={() => { onArchive(variant.id); onClose(); }}
className="flex items-center gap-1.5 px-3 py-1.5 border border-amber-200 text-amber-700 bg-amber-50 hover:bg-amber-100 rounded-lg text-xs font-semibold transition-colors"
>
<Archive className="w-3.5 h-3.5" />
Archive
</button>
)}
{!readOnly && onDelete && (
<button
type="button"
onClick={() => { if (window.confirm('Delete this variant?')) { onDelete(variant.id); onClose(); } }}
className="flex items-center gap-1.5 px-3 py-1.5 border border-red-200 text-red-600 bg-red-50 hover:bg-red-100 rounded-lg text-xs font-semibold transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
Delete
</button>
)}
</div>
{/* Primary actions */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 border border-border text-muted-foreground hover:bg-background rounded-lg text-sm font-medium transition-colors"
>
{readOnly ? 'Close' : 'Cancel'}
</button>
{!readOnly && (
<button
type="button"
onClick={handleSave}
disabled={saveState === 'saving'}
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold transition-colors disabled:opacity-60"
>
{saveState === 'saving' && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{saveState === 'saved' && <CheckCircle className="w-3.5 h-3.5 text-white" />}
{saveState === 'error' && <AlertCircle className="w-3.5 h-3.5 text-white" />}
{saveState === 'idle' && <Save className="w-3.5 h-3.5" />}
{saveState === 'saving' ? 'Saving…' : saveState === 'saved' ? 'Saved!' : saveState === 'error' ? 'Error' : 'Save Changes'}
</button>
)}
</div>
</div>
</div>
</div>
);
};
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import type { Variant, VariantStatus } from '../../types/variant.types';
import { Trash2, Archive, Loader, Check, CircleAlert } from 'lucide-react';
import { Trash2, Archive, Loader, Check, CircleAlert, Image as ImageIcon, Eye } from 'lucide-react';
interface VariantEditorRowProps {
variant: Variant;
@@ -10,6 +10,8 @@ interface VariantEditorRowProps {
onUpdate: (id: string, updates: Partial<Variant>) => Promise<any>;
onDelete: (id: string) => void;
onArchive: (id: string) => void;
onViewDetail?: () => void;
readOnly?: boolean;
}
export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
@@ -19,7 +21,9 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
onSelect,
onUpdate,
onDelete,
onArchive
onArchive,
onViewDetail,
readOnly
}) => {
const [sku, setSku] = useState(variant.sku);
const [price, setPrice] = useState(String(variant.price));
@@ -29,7 +33,6 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
const [savingStatus, setSavingStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
// Synchronize state if variant updates externally
useEffect(() => {
setSku(variant.sku);
setPrice(String(variant.price));
@@ -39,7 +42,6 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
}, [variant]);
const handleFieldSave = async () => {
// Basic validation
const pNum = parseFloat(price);
const cpNum = parseFloat(costPrice);
const sNum = parseInt(stock, 10);
@@ -77,6 +79,57 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
}
};
const images = variant.images || [];
const primaryImg = images.find(i => i.isPrimary) || images[0];
const thumbUrl = primaryImg?.thumbnailUrl || primaryImg?.url;
// ── Read-only row ──────────────────────────────────────────────────────────
if (readOnly) {
return (
<tr className="hover:bg-background/50 transition-colors border-b border-border">
<td className="px-3 py-2 text-center">
{thumbUrl ? (
<img src={thumbUrl} alt={variant.name} className="w-8 h-8 object-cover rounded border border-border mx-auto" />
) : (
<div className="w-8 h-8 rounded border border-border bg-surface-muted flex items-center justify-center mx-auto text-muted-foreground">
<ImageIcon className="w-3.5 h-3.5" />
</div>
)}
</td>
<td className="px-4 py-3 font-mono text-xs text-foreground">{sku}</td>
<td className="px-4 py-3">
<div className="flex flex-col">
<span className="font-semibold text-foreground text-xs truncate max-w-[200px]" title={variant.name || ''}>
{(variant.name || '').split(' - ')[1] || variant.name || variant.sku}
</span>
<div className="flex flex-wrap gap-1 mt-1">
{axesKeys.map(key => {
const val = variant.attributes[key];
if (!val) return null;
return (
<span key={key} className="inline-block bg-surface-muted text-[10px] px-1.5 py-0.5 rounded text-muted-foreground font-mono">
{key}: {val}
</span>
);
})}
</div>
</div>
</td>
<td className="px-4 py-3 text-right text-xs text-foreground">${price}</td>
<td className="px-4 py-3 text-right text-xs text-foreground">${costPrice}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider ${
status === 'active' ? 'bg-success/10 text-success' :
status === 'draft' ? 'bg-warning/10 text-warning' : 'bg-surface-muted text-muted-foreground'
}`}>
{status}
</span>
</td>
</tr>
);
}
// ── Editable row ───────────────────────────────────────────────────────────
return (
<tr className={`hover:bg-background/50 transition-colors border-b border-border ${isSelected ? 'bg-primary/5/10' : ''}`}>
{/* Checkbox */}
@@ -89,27 +142,27 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
/>
</td>
{/* Cartesian Combination specifications */}
<td className="px-4 py-3">
<div className="flex flex-col">
<span className="font-semibold text-foreground text-xs truncate max-w-[200px]" title={variant.name}>
{variant.name.split(' - ')[1] || variant.name}
</span>
<div className="flex flex-wrap gap-1 mt-1">
{axesKeys.map(key => {
const val = variant.attributes[key];
if (!val) return null;
return (
<span
key={key}
className="inline-block bg-surface-muted text-[10px] px-1.5 py-0.5 rounded text-muted-foreground font-mono"
>
{key}: {val}
</span>
);
})}
</div>
</div>
{/* Image Thumbnail */}
<td className="px-3 py-2 text-center">
{onViewDetail ? (
<button type="button" onClick={onViewDetail} className="group relative block mx-auto focus:outline-none">
{thumbUrl ? (
<img src={thumbUrl} alt={variant.name} className="w-9 h-9 object-cover rounded-lg border border-border transition-all group-hover:border-primary" />
) : (
<div className="w-9 h-9 rounded-lg border border-border bg-surface-muted flex items-center justify-center text-muted-foreground transition-all group-hover:border-primary">
<ImageIcon className="w-4 h-4" />
</div>
)}
</button>
) : (
thumbUrl ? (
<img src={thumbUrl} alt={variant.name} className="w-9 h-9 object-cover rounded-lg border border-border mx-auto" />
) : (
<div className="w-9 h-9 rounded-lg border border-border bg-surface-muted flex items-center justify-center mx-auto text-muted-foreground">
<ImageIcon className="w-4 h-4" />
</div>
)
)}
</td>
{/* SKU Input */}
@@ -124,6 +177,26 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
/>
</td>
{/* Variant Specification */}
<td className="px-4 py-3">
<div className="flex flex-col">
<span className="font-semibold text-foreground text-xs truncate max-w-[200px]" title={variant.name || ''}>
{(variant.name || '').split(' - ')[1] || variant.name || variant.sku}
</span>
<div className="flex flex-wrap gap-1 mt-1">
{axesKeys.map(key => {
const val = variant.attributes[key];
if (!val) return null;
return (
<span key={key} className="inline-block bg-surface-muted text-[10px] px-1.5 py-0.5 rounded text-muted-foreground font-mono">
{key}: {val}
</span>
);
})}
</div>
</div>
</td>
{/* Price Input */}
<td className="px-4 py-3">
<div className="relative">
@@ -156,18 +229,6 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
</div>
</td>
{/* Inventory Stock Input */}
<td className="px-4 py-3">
<input
type="number"
value={stock}
onChange={(e) => setStock(e.target.value)}
onBlur={handleFieldSave}
onKeyDown={handleKeyDown}
className="w-full text-xs border border-border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-center"
/>
</td>
{/* Status dropdown */}
<td className="px-4 py-3">
<select
@@ -183,7 +244,7 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
</select>
</td>
{/* Save Status Spinner/Tick */}
{/* Save Status */}
<td className="px-4 py-3 text-center">
{savingStatus === 'saving' && <Loader className="w-3.5 h-3.5 text-primary animate-spin mx-auto" />}
{savingStatus === 'saved' && <Check className="w-3.5 h-3.5 text-emerald-500 mx-auto" />}
@@ -193,6 +254,16 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
{/* Row Actions */}
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-1.5">
{onViewDetail && (
<button
type="button"
onClick={onViewDetail}
title="View & Edit Details"
className="p-1 hover:bg-primary/10 text-muted-foreground hover:text-primary rounded transition-colors"
>
<Eye className="w-3.5 h-3.5" />
</button>
)}
<button
type="button"
onClick={() => onArchive(variant.id)}
@@ -1,6 +1,7 @@
import React from 'react';
import React, { useState } from 'react';
import type { Variant } from '../../types/variant.types';
import { VariantEditorRow } from './VariantEditorRow';
import { VariantDetailModal } from './VariantDetailModal';
interface VariantListViewProps {
variants: Variant[];
@@ -11,6 +12,7 @@ interface VariantListViewProps {
onUpdate: (id: string, updates: Partial<Variant>) => Promise<any>;
onDelete: (id: string) => void;
onArchive: (id: string) => void;
readOnly?: boolean;
}
export const VariantListView: React.FC<VariantListViewProps> = ({
@@ -21,59 +23,83 @@ export const VariantListView: React.FC<VariantListViewProps> = ({
onSelectAllChange,
onUpdate,
onDelete,
onArchive
onArchive,
readOnly
}) => {
const [modalVariant, setModalVariant] = useState<Variant | null>(null);
const allSelected = variants.length > 0 && variants.every(v => selectedIds.has(v.id));
const someSelected = variants.length > 0 && variants.some(v => selectedIds.has(v.id)) && !allSelected;
return (
<div className="overflow-x-auto border border-border rounded-xl bg-surface shadow-xs">
<table className="w-full border-collapse text-left min-w-[800px]">
<thead>
<tr className="bg-background/70 border-b border-border text-[10px] font-bold text-muted-foreground uppercase tracking-wider">
<th className="px-4 py-3 text-center w-12">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={(e) => onSelectAllChange(e.target.checked)}
className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
/>
</th>
<th className="px-4 py-3">Variant Specification</th>
<th className="px-4 py-3 w-48">SKU Code</th>
<th className="px-4 py-3 w-28 text-right">Sale Price</th>
<th className="px-4 py-3 w-28 text-right">Cost Price</th>
<th className="px-4 py-3 w-24 text-center">Stock</th>
<th className="px-4 py-3 w-32">Status</th>
<th className="px-4 py-3 w-16 text-center">Save</th>
<th className="px-4 py-3 w-24 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{variants.map(variant => (
<VariantEditorRow
key={variant.id}
variant={variant}
axesKeys={axesKeys}
isSelected={selectedIds.has(variant.id)}
onSelect={(checked) => onSelectChange(variant.id, checked)}
onUpdate={onUpdate}
onDelete={onDelete}
onArchive={onArchive}
/>
))}
{variants.length === 0 && (
<tr>
<td colSpan={9} className="px-6 py-10 text-center text-xs text-muted-foreground font-medium">
No variants found matching criteria.
</td>
<>
<div className="overflow-x-auto border border-border rounded-xl bg-surface shadow-xs">
<table className="w-full border-collapse text-left min-w-[800px]">
<thead>
<tr className="bg-background/70 border-b border-border text-[10px] font-bold text-muted-foreground uppercase tracking-wider">
{!readOnly && (
<th className="px-4 py-3 text-center w-10">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={(e) => onSelectAllChange(e.target.checked)}
className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
/>
</th>
)}
<th className="px-4 py-3 w-14">Image</th>
<th className="px-4 py-3 w-44">SKU Code</th>
<th className="px-4 py-3">Variant Specification</th>
<th className="px-4 py-3 w-28 text-right">Sale Price</th>
<th className="px-4 py-3 w-28 text-right">Cost Price</th>
<th className="px-4 py-3 w-28">Status</th>
{!readOnly && <th className="px-4 py-3 w-14 text-center">Save</th>}
{!readOnly && <th className="px-4 py-3 w-28 text-right">Actions</th>}
</tr>
)}
</tbody>
</table>
</div>
</thead>
<tbody className="divide-y divide-border">
{variants.map(variant => (
<VariantEditorRow
key={variant.id}
variant={variant}
axesKeys={axesKeys}
isSelected={selectedIds.has(variant.id)}
onSelect={(checked) => onSelectChange(variant.id, checked)}
onUpdate={onUpdate}
onDelete={onDelete}
onArchive={onArchive}
onViewDetail={() => setModalVariant(variant)}
readOnly={readOnly}
/>
))}
{variants.length === 0 && (
<tr>
<td colSpan={readOnly ? 6 : 9} className="px-6 py-10 text-center text-xs text-muted-foreground font-medium">
No variants found matching criteria.
</td>
</tr>
)}
</tbody>
</table>
</div>
{modalVariant && (
<VariantDetailModal
variant={modalVariant}
onClose={() => setModalVariant(null)}
onUpdate={async (id, updates) => {
const updated = await onUpdate(id, updates);
if (updated) setModalVariant(prev => prev ? { ...prev, ...updates } : null);
return updated;
}}
onDelete={id => { onDelete(id); setModalVariant(null); }}
onArchive={id => { onArchive(id); setModalVariant(null); }}
readOnly={readOnly}
/>
)}
</>
);
};
@@ -1,5 +1,7 @@
import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import type { Variant } from '../../types/variant.types';
import { VariantDetailModal } from './VariantDetailModal';
import { Image as ImageIcon, Tag, Edit2, Trash2, Archive, CheckSquare, Square } from 'lucide-react';
interface VariantMatrixViewProps {
variants: Variant[];
@@ -11,262 +13,233 @@ interface VariantMatrixViewProps {
onUpdate: (id: string, updates: Partial<Variant>) => Promise<any>;
onDelete: (id: string) => void;
onArchive: (id: string) => void;
readOnly?: boolean;
}
export const VariantMatrixView: React.FC<VariantMatrixViewProps> = ({
variants,
axesKeys,
axesNames,
axesKeys: _axesKeys,
axesNames: _axesNames,
selectedIds,
onSelectChange,
onSelectAllChange,
onUpdate,
onDelete,
onArchive
onArchive,
readOnly
}) => {
const [modalVariant, setModalVariant] = useState<Variant | null>(null);
const allSelected = variants.length > 0 && variants.every(v => selectedIds.has(v.id));
const someSelected = variants.length > 0 && variants.some(v => selectedIds.has(v.id)) && !allSelected;
return (
<div className="overflow-x-auto border border-border rounded-xl bg-surface shadow-xs">
<table className="w-full border-collapse text-left min-w-[900px]">
<thead>
<tr className="bg-primary/5/30 border-b border-border text-[10px] font-bold text-muted-foreground uppercase tracking-wider">
<th className="px-4 py-3.5 text-center w-12">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={(e) => onSelectAllChange(e.target.checked)}
className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
/>
</th>
{/* Dynamic columns for each variant axis */}
{axesKeys.map(key => (
<th key={key} className="px-4 py-3.5 font-bold">
{axesNames[key] || key}
</th>
))}
<th className="px-4 py-3.5 w-48">SKU Code</th>
<th className="px-4 py-3.5 w-28 text-right">Sale Price</th>
<th className="px-4 py-3.5 w-28 text-right">Cost Price</th>
<th className="px-4 py-3.5 w-24 text-center">Stock</th>
<th className="px-4 py-3.5 w-32">Status</th>
<th className="px-4 py-3.5 w-16 text-center">Save</th>
<th className="px-4 py-3.5 w-24 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{variants.map(variant => (
<tr
key={variant.id}
className={`hover:bg-background/50 transition-colors border-b border-border ${
selectedIds.has(variant.id) ? 'bg-primary/5/10' : ''
}`}
>
{/* Checkbox */}
<td className="px-4 py-3 text-center">
<input
type="checkbox"
checked={selectedIds.has(variant.id)}
onChange={(e) => onSelectChange(variant.id, e.target.checked)}
className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
/>
</td>
{/* Dynamic cells for each variant axis */}
{axesKeys.map(key => {
const val = variant.attributes[key];
return (
<td key={key} className="px-4 py-3">
<span className="inline-block bg-primary-light text-primary-dark font-semibold text-xs px-2 py-0.5 rounded-full font-mono">
{val || '—'}
</span>
</td>
);
})}
{/* Delegate fields to VariantEditorRow columns via inline styles or matching markup */}
{/* Note: Instead of nesting a complete table inside a tr, we just render the editor cells directly in the matrix tr. */}
{/* To make it extremely clean and reuse the state, we can let VariantEditorRow handle the cells but structure it to match. */}
{/* But since VariantEditorRow expects specific column layouts, we can render the matching tds right here in VariantMatrixView or adapt it. */}
{/* Adapting: Since a tr cannot easily contain another tr, let's render the editor cells inline here for the matrix view. */}
<InlineEditorCells
variant={variant}
onUpdate={onUpdate}
onDelete={onDelete}
onArchive={onArchive}
/>
</tr>
))}
{variants.length === 0 && (
<tr>
<td colSpan={axesKeys.length + 8} className="px-6 py-10 text-center text-xs text-muted-foreground font-medium">
No variants found matching criteria.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
};
// ── Inline Editor Cells Helper ────────────────────────────────────────────────
interface InlineCellsProps {
variant: Variant;
onUpdate: (id: string, updates: Partial<Variant>) => Promise<any>;
onDelete: (id: string) => void;
onArchive: (id: string) => void;
}
const InlineEditorCells: React.FC<InlineCellsProps> = ({ variant, onUpdate, onDelete, onArchive }) => {
const [sku, setSku] = useState(variant.sku);
const [price, setPrice] = useState(String(variant.price));
const [costPrice, setCostPrice] = useState(String(variant.costPrice));
const [stock, setStock] = useState(String(variant.stock));
const [status, setStatus] = useState(variant.status);
const [savingStatus, setSavingStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
useEffect(() => {
setSku(variant.sku);
setPrice(String(variant.price));
setCostPrice(String(variant.costPrice));
setStock(String(variant.stock));
setStatus(variant.status);
}, [variant]);
const handleSave = async () => {
const pNum = parseFloat(price);
const cpNum = parseFloat(costPrice);
const sNum = parseInt(stock, 10);
const hasChanges =
sku !== variant.sku ||
pNum !== variant.price ||
cpNum !== variant.costPrice ||
sNum !== variant.stock ||
status !== variant.status;
if (!hasChanges) return;
setSavingStatus('saving');
try {
await onUpdate(variant.id, {
sku,
price: isNaN(pNum) ? 0 : pNum,
costPrice: isNaN(cpNum) ? 0 : cpNum,
stock: isNaN(sNum) ? 0 : sNum,
status
});
setSavingStatus('saved');
setTimeout(() => setSavingStatus('idle'), 1500);
} catch (err) {
setSavingStatus('error');
setTimeout(() => setSavingStatus('idle'), 3000);
const statusStyle = (s: string) => {
switch (s) {
case 'active': return 'bg-emerald-100 text-emerald-700 border-emerald-200';
case 'draft': return 'bg-amber-100 text-amberald-700 border-amber-200';
case 'inactive': return 'bg-slate-100 text-slate-500 border-slate-200';
case 'archived': return 'bg-red-50 text-red-500 border-red-200';
default: return 'bg-surface-muted text-muted-foreground border-border';
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
(e.target as HTMLElement).blur();
}
};
if (variants.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 bg-surface border border-dashed border-border rounded-xl text-muted-foreground gap-3">
<ImageIcon className="w-10 h-10 opacity-20" />
<p className="text-sm font-medium">No variants found</p>
</div>
);
}
return (
<>
<td className="px-4 py-3">
<input
type="text"
value={sku}
onChange={(e) => setSku(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className="w-full text-xs border border-border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary font-mono bg-surface"
/>
</td>
<td className="px-4 py-3">
<div className="relative">
<span className="absolute left-1.5 top-1/2 -translate-y-1/2 text-muted-foreground text-[10px] font-semibold">$</span>
<input
type="number"
step="0.01"
value={price}
onChange={(e) => setPrice(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className="w-full text-xs border border-border rounded pl-4 pr-1 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-right"
/>
</div>
</td>
<td className="px-4 py-3">
<div className="relative">
<span className="absolute left-1.5 top-1/2 -translate-y-1/2 text-muted-foreground text-[10px] font-semibold">$</span>
<input
type="number"
step="0.01"
value={costPrice}
onChange={(e) => setCostPrice(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className="w-full text-xs border border-border rounded pl-4 pr-1 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-right"
/>
</div>
</td>
<td className="px-4 py-3">
<input
type="number"
value={stock}
onChange={(e) => setStock(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className="w-full text-xs border border-border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-center"
/>
</td>
<td className="px-4 py-3">
<select
value={status}
onChange={(e) => setStatus(e.target.value as any)}
onBlur={handleSave}
className="text-xs border border-border rounded px-1.5 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface font-medium text-foreground"
>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="archived">Archived</option>
</select>
</td>
<td className="px-4 py-3 text-center">
{savingStatus === 'saving' && <span className="inline-block w-3.5 h-3.5 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto" />}
{savingStatus === 'saved' && <span className="text-emerald-500 font-bold text-xs"></span>}
{savingStatus === 'error' && <span className="text-red-500 font-bold text-xs"></span>}
</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-1.5">
{/* Select-all toolbar */}
{!readOnly && (
<div className="flex items-center gap-3 mb-3 px-1">
<button
type="button"
onClick={() => onArchive(variant.id)}
title="Archive variant"
className="p-1 hover:bg-amber-50 text-muted-foreground hover:text-amber-600 rounded transition-colors"
onClick={() => onSelectAllChange(!allSelected)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground font-medium transition-colors"
>
<span className="text-[11px]">Archive</span>
</button>
<button
type="button"
onClick={() => onDelete(variant.id)}
title="Delete variant"
className="p-1 hover:bg-red-50 text-muted-foreground hover:text-red-500 rounded transition-colors"
>
<span className="text-[11px]">Delete</span>
{allSelected ? (
<CheckSquare className="w-3.5 h-3.5 text-primary" />
) : someSelected ? (
<CheckSquare className="w-3.5 h-3.5 text-muted-foreground" />
) : (
<Square className="w-3.5 h-3.5" />
)}
{allSelected ? 'Deselect All' : 'Select All'}
</button>
{selectedIds.size > 0 && (
<span className="text-xs font-semibold text-primary bg-primary/10 border border-primary/20 px-2 py-0.5 rounded-full">
{selectedIds.size} selected
</span>
)}
</div>
</td>
)}
{/* Card grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{variants.map(variant => {
const images = variant.images || [];
const primaryImg = images.find(i => i.isPrimary) || images[0];
const thumbUrl = primaryImg?.thumbnailUrl || primaryImg?.url;
const isSelected = selectedIds.has(variant.id);
const axisEntries = Object.entries(variant.attributes || {});
return (
<div
key={variant.id}
className={`group relative flex flex-col bg-surface border rounded-2xl overflow-hidden shadow-xs transition-all duration-200 hover:shadow-md hover:-translate-y-0.5 ${
isSelected
? 'border-primary ring-2 ring-primary/20'
: 'border-border hover:border-primary/30'
}`}
>
{/* Selection checkbox overlay */}
{!readOnly && (
<div className="absolute top-2.5 left-2.5 z-10">
<button
type="button"
onClick={e => { e.stopPropagation(); onSelectChange(variant.id, !isSelected); }}
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
isSelected
? 'bg-primary border-primary'
: 'bg-white/80 border-border opacity-0 group-hover:opacity-100'
}`}
>
{isSelected && (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 12 12">
<path d="M10 3L5 8.5 2 5.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
</svg>
)}
</button>
</div>
)}
{/* Status badge */}
<div className="absolute top-2.5 right-2.5 z-10">
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold uppercase border ${statusStyle(variant.status)}`}>
{variant.status}
</span>
</div>
{/* Image area */}
<button
type="button"
onClick={() => setModalVariant(variant)}
className="relative w-full aspect-square bg-background overflow-hidden focus:outline-none"
>
{thumbUrl ? (
<img
src={thumbUrl}
alt={variant.name}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center gap-1.5 text-muted-foreground/40">
<ImageIcon className="w-8 h-8" />
<span className="text-[10px] font-medium">No image</span>
</div>
)}
{/* Image count badge */}
{images.length > 1 && (
<div className="absolute bottom-2 right-2 bg-black/60 text-white text-[10px] font-semibold px-1.5 py-0.5 rounded-md backdrop-blur-sm">
+{images.length - 1}
</div>
)}
{/* Hover overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-200 flex items-center justify-center">
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-white/90 backdrop-blur-sm rounded-full p-2 shadow-lg">
<Edit2 className="w-4 h-4 text-foreground" />
</div>
</div>
</button>
{/* Card body */}
<div className="p-3 flex flex-col gap-2 flex-1">
{/* Axis pills */}
<div className="flex flex-wrap gap-1">
{axisEntries.map(([key, val]) => (
<span
key={key}
className="inline-flex items-center gap-0.5 px-2 py-0.5 bg-primary/8 border border-primary/15 rounded-full text-[10px] font-semibold text-primary"
>
<Tag className="w-2.5 h-2.5 opacity-60" />
{val}
</span>
))}
{axisEntries.length === 0 && (
<span className="text-[10px] text-muted-foreground">No axes</span>
)}
</div>
{/* SKU */}
<div className="font-mono text-[10px] text-muted-foreground truncate" title={variant.sku}>
{variant.sku || '—'}
</div>
{/* Price row */}
<div className="flex items-center justify-between mt-auto pt-1 border-t border-border">
<span className="text-sm font-bold text-foreground">
{variant.price > 0 ? `$${variant.price.toFixed(2)}` : <span className="text-muted-foreground text-xs">No price</span>}
</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{!readOnly && (
<>
<button
type="button"
onClick={e => { e.stopPropagation(); setModalVariant(variant); }}
title="Edit"
className="p-1 hover:bg-primary/10 text-muted-foreground hover:text-primary rounded transition-colors"
>
<Edit2 className="w-3 h-3" />
</button>
<button
type="button"
onClick={e => { e.stopPropagation(); onArchive(variant.id); }}
title="Archive"
className="p-1 hover:bg-amber-50 text-muted-foreground hover:text-amber-600 rounded transition-colors"
>
<Archive className="w-3 h-3" />
</button>
<button
type="button"
onClick={e => { e.stopPropagation(); if (window.confirm('Delete variant?')) onDelete(variant.id); }}
title="Delete"
className="p-1 hover:bg-red-50 text-muted-foreground hover:text-red-500 rounded transition-colors"
>
<Trash2 className="w-3 h-3" />
</button>
</>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
{/* Variant Detail Modal */}
{modalVariant && (
<VariantDetailModal
variant={modalVariant}
onClose={() => setModalVariant(null)}
onUpdate={async (id, updates) => {
const updated = await onUpdate(id, updates);
// Reflect updated data in the modal
if (updated) setModalVariant(prev => prev ? { ...prev, ...updates } : null);
return updated;
}}
onDelete={onDelete ? id => { onDelete(id); setModalVariant(null); } : undefined}
onArchive={onArchive ? id => { onArchive(id); setModalVariant(null); } : undefined}
readOnly={readOnly}
/>
)}
</>
);
};
@@ -5,24 +5,36 @@ import { VariantListView } from './VariantListView';
import { VariantMatrixView } from './VariantMatrixView';
import { VariantBulkActions } from './VariantBulkActions';
import type { VariantAxis, VariantStatus, Variant } from '../../types/variant.types';
import { Info, LayoutGrid, List, Plus, RefreshCw, Layers } from 'lucide-react';
import { Info, LayoutGrid, List, Plus, RefreshCw, Layers, X } from 'lucide-react';
import { Loader } from '../../../../components/customs/Loader';
import { notify } from '../../../../services/toast';
interface VariantsTabProps {
productId?: string;
productType: string;
parentSku: string;
family: any; // Product Family details
readOnly?: boolean;
productAttributes?: Record<string, any>;
availableAttributes?: any[];
onVariantsChange?: (variants: Variant[]) => void;
initialVariants?: Variant[];
}
export const VariantsTab: React.FC<VariantsTabProps> = ({
productId,
productType,
parentSku,
family
family,
readOnly,
productAttributes = {},
availableAttributes = [],
onVariantsChange,
initialVariants = []
}) => {
const {
variants,
setVariants,
loading,
generating,
fetchByProduct,
@@ -31,12 +43,37 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
archiveVariant,
generateBatch,
bulkUpdate
} = useVariant();
} = useVariant(initialVariants);
// Sync to parent when variants change
useEffect(() => {
if (onVariantsChange) {
onVariantsChange(variants);
}
}, [variants, onVariantsChange]);
const [viewLayout, setViewLayout] = useState<'list' | 'matrix'>('matrix');
const [showGenerator, setShowGenerator] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Local state for dynamically configured variant axes (flows from family, reconstructed from variants, or custom selected)
const [localAxes, setLocalAxes] = useState<VariantAxis[]>([]);
// Filter attributes that are marked as variant eligible OR are of eligible types (including type 'color')
const selectableAttributes = useMemo(() => {
return availableAttributes.filter(attr =>
attr.is_variant_eligible === true ||
attr.isVariantEligible === true ||
['select', 'enumeration', 'swatch', 'multiselect', 'color'].includes(attr.type || '')
);
}, [availableAttributes]);
// Form states for axis addition
const [selectedAttrId, setSelectedAttrId] = useState('');
const [customAxisName, setCustomAxisName] = useState('');
const [customAxisCode, setCustomAxisCode] = useState('');
const [isAxesConfigOpen, setIsAxesConfigOpen] = useState(false);
// Load existing variants if product is created
useEffect(() => {
if (productId) {
@@ -44,34 +81,202 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
}
}, [productId, fetchByProduct]);
// Extract configured axes from the family
const variantAxes: VariantAxis[] = useMemo(() => {
if (!family || !Array.isArray(family.variantAxes)) return [];
return family.variantAxes;
}, [family]);
// Resolve relevant variant axes for this product (prioritized hierarchy: family axes -> existing variants -> product attributes with values)
useEffect(() => {
const axesMap = new Map<string, VariantAxis>();
// Priority 1: Family blueprint variant axes (if explicitly configured)
if (family && Array.isArray(family.variantAxes) && family.variantAxes.length > 0) {
family.variantAxes.forEach((fa: any) => axesMap.set(fa.code, fa));
}
// Priority 2: Existing variants' actual attribute keys
if (variants.length > 0) {
variants.forEach(v => {
if (v.attributes) {
Object.keys(v.attributes).forEach(key => {
if (!axesMap.has(key)) {
const foundAttr = selectableAttributes.find(a => a.code === key);
axesMap.set(key, {
id: foundAttr?.id || key,
code: key,
name: foundAttr?.name || key.toUpperCase().replace(/_VARIANT/g, '').replace(/_/g, ' '),
type: foundAttr?.type || 'select',
optionsList: foundAttr?.optionsList || []
});
}
});
}
});
}
// Priority 3: Only if no axes found yet, look at product attributes that have values set
if (axesMap.size === 0 && productAttributes && selectableAttributes.length > 0) {
selectableAttributes.forEach(attr => {
const val = productAttributes[attr.code];
if (val !== undefined && val !== null && val !== '' && !(Array.isArray(val) && val.length === 0)) {
if (!axesMap.has(attr.code)) {
axesMap.set(attr.code, {
...attr,
id: attr.id,
code: attr.code,
name: attr.name,
type: attr.type || 'select',
optionsList: attr.optionsList || []
});
}
}
});
}
// Preserve manually added local axes
localAxes.forEach(la => {
if (!axesMap.has(la.code)) {
axesMap.set(la.code, la);
}
});
const resolved = Array.from(axesMap.values());
const currentKeys = localAxes.map(la => la.code).sort().join(',');
const newKeys = resolved.map(r => r.code).sort().join(',');
if (currentKeys !== newKeys && resolved.length > 0) {
setLocalAxes(resolved);
}
}, [family, variants, selectableAttributes, productAttributes]);
const handleAddAttributeAxis = () => {
if (!selectedAttrId) return;
const attr = selectableAttributes.find(a => a.id === selectedAttrId);
if (!attr) return;
if (localAxes.some(la => la.code === attr.code)) {
notify.error(`Axis with code "${attr.code}" is already added.`);
return;
}
const newAxis: VariantAxis = {
...attr,
id: attr.id,
code: attr.code,
name: attr.name,
type: attr.type || 'select',
optionsList: attr.optionsList || []
};
setLocalAxes(prev => [...prev, newAxis]);
setSelectedAttrId('');
notify.success(`Added axis: ${attr.name}`);
};
const handleAddCustomAxis = () => {
const name = customAxisName.trim();
let code = customAxisCode.trim().toLowerCase().replace(/[^a-z0-9]/g, '_');
if (!name) {
notify.error('Please enter a name for the custom axis.');
return;
}
if (!code) {
code = name.toLowerCase().replace(/[^a-z0-9]/g, '_');
}
if (localAxes.some(la => la.code === code)) {
notify.error(`Axis with code "${code}" is already added.`);
return;
}
const newAxis: VariantAxis = {
id: `custom-${Date.now()}`,
code,
name,
type: 'select',
optionsList: []
};
setLocalAxes(prev => [...prev, newAxis]);
setCustomAxisName('');
setCustomAxisCode('');
notify.success(`Added custom axis: ${name}`);
};
const handleRemoveAxis = (code: string) => {
setLocalAxes(prev => prev.filter(la => la.code !== code));
notify.info(`Removed axis: ${code}`);
};
const initialSelectedValues = useMemo(() => {
const map: Record<string, string[]> = {};
if (!productAttributes) return map;
localAxes.forEach(axis => {
const val = productAttributes[axis.code];
if (val !== undefined && val !== null && val !== '') {
if (Array.isArray(val)) {
map[axis.code] = val.map(String);
} else if (typeof val === 'string' && val.includes(',')) {
map[axis.code] = val.split(',').map(s => s.trim());
} else {
map[axis.code] = [String(val)];
}
}
});
return map;
}, [localAxes, productAttributes]);
// Helper: cartesian product of axes
const cartesian = (axes: { code: string; name: string; values: string[] }[]): Array<Array<{ code: string; value: string }>> => {
if (!axes || axes.length === 0) return [];
return axes.reduce<Array<Array<{ code: string; value: string }>>>((acc, axis) => {
if (!axis.values || axis.values.length === 0) return acc;
if (acc.length === 0) return axis.values.map(v => [{ code: axis.code, value: v }]);
return acc.flatMap(combo => axis.values.map(v => [...combo, { code: axis.code, value: v }]));
}, []);
};
// Helper: build SKU from template
const buildSku = (template: string | undefined, pSku: string, combo: Array<{ code: string; value: string }>): string => {
let sku = template || '{PARENT_SKU}-{COMBO}';
sku = sku.replace('{PARENT_SKU}', pSku || 'SKU');
const comboStr = combo.map(c => c.value.replace(/\s+/g, '')).join('-');
sku = sku.replace('{COMBO}', comboStr);
for (const { code, value } of combo) {
sku = sku.replace(new RegExp(`\\{${code}\\}`, 'gi'), value.replace(/\s+/g, ''));
}
return sku.toUpperCase();
};
// Filter out unconfigured simple master variants (0 attributes) and ensure strict parent productId matching when productId is present
const configuredVariants = useMemo(() => {
if (!Array.isArray(variants)) return [];
return variants.filter(v => {
if (!v) return false;
const vParentId = v.parentProductId || (v as any).product_id || (v as any).productId;
if (productId && vParentId && vParentId !== productId) return false;
return v.attributes && typeof v.attributes === 'object' && Object.keys(v.attributes).length > 0;
});
}, [variants, productId]);
// Derive axesKeys dynamically from actual configured variants if present, or fallback to localAxes
const axesKeys = useMemo(() => {
const keysSet = new Set<string>();
configuredVariants.forEach(v => {
if (v.attributes) {
Object.keys(v.attributes).forEach(k => keysSet.add(k));
}
});
if (keysSet.size > 0) {
return Array.from(keysSet);
}
return (localAxes || []).map(a => a.code);
}, [configuredVariants, localAxes]);
const axesKeys = useMemo(() => variantAxes.map(a => a.code), [variantAxes]);
const axesNames = useMemo(() => {
const map: Record<string, string> = {};
variantAxes.forEach(a => {
(localAxes || []).forEach(a => {
map[a.code] = a.name;
});
return map;
}, [variantAxes]);
// If the family does not support variants
if (variantAxes.length === 0) {
return (
<div className="p-8 text-center bg-surface rounded-xl border border-border shadow-sm">
<Layers className="w-10 h-10 text-muted-foreground mx-auto mb-3" />
<h3 className="font-semibold text-foreground mb-1">This Product Family does not support variants.</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
The assigned Product Family ({family?.name || 'Selected Family'}) has no variant axes configured.
</p>
</div>
);
}
}, [localAxes]);
// If the product is not Configurable (type !== 'variant')
if (productType !== 'variant') {
@@ -86,22 +291,9 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
);
}
// If parent product has not been created/saved yet
if (!productId) {
return (
<div className="p-8 text-center bg-surface rounded-xl border border-border shadow-sm">
<Info className="w-10 h-10 text-primary mx-auto mb-3 animate-bounce" />
<h3 className="font-semibold text-foreground mb-1">Save Product to Configure Variants</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
You must create and save the basic product information first before you can configure and generate variants. Please fill in the required fields in the General step and click **Create Draft** on the header bar.
</p>
</div>
);
}
const handleGenerate = async (selected: Record<string, string[]>, skuTemplate: string) => {
const formattedAxes = Object.entries(selected).map(([code, values]) => {
const axisInfo = variantAxes.find(a => a.code === code);
const axisInfo = localAxes.find(a => a.code === code);
return {
code,
name: axisInfo?.name || code,
@@ -109,15 +301,82 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
};
});
try {
await generateBatch({
productId,
axes: formattedAxes,
skuTemplate
});
setShowGenerator(false);
} catch (err) {
// toast notification is done inside the hook
if (productId) {
try {
await generateBatch({
productId,
axes: formattedAxes,
skuTemplate
});
setShowGenerator(false);
} catch (err) {
// handled in hook
}
} else {
// Local client-side generation before product is persisted
const combinations = cartesian(formattedAxes);
if (combinations.length === 0) {
notify.error('No combinations could be generated from the selected values');
return;
}
// Detect duplicates
const existingSignatures = new Set(
(variants || []).map(v => {
return JSON.stringify(Object.fromEntries(Object.entries(v.attributes || {}).sort()));
})
);
const parentSkuVal = parentSku || 'SKU';
const newVariants: Variant[] = [];
let createdCount = 0;
let skippedCount = 0;
for (const combo of combinations) {
const attrMap: Record<string, string> = {};
for (const { code, value } of combo) {
attrMap[code] = value;
}
const sig = JSON.stringify(Object.fromEntries(Object.entries(attrMap).sort()));
if (existingSignatures.has(sig)) {
skippedCount++;
continue;
}
const generatedSku = buildSku(skuTemplate, parentSkuVal, combo);
const variantName = combo.map(c => c.value).join(' / ');
const newVariant: Variant = {
id: `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
sku: generatedSku,
name: variantName,
parentProductId: '',
attributes: attrMap,
status: 'draft',
price: 0,
costPrice: 0,
currency: 'USD',
stock: 0,
availableStock: 0,
reservedStock: 0,
safetyStock: 0,
images: []
};
existingSignatures.add(sig);
newVariants.push(newVariant);
createdCount++;
}
if (createdCount > 0) {
setVariants(prev => [...prev, ...newVariants]);
notify.success(`Generated ${createdCount} variant(s)${skippedCount > 0 ? `, skipped ${skippedCount} duplicates` : ''}`);
setShowGenerator(false);
} else {
notify.info('All combinations already exist — no new variants created');
setShowGenerator(false);
}
}
};
@@ -141,17 +400,16 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
}
};
const handleBulkUpdates = async (updates: { price?: number; costPrice?: number; stock?: number; status?: VariantStatus }) => {
const handleBulkUpdates = async (updates: { price?: number; costPrice?: number; status?: VariantStatus }) => {
try {
const ids = Array.from(selectedIds);
await bulkUpdate({
ids,
updates
});
// Refresh items
fetchByProduct(productId);
if (productId) fetchByProduct(productId);
setSelectedIds(new Set());
} catch (err) {}
} catch (err) { }
};
const handleBulkDelete = async () => {
@@ -159,7 +417,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
try {
await Promise.all(Array.from(selectedIds).map(id => deleteVariant(id)));
setSelectedIds(new Set());
} catch (err) {}
} catch (err) { }
};
const handleBulkArchive = async () => {
@@ -167,13 +425,108 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
try {
await Promise.all(Array.from(selectedIds).map(id => archiveVariant(id)));
setSelectedIds(new Set());
} catch (err) {}
} catch (err) { }
};
const handleSingleUpdate = async (id: string, updates: Partial<Variant>) => {
return updateVariant(id, updates);
};
const renderAxisCreatorControls = () => {
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-2">
{/* Option A: Choose from Attribute Set */}
{selectableAttributes.length > 0 && (
<div className="border border-border/80 rounded-xl p-5 bg-background/25 space-y-4">
<div>
<h4 className="font-semibold text-foreground text-xs uppercase tracking-wider">A. Choose from Attribute Set</h4>
<p className="text-[11px] text-muted-foreground mt-1">Designate a dropdown/select attribute from your assigned Attribute Set.</p>
</div>
<div className="flex gap-2">
<select
value={selectedAttrId}
onChange={(e) => setSelectedAttrId(e.target.value)}
className="w-full text-xs border border-border rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary bg-surface font-medium text-foreground"
>
<option value="">Select Attribute...</option>
{selectableAttributes.map(attr => (
<option key={attr.id} value={attr.id}>
{attr.name} ({attr.code})
</option>
))}
</select>
<button
type="button"
onClick={handleAddAttributeAxis}
disabled={!selectedAttrId}
className="px-3 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
Add Axis
</button>
</div>
</div>
)}
{/* Option B: Create Custom Axis */}
<div className="border border-border/80 rounded-xl p-5 bg-background/25 space-y-4">
<div>
<h4 className="font-semibold text-foreground text-xs uppercase tracking-wider">B. Create Custom Axis</h4>
<p className="text-[11px] text-muted-foreground mt-1">Create a custom variant axis not present in the attribute set (e.g. Size, Color).</p>
</div>
<div className="grid grid-cols-2 gap-2">
<input
type="text"
placeholder="Axis Name (e.g. Size)"
value={customAxisName}
onChange={(e) => setCustomAxisName(e.target.value)}
className="w-full text-xs border border-border rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-foreground"
/>
<input
type="text"
placeholder="Axis Code (e.g. size)"
value={customAxisCode}
onChange={(e) => setCustomAxisCode(e.target.value)}
className="w-full text-xs border border-border rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-foreground"
/>
</div>
<div className="flex justify-end">
<button
type="button"
onClick={handleAddCustomAxis}
className="px-3 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold"
>
Add Custom Axis
</button>
</div>
</div>
</div>
{/* List of currently added local axes */}
{localAxes.length > 0 && (
<div className="border border-border rounded-xl p-5 bg-background/10 space-y-3">
<h4 className="font-semibold text-foreground text-xs uppercase tracking-wider">Designated Variant Axes ({localAxes.length})</h4>
<div className="flex flex-wrap gap-2">
{localAxes.map(axis => (
<span key={axis.code} className="inline-flex items-center gap-1.5 px-3 py-1 bg-surface border border-border rounded-lg text-xs font-semibold text-foreground">
{axis.name} <span className="text-muted-foreground font-mono">({axis.code})</span>
<button
type="button"
onClick={() => handleRemoveAxis(axis.code)}
className="p-0.5 hover:bg-red-50 hover:text-red-500 rounded transition-colors text-muted-foreground"
title="Remove axis"
>
<X className="w-3.5 h-3.5" />
</button>
</span>
))}
</div>
</div>
)}
</div>
);
};
// Loading spinner for variant fetching
if (loading && variants.length === 0) {
return (
@@ -183,26 +536,64 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
);
}
// Show generator UI if variants list is empty or generator toggled on
if (variants.length === 0 || showGenerator) {
// If local variant axes list is empty, they must add at least one axis
if (localAxes.length === 0) {
return (
<div className="space-y-4 bg-surface rounded-xl border border-border p-6 shadow-sm">
<div className="border-b border-border pb-4">
<div className="flex items-center gap-2">
<Layers className="w-5 h-5 text-primary" />
<h3 className="font-semibold text-foreground text-sm">Configure Variant Axes</h3>
</div>
<p className="text-xs text-muted-foreground mt-1">
This product has no variant axes defined. Designate attributes from your Attribute Set or add custom ones to enable variant generation.
</p>
</div>
{renderAxisCreatorControls()}
</div>
);
}
// Show generator UI if configured variants list is empty or generator toggled on
if (configuredVariants.length === 0 || showGenerator) {
return (
<div className="space-y-4">
{variants.length > 0 && (
<div className="flex justify-start">
<div className="flex justify-between items-center gap-4 flex-wrap">
<div className="flex gap-2">
{configuredVariants.length > 0 && (
<button
type="button"
onClick={() => setShowGenerator(false)}
className="px-3 py-1.5 border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold"
>
Cancel and view variants
</button>
)}
<button
type="button"
onClick={() => setShowGenerator(false)}
className="px-3 py-1.5 border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold"
onClick={() => setIsAxesConfigOpen(!isAxesConfigOpen)}
className="px-3 py-1.5 bg-surface hover:bg-background border border-border text-foreground rounded-lg text-xs font-semibold flex items-center gap-1.5"
>
Cancel and view variants
<Layers className="w-3.5 h-3.5 text-muted-foreground" />
{isAxesConfigOpen ? 'Hide Axes Config' : 'Configure Variant Axes'}
</button>
</div>
</div>
{isAxesConfigOpen && (
<div className="bg-surface rounded-xl border border-border p-5 shadow-xs space-y-4">
<h4 className="font-semibold text-foreground text-xs uppercase tracking-wider">Configure Variant Axes</h4>
{renderAxisCreatorControls()}
</div>
)}
<VariantAxesSelector
axes={variantAxes}
axes={localAxes}
onGenerate={handleGenerate}
generating={generating}
parentSku={parentSku}
initialSelectedValues={initialSelectedValues}
/>
</div>
);
@@ -213,17 +604,21 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
{/* View settings panel */}
<div className="flex items-center justify-between bg-surface rounded-xl border border-border p-4 shadow-xs">
<div className="flex items-center gap-4">
{!readOnly && (
<button
type="button"
onClick={() => setShowGenerator(true)}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Add / Generate Variants
</button>
)}
<button
type="button"
onClick={() => setShowGenerator(true)}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Add / Generate Variants
</button>
<button
type="button"
onClick={() => fetchByProduct(productId)}
onClick={() => {
if (productId) fetchByProduct(productId);
}}
className="p-1.5 border border-border hover:bg-background text-muted-foreground rounded-lg"
title="Refresh variants list"
>
@@ -236,11 +631,10 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
<button
type="button"
onClick={() => setViewLayout('matrix')}
className={`inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium transition-all ${
viewLayout === 'matrix'
? 'bg-surface text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground'
}`}
className={`inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium transition-all ${viewLayout === 'matrix'
? 'bg-surface text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<LayoutGrid className="w-3.5 h-3.5" />
Matrix Grid
@@ -248,11 +642,10 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
<button
type="button"
onClick={() => setViewLayout('list')}
className={`inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium transition-all ${
viewLayout === 'list'
? 'bg-surface text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground'
}`}
className={`inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium transition-all ${viewLayout === 'list'
? 'bg-surface text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<List className="w-3.5 h-3.5" />
List Table
@@ -271,7 +664,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
{/* Primary variants view switcher */}
{viewLayout === 'matrix' ? (
<VariantMatrixView
variants={variants}
variants={configuredVariants}
axesKeys={axesKeys}
axesNames={axesNames}
selectedIds={selectedIds}
@@ -280,10 +673,11 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
onUpdate={handleSingleUpdate}
onDelete={deleteVariant}
onArchive={archiveVariant}
readOnly={readOnly}
/>
) : (
<VariantListView
variants={variants}
variants={configuredVariants}
axesKeys={axesKeys}
selectedIds={selectedIds}
onSelectChange={handleSelectChange}
@@ -291,6 +685,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
onUpdate={handleSingleUpdate}
onDelete={deleteVariant}
onArchive={archiveVariant}
readOnly={readOnly}
/>
)}
</div>
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { familyService } from '../../family/services/family.service';
import { attributeSetsService } from '../../attribute-sets/services/attribute-sets.service';
export const useProductFamilyConfiguration = (familyId?: string) => {
const [family, setFamily] = useState<any | null>(null);
@@ -30,9 +31,18 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
setFamily(blueprint);
setAllowedBrands(blueprint.allowedBrands || []);
setCategory(blueprint.category || null);
setAttributeSet(blueprint.attributeSet || null);
const groupsData = blueprint.groups || blueprint.attributeGroups || [];
let setObj = blueprint.attributeSet || blueprint.attribute_set;
const setId = blueprint.attribute_set_id || blueprint.attributeSetId || (setObj ? setObj.id : null);
if ((!setObj || !setObj.groups || setObj.groups.length === 0) && setId) {
const fetchedSet = await attributeSetsService.getById(setId).catch(() => null);
if (fetchedSet) setObj = fetchedSet;
}
setAttributeSet(setObj || null);
const groupsData = (setObj && Array.isArray(setObj.groups) && setObj.groups.length > 0)
? setObj.groups
: (blueprint.groups || blueprint.attributeGroups || []);
let flatAttrs: any[] = [];
if (Array.isArray(groupsData) && groupsData.length > 0) {
setGroups(groupsData);
@@ -64,8 +74,20 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
? blueprint.variantAxes.map((va: any) => typeof va === 'string' ? (allAttrsMap.get(va) || { id: va, code: va, name: va }) : va)
: [];
const resolvedProductType =
blueprint.productType ||
blueprint.product_type ||
(blueprint.completenessRules ? blueprint.completenessRules.productType : null) ||
null;
const normalizedBlueprint = {
...blueprint,
attributeSet: setObj || null,
attributeSetId: setId || null,
attribute_set_id: setId || null,
groups: groupsData,
attributes: flatAttrs,
productType: resolvedProductType,
variantAxes: resolvedVariantAxes,
variantEnabled: resolvedVariantAxes.length > 0 || Boolean(blueprint.variantEnabled)
};
@@ -73,7 +95,7 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
setFamily(normalizedBlueprint);
setAllowedBrands(blueprint.allowedBrands || []);
setCategory(blueprint.category || null);
setAttributeSet(blueprint.attributeSet || null);
setAttributeSet(setObj || null);
setWorkflow(blueprint.workflow || (blueprint.workflowCode ? { code: blueprint.workflowCode } : null));
setAssetFamily(blueprint.assetRequirements || blueprint.assetFamily || null);
+42 -14
View File
@@ -8,12 +8,15 @@ import type {
} from '../types/variant.types';
import { toast } from 'react-toastify';
export const useVariant = () => {
const [variants, setVariants] = useState<Variant[]>([]);
export const useVariant = (initialVariants: Variant[] = []) => {
const [variants, setVariants] = useState<Variant[]>(initialVariants);
const [loading, setLoading] = useState(false);
const [generating, setGenerating] = useState(false);
const fetchByProduct = useCallback(async (productId: string) => {
if (!productId || productId === 'new' || productId === 'null' || productId === 'undefined') {
return;
}
setLoading(true);
try {
const data = await variantService.getByProduct(productId);
@@ -26,6 +29,10 @@ export const useVariant = () => {
}, []);
const updateVariant = useCallback(async (id: string, updates: VariantUpdateRequest) => {
if (id.startsWith('temp-')) {
setVariants(prev => prev.map(v => v.id === id ? { ...v, ...updates } : v));
return { id, ...updates } as Variant;
}
try {
const updated = await variantService.update(id, updates);
setVariants(prev => prev.map(v => v.id === id ? { ...v, ...updated } : v));
@@ -37,6 +44,11 @@ export const useVariant = () => {
}, []);
const deleteVariant = useCallback(async (id: string) => {
if (id.startsWith('temp-')) {
setVariants(prev => prev.filter(v => v.id !== id));
toast.success('Variant deleted');
return;
}
try {
await variantService.delete(id);
setVariants(prev => prev.filter(v => v.id !== id));
@@ -48,6 +60,11 @@ export const useVariant = () => {
}, []);
const archiveVariant = useCallback(async (id: string) => {
if (id.startsWith('temp-')) {
setVariants(prev => prev.map(v => v.id === id ? { ...v, status: 'archived' } : v));
toast.success('Variant archived');
return;
}
try {
await variantService.archive(id);
setVariants(prev => prev.map(v => v.id === id ? { ...v, status: 'archived' } : v));
@@ -78,18 +95,29 @@ export const useVariant = () => {
}, []);
const bulkUpdate = useCallback(async (req: BulkUpdateRequest) => {
setLoading(true);
try {
const results = await variantService.bulkUpdate(req);
const successCount = results.filter(r => r.success).length;
// Refresh variants after bulk
toast.success(`Updated ${successCount}/${req.ids.length} variants`);
return results;
} catch (err: any) {
toast.error(err?.message || 'Bulk update failed');
throw err;
} finally {
setLoading(false);
const tempIds = req.ids.filter(id => id.startsWith('temp-'));
const realIds = req.ids.filter(id => !id.startsWith('temp-'));
if (tempIds.length > 0) {
setVariants(prev => prev.map(v => tempIds.includes(v.id) ? { ...v, ...req.updates } : v));
}
if (realIds.length > 0) {
setLoading(true);
try {
const results = await variantService.bulkUpdate({ ...req, ids: realIds });
const successCount = results.filter(r => r.success).length;
toast.success(`Updated ${successCount + tempIds.length}/${req.ids.length} variants`);
return results;
} catch (err: any) {
toast.error(err?.message || 'Bulk update failed');
throw err;
} finally {
setLoading(false);
}
} else {
toast.success(`Updated ${tempIds.length}/${req.ids.length} variants`);
return tempIds.map(id => ({ id, success: true }));
}
}, []);
File diff suppressed because it is too large Load Diff
+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
}}
/>
+4 -1
View File
@@ -47,16 +47,19 @@ export interface Variant {
barcode?: string;
weight?: number;
dimensions?: { length?: number; width?: number; height?: number };
images?: VariantImageSlot[];
images: VariantImageSlot[];
}
// ─── Image slot (prepared for Phase 3 DAM integration) ────────────────────
export interface VariantImageSlot {
assetId?: string;
url?: string;
thumbnailUrl?: string;
name?: string;
role: 'primary' | 'gallery' | 'swatch';
isPrimary: boolean;
displayOrder: number;
assetType?: { id: string; code: string; name: string } | null;
}
// ─── Axis configuration for batch generation ──────────────────────────────
+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,32 @@
import type { Setting, SettingCreateRequest, SettingUpdateRequest } from '../types/settings.types';
const STORAGE_KEY = 'pim_settings';
const getStored = (): Setting[] => {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return [];
return JSON.parse(stored);
};
const setStored = (items: Setting[]) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
};
import axiosInstance from '../../../api/axiosInstance';
export const settingsService = {
getAll: async (): Promise<Setting[]> => {
return new Promise((resolve) => setTimeout(() => resolve(getStored()), 300));
getCategorySettings: async (category: string) => {
const response: any = await axiosInstance.get(`/settings/by-category/${category}`);
return response.data?.data;
},
getById: async (id: string): Promise<Setting | undefined> => {
return new Promise((resolve) => setTimeout(() => resolve(getStored().find(p => p.id === id)), 200));
updateCategorySettings: async (category: string, data: Record<string, any>) => {
const response: any = await axiosInstance.put(`/settings/by-category/${category}`, data);
return response.data;
},
create: async (req: SettingCreateRequest): Promise<Setting> => {
return new Promise((resolve) => {
setTimeout(() => {
const list = getStored();
const newItem: Setting = { ...req, id: String(Date.now()), createdAt: new Date().toISOString() };
list.push(newItem);
setStored(list);
resolve(newItem);
}, 300);
});
getAll: async (): Promise<any[]> => {
const response: any = await axiosInstance.get('/settings');
return response.data?.data || response.data || [];
},
update: async (id: string, req: SettingUpdateRequest): Promise<Setting> => {
return new Promise((resolve, reject) => {
setTimeout(() => {
const list = getStored();
const index = list.findIndex(p => p.id === id);
if (index === -1) { reject(new Error('Not found')); return; }
const updated = { ...list[index], ...req };
list[index] = updated;
setStored(list);
resolve(updated);
}, 300);
});
getById: async (id: string): Promise<any> => {
const response: any = await axiosInstance.get(`/settings/${id}`);
return response.data?.data || response.data;
},
delete: async (id: string): Promise<boolean> => {
return new Promise((resolve) => {
setTimeout(() => {
const list = getStored().filter(p => p.id !== id);
setStored(list);
resolve(true);
}, 300);
});
create: async (data: any): Promise<any> => {
const response: any = await axiosInstance.post('/settings', data);
return response.data?.data || response.data;
},
update: async (id: string, data: any): Promise<any> => {
const response: any = await axiosInstance.put(`/settings/${id}`, data);
return response.data?.data || response.data;
},
delete: async (id: string): Promise<any> => {
const response: any = await axiosInstance.delete(`/settings/${id}`);
return response.data;
}
};
+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>
);
}
+49 -5
View File
@@ -181,6 +181,39 @@ export default function NewVariant() {
const activeProducts = products.filter(p => p.status === 'active');
const isLoading = productsLoading || variantLoading || parentLoading;
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
useEffect(() => {
if (isEdit || isView) {
setHighestVisitedStep(STEPS.length);
}
}, [isEdit, isView]);
const isBasicValid = Boolean(formik.values.sku?.trim() && formik.values.name?.trim() && formik.values.productId && !formik.errors.sku && !formik.errors.name && !formik.errors.productId);
const isStepAccessible = useCallback((stepNum: number) => {
if (isEdit || isView) return true;
if (stepNum === 1) return true;
if (!isBasicValid) return false;
return stepNum <= highestVisitedStep + 1;
}, [isEdit, isView, isBasicValid, highestVisitedStep]);
const handleNextStep = useCallback(() => {
if (activeStep === 'basic') {
if (!isBasicValid) {
formik.setFieldTouched('sku', true);
formik.setFieldTouched('name', true);
formik.setFieldTouched('productId', true);
return;
}
}
if (activeIndex < STEPS.length - 1) {
const nextStepObj = STEPS[activeIndex + 1];
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
setActiveStep(nextStepObj.id);
}
}, [activeStep, isBasicValid, activeIndex, formik]);
return (
<ProtectedRoute node="products.variants">
<PageWrapper>
@@ -235,17 +268,23 @@ export default function NewVariant() {
const isActive = activeStep === s.id;
const isDone = idx < activeIndex;
const isLast = idx === STEPS.length - 1;
const accessible = isStepAccessible(s.step);
return (
<div key={s.id} className="flex gap-3">
<div className="flex flex-col items-center" style={{ width: 24 }}>
<button
type="button"
onClick={() => setActiveStep(s.id)}
disabled={!accessible}
onClick={() => {
if (accessible) {
setActiveStep(s.id);
}
}}
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${
isActive ? 'bg-primary ring-2 ring-primary/20' :
isDone ? 'bg-success' :
'bg-surface border-2 border-border hover:border-primary/30'
}`}
} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
>
{isDone
? <Check className="w-3 h-3 text-white" />
@@ -258,8 +297,13 @@ export default function NewVariant() {
</div>
<button
type="button"
onClick={() => setActiveStep(s.id)}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''}`}
disabled={!accessible}
onClick={() => {
if (accessible) {
setActiveStep(s.id);
}
}}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
>
<span className={`text-xs font-medium leading-tight block ${
isActive ? 'text-primary' : isDone ? 'text-muted-foreground' : 'text-muted-foreground hover:text-muted-foreground'
@@ -586,7 +630,7 @@ export default function NewVariant() {
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm font-medium text-muted-foreground hover:bg-background transition-colors">Back</button>
)}
{activeIndex < STEPS.length - 1 && (
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
<button type="button" onClick={handleNextStep} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
)}
</div>
</div>
+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 -9
View File
@@ -5,7 +5,6 @@ import {
Grid3x3,
Tag,
Layers,
Database,
Ruler,
Award,
Image,
@@ -19,7 +18,10 @@ import {
Settings,
List,
Layers2,
Bell
Bell,
ShieldCheck,
Activity,
Building2
} from 'lucide-react';
import React from 'react';
@@ -36,10 +38,21 @@ export interface SidebarItem {
href: string;
icon: React.ElementType;
permission?: string;
platformOnly?: boolean;
children?: SidebarSubItem[];
}
export const sidebarConfig: SidebarItem[] = [
{
label: 'Platform Control',
href: '/platform',
icon: ShieldCheck,
platformOnly: true,
children: [
{ label: 'SaaS Overview & Metrics', href: '/platform/overview', icon: Activity },
{ label: 'Tenant Provisioning', href: '/platform/tenants', icon: Building2 },
]
},
{
label: 'Dashboard',
href: '/dashboard',
@@ -95,24 +108,27 @@ export const sidebarConfig: SidebarItem[] = [
label: 'Asset Management',
href: '/assets',
icon: Image,
permission: 'media.assets',
children: [
{ label: 'Asset Manager', href: '/assets', icon: Folder },
{ label: 'Asset Types', href: '/asset-types', icon: LayoutGrid },
{ label: 'Asset Families', href: '/asset-families', icon: Layers },
{ label: 'Asset Manager', href: '/assets', icon: Folder, permission: 'media.assets' },
{ label: 'Asset Types', href: '/asset-types', icon: LayoutGrid, permission: 'media.taxonomy' },
{ label: 'Asset Families', href: '/asset-families', icon: Layers, permission: 'media.taxonomy' },
]
},
{
label: 'Workflow & Approvals',
href: '/workflow',
icon: Workflow
icon: Workflow,
permission: 'products.items'
},
{
label: 'Channels & Integration',
href: '/channels',
icon: Radio,
permission: 'channels.syndication',
children: [
{ label: 'Channel Registry', href: '/channels', icon: Radio },
{ label: 'Channel Types', href: '/channel-types', icon: Layers2, permission: 'channels.types' },
{ label: 'Channel Registry', href: '/channels', icon: Radio, permission: 'channels.syndication' },
{ label: 'Channel Types', href: '/channel-types', icon: Layers2, permission: 'channels.syndication' },
{ label: 'Integration Hub', href: '/integrations', icon: Plug, permission: 'settings.integrations' },
]
},
@@ -122,7 +138,6 @@ export const sidebarConfig: SidebarItem[] = [
icon: Users,
children: [
{ label: 'Users', href: '/users', icon: Users, permission: 'settings.users' },
{ label: 'Tenants', href: '/users/tenants', icon: Database, permission: 'settings.tenants' },
{ label: 'Roles', href: '/users/roles', icon: Users, permission: 'settings.roles' }
]
},
+1 -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;
+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;

Some files were not shown because too many files have changed in this diff Show More