Author SHA1 Message Date
Inamul-hasan-tec 1d6e96d5af Merge branch 'origin/dev' into feature/inam-platform-core-setup 2026-09-03 13:20:48 +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
Inamul-hasan-tec b16018cdd0 fix(rbac): gate PIM navigation and notification calls 2026-09-01 12:45:32 +05:30
Inamul-hasan-tec 4401683197 fix(sso): deduplicate one-time grant exchange 2026-09-01 11:22:02 +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
Inamul-hasan-tec cc9807b70d fix(integrations): clarify support mode API access 2026-08-31 18:13:04 +05:30
Inamul-hasan-tec 0f8e7cebc1 fix(auth): isolate SSO styles and repair login layout 2026-08-31 17:05:35 +05:30
Inamul-hasan-tec 136365a05e feat(platform): add tenant-aware channels integrations and SaaS SSO UI 2026-08-31 16:07:14 +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
125 changed files with 11978 additions and 3238 deletions
+6
View File
@@ -0,0 +1,6 @@
# Public browser configuration. Do not place SSO module secrets here.
VITE_API_URL=http://localhost:5002
# Use / for a dedicated PIM hostname, or /pim_frontend/ when hosted below
# https://id-ms.maskantech.in/pim_frontend/.
VITE_BASE_PATH=/
@@ -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"
}
+12 -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,14 @@ 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');
const isSsoExchangeEndpoint = error.config?.url?.includes('/auth/sso/exchange');
if (error.response?.status === 401 && !isLoginEndpoint && !isSsoExchangeEndpoint) {
localStorage.removeItem('accessToken');
window.location.href = '/login';
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
return Promise.reject(error);
}
@@ -1,6 +1,6 @@
// src/authentication/components/ProtectedRoute.tsx
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAppSelector } from '../../store';
import { usePermissions } from '../../hooks/usePermission';
import { AccessDenied } from '../../components/customs/AccessDenied';
@@ -31,4 +31,36 @@ 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}</>;
};
/**
* Prevents Global SaaS administrators from opening tenant operational modules.
* A platform administrator must explicitly enter audited Support Mode first.
*/
export const TenantWorkspaceGuard: React.FC = () => {
const { user } = useAppSelector((state) => state.auth);
const isPlatformUser = user?.type === 'platform' || user?.user_type === 'platform';
const isSupportMode = Boolean(localStorage.getItem('impersonatedTenantId'));
if (isPlatformUser && !isSupportMode) {
return <Navigate to="/platform/overview" replace />;
}
return <Outlet />;
};
export default AuthGuard;
+28 -4
View File
@@ -5,6 +5,25 @@ 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";
const GoogleIcon = () => (
<svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden="true">
<path fill="#4285F4" d="M21.6 12.2c0-.7-.1-1.4-.2-2H12v3.9h5.4a4.6 4.6 0 0 1-2 3v2.5h3.3c1.9-1.8 2.9-4.4 2.9-7.4Z" />
<path fill="#34A853" d="M12 22c2.7 0 5-.9 6.7-2.4l-3.3-2.5c-.9.6-2.1 1-3.4 1a5.9 5.9 0 0 1-5.5-4.1H3.1v2.6A10 10 0 0 0 12 22Z" />
<path fill="#FBBC05" d="M6.5 14a6 6 0 0 1 0-3.9V7.4H3.1a10 10 0 0 0 0 9.2L6.5 14Z" />
<path fill="#EA4335" d="M12 5.9c1.5 0 2.8.5 3.8 1.5l2.9-2.8A9.7 9.7 0 0 0 3.1 7.4l3.4 2.7A5.9 5.9 0 0 1 12 5.9Z" />
</svg>
);
const MicrosoftIcon = () => (
<svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden="true">
<path fill="#F25022" d="M2 2h9.5v9.5H2z" />
<path fill="#7FBA00" d="M12.5 2H22v9.5h-9.5z" />
<path fill="#00A4EF" d="M2 12.5h9.5V22H2z" />
<path fill="#FFB900" d="M12.5 12.5H22V22h-9.5z" />
</svg>
);
export const Login = () => {
const [email, setEmail] = useState("");
@@ -45,13 +64,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);
}
@@ -169,11 +193,11 @@ export const Login = () => {
<div className="mt-4 grid grid-cols-2 gap-3">
<button className="w-full inline-flex justify-center py-2 px-4 border border-border rounded-xl shadow-sm bg-surface text-sm font-medium text-muted-foreground hover:bg-background transition-colors">
<img className="h-4 w-4" src="https://www.svgrepo.com/show/475656/google-color.svg" alt="Google" />
<GoogleIcon />
<span className="ml-2">Google</span>
</button>
<button className="w-full inline-flex justify-center py-2 px-4 border border-border rounded-xl shadow-sm bg-surface text-sm font-medium text-muted-foreground hover:bg-background transition-colors">
<img className="h-4 w-4" src="https://www.svgrepo.com/show/475662/microsoft.svg" alt="Microsoft" />
<MicrosoftIcon />
<span className="ml-2">Microsoft</span>
</button>
</div>
+73 -39
View File
@@ -1,55 +1,89 @@
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Package } from 'lucide-react';
import './Login.css';
import { AlertCircle, Loader2, ShieldCheck } from 'lucide-react';
import { useDispatch } from 'react-redux';
import { authService } from '../services/authService';
import { setCredentials } from '../../store/slices/authSlice';
import { AuthLayout } from '../components/AuthLayout';
type SSOExchangeResponse = {
data?: { user: unknown; accessToken: string; refreshToken?: string; permissions?: Record<string, unknown> };
user?: unknown;
accessToken?: string;
refreshToken?: string;
permissions?: Record<string, unknown>;
};
let activeGrant: string | null = null;
let activeExchange: Promise<unknown> | null = null;
function exchangeGrantOnce(grant: string) {
if (activeGrant !== grant || !activeExchange) {
activeGrant = grant;
activeExchange = authService.exchangeSaaSToken(grant);
}
return activeExchange;
}
export const SSOCallback = () => {
const [searchParams] = useSearchParams();
const initialError = searchParams.get('error');
const initialGrant = searchParams.get('grant');
const validInitialGrant = Boolean(initialGrant && /^[a-f0-9]{32}$/i.test(initialGrant));
const navigate = useNavigate();
const [error, setError] = useState<string>('');
const [status, setStatus] = useState<string>('Processing SSO login...');
const dispatch = useDispatch();
const [error, setError] = useState<string>(() => initialError
? `SSO Error: ${initialError}`
: validInitialGrant ? '' : 'The SSO link is missing, invalid, or already used. Please open PIM again from the SaaS portal.');
const [status, setStatus] = useState<string>(() => initialError || !validInitialGrant ? 'SSO login failed' : 'Processing SSO login...');
useEffect(() => {
const errorParam = searchParams.get('error');
if (errorParam) {
setError(`SSO Error: ${errorParam}`);
const grant = searchParams.get('grant');
const callbackPath = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/sso/callback`;
window.history.replaceState({}, document.title, callbackPath);
if (searchParams.get('error') || !grant || !/^[a-f0-9]{32}$/i.test(grant)) return;
let cancelled = false;
exchangeGrantOnce(grant).then((response) => {
if (cancelled) return;
const result = response as SSOExchangeResponse;
const session = result.data ?? result;
const { user, accessToken, refreshToken, permissions } = session;
if (!accessToken) throw new Error('PIM session token was not returned');
localStorage.removeItem('impersonatedTenantId');
localStorage.setItem('accessToken', accessToken);
if (refreshToken) localStorage.setItem('refreshToken', refreshToken);
dispatch(setCredentials({ user, accessToken, refreshToken, permissions: permissions ?? {} }));
const tenantName = (user as { tenant?: { name?: string } } | undefined)?.tenant?.name;
setStatus(`Welcome to ${tenantName || 'PIM'}. Redirecting...`);
navigate('/dashboard', { replace: true });
}).catch((requestError: unknown) => {
if (cancelled) return;
const message = (requestError as { response?: { data?: { message?: string } } })?.response?.data?.message;
setError(message || 'SSO login failed. Please open PIM again from the SaaS portal.');
setStatus('SSO login failed');
setTimeout(() => navigate('/login'), 3000);
return;
}
setStatus('Login successful! Redirecting...');
setTimeout(() => navigate('/dashboard'), 1000);
}, [searchParams, navigate]);
});
return () => { cancelled = true; };
}, [searchParams, navigate, dispatch]);
return (
<div className="login-container">
<div className="login-background" />
<div className="login-right-panel" style={{ width: '100%', maxWidth: '500px', margin: '0 auto' }}>
<div className="login-card">
<div className="login-card-header" style={{ textAlign: 'center' }}>
<div className="login-logo-badge" style={{ margin: '0 auto 1rem' }}>
<Package className="login-logo-icon" />
</div>
<h2 className="login-card-title">SSO Authentication</h2>
<p className="login-card-subtitle">{status}</p>
</div>
{error ? (
<div className="error-message" style={{ marginTop: '1rem' }}>
{error}
</div>
) : (
<div style={{ textAlign: 'center', padding: '2rem 0' }}>
<div style={{
border: '3px solid #f3f3f3', borderTop: '3px solid #3498db',
borderRadius: '50%', width: '40px', height: '40px',
animation: 'spin 1s linear infinite', margin: '0 auto'
}} />
<style>{`@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }`}</style>
</div>
)}
<AuthLayout>
<div className="flex flex-col items-center text-center py-8">
<div className={`w-14 h-14 rounded-full flex items-center justify-center mb-4 ${error ? 'bg-danger/10' : 'bg-primary/10'}`}>
{error
? <AlertCircle className="w-7 h-7 text-danger" />
: <ShieldCheck className="w-7 h-7 text-primary" />}
</div>
<h2 className="text-xl font-bold text-foreground">Secure SaaS Sign-in</h2>
<p className="text-sm text-muted-foreground mt-2">{status}</p>
{error ? (
<div className="w-full mt-6 p-4 rounded-xl bg-danger/10 border border-danger/20 text-danger text-sm text-left">
{error}
</div>
) : (
<Loader2 className="w-8 h-8 text-primary animate-spin mt-8" aria-label="Completing secure sign-in" />
)}
</div>
</div>
</AuthLayout>
);
};
+3 -2
View File
@@ -40,8 +40,9 @@ export const authService = {
return response;
},
exchangeSaaSToken: async (_code: string): Promise<LoginResponse> => {
throw new Error('SSO not implemented');
exchangeSaaSToken: async (grant: string): Promise<LoginResponse> => {
const response = await apiClient.post<LoginResponse>('/api/v1/auth/sso/exchange', { grant });
return response;
},
acceptInvite: async (email: string, tempPassword: string, newPassword: string): Promise<LoginResponse> => {
+20 -23
View File
@@ -1,6 +1,6 @@
import React, { useState, useMemo, useEffect, useRef } from 'react';
import {
ChevronDown, ChevronRight, Folder, FolderOpen, Search, Plus, X,
ChevronDown, ChevronRight, Folder, FolderOpen, Search, X,
Check, Loader2, FolderPlus
} from 'lucide-react';
import { categoryService } from '../../features/categories/services/category.service';
@@ -19,6 +19,7 @@ interface CategoryNode {
interface CategoryTreeSelectProps {
value?: string;
onChange: (categoryId: string) => void;
onBlur?: () => void;
placeholder?: string;
error?: string;
disabled?: boolean;
@@ -60,6 +61,7 @@ function buildCategoryTree(categories: any[]): CategoryNode[] {
export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
value,
onChange,
onBlur,
placeholder = 'Select Category...',
error,
disabled = false,
@@ -68,7 +70,7 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
// Quick Create Drawer state
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [newCatName, setNewCatName] = useState('');
@@ -86,12 +88,17 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
useEffect(() => {
const handleOutsideClick = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
setIsOpen((prev) => {
if (prev) {
onBlur?.();
}
return false;
});
}
};
document.addEventListener('mousedown', handleOutsideClick);
return () => document.removeEventListener('mousedown', handleOutsideClick);
}, []);
}, [onBlur]);
const categoryTree = useMemo(() => buildCategoryTree(categories), [categories]);
@@ -138,7 +145,7 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
const createdId = created?.id || created?.data?.id;
notify.success(`Category "${newCatName}" created successfully!`);
await fetchCategories();
if (createdId) {
onChange(createdId);
@@ -178,11 +185,10 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
<div key={node.id} className="select-none">
<div
onClick={() => handleSelect(node.id)}
className={`flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium cursor-pointer transition-colors ${
isSelected
? 'bg-primary/10 text-primary font-bold'
: 'text-gray-700 hover:bg-gray-100'
}`}
className={`flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium cursor-pointer transition-colors ${isSelected
? 'bg-primary/10 text-primary font-bold'
: 'text-gray-700 hover:bg-gray-100'
}`}
style={{ paddingLeft: `${node.depth * 16 + 12}px` }}
>
<div className="flex items-center gap-2 min-w-0">
@@ -233,11 +239,10 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
{/* Trigger Button */}
<div
onClick={() => !disabled && setIsOpen(!isOpen)}
className={`w-full border rounded-lg px-3 py-2.5 text-sm flex items-center justify-between bg-white cursor-pointer transition-all ${
disabled ? 'bg-gray-50 opacity-60 cursor-not-allowed border-gray-200' :
className={`w-full border rounded-lg px-3 py-2.5 text-sm flex items-center justify-between bg-white cursor-pointer transition-all ${disabled ? 'bg-gray-50 opacity-60 cursor-not-allowed border-gray-200' :
isOpen ? 'border-primary ring-2 ring-primary/20' :
error ? 'border-red-300' : 'border-gray-200 hover:border-gray-300'
}`}
error ? 'border-red-300' : 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center gap-2 truncate">
<Folder className={`w-4 h-4 ${selectedCategory ? 'text-primary' : 'text-gray-400'}`} />
@@ -254,7 +259,7 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
{isOpen && (
<div className="absolute z-50 mt-1.5 w-full bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden flex flex-col max-h-80 animate-in fade-in zoom-in-95 duration-100">
{/* Search Header */}
<div className="p-2 border-b border-gray-100 flex items-center gap-2 bg-gray-50/50">
<div className="p-2 border-b border-gray-100 flex items-center bg-gray-50/50">
<div className="relative flex-1">
<Search className="w-3.5 h-3.5 text-gray-400 absolute left-2.5 top-2.5" />
<input
@@ -265,14 +270,6 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
className="w-full pl-8 pr-3 py-1.5 text-xs border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary bg-white"
/>
</div>
<button
type="button"
onClick={() => setIsDrawerOpen(true)}
className="px-2.5 py-1.5 bg-primary hover:bg-primary-hover text-white text-xs font-semibold rounded-lg flex items-center gap-1 shrink-0 transition-colors shadow-2xs"
>
<Plus className="w-3.5 h-3.5" />
Quick Create
</button>
</div>
{/* Category Tree Body */}
+5 -2
View File
@@ -7,6 +7,7 @@ import {
import { ActionMenu, type CustomAction } from "./ActionMenu";
export interface DataTableColumn<T = any> {
id?: string;
key: string;
label: string;
sortable?: boolean;
@@ -300,9 +301,10 @@ export function DataTable<T extends Record<string, any> = any>({
</th>
{columns.map((col) => {
const align = col.align || "center";
const colKey = col.id || col.key;
return (
<th
key={col.key}
key={colKey}
onClick={() => col.sortable && handleSort(col.key)}
className={`px-4 py-3 text-xs font-semibold uppercase tracking-wider ${col.sortable ? "cursor-pointer" : ""}`}
style={{
@@ -376,9 +378,10 @@ export function DataTable<T extends Record<string, any> = any>({
</td>
{columns.map(col => {
const align = col.align || "center";
const colKey = col.id || col.key;
return (
<td
key={col.key}
key={colKey}
className={`px-4 py-3 text-sm text-foreground ${align === "center" ? "text-center" : align === "right" ? "text-right" : "text-left"}`}
style={{ borderRight: "1px solid var(--color-table-header-border)" }}
>
@@ -0,0 +1,30 @@
import React from 'react';
import { useSelector } from 'react-redux';
import type { RootState } from '../../store';
import { hasPermission } from '../../utils/permissionUtils';
import type { PermissionNodes, PermissionAction } from '../../types/auth.types';
interface PermissionGuardProps {
node: PermissionNodes | string;
action?: PermissionAction;
children: React.ReactNode;
fallback?: React.ReactNode;
}
export const PermissionGuard: React.FC<PermissionGuardProps> = ({
node,
action = 'view',
children,
fallback = null
}) => {
const permissions = useSelector((state: RootState) => state.auth.permissions);
const user = useSelector((state: RootState) => state.auth.user);
const allowed = hasPermission(permissions, node, action, user);
if (!allowed) {
return <>{fallback}</>;
}
return <>{children}</>;
};
+3 -1
View File
@@ -77,10 +77,11 @@ export function Select({
dropdownRef.current?.contains(e.target as Node)
) return;
setIsOpen(false);
onBlur?.({ target: { name } } as any);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
}, [isOpen, name, onBlur]);
const handleToggle = () => {
if (disabled) return;
@@ -100,6 +101,7 @@ export function Select({
<button
ref={buttonRef}
id={id}
name={name}
type="button"
disabled={disabled}
onClick={handleToggle}
+145 -69
View File
@@ -1,13 +1,15 @@
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";
import { hasPermission } from "../../utils/permissionUtils";
export function Header() {
const { title, subtitle } = useHeader();
@@ -15,24 +17,75 @@ export function Header() {
const { language, setLanguage } = useLanguage();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { user } = useAppSelector((state) => state.auth);
const { user, permissions } = useAppSelector((state) => state.auth);
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';
const canViewNotifications = hasPermission(permissions, 'notifications', 'view', user);
// 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 && impersonatedTenantId) {
try {
const tenants = await tenantService.getPlatformTenants();
if (Array.isArray(tenants)) {
setPlatformTenants(tenants);
}
} catch (err) {
// Fallback silently if not available
}
}
}, [isPlatformUser, impersonatedTenantId]);
useEffect(() => {
loadPlatformTenants();
}, [loadPlatformTenants]);
useEffect(() => {
const syncImpersonation = (event: Event) => {
const tenantId = (event as CustomEvent<string | null>).detail;
setImpersonatedTenantId(tenantId || localStorage.getItem('impersonatedTenantId'));
};
window.addEventListener('pim:impersonation-changed', syncImpersonation);
return () => window.removeEventListener('pim:impersonation-changed', syncImpersonation);
}, []);
const activeTenantObj = platformTenants.find(t => String(t.id) === String(impersonatedTenantId));
const tenantName = impersonatedTenantId
? `${activeTenantObj?.name || activeTenantObj?.tenant_name || 'Tenant'} (#${impersonatedTenantId}) [Support Mode]`
: (user?.tenant?.name || user?.tenant_name || (isPlatformUser ? "Platform Core" : `Tenant #${user?.tenant_id}`));
const handleExitSupportMode = () => {
localStorage.removeItem('impersonatedTenantId');
setImpersonatedTenantId(null);
window.dispatchEvent(new CustomEvent('pim:impersonation-changed', { detail: null }));
notify.info("Support impersonation session ended");
navigate("/platform/tenants");
window.location.reload();
};
useEffect(() => {
if (!canViewNotifications) {
setHeaderUnread(0);
return;
}
let mounted = true;
const sync = async () => {
const count = await notificationService.getUnreadCount();
@@ -56,7 +109,7 @@ export function Header() {
socketService.off("notification:unread-count", handleUnreadCount);
});
};
}, []);
}, [canViewNotifications]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -69,79 +122,101 @@ 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 || activeTenantObj?.tenant_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">
{/* 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 */}
{canViewNotifications && <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 +328,6 @@ export function Header() {
</div>
</div>
</header>
</div>
);
}
+13 -2
View File
@@ -6,6 +6,7 @@ import { AccessDenied } from '../customs/AccessDenied';
interface ProtectedRouteProps {
node: PermissionNodes | string;
action?: 'view' | 'create' | 'edit' | 'delete' | 'alter' | 'import' | 'export';
children: React.ReactNode;
fallback?: React.ReactNode; // Optional custom fallback
}
@@ -22,12 +23,22 @@ interface ProtectedRouteProps {
*/
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
node,
action = 'view',
children,
fallback = <AccessDenied />
}) => {
const { canView } = usePermissions(node);
const permissions = usePermissions(node);
const allowed = {
view: permissions.canView,
create: permissions.canCreate,
edit: permissions.canEdit,
delete: permissions.canDelete,
alter: permissions.canAlter,
import: permissions.canImport,
export: permissions.canExport,
}[action];
if (!canView) {
if (!allowed) {
return <>{fallback}</>;
}
+37 -11
View File
@@ -18,10 +18,11 @@ interface NavItem {
href: string;
badge?: string;
permission?: string;
platformOnly?: boolean;
children?: NavItem[];
}
import { sidebarConfig as navItems } from "../../routes/sidebar.config";
import { sidebarConfig as tenantNavItems, platformSidebarConfig } from "../../routes/sidebar.config";
export function Sidebar() {
const navigate = useNavigate();
@@ -62,6 +63,20 @@ 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 [impersonatedTenantId, setImpersonatedTenantId] = useState<string | null>(
localStorage.getItem('impersonatedTenantId')
);
const isSupportMode = isPlatformUser && Boolean(impersonatedTenantId);
React.useEffect(() => {
const syncImpersonation = (event: Event) => {
setImpersonatedTenantId((event as CustomEvent<string | null>).detail || localStorage.getItem('impersonatedTenantId'));
};
window.addEventListener('pim:impersonation-changed', syncImpersonation);
return () => window.removeEventListener('pim:impersonation-changed', syncImpersonation);
}, []);
const toggleSection = (href: string) => {
setExpandedSections(prev =>
@@ -83,7 +98,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;
}
@@ -100,7 +119,10 @@ export function Sidebar() {
}, []);
};
const filteredNavItems = filterNavItems(navItems);
const navigationSource = isPlatformUser && !isSupportMode
? platformSidebarConfig
: tenantNavItems.filter(item => !item.platformOnly);
const filteredNavItems = filterNavItems(navigationSource);
return (
<div
@@ -212,14 +234,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">
{isSupportMode ? `Tenant #${impersonatedTenantId}` : isPlatformUser ? 'Platform Super Admin' : (user?.tenant?.name || 'Tenant Workspace')}
</div>
<div className="text-xs text-muted-foreground truncate">
{isSupportMode ? 'Audited Support Mode' : isPlatformUser ? 'Global SaaS Mode' : `${user?.tenant?.plan_name || 'Active'} Plan`}
</div>
</div>
</div>
</div>
@@ -230,10 +256,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>
)}
+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>
);
}
+3 -2
View File
@@ -1,11 +1,12 @@
import { Routes, Route } from 'react-router-dom';
import AssetList from '../pages/AssetList';
import NewAsset from '../pages/NewAsset';
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
export const AssetRoutes = () => (
<Routes>
<Route index element={<AssetList />} />
<Route path="new" element={<NewAsset />} />
<Route path=":id/edit" element={<NewAsset />} />
<Route path="new" element={<ProtectedRoute node="media.assets" action="create"><NewAsset /></ProtectedRoute>} />
<Route path=":id/edit" element={<ProtectedRoute node="media.assets" action="edit"><NewAsset /></ProtectedRoute>} />
</Routes>
);
@@ -28,6 +28,8 @@ export interface AssetAnalytics {
export interface AssetMapping {
id: string;
asset_id: string;
product_id?: string;
productId?: string;
role: string;
display_order: number;
is_primary: boolean;
@@ -49,9 +51,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)) {
@@ -228,20 +228,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 +245,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>
@@ -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>
+74 -13
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([]);
}
}
});
}
@@ -264,19 +291,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 +304,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 +332,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">
@@ -2,14 +2,15 @@ import { Routes, Route } from 'react-router-dom';
import AttributeList from '../pages/AttributeList';
import NewAttribute from '../pages/NewAttribute';
import AttributeGroupList from '../../attribute-groups/pages/AttributeGroupList';
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
export const AttributeRoutes = () => {
return (
<Routes>
<Route index element={<AttributeList />} />
<Route path="groups" element={<AttributeGroupList />} />
<Route path="new" element={<NewAttribute />} />
<Route path=":id/edit" element={<NewAttribute />} />
<Route path="new" element={<ProtectedRoute node="products.attributes" action="create"><NewAttribute /></ProtectedRoute>} />
<Route path=":id/edit" element={<ProtectedRoute node="products.attributes" action="edit"><NewAttribute /></ProtectedRoute>} />
<Route path=":id/view" element={<NewAttribute />} />
</Routes>
);
@@ -7,8 +7,8 @@ import type { Brand } from '../types/brand.types';
interface BrandTableProps {
brands: Brand[];
onView: (row: Brand) => void;
onEdit: (row: Brand) => void;
onDelete: (row: Brand) => void;
onEdit?: (row: Brand) => void;
onDelete?: (row: Brand) => void;
onRowClick?: (row: Brand) => void;
}
+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>
+3 -2
View File
@@ -1,13 +1,14 @@
import { Routes, Route } from 'react-router-dom';
import BrandList from '../pages/BrandList';
import NewBrandForm from '../pages/NewBrandForm';
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
export const BrandRoutes = () => {
return (
<Routes>
<Route index element={<BrandList />} />
<Route path="new" element={<NewBrandForm />} />
<Route path=":id/edit" element={<NewBrandForm />} />
<Route path="new" element={<ProtectedRoute node="masters.brands" action="create"><NewBrandForm /></ProtectedRoute>} />
<Route path=":id/edit" element={<ProtectedRoute node="masters.brands" action="edit"><NewBrandForm /></ProtectedRoute>} />
<Route path=":id/view" element={<NewBrandForm />} />
</Routes>
);
@@ -101,6 +101,7 @@ function TreeNodeRow({
const hasChildren = node.children.length > 0;
const isRoot = node.depth === 0;
const isActive = node.category.status === "active";
const isSharedBaseline = node.category.tenant_id == null;
const linePrefix = node.depth === 0 ? "" : prefix + (isLast ? "└── " : "├── ");
const childPrefix = node.depth === 0 ? "" : prefix + (isLast ? " " : "│ ");
const descendants = countDescendants(node);
@@ -220,22 +221,26 @@ function TreeNodeRow({
>
<Plus className="w-4 h-4 md:w-3.5 md:h-3.5" />
</button>
<button
type="button"
onClick={() => onEdit(node.category)}
className="p-2 md:p-1 rounded hover:bg-primary/10 text-muted-foreground hover:text-primary transition-colors flex items-center justify-center min-h-[32px] min-w-[32px] md:min-h-0 md:min-w-0"
title="Edit"
>
<Pencil className="w-4 h-4 md:w-3.5 md:h-3.5" />
</button>
<button
type="button"
onClick={() => onDelete(node.category)}
className="p-2 md:p-1 rounded hover:bg-red-50 text-muted-foreground hover:text-red-500 transition-colors flex items-center justify-center min-h-[32px] min-w-[32px] md:min-h-0 md:min-w-0"
title="Delete"
>
<Trash2 className="w-4 h-4 md:w-3.5 md:h-3.5" />
</button>
{!isSharedBaseline && (
<>
<button
type="button"
onClick={() => onEdit(node.category)}
className="p-2 md:p-1 rounded hover:bg-primary/10 text-muted-foreground hover:text-primary transition-colors flex items-center justify-center min-h-[32px] min-w-[32px] md:min-h-0 md:min-w-0"
title="Edit"
>
<Pencil className="w-4 h-4 md:w-3.5 md:h-3.5" />
</button>
<button
type="button"
onClick={() => onDelete(node.category)}
className="p-2 md:p-1 rounded hover:bg-red-50 text-muted-foreground hover:text-red-500 transition-colors flex items-center justify-center min-h-[32px] min-w-[32px] md:min-h-0 md:min-w-0"
title="Delete"
>
<Trash2 className="w-4 h-4 md:w-3.5 md:h-3.5" />
</button>
</>
)}
</span>
</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>
@@ -2,12 +2,13 @@ import { Routes, Route } from 'react-router-dom';
import CategoryList from '../pages/CategoryList';
import NewCategory from '../pages/NewCategory';
import CategoryView from '../pages/CategoryView';
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
export const CategoryRoutes = () => (
<Routes>
<Route index element={<CategoryList />} />
<Route path="new" element={<NewCategory />} />
<Route path=":id/edit" element={<NewCategory />} />
<Route path="new" element={<ProtectedRoute node="products.categories" action="create"><NewCategory /></ProtectedRoute>} />
<Route path=":id/edit" element={<ProtectedRoute node="products.categories" action="edit"><NewCategory /></ProtectedRoute>} />
<Route path=":id/view" element={<CategoryView />} />
</Routes>
);
@@ -11,6 +11,9 @@ export interface Category {
lastUpdated: string;
createdBy: string;
path?: string;
tenant_id?: string | number | null;
productCount?: number;
familyCount?: number;
}
export type CategoryCreateRequest = Omit<Category, 'id' | 'lastUpdated' | 'createdBy' | 'parentName'>;
@@ -1,6 +1,5 @@
import { useEffect, useState } from "react";
import {
Plus,
import { useEffect } from "react";
import {
RefreshCw,
Layers2,
CheckCircle,
@@ -17,7 +16,6 @@ import {
Monitor
} 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";
@@ -25,7 +23,6 @@ import { DataTable } from "../../../components/customs/DataTable";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { useChannelType } from "../hook/useChannelType";
import type { ChannelType } from "../types/channel-types.types";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { StatsCard } from "../../../components/customs/StatsCard";
const ICON_MAP: Record<string, any> = {
@@ -41,15 +38,7 @@ const ICON_MAP: Record<string, any> = {
};
export default function ChannelTypeList() {
const navigate = useNavigate();
const { items, fetchItems, loading, deleteItem } = useChannelType();
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({
isOpen: false,
id: "",
name: "",
});
const [isDeleting, setIsDeleting] = useState(false);
const { items, fetchItems, loading } = useChannelType();
useEffect(() => {
fetchItems();
@@ -112,19 +101,6 @@ export default function ChannelTypeList() {
},
];
const handleDeleteConfirm = async () => {
if (!deleteModal.id) return;
setIsDeleting(true);
try {
await deleteItem(deleteModal.id);
setDeleteModal({ isOpen: false, id: "", name: "" });
} catch (error) {
console.error(error);
} finally {
setIsDeleting(false);
}
};
return (
<PageWrapper>
<Breadcrumb
@@ -139,10 +115,6 @@ export default function ChannelTypeList() {
<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 Type
</Button>
</>
}
/>
@@ -185,20 +157,6 @@ export default function ChannelTypeList() {
rowIdKey="id"
resultLabel="channel types"
statusKey="status"
actionConfig={{
onEdit: (row) => navigate(`${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name }),
}}
/>
<ConfirmationModal
isOpen={deleteModal.isOpen}
title="Delete Channel Type"
description="Are you sure you want to delete this channel type? Channels using this type may be affected."
itemName={deleteModal.name}
loading={isDeleting}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</PageWrapper>
);
@@ -1,68 +1,34 @@
import apiClient from '../../../api/axiosInstance';
import type { ChannelType, ChannelTypeCreateRequest, ChannelTypeUpdateRequest } from '../types/channel-types.types';
const STORAGE_KEY = 'pim_channel_types';
const INITIAL_DATA: ChannelType[] = [
{ id: '1', name: 'E-Commerce', code: 'ecommerce', description: 'Online retail storefronts and shopping platforms', icon: 'ShoppingCart', status: 'active', channelCount: 4, createdAt: '2025-01-10', author: 'Admin User' },
{ id: '2', name: 'Marketplace', code: 'marketplace', description: 'Third-party marketplace listings like Amazon and eBay', icon: 'ShoppingBag', status: 'active', channelCount: 3, createdAt: '2025-01-12', author: 'Sarah Chen' },
{ id: '3', name: 'ERP System', code: 'erp', description: 'Enterprise resource planning system integrations', icon: 'Server', status: 'active', channelCount: 2, createdAt: '2025-01-15', author: 'Michael Torres' },
{ id: '4', name: 'Warehouse (WMS)', code: 'wms', description: 'Warehouse management system for stock and logistics', icon: 'Warehouse', status: 'active', channelCount: 1, createdAt: '2025-01-18', author: 'Emma Wilson' },
{ id: '5', name: 'Point of Sale', code: 'pos', description: 'In-store point of sale and retail terminal systems', icon: 'Store', status: 'active', channelCount: 2, createdAt: '2025-02-01', author: 'Admin User' },
{ id: '6', name: 'B2B Portal', code: 'b2b_portal', description: 'Business-to-business buyer portals and wholesale platforms', icon: 'Globe', status: 'active', channelCount: 1, createdAt: '2025-02-10', author: 'Sarah Chen' },
{ id: '7', name: 'Mobile App', code: 'mobile_app', description: 'Native mobile applications for iOS and Android', icon: 'Smartphone', status: 'inactive', channelCount: 0, createdAt: '2025-02-20', author: 'Michael Torres' },
{ id: '8', name: 'Corporate Website', code: 'website', description: 'Company marketing websites and product catalogues', icon: 'Monitor', status: 'active', channelCount: 2, createdAt: '2025-03-01', author: 'Emma Wilson' },
];
const getStored = (): ChannelType[] => {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(INITIAL_DATA));
return INITIAL_DATA;
}
return JSON.parse(stored);
};
const setStored = (items: ChannelType[]) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
};
interface ApiResponse<T> { success: boolean; data: T; message?: string }
const base = '/api/v1/channel-types';
const normalize = (raw: any): ChannelType => ({
...raw,
createdAt: raw.createdAt || raw.created_at,
updatedAt: raw.updatedAt || raw.updated_at,
channelCount: raw.channelCount ?? raw.channels?.length ?? 0
});
export const channelTypesService = {
getAll: async (): Promise<ChannelType[]> =>
new Promise((resolve) => setTimeout(() => resolve(getStored()), 300)),
getById: async (id: string): Promise<ChannelType | undefined> =>
new Promise((resolve) => setTimeout(() => resolve(getStored().find(p => p.id === id)), 200)),
create: async (req: ChannelTypeCreateRequest): Promise<ChannelType> =>
new Promise((resolve) => {
setTimeout(() => {
const list = getStored();
const newItem: ChannelType = { ...req, id: String(Date.now()), createdAt: new Date().toISOString() };
list.push(newItem);
setStored(list);
resolve(newItem);
}, 300);
}),
update: async (id: string, req: ChannelTypeUpdateRequest): Promise<ChannelType> =>
new Promise((resolve, reject) => {
setTimeout(() => {
const list = getStored();
const index = list.findIndex(p => p.id === id);
if (index === -1) { reject(new Error('Not found')); return; }
const updated = { ...list[index], ...req };
list[index] = updated;
setStored(list);
resolve(updated);
}, 300);
}),
delete: async (id: string): Promise<boolean> =>
new Promise((resolve) => {
setTimeout(() => {
const list = getStored().filter(p => p.id !== id);
setStored(list);
resolve(true);
}, 300);
}),
getAll: async (): Promise<ChannelType[]> => {
const response = await apiClient.get<ApiResponse<any[]>>(base);
return (response.data || []).map(normalize);
},
getById: async (id: string): Promise<ChannelType | undefined> => {
const response = await apiClient.get<ApiResponse<any>>(`${base}/${id}`);
return response.data ? normalize(response.data) : undefined;
},
create: async (req: ChannelTypeCreateRequest): Promise<ChannelType> => {
const response = await apiClient.post<ApiResponse<any>>(base, req);
return normalize(response.data);
},
update: async (id: string, req: ChannelTypeUpdateRequest): Promise<ChannelType> => {
const response = await apiClient.put<ApiResponse<any>>(`${base}/${id}`, req);
return normalize(response.data);
},
delete: async (id: string): Promise<boolean> => {
const response = await apiClient.delete<ApiResponse<unknown>>(`${base}/${id}`);
return response.success;
}
};
+84 -5
View File
@@ -1,4 +1,4 @@
import apiClient from '../../../api/axiosInstance';
import apiClient, { axiosInstance } from '../../../api/axiosInstance';
import type { Channel, ChannelCreateRequest, ChannelUpdateRequest } from '../types/channels.types';
interface ApiResponse<T> {
@@ -8,32 +8,111 @@ interface ApiResponse<T> {
}
const BASE_URL = '/api/v1/channels';
const normalizeChannel = (raw: any): Channel => ({
...raw,
channelType: raw.channelType || raw.type_id || '',
allowPublishing: raw.allowPublishing ?? raw.metadata?.allowPublishing ?? true,
createdAt: raw.createdAt || raw.created_at,
updatedAt: raw.updatedAt || raw.updated_at
});
export const channelsApi = {
getAll: async (): Promise<Channel[]> => {
const res = await apiClient.get<ApiResponse<Channel[]>>(BASE_URL);
return res.data || [];
return (res.data || []).map(normalizeChannel);
},
getById: async (id: string): Promise<Channel | undefined> => {
const res = await apiClient.get<ApiResponse<Channel>>(`${BASE_URL}/${id}`);
return res.data;
return res.data ? normalizeChannel(res.data) : undefined;
},
create: async (req: ChannelCreateRequest): Promise<Channel> => {
const res = await apiClient.post<ApiResponse<Channel>>(BASE_URL, req);
return res.data;
return normalizeChannel(res.data);
},
update: async (id: string, req: ChannelUpdateRequest): Promise<Channel> => {
const res = await apiClient.put<ApiResponse<Channel>>(`${BASE_URL}/${id}`, req);
return res.data;
return normalizeChannel(res.data);
},
remove: async (id: string): Promise<boolean> => {
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;
},
getAllJobs: async (): Promise<any[]> => {
const res = await apiClient.get<ApiResponse<any[]>>(`${BASE_URL}/operations/jobs`);
return res.data || [];
},
getErrors: async (): Promise<any[]> => {
const res = await apiClient.get<ApiResponse<any[]>>(`${BASE_URL}/operations/errors?includeRetrying=true`);
return res.data || [];
},
getQueueHealth: async (): Promise<any> => {
const res = await apiClient.get<ApiResponse<any>>(`${BASE_URL}/queue/health`);
return res.data;
},
getOperationsAudit: async (): Promise<any[]> => {
const res = await apiClient.get<ApiResponse<any[]>>(`${BASE_URL}/operations/audit`);
return res.data || [];
},
cancelJob: async (jobId: string): Promise<any> => {
const res = await apiClient.post<ApiResponse<any>>(`${BASE_URL}/jobs/${jobId}/cancel`);
return res.data;
},
retryJob: async (jobId: string): Promise<any> => {
const res = await apiClient.post<ApiResponse<any>>(`${BASE_URL}/jobs/${jobId}/retry`);
return res.data;
},
downloadCsv: async (channelId: string): Promise<{ blob: Blob; filename: string }> => {
const response = await axiosInstance.get(`${BASE_URL}/${channelId}/export.csv`, { responseType: 'blob' });
const disposition = String(response.headers['content-disposition'] || '');
const filename = disposition.match(/filename="?([^";]+)"?/i)?.[1] || 'channel-products.csv';
return { blob: response.data, filename };
},
};
export default channelsApi;
@@ -0,0 +1,219 @@
import { useState, useEffect } from "react";
import { Save, Plus, Trash2, ArrowRight, Eye } from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { channelsApi } from "../api/channels.api";
import { notify } from "../../../services/toast";
import { PayloadPreviewModal } from "./PayloadPreviewModal";
const COMMON_PIM_ATTRIBUTES = [
{ code: "name", label: "Product Title / Name (name)" },
{ code: "code", label: "Product Code / SKU (code)" },
{ code: "description", label: "Product Description (description)" },
{ code: "status", label: "Publication Status (status)" },
{ code: "created_at", label: "Creation Timestamp (created_at)" },
];
const 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,166 @@
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 queued 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>;
case 'retrying':
case 'queued':
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"><Clock className="w-3.5 h-3.5" /> {status}</span>;
case 'partial':
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" /> Partial</span>;
case 'cancelled':
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">Cancelled</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: {err.sku || err.product_code || 'N/A'} (ID: {err.productId || err.product_id || 'N/A'})</div>
<div className="text-muted-foreground mt-1">{err.error || err.error_message || err.message}</div>
</div>
))}
</div>
<div className="flex justify-end pt-2">
<Button variant="outline" onClick={() => setSelectedErrorLog(null)}>
Close Inspector
</Button>
</div>
</div>
</div>
)}
</div>
);
}
+86 -19
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, Clock, TrendingUp, ShoppingCart, ShoppingBag, Server, Warehouse, Store, Smartphone, Globe, Monitor, Play, CircleHelp, Download } from "lucide-react";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
@@ -11,6 +11,12 @@ 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";
import { useChannelType } from "../../channel-types/hook/useChannelType";
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,14 +30,23 @@ 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 { items: channelTypes, fetchItems: fetchChannelTypes } = useChannelType();
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: "", name: "" });
const [isDeleting, setIsDeleting] = useState(false);
const [operations, setOperations] = useState<{ ready: number; published: number }>({ ready: 0, published: 0 });
useEffect(() => {
fetchItems();
}, [fetchItems]);
fetchChannelTypes();
Promise.all([channelsApi.getQueueHealth(), channelsApi.getAllJobs()])
.then(([health, jobs]) => setOperations({ ready: health.ready || 0, published: jobs.reduce((sum, job) => sum + Number(job.success_count || 0), 0) }))
.catch(() => setOperations({ ready: 0, published: 0 }));
}, [fetchItems, fetchChannelTypes]);
const typeCodeFor = (value?: string) => channelTypes.find(type => type.id === value)?.code || value || '';
const columns = [
{
@@ -39,7 +54,7 @@ export default function ChannelList() {
label: "Channel Name",
sortable: true,
render: (_: any, row: Channel) => {
const meta = CHANNEL_TYPES_META[row.channelType || ""] || CHANNEL_TYPES_META.website;
const meta = CHANNEL_TYPES_META[typeCodeFor(row.channelType)] || { label: "Unassigned", icon: CircleHelp, typeColor: "text-muted-foreground", typeBg: "bg-surface-muted" };
const Icon = meta.icon;
return (
<div className="flex items-center gap-3">
@@ -63,7 +78,7 @@ export default function ChannelList() {
key: "channelType",
label: "Type",
render: (val: string) => {
const meta = CHANNEL_TYPES_META[val] || CHANNEL_TYPES_META.website;
const meta = CHANNEL_TYPES_META[typeCodeFor(val)] || { label: "Unassigned", icon: CircleHelp, typeColor: "text-muted-foreground", typeBg: "bg-surface-muted" };
const Icon = meta.icon;
return (
<div className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium border border-transparent ${meta.typeBg} ${meta.typeColor}`}>
@@ -80,6 +95,40 @@ 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: "Actions",
render: (_: any, row: Channel) => (
<div className="flex items-center gap-1.5"><button
type="button"
onClick={async () => {
try {
notify.info(`Triggering syndication for ${row.name}...`);
const res = await channelsApi.triggerSyndication(row.id);
notify.success(`Syndication for ${row.name} queued (${res.total_products || 0} products)`);
} 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><button
type="button"
onClick={async () => {
try {
const { blob, filename } = await channelsApi.downloadCsv(row.id);
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url; anchor.download = filename; anchor.click();
URL.revokeObjectURL(url);
notify.success(`Downloaded ${filename}`);
} catch { notify.error("Configure Channel mappings before downloading CSV"); }
}}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-semibold text-foreground bg-surface-muted hover:bg-border rounded transition-colors"
><Download className="w-3 h-3"/> Download CSV</button></div>
),
},
{
key: "createdAt",
label: "Updated",
@@ -109,20 +158,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(`Queued syndication 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>
</>
}
/>
@@ -130,29 +197,29 @@ export default function ChannelList() {
{/* Stats Cards */}
<StatsCard
title="Total Channels"
value="9"
value={items.length}
subtitle="Active and inactive"
color="purple"
icon={<Radio className="w-5 h-5" />}
/>
<StatsCard
title="Active Channels"
value="8"
value={items.filter(item => item.status === 'active').length}
subtitle="Serving traffic"
color="green"
icon={<CheckCircle className="w-5 h-5" />}
/>
<StatsCard
title="Families Assigned"
value="183"
subtitle="Across all storefronts"
title="Ready Queue Items"
value={operations.ready}
subtitle="Awaiting worker execution"
color="blue"
icon={<Layers className="w-5 h-5" />}
icon={<Clock className="w-5 h-5" />}
/>
<StatsCard
title="Products Published"
value="69,905"
subtitle="Synced items"
value={operations.published}
subtitle="Persisted successful deliveries"
color="orange"
icon={<TrendingUp className="w-5 h-5" />}
/>
@@ -167,8 +234,8 @@ export default function ChannelList() {
data={items}
actionConfig={{
onView: (row) => navigate(`${row.id}/view`),
onEdit: (row) => navigate(`${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
onEdit: canEdit ? ((row) => navigate(`${row.id}/edit`)) : undefined,
onDelete: canDelete ? ((row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })) : undefined
}}
/>
</div>
+23 -4
View File
@@ -28,10 +28,15 @@ const CHANNEL_TYPES = [
{ id: "website", label: "Website", icon: Monitor, color: "text-primary", bg: "bg-primary/5" },
];
import { ChannelMappingTab } from "../components/ChannelMappingTab";
import { SyndicationHistoryTab } from "../components/SyndicationHistoryTab";
const STEPS = [
{ id: "basic", label: "Basic Information", step: 1 },
{ id: "availability",label: "Availability", step: 2 },
{ id: "summary", label: "Summary", step: 3 },
{ id: "basic", label: "Basic Information", step: 1 },
{ id: "availability", label: "Availability", step: 2 },
{ id: "mapping", label: "Field Mapping Matrix", step: 3 },
{ id: "syndication", label: "Syndication History", step: 4 },
{ id: "summary", label: "Summary", step: 5 },
];
const channelSchema = Yup.object({
@@ -330,7 +335,21 @@ export default function NewChannel() {
</div>
)}
{/* Step 3 — Summary */}
{/* Step 3 — Field Mapping Matrix */}
{activeStep === "mapping" && (
<div className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden p-6">
<ChannelMappingTab channelId={id || "demo-channel-id"} />
</div>
)}
{/* Step 4 — Syndication History */}
{activeStep === "syndication" && (
<div className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden p-6">
<SyndicationHistoryTab channelId={id || "demo-channel-id"} />
</div>
)}
{/* Step 5 — Summary */}
{activeStep === "summary" && (
<div className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader title="Summary" subtitle="Review your channel configuration before saving" />
@@ -1,12 +1,13 @@
import { Routes, Route } from 'react-router-dom';
import ChannelList from '../pages/ChannelList';
import NewChannel from '../pages/NewChannel';
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
export const ChannelRoutes = () => (
<Routes>
<Route index element={<ChannelList />} />
<Route path="new" element={<NewChannel />} />
<Route path=":id/edit" element={<NewChannel />} />
<Route path="new" element={<ProtectedRoute node="channels.syndication" action="create"><NewChannel /></ProtectedRoute>} />
<Route path=":id/edit" element={<ProtectedRoute node="channels.syndication" action="edit"><NewChannel /></ProtectedRoute>} />
<Route path=":id/view" element={<NewChannel />} />
</Routes>
);
+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?.name || (user?.user_type === 'platform' || 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>
}
/>
+673 -133
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import apiClient from '../../../api/axiosInstance';
import { useFamily } from '../hook/useFamily';
@@ -6,14 +6,16 @@ import { useAttribute } from '../../attributes/hook/useAttribute';
import { useChannel } from '../../channels/hook/useChannel';
import { useWorkflow } from '../../workflow/hook/useWorkflow';
import { StageTimeline } from '../../workflow/components/StageTimeline';
import { Search, X, Layers, Activity } from 'lucide-react';
import { Search, X, Layers, Activity, Plus, Loader2, ChevronDown } from 'lucide-react';
import { useUnit } from '../../units/hook/useUnit';
import { useAttributeSet } from '../../attribute-sets/hook/useAttributeSet';
import { useAttributeGroup } from '../../attribute-groups/hook/useAttributeGroup';
import { attributeGroupsService } from '../../attribute-groups/services/attribute-groups.service';
import { useAssetFamily } from '../../asset-families/hook/useAssetFamily';
import { useBrand } from '../../brands/hook/useBrand';
import {
FileText, LayoutGrid, Tags, Globe, Eye, Settings2, Save,
CheckCircle2, AlertCircle, CheckSquare, Image as ImageIcon, Check,
CheckCircle2, AlertCircle, Image as ImageIcon, Check,
Box, Info
} from 'lucide-react';
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
@@ -35,8 +37,7 @@ const TABS = [
{ id: 'channels', label: 'Allowed Channels', icon: Globe, step: 4 },
{ id: 'assets', label: 'Asset Families', icon: ImageIcon, step: 5 },
{ id: 'workflow', label: 'Workflow Assignment', icon: Settings2, step: 6 },
{ id: 'rules', label: 'Completeness Rules', icon: CheckSquare, step: 7 },
{ id: 'summary', label: 'Inheritance Summary', icon: Eye, step: 8 },
{ id: 'summary', label: 'Inheritance Summary', icon: Eye, step: 7 },
];
const inputClass = "w-full border border-primary/10 focus:ring-primary-light rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-surface placeholder-muted-foreground";
@@ -75,7 +76,8 @@ export default function NewFamily() {
const isEdit = Boolean(id) && !isView;
const { createFamily, updateFamily } = useFamily();
const { attributes, fetchAttributes, loading: attributesLoading } = useAttribute();
const { attributes, fetchAttributes, createAttribute, loading: attributesLoading } = useAttribute();
const { items: attributeGroupsList, fetchItems: fetchAttributeGroups } = useAttributeGroup();
const { items: channelsList, fetchItems: fetchChannels, loading: channelsLoading } = useChannel();
const { items: assetFamiliesList, fetchItems: fetchAssetFamilies, loading: assetFamiliesLoading } = useAssetFamily();
const { items: workflowsList, fetchItems: fetchWorkflows, loading: workflowsLoading } = useWorkflow();
@@ -95,6 +97,54 @@ export default function NewFamily() {
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
const [submitError, setSubmitError] = useState<string | null>(null);
// Step 2 Filtering & Inline Quick Create Attribute state
const [selectedGroupFilter, setSelectedGroupFilter] = useState('');
const [attributeSearchQuery, setAttributeSearchQuery] = useState('');
// Searchable Attribute Set Dropdown state & ref
const [isAttributeSetDropdownOpen, setIsAttributeSetDropdownOpen] = useState(false);
const [attributeSetSearchQuery, setAttributeSetSearchQuery] = useState('');
const attributeSetDropdownRef = useRef<HTMLDivElement>(null);
// Step 5 Asset Families Search & Dropdown state & ref
const [assetFamilySearchQuery, setAssetFamilySearchQuery] = useState('');
const [isAssetFamilyDropdownOpen, setIsAssetFamilyDropdownOpen] = useState(false);
const assetFamilyDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (attributeSetDropdownRef.current && !attributeSetDropdownRef.current.contains(event.target as Node)) {
setIsAttributeSetDropdownOpen(false);
}
if (assetFamilyDropdownRef.current && !assetFamilyDropdownRef.current.contains(event.target as Node)) {
setIsAssetFamilyDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const filteredAttributeSets = useMemo(() => {
if (!attributeSetSearchQuery.trim()) return attributeSetsList;
const q = attributeSetSearchQuery.toLowerCase().trim();
return attributeSetsList.filter((s: any) =>
(s.name && s.name.toLowerCase().includes(q)) ||
(s.code && s.code.toLowerCase().includes(q))
);
}, [attributeSetsList, attributeSetSearchQuery]);
const [showAttributeModal, setShowAttributeModal] = useState(false);
const [inlineAttributeName, setInlineAttributeName] = useState('');
const [inlineAttributeType, setInlineAttributeType] = useState('text');
const [inlineAttributeRequired, setInlineAttributeRequired] = useState(false);
const [inlineAttributeOptions, setInlineAttributeOptions] = useState('');
const [inlineAttributeGroupId, setInlineAttributeGroupId] = useState('');
const [inlineAttributeSubmitting, setInlineAttributeSubmitting] = useState(false);
// Maps form fields to which tab they live on for auto-navigation
const FIELD_TAB_MAP: Record<string, string> = {
name: 'basic', code: 'basic', status: 'basic',
@@ -103,7 +153,6 @@ export default function NewFamily() {
variantAxes: 'variants',
channels: 'channels',
assetRequirements: 'assets',
completenessRules: 'rules',
};
@@ -157,6 +206,16 @@ export default function NewFamily() {
onSubmit: async (values, { setSubmitting }) => {
setSubmitError(null);
// Validate completeness rules sum to 100%
const rulesSum = Object.values(values.completenessRules || {}).reduce((sum, v) => sum + (Number(v) || 0), 0);
if (rulesSum !== 100) {
const msg = `Completeness rules weights must equal 100%. Current sum: ${rulesSum}%`;
notify.error(msg);
setSubmitError(msg);
setSubmitting(false);
return;
}
// Validate the full form first and navigate to first tab with an error
const errors = await formik.validateForm();
const errorFields = Object.keys(errors);
@@ -215,13 +274,14 @@ export default function NewFamily() {
// Fetch dynamic lookup lists on mount
useEffect(() => {
fetchAttributes();
fetchAttributeGroups();
fetchChannels();
fetchAssetFamilies();
fetchWorkflows();
fetchAttributeSets();
fetchBrands();
fetchUnits();
}, [fetchAttributes, fetchChannels, fetchAssetFamilies, fetchWorkflows, fetchAttributeSets, fetchBrands, fetchUnits]);
}, [fetchAttributes, fetchAttributeGroups, fetchChannels, fetchAssetFamilies, fetchWorkflows, fetchAttributeSets, fetchBrands, fetchUnits]);
// Load family details in Edit mode
useEffect(() => {
@@ -298,6 +358,34 @@ export default function NewFamily() {
const activeIndex = TABS.findIndex(t => t.id === activeTab);
const isLoading = familyLoading || attributesLoading || channelsLoading || assetFamiliesLoading || workflowsLoading || setsLoading || brandsLoading || unitsLoading;
const familyCompleteness = useMemo(() => {
let score = 0;
if (formik.values.name && formik.values.code) score += 20;
if (formik.values.category) score += 20;
if (formik.values.attributeSetId) score += 20;
if (formik.values.channels && formik.values.channels.length > 0) score += 20;
if (formik.values.assetRequirements && formik.values.assetRequirements.length > 0) score += 20;
return score;
}, [formik.values.name, formik.values.code, formik.values.category, formik.values.attributeSetId, formik.values.channels, formik.values.assetRequirements]);
const assignedAssetFamiliesList = useMemo(() => {
const assignedIds = new Set(formik.values.assetRequirements || []);
return assetFamiliesList.filter((af: any) => assignedIds.has(af.id));
}, [assetFamiliesList, formik.values.assetRequirements]);
const filteredUnassignedAssetFamilies = useMemo(() => {
const assignedIds = new Set(formik.values.assetRequirements || []);
let unassigned = assetFamiliesList.filter((af: any) => !assignedIds.has(af.id));
if (assetFamilySearchQuery.trim()) {
const q = assetFamilySearchQuery.toLowerCase().trim();
unassigned = unassigned.filter((af: any) =>
(af.name && af.name.toLowerCase().includes(q)) ||
(af.code && af.code.toLowerCase().includes(q))
);
}
return unassigned;
}, [assetFamiliesList, formik.values.assetRequirements, assetFamilySearchQuery]);
// Selected attributes list for Step 3 Axis Filtering
const selectedAttributesList = useMemo(() => {
const selectedIds = new Set(formik.values.attributes || []);
@@ -402,6 +490,16 @@ export default function NewFamily() {
const handleSaveFamily = async () => {
setSubmitError(null);
// Validate completeness rules sum to 100%
const rulesSum = Object.values(formik.values.completenessRules || {}).reduce((sum, v) => sum + (Number(v) || 0), 0);
if (rulesSum !== 100) {
const msg = `Completeness rules weights must equal 100%. Current sum: ${rulesSum}%`;
notify.error(msg);
setSubmitError(msg);
return;
}
const errors = await formik.validateForm();
const errorFields = Object.keys(errors);
@@ -561,6 +659,47 @@ export default function NewFamily() {
);
})}
</nav>
{/* Completeness Score */}
<div className="mx-3 mb-3 border border-primary/10 rounded-lg p-3 bg-primary/5">
<p className="text-[10px] font-semibold text-primary uppercase tracking-widest mb-2">Completeness</p>
<div className="flex justify-center mb-3">
<div className="relative w-16 h-16">
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="45" fill="none" stroke="var(--color-border)" strokeWidth="10" />
<circle
cx="50"
cy="50"
r="45"
fill="none"
stroke="var(--color-primary)"
strokeWidth="10"
strokeDasharray="283"
strokeDashoffset={283 - (283 * familyCompleteness) / 100}
strokeLinecap="round"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-sm font-bold text-foreground">
{familyCompleteness}%
</span>
</div>
</div>
</div>
<div className="space-y-1.5 text-[11px]">
{[
{ label: 'Variants', value: String(formik.values.variantAxes?.length || 0) },
{ label: 'Assets', value: String(formik.values.assetRequirements?.length || 0) },
{ label: 'Category', value: formik.values.category ? '1' : '0' },
{ label: 'Channels', value: String(formik.values.channels?.length || 0) },
].map(({ label, value }) => (
<div key={label} className="flex justify-between items-center">
<span className="text-muted-foreground">{label}</span>
<span className="font-bold text-foreground">{value}</span>
</div>
))}
</div>
</div>
</aside>
{/* Content */}
@@ -686,55 +825,215 @@ export default function NewFamily() {
{/* ── Attribute Assignment ── */}
{activeTab === 'attributes' && (
<Card>
<CardHeader title="Attribute Set Blueprint Groups" subtitle="Attributes are defined by the Attribute Set. Expand groups to enable/disable optional items." />
<CardHeader title="Attribute Set Blueprint Groups" subtitle="Attributes are defined by the Attribute Set. Filter, search, or quick-create attributes inline." />
<div className="p-6 space-y-6">
{/* Row: Attribute Set Select */}
<div className="border-b border-border pb-5">
<label className={labelClass}>Attribute Set <span className="text-red-400">*</span></label>
<Select
name="attributeSetId"
value={formik.values.attributeSetId}
onChange={(e) => {
formik.handleChange(e);
const setId = e.target.value;
const selectedSet = attributeSetsList.find(s => s.id === setId);
if (selectedSet && selectedSet.groups) {
const inherited: string[] = [];
for (const g of selectedSet.groups) {
if (g.attributes) {
for (const a of g.attributes) {
inherited.push(a.id || a);
{/* Top Control Panel */}
<div className="bg-surface-muted/40 border border-border rounded-xl p-4 space-y-4 font-sans">
{/* Row 1: Attribute Set & Attribute Group Selectors */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Attribute Set <span className="text-red-400">*</span></label>
<div ref={attributeSetDropdownRef} className="relative">
<div
onClick={() => {
if (!isView) {
setIsAttributeSetDropdownOpen(!isAttributeSetDropdownOpen);
}
}
}
formik.setFieldValue('attributes', [...new Set(inherited)]);
} else {
formik.setFieldValue('attributes', []);
}
}}
>
<option value="">Select an Attribute Set...</option>
{attributeSetsList.map((set) => (
<option key={set.id} value={set.id}>{set.name} ({set.code})</option>
))}
</Select>
{formik.touched.attributeSetId && formik.errors.attributeSetId && (
<div className="text-xs text-red-500 mt-1 font-medium">{formik.errors.attributeSetId}</div>
)}
}}
className={`w-full border rounded-lg px-3 py-2 text-sm flex items-center justify-between bg-surface ${
formik.touched.attributeSetId && formik.errors.attributeSetId ? 'border-red-400' : 'border-border'
} ${isView ? 'cursor-not-allowed opacity-75' : 'cursor-pointer hover:border-primary/30'}`}
>
{(() => {
const selectedObj = attributeSetsList.find((s: any) => String(s.id) === String(formik.values.attributeSetId));
return (
<span className={selectedObj?.name ? 'text-foreground font-medium' : 'text-muted-foreground'}>
{selectedObj?.name
? `${selectedObj.name} (${selectedObj.code})`
: 'Select Attribute Set...'}
</span>
);
})()}
<div className="flex items-center gap-1.5">
{formik.values.attributeSetId && !isView && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
formik.setFieldValue('attributeSetId', '');
formik.setFieldValue('attributes', []);
setSelectedGroupFilter('');
setAttributeSearchQuery('');
setBlueprintPreview(null);
}}
className="p-0.5 hover:bg-background rounded-full text-muted-foreground hover:text-foreground shrink-0"
>
<X className="w-3.5 h-3.5" />
</button>
)}
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
</div>
</div>
{isAttributeSetDropdownOpen && (
<div className="absolute z-50 mt-1.5 w-full bg-surface border border-border rounded-xl shadow-xl overflow-hidden flex flex-col max-h-60 animate-in fade-in zoom-in-95 duration-100 font-sans">
<div className="p-2 border-b border-border bg-surface-muted flex items-center gap-2">
<Search className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<input
type="text"
value={attributeSetSearchQuery}
onChange={(e) => setAttributeSetSearchQuery(e.target.value)}
placeholder="Search by name or code..."
autoFocus
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"
/>
</div>
<div className="p-2 overflow-y-auto flex-1 space-y-1">
{filteredAttributeSets.length > 0 ? (
filteredAttributeSets.map((s: any) => (
<div
key={s.id}
onClick={() => {
formik.setFieldValue('attributeSetId', s.id);
setSelectedGroupFilter('');
setAttributeSearchQuery('');
if (s.groups) {
const inherited: string[] = [];
for (const g of s.groups) {
if (g.attributes) {
for (const a of g.attributes) {
inherited.push(a.id || a);
}
}
}
formik.setFieldValue('attributes', [...new Set(inherited)]);
} else {
formik.setFieldValue('attributes', []);
}
loadBlueprintPreview(s.id);
setIsAttributeSetDropdownOpen(false);
setAttributeSetSearchQuery('');
}}
className={`px-3 py-2 rounded-lg text-xs font-medium cursor-pointer transition-colors ${
formik.values.attributeSetId === s.id
? 'bg-primary/10 text-primary font-bold'
: 'text-foreground hover:bg-background/50'
}`}
>
<div className="font-semibold">{s.name}</div>
<div className="text-[10px] text-muted-foreground font-mono mt-0.5">Code: {s.code}</div>
</div>
))
) : (
<div className="p-4 text-center text-xs text-muted-foreground">No Attribute Sets found.</div>
)}
</div>
</div>
)}
</div>
{formik.touched.attributeSetId && formik.errors.attributeSetId && (
<div className="text-xs text-red-500 mt-1 font-medium">{formik.errors.attributeSetId}</div>
)}
</div>
<div>
<label className={labelClass}>Attribute Group</label>
<Select
value={selectedGroupFilter}
disabled={!formik.values.attributeSetId || isView}
onChange={(e) => setSelectedGroupFilter(e.target.value)}
>
<option value="">All Attribute Groups</option>
{(() => {
const selectedSet = attributeSetsList.find(s => s.id === formik.values.attributeSetId);
const availableGroups = blueprintPreview?.groups || selectedSet?.groups || attributeGroupsList || [];
return availableGroups.map((g: any) => (
<option key={g.id || g.code} value={g.id || g.code}>{g.name} ({g.code})</option>
));
})()}
</Select>
</div>
</div>
{/* Row 2: Search Input + Quick Create Button */}
<div className="flex items-center gap-3">
<div className="relative flex items-center flex-1">
<Search className="w-4 h-4 text-muted-foreground absolute left-3 pointer-events-none" />
<input
type="text"
value={attributeSearchQuery}
onChange={(e) => setAttributeSearchQuery(e.target.value)}
placeholder="Search attributes..."
disabled={!formik.values.attributeSetId || isView}
className="w-full border border-border focus:ring-primary rounded-lg pl-9 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-surface text-foreground"
/>
{attributeSearchQuery && (
<button
type="button"
onClick={() => setAttributeSearchQuery('')}
className="absolute right-3 p-0.5 text-muted-foreground hover:text-foreground"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{!isView && (
<button
type="button"
onClick={() => {
const selectedSet = attributeSetsList.find(s => s.id === formik.values.attributeSetId);
const availableGroups = blueprintPreview?.groups || selectedSet?.groups || attributeGroupsList || [];
setInlineAttributeGroupId(availableGroups[0]?.id || '');
setShowAttributeModal(true);
}}
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 shrink-0 h-[38px] cursor-pointer"
>
<Plus className="w-4 h-4" /> Create Attribute
</button>
)}
</div>
</div>
{/* Attribute Groups & Attributes List */}
{formik.values.attributeSetId ? (
<div className="space-y-4 max-h-[420px] overflow-y-auto pr-2">
<div className="space-y-4 max-h-[420px] overflow-y-auto pr-2 font-sans">
{(() => {
const selectedSet = attributeSetsList.find(s => s.id === formik.values.attributeSetId);
const groupsToRender = blueprintPreview?.groups || selectedSet?.groups || [];
let groupsToRender = blueprintPreview?.groups || selectedSet?.groups || [];
if (groupsToRender.length === 0) {
return <div className="text-center py-8 text-muted-foreground text-sm">No groups found in this Attribute Set.</div>;
// Filter by Attribute Group if selected
if (selectedGroupFilter) {
groupsToRender = groupsToRender.filter((g: any) =>
String(g.id) === String(selectedGroupFilter) || String(g.code) === String(selectedGroupFilter)
);
}
return groupsToRender.map((group: any) => {
if (groupsToRender.length === 0) {
return <div className="text-center py-8 text-muted-foreground text-sm">No attribute groups match the selected filter.</div>;
}
let totalMatchingAttributes = 0;
const renderedGroups = groupsToRender.map((group: any) => {
const isExpanded = expandedGroups[group.id] !== false; // default true
const groupAttributes = group.attributes || [];
let groupAttributes = group.attributes || [];
// Filter attributes by search query
if (attributeSearchQuery.trim()) {
const q = attributeSearchQuery.toLowerCase().trim();
groupAttributes = groupAttributes.filter((attr: any) =>
(attr.name && attr.name.toLowerCase().includes(q)) ||
(attr.code && attr.code.toLowerCase().includes(q))
);
}
totalMatchingAttributes += groupAttributes.length;
if (attributeSearchQuery.trim() && groupAttributes.length === 0) {
return null; // Skip rendering empty group during active search
}
return (
<div key={group.id} className="border border-border rounded-lg overflow-hidden bg-surface shadow-xs">
@@ -760,13 +1059,12 @@ export default function NewFamily() {
<div className="p-4 grid grid-cols-2 gap-3 bg-surface">
{groupAttributes.map((attr: any) => {
const isChecked = formik.values.attributes.includes(attr.id);
const isRequired = attr.is_required || attr.isRequired;
const isVariant = attr.is_variant_eligible || attr.is_variant_axis || attr.isVariantEligible;
const handleToggle = () => {
if (isView) return;
const current = [...formik.values.attributes];
if (isChecked) {
if (isRequired) return; // Prevent disabling required fields
formik.setFieldValue('attributes', current.filter(id => id !== attr.id));
// Remove from variant axes if deselected
formik.setFieldValue('variantAxes', formik.values.variantAxes.filter(id => id !== attr.id));
@@ -793,7 +1091,6 @@ export default function NewFamily() {
<div className="font-semibold text-sm text-foreground">{attr.name}</div>
<div className="text-[11px] text-muted-foreground mt-0.5">
{attr.code} {attr.type?.toUpperCase()}
{isRequired && <span className="ml-2 text-red-500 font-bold">* Required</span>}
{isVariant && <span className="ml-2 text-primary font-medium">(Variant Axis)</span>}
</div>
</div>
@@ -810,11 +1107,17 @@ export default function NewFamily() {
</div>
);
});
if (attributeSearchQuery.trim() && totalMatchingAttributes === 0) {
return <div className="text-center py-8 text-muted-foreground text-sm">No attributes match search "{attributeSearchQuery}".</div>;
}
return renderedGroups;
})()}
</div>
) : (
<div className="text-center py-12 text-muted-foreground text-sm">
Please select an Attribute Set in Step 1 (Basic Details) to view and assign inherited attributes.
Please select an Attribute Set to view, filter, and assign attributes.
</div>
)}
</div>
@@ -888,39 +1191,157 @@ export default function NewFamily() {
{/* ── Asset Families ── */}
{activeTab === 'assets' && (
<Card>
<CardHeader title="Asset Families" subtitle="Select which asset families are assigned to this product family" />
<div className="p-6 space-y-2">
{assetFamiliesList.map((assetFamily) => {
const isChecked = formik.values.assetRequirements.includes(assetFamily.id);
const handleToggle = () => {
const current = [...formik.values.assetRequirements];
if (isChecked) {
formik.setFieldValue('assetRequirements', current.filter(id => id !== assetFamily.id));
} else {
formik.setFieldValue('assetRequirements', [...current, assetFamily.id]);
}
};
return (
<label key={assetFamily.id} onClick={handleToggle} className={`flex items-center justify-between px-4 py-3.5 rounded-lg border cursor-pointer transition-all ${isChecked ? 'border-primary/20 bg-primary/5/40 shadow-sm' : 'border-border hover:border-primary/10 hover:bg-primary/5/20'
}`}>
<div className="flex items-center gap-4">
<input type="checkbox" checked={isChecked} readOnly className="w-4 h-4 text-primary rounded border-border focus:ring-primary-light" />
<div>
<div className="font-medium text-sm text-foreground">{assetFamily.name}</div>
{assetFamily.assetTypes && assetFamily.assetTypes.length > 0 && (
<div className="text-xs text-muted-foreground mt-0.5">
Expected Types: {assetFamily.assetTypes.map((t: any) => t.name).join(', ')}
<CardHeader
title="Asset Families Configuration"
subtitle="Search and assign asset families to configure asset requirements for products in this family."
/>
<div className="p-6 space-y-6">
{/* Searchable Asset Family Selector */}
<div className="bg-surface-muted/40 border border-border rounded-xl p-4 font-sans space-y-3">
<label className={labelClass}>Assign Asset Family</label>
<div className="relative">
<div className="relative flex items-center">
<Search className="w-4 h-4 text-muted-foreground absolute left-3 pointer-events-none" />
<input
type="text"
value={assetFamilySearchQuery}
onChange={(e) => {
setAssetFamilySearchQuery(e.target.value);
setIsAssetFamilyDropdownOpen(true);
}}
onFocus={() => setIsAssetFamilyDropdownOpen(true)}
placeholder="Search asset family by name or code to assign..."
disabled={isView}
className="w-full border border-border focus:ring-primary rounded-lg pl-9 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-surface text-foreground"
/>
{assetFamilySearchQuery && (
<button
type="button"
onClick={() => setAssetFamilySearchQuery('')}
className="absolute right-3 p-0.5 text-muted-foreground hover:text-foreground"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Dropdown list for search & select */}
{isAssetFamilyDropdownOpen && (
<div
ref={assetFamilyDropdownRef}
className="absolute z-50 mt-1.5 w-full bg-surface border border-border rounded-xl shadow-xl overflow-hidden max-h-60 overflow-y-auto animate-in fade-in zoom-in-95 duration-100"
>
{filteredUnassignedAssetFamilies.length > 0 ? (
filteredUnassignedAssetFamilies.map((family: any) => (
<div
key={family.id}
onClick={() => {
if (!formik.values.assetRequirements.includes(family.id)) {
formik.setFieldValue('assetRequirements', [...formik.values.assetRequirements, family.id]);
}
setAssetFamilySearchQuery('');
setIsAssetFamilyDropdownOpen(false);
}}
className="px-4 py-3 border-b border-border/50 last:border-b-0 hover:bg-background/80 cursor-pointer transition-colors"
>
<div className="font-semibold text-sm text-foreground">{family.name}</div>
<div className="text-xs text-muted-foreground font-mono mt-0.5">Code: {family.code}</div>
{family.assetTypes && family.assetTypes.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{family.assetTypes.map((t: any) => (
<span key={t.id || t.name} className="px-2 py-0.5 bg-primary/10 text-primary rounded-full text-[10px] font-medium">
{t.name}
</span>
))}
</div>
)}
</div>
)}
</div>
))
) : (
<div className="p-4 text-center text-xs text-muted-foreground">
{assetFamiliesList.length === 0
? 'No asset families available.'
: 'All matching asset families have been assigned.'}
</div>
)}
</div>
{isChecked && <CheckCircle2 className="w-4 h-4 text-primary" />}
</label>
);
})}
{assetFamiliesList.length === 0 && (
<div className="text-center py-8 text-muted-foreground text-sm">No asset families available.</div>
)}
)}
</div>
</div>
{/* Assigned Asset Families List */}
<div className="space-y-3 font-sans">
<div className="flex items-center justify-between">
<h4 className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
Assigned Asset Families ({assignedAssetFamiliesList.length})
</h4>
</div>
{assignedAssetFamiliesList.length > 0 ? (
<div className="space-y-3">
{assignedAssetFamiliesList.map((family: any) => {
const assetTypes = family.assetTypes || [];
return (
<div
key={family.id}
className="flex items-start justify-between p-4 rounded-xl border border-primary/20 bg-primary/5/30 shadow-xs transition-all"
>
<div className="space-y-2 flex-1 mr-4">
<div className="flex items-center gap-2">
<Box className="w-4 h-4 text-primary shrink-0" />
<span className="font-semibold text-sm text-foreground">{family.name}</span>
<span className="text-xs text-muted-foreground font-mono">({family.code})</span>
</div>
{/* Asset Type Classifications associated with this Asset Family */}
<div>
<div className="text-[11px] font-semibold text-muted-foreground mb-1">
Asset Type Classifications ({assetTypes.length}):
</div>
{assetTypes.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{assetTypes.map((type: any) => (
<span
key={type.id || type.name}
className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-surface border border-border rounded-md text-xs font-medium text-foreground shadow-2xs"
>
<ImageIcon className="w-3 h-3 text-muted-foreground" />
{type.name}
{type.code && <code className="text-[10px] text-muted-foreground">({type.code})</code>}
</span>
))}
</div>
) : (
<span className="text-xs text-muted-foreground italic">No classifications defined for this asset family.</span>
)}
</div>
</div>
{!isView && (
<button
type="button"
onClick={() => {
formik.setFieldValue(
'assetRequirements',
formik.values.assetRequirements.filter((id: string) => id !== family.id)
);
}}
className="p-1.5 hover:bg-red-50 text-muted-foreground hover:text-red-500 rounded-lg transition-colors shrink-0 cursor-pointer"
title="Unassign Asset Family"
>
<X className="w-4 h-4" />
</button>
)}
</div>
);
})}
</div>
) : (
<div className="p-8 border border-dashed border-border rounded-xl text-center text-sm text-muted-foreground bg-surface-muted/20">
No Asset Families assigned to this Product Family yet. Search and select an Asset Family above to assign.
</div>
)}
</div>
</div>
</Card>
)}
@@ -1053,57 +1474,6 @@ export default function NewFamily() {
</Card>
)}
{/* ── Completeness Rules ── */}
{activeTab === 'rules' && (
<Card>
<CardHeader title="Completeness Rules" subtitle="Define weights that sum up to 100%" />
<div className="p-6 space-y-4">
<div className="bg-primary/5 border border-primary/10 rounded-lg px-4 py-3 flex items-start gap-3">
<AlertCircle className="w-4.5 h-4.5 text-primary shrink-0 mt-0.5" />
<div className="text-sm text-primary-dark">
Configure weights for completeness rules. The sum of all weights must equal exactly 100%. Current sum: {' '}
<strong className="underline">
{Object.values(formik.values.completenessRules).reduce((sum, v) => sum + (Number(v) || 0), 0)}%
</strong>
</div>
</div>
<div className="space-y-3">
{[
{ key: 'required_attributes', name: 'All required attributes filled' },
{ key: 'at_least_one_image', name: 'At least one product image' },
{ key: 'marketing_content', name: 'Marketing content complete' },
{ key: 'tech_specs', name: 'Technical specifications complete' },
{ key: 'skus_assigned', name: 'All variants have SKUs' }
].map((rule) => {
const value = formik.values.completenessRules[rule.key] ?? 0;
const handleWeightChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = Math.max(0, parseInt(e.target.value) || 0);
formik.setFieldValue(`completenessRules.${rule.key}`, val);
};
return (
<div key={rule.key} className="flex items-center justify-between p-4 rounded-lg border border-border bg-surface shadow-xs">
<div className="font-medium text-sm text-foreground">{rule.name}</div>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
max="100"
value={value}
onChange={handleWeightChange}
className="w-20 border border-border rounded-lg px-3 py-1.5 text-sm text-right focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary"
/>
<span className="text-sm font-semibold text-muted-foreground">%</span>
</div>
</div>
);
})}
</div>
</div>
</Card>
)}
{/* ── Inheritance Summary ── */}
{activeTab === 'summary' && (
<Card>
@@ -1206,6 +1576,176 @@ export default function NewFamily() {
</div>
</div>
</div>
{/* Quick Create Attribute Modal */}
{showAttributeModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs animate-in fade-in duration-150">
<div className="bg-surface rounded-2xl border border-border shadow-2xl p-6 w-full max-w-md animate-in zoom-in-95 duration-200 overflow-y-auto max-h-[90vh] font-sans">
<div className="flex items-center justify-between mb-4 border-b border-border pb-3">
<h3 className="font-bold text-foreground text-base">Quick Create Attribute</h3>
<button
type="button"
onClick={() => setShowAttributeModal(false)}
className="p-1 hover:bg-background rounded text-muted-foreground transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="text-xs font-semibold text-muted-foreground block mb-1">Attribute Name *</label>
<input
type="text"
value={inlineAttributeName}
onChange={(e) => setInlineAttributeName(e.target.value)}
placeholder="e.g. Pack Size"
className="w-full border border-border focus:ring-primary rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-background text-foreground"
/>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground block mb-1">Attribute Type *</label>
<Select
value={inlineAttributeType}
onChange={(e) => setInlineAttributeType(e.target.value)}
>
<option value="text">Text</option>
<option value="textarea">Textarea</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="select">Select (Dropdown)</option>
<option value="multiselect">Multi-select</option>
<option value="date">Date</option>
</Select>
</div>
{(inlineAttributeType === 'select' || inlineAttributeType === 'multiselect') && (
<div>
<label className="text-xs font-semibold text-muted-foreground block mb-1">Options (Comma separated) *</label>
<input
type="text"
value={inlineAttributeOptions}
onChange={(e) => setInlineAttributeOptions(e.target.value)}
placeholder="e.g. Small, Medium, Large"
className="w-full border border-border focus:ring-primary rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-background text-foreground"
/>
</div>
)}
<div>
<label className="text-xs font-semibold text-muted-foreground block mb-1">Attribute Group</label>
<Select
value={inlineAttributeGroupId}
onChange={(e) => setInlineAttributeGroupId(e.target.value)}
>
{(() => {
const selectedSet = attributeSetsList.find(s => s.id === formik.values.attributeSetId);
const availableGroups = blueprintPreview?.groups || selectedSet?.groups || attributeGroupsList || [];
return availableGroups.map((g: any) => (
<option key={g.id} value={g.id}>{g.name}</option>
));
})()}
</Select>
</div>
<div className="pt-2">
<label className="flex items-center gap-2 cursor-pointer text-xs font-medium text-foreground">
<input
type="checkbox"
checked={inlineAttributeRequired}
onChange={(e) => setInlineAttributeRequired(e.target.checked)}
className="w-4 h-4 text-primary rounded border-border focus:ring-primary"
/>
Mark as Required
</label>
</div>
</div>
<div className="flex justify-end gap-3 mt-6 border-t border-border pt-4">
<button
type="button"
onClick={() => setShowAttributeModal(false)}
className="px-4 py-2 border border-border rounded-lg text-sm font-medium text-foreground hover:bg-background bg-surface transition-colors"
>
Cancel
</button>
<button
type="button"
disabled={
inlineAttributeSubmitting ||
!inlineAttributeName.trim() ||
!inlineAttributeGroupId ||
((inlineAttributeType === 'select' || inlineAttributeType === 'multiselect') && !inlineAttributeOptions.trim())
}
onClick={async () => {
setInlineAttributeSubmitting(true);
let createdAttr: any;
try {
const opts = (inlineAttributeType === 'select' || inlineAttributeType === 'multiselect')
? inlineAttributeOptions.split(',').map((o: string) => o.trim()).filter(Boolean)
: undefined;
const attrPayload: any = {
name: inlineAttributeName.trim(),
type: inlineAttributeType,
isRequired: inlineAttributeRequired,
options: opts,
status: 'active'
};
createdAttr = await createAttribute(attrPayload as any);
} catch (err) {
notify.error('Failed to create attribute');
setInlineAttributeSubmitting(false);
return;
}
try {
const newId = createdAttr?.id || (createdAttr as any)?.data?.id;
if (newId && inlineAttributeGroupId) {
const groupDetails = await attributeGroupsService.getById(inlineAttributeGroupId);
const groupData = (groupDetails as any)?.data || groupDetails;
const existingAttributeIds = (groupData?.attributes || []).map((a: any) => {
if (typeof a === 'string') return a;
return a?.id || a?._id;
}).filter(Boolean);
await attributeGroupsService.update(inlineAttributeGroupId, {
attributes: [...existingAttributeIds, newId]
} as any);
// Add new attribute to form attributes list
if (!formik.values.attributes.includes(newId)) {
formik.setFieldValue('attributes', [...formik.values.attributes, newId]);
}
}
fetchAttributes();
fetchAttributeGroups();
if (formik.values.attributeSetId) {
await loadBlueprintPreview(formik.values.attributeSetId);
}
notify.success('Attribute created and added to group successfully');
} catch (assocErr) {
console.error("Failed to associate attribute with group:", assocErr);
} finally {
setInlineAttributeName('');
setInlineAttributeType('text');
setInlineAttributeRequired(false);
setInlineAttributeOptions('');
setShowAttributeModal(false);
setInlineAttributeSubmitting(false);
}
}}
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 flex items-center gap-2"
>
{inlineAttributeSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
Save
</button>
</div>
</div>
</div>
)}
</div>
</ProtectedRoute>
);
+3 -2
View File
@@ -2,13 +2,14 @@
import { Routes, Route } from 'react-router-dom';
import FamilyList from '../pages/FamilyList';
import NewFamily from '../pages/NewFamily';
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
export const FamilyRoutes = () => {
return (
<Routes>
<Route path="/" element={<FamilyList />} />
<Route path="new" element={<NewFamily />} />
<Route path=":id/edit" element={<NewFamily />} />
<Route path="new" element={<ProtectedRoute node="products.families" action="create"><NewFamily /></ProtectedRoute>} />
<Route path=":id/edit" element={<ProtectedRoute node="products.families" action="edit"><NewFamily /></ProtectedRoute>} />
<Route path=":id/view" element={<NewFamily />} />
</Routes>
);
@@ -0,0 +1,95 @@
import { useEffect, useState } from "react";
import { Copy, KeyRound, Plus, ShieldCheck, Trash2 } from "lucide-react";
import apiClient from "../../../api/axiosInstance";
import { useAppSelector } from "../../../store";
type ApiKeyRecord = {
id: string; name: string; prefix: string; scopes: string[]; status: string;
expiresAt: string; lastUsedAt?: string | null; createdAt: string; apiKey?: string;
};
type ApiResponse<T> = { success: boolean; data: T };
export default function ApiAccessTab() {
const user = useAppSelector((state) => state.auth.user);
const isPlatformUser = user?.type === "platform" || user?.user_type === "platform";
const isSupportMode = isPlatformUser && Boolean(localStorage.getItem("impersonatedTenantId"));
const [keys, setKeys] = useState<ApiKeyRecord[]>([]);
const [name, setName] = useState("");
const [expiresInDays, setExpiresInDays] = useState(90);
const [createdKey, setCreatedKey] = useState<ApiKeyRecord | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const load = async () => {
const response = await apiClient.get<ApiResponse<ApiKeyRecord[]>>("/api/v1/api-keys");
setKeys(response.data || []);
};
useEffect(() => {
if (!isSupportMode) load().catch((cause: any) => setError(cause?.response?.data?.message || "Could not load API keys."));
}, [isSupportMode]);
if (isSupportMode) {
return (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-5 text-sm text-amber-950">
<div className="flex items-center gap-2 font-semibold"><ShieldCheck className="h-4 w-4" />API-key management is protected in Support Mode</div>
<p className="mt-2">Platform support can inspect tenant data for troubleshooting, but cannot create, view, or revoke credentials on the tenant's behalf.</p>
<p className="mt-2">A real tenant administrator must sign in to this workspace and open <strong>Integration Hub API Access</strong> to manage product API keys.</p>
</div>
);
}
const createKey = async () => {
setBusy(true); setError("");
try {
const response = await apiClient.post<ApiResponse<ApiKeyRecord>>("/api/v1/api-keys", { name, expiresInDays });
setCreatedKey(response.data); setName(""); await load();
} catch (cause: any) {
setError(cause?.response?.data?.message || "Could not create API key.");
} finally { setBusy(false); }
};
const revoke = async (id: string) => {
if (!window.confirm("Revoke this API key? Applications using it will immediately lose access.")) return;
await apiClient.delete(`/api/v1/api-keys/${id}`); await load();
};
return (
<div className="space-y-6">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-900">
<div className="flex items-center gap-2 font-semibold"><ShieldCheck className="h-4 w-4" />What this key can do</div>
<p className="mt-2">It can only read products belonging to your tenant through <code>GET /api/v1/external/products</code>. It cannot create, edit, delete, publish, or access another tenant.</p>
</div>
<div className="rounded-lg border border-border p-4">
<h3 className="font-semibold text-foreground">Create a read-only product key</h3>
<div className="mt-3 flex flex-wrap gap-3">
<input aria-label="API key name" value={name} onChange={(event) => setName(event.target.value)} placeholder="Example: Website product reader" maxLength={120} className="min-w-64 rounded-lg border border-border bg-surface px-3 py-2 text-sm" />
<select aria-label="API key expiry" value={expiresInDays} onChange={(event) => setExpiresInDays(Number(event.target.value))} className="rounded-lg border border-border bg-surface px-3 py-2 text-sm">
<option value={30}>30 days</option><option value={90}>90 days</option><option value={180}>180 days</option><option value={365}>365 days</option>
</select>
<button disabled={busy || !name.trim()} onClick={createKey} className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50"><Plus className="h-4 w-4" />Create key</button>
</div>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
</div>
{createdKey?.apiKey && (
<div className="rounded-lg border-2 border-amber-300 bg-amber-50 p-4">
<p className="font-semibold text-amber-950">Copy this key now it will never be shown again.</p>
<div className="mt-3 flex gap-2"><code className="min-w-0 flex-1 overflow-x-auto rounded bg-white p-3 text-sm">{createdKey.apiKey}</code><button aria-label="Copy API key" onClick={() => navigator.clipboard.writeText(createdKey.apiKey!)} className="rounded-lg border border-amber-300 bg-white px-3"><Copy className="h-4 w-4" /></button></div>
<button onClick={() => setCreatedKey(null)} className="mt-3 text-sm font-medium text-amber-900 underline">I have saved it securely</button>
</div>
)}
<div className="overflow-hidden rounded-lg border border-border">
<table className="w-full text-left text-sm"><thead className="bg-surface-muted"><tr><th className="p-3">Name</th><th className="p-3">Prefix</th><th className="p-3">Scope</th><th className="p-3">Expires</th><th className="p-3">Last used</th><th className="p-3">Status</th><th className="p-3"></th></tr></thead>
<tbody>{keys.map((key) => <tr key={key.id} className="border-t border-border"><td className="p-3 font-medium"><span className="inline-flex items-center gap-2"><KeyRound className="h-4 w-4" />{key.name}</span></td><td className="p-3 font-mono text-xs">{key.prefix}</td><td className="p-3">products:read</td><td className="p-3">{new Date(key.expiresAt).toLocaleDateString()}</td><td className="p-3">{key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleString() : "Never"}</td><td className="p-3 capitalize">{key.status}</td><td className="p-3 text-right">{key.status === "active" && <button aria-label={`Revoke ${key.name}`} onClick={() => revoke(key.id)} className="text-red-600"><Trash2 className="h-4 w-4" /></button>}</td></tr>)}</tbody>
</table>
{keys.length === 0 && <p className="p-6 text-center text-muted-foreground">No API keys yet.</p>}
</div>
<div className="rounded-lg border border-border p-4 text-sm"><h3 className="font-semibold">Example request</h3><pre className="mt-2 overflow-x-auto rounded bg-slate-950 p-3 text-slate-100">{`curl https://YOUR-PIM/api/v1/external/products \\\n -H "X-API-Key: pim_live_your_key"`}</pre></div>
</div>
);
}
@@ -1,115 +1,19 @@
import { useState } from "react";
import { Download, Filter, Clock } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Clock, RefreshCw } from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { SearchBar } from "../../../components/customs/SearchBar";
const MOCK_LOGS = [
{
time: "2025-06-09 14:35",
event: "Started Sync",
integration: "Amazon India",
description: "Manual full catalogue sync initiated — 4,240 products by Sarah Chen",
user: "Sarah Chen"
},
{
time: "2025-06-09 12:00",
event: "Scheduled Sync",
integration: "Retail POS Network",
description: "Daily catalogue sync triggered by schedule",
user: "System"
},
{
time: "2025-06-08 16:22",
event: "Updated Credentials",
integration: "Shopify Main Store",
description: "Access token rotated. Previous token expires 2025-07-01",
user: "Michael Torres"
},
{
time: "2025-06-08 09:00",
event: "Created Integration",
integration: "WooCommerce EU",
description: "New integration created in staging environment",
user: "Admin User"
},
];
import { channelsApi } from "../../channels/api/channels.api";
import { notify } from "../../../services/toast";
export default function AuditLogsList() {
const [searchQuery, setSearchQuery] = useState("");
// Optional: Filter logs based on search
const filteredLogs = MOCK_LOGS.filter(log =>
log.event.toLowerCase().includes(searchQuery.toLowerCase()) ||
log.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
log.integration.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div className="space-y-6 pb-6">
{/* SearchBar + Action Buttons */}
<div className="flex items-center gap-3">
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search logs..."
className="flex-1"
/>
<Button variant="outline" className="border-border bg-surface px-4">
<Filter className="w-4 h-4" />
</Button>
<Button variant="outline" className="border-border bg-surface px-4">
<Download className="w-4 h-4" /> Export
</Button>
</div>
{/* Vertical Timeline */}
<div className="bg-surface rounded-2xl border border-border shadow-sm overflow-hidden">
<div className="divide-y divide-border">
{filteredLogs.length > 0 ? (
filteredLogs.map((log, index) => (
<div key={index} className="p-6 flex gap-5 hover:bg-background transition-colors group">
{/* Purple Dot */}
<div className="mt-1.5 w-3 h-3 rounded-full bg-primary flex-shrink-0 ring-4 ring-primary/10" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<div className="font-semibold text-foreground text-[15px]">{log.event}</div>
<span className="px-3 py-0.5 text-xs font-medium bg-primary-light text-primary-dark rounded-full">
{log.integration}
</span>
</div>
<div className="mt-2 text-sm text-muted-foreground leading-relaxed">
{log.description}
</div>
<div className="mt-4 flex items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{log.time}
</div>
<span className="text-muted-foreground"></span>
<span>by {log.user}</span>
</div>
</div>
</div>
))
) : (
<div className="p-12 text-center text-muted-foreground">
No matching logs found.
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-5 border-t border-border bg-background text-center">
<button className="text-sm text-primary font-medium hover:text-primary-dark flex items-center gap-1.5 mx-auto">
Load More Activity
<span className="text-base leading-none"></span>
</button>
</div>
</div>
const [logs, setLogs] = useState<any[]>([]); const [search, setSearch] = useState(""); const [loading, setLoading] = useState(false);
const load = useCallback(async () => { setLoading(true); try { setLogs(await channelsApi.getOperationsAudit()); } catch { notify.error("Unable to load operations audit"); } finally { setLoading(false); } }, []);
useEffect(() => { load(); }, [load]);
const shown = logs.filter(log => `${log.action} ${log.resource} ${JSON.stringify(log.details || {})}`.toLowerCase().includes(search.toLowerCase()));
return <div className="space-y-4">
<div className="flex gap-3"><SearchBar value={search} onChange={setSearch} placeholder="Search audit events..." className="flex-1"/><Button variant="outline" onClick={load} disabled={loading}><RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`}/>Refresh</Button></div>
<div className="border border-border rounded-xl divide-y divide-border bg-surface">
{shown.length ? shown.map(log => <div key={log.id} className="p-5 flex gap-4"><div className="mt-1.5 w-2.5 h-2.5 rounded-full bg-primary shrink-0"/><div><div className="font-semibold">{log.action}</div><div className="text-sm text-muted-foreground mt-1">{log.resource}{log.resource_id ? ` · ${log.resource_id}` : ""}</div><div className="flex items-center gap-1 text-xs text-muted-foreground mt-2"><Clock className="w-3.5 h-3.5"/>{new Date(log.createdAt || log.created_at).toLocaleString()}</div></div></div>) : <div className="p-10 text-center text-sm text-muted-foreground">No tenant-owned channel or integration audit events found.</div>}
</div>
);
</div>;
}
@@ -1,171 +1,23 @@
import { useNavigate } from "react-router-dom";
import { AlertTriangle } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { RefreshCw } from "lucide-react";
import { DataTable } from "../../../components/customs/DataTable";
import { StatusBadge, type BadgeVariant } from "../../../components/customs/StatusBadge";
const MOCK_ERRORS = [
{
id: "1",
product: "Samsung Galaxy S25 Ultra",
variant: "256GB Black",
sku: "P-8841",
integration: "Amazon India",
errorType: "Validation Error",
message: "Required attribute 'bullet_points' missing for Amazon listing compliance",
date: "2025-06-09 14:32",
severity: "High",
status: "Open"
},
{
id: "2",
product: "Sony WH-1000XM5",
variant: "Black",
sku: "P-6621",
integration: "Amazon India",
errorType: "Image Rejected",
message: "Primary image does not meet Amazon image guidelines (minimum 1000px)",
date: "2025-06-09 14:28",
severity: "High",
status: "Open"
},
{
id: "3",
product: "Nike Air Max 270",
variant: "UK 8 White",
sku: "P-4412",
integration: "Shopify Main Store",
errorType: "SKU Conflict",
message: "SKU 'NAM270-W8' already exists in Shopify with different product ID",
date: "2025-06-09 14:01",
severity: "Critical",
status: "Retrying"
},
{
id: "4",
product: "Adidas Ultraboost 24",
variant: "UK 9 Blue",
sku: "P-4520",
integration: "Shopify Main Store",
errorType: "Rate Limit",
message: "Shopify API rate limit exceeded (40/s). Request queued for retry.",
date: "2025-06-09 13:58",
severity: "Medium",
status: "Resolved"
},
{
id: "5",
product: "WMS Inventory Batch",
variant: "-",
sku: "BATCH-284",
integration: "Warehouse WMS",
errorType: "Connection Timeout",
message: "Connection to warehouse endpoint timed out after 30s. Host: wms.internal:8080",
date: "2025-06-08 08:03",
severity: "Critical",
status: "Open"
},
];
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { Button } from "../../../components/customs/Button";
import { channelsApi } from "../../channels/api/channels.api";
import { notify } from "../../../services/toast";
export default function ErrorCenterList() {
const navigate = useNavigate();
const [items, setItems] = useState<any[]>([]); const [loading, setLoading] = useState(false);
const load = useCallback(async () => { setLoading(true); try { setItems(await channelsApi.getErrors()); } catch { notify.error("Unable to load syndication errors"); } finally { setLoading(false); } }, []);
useEffect(() => { load(); }, [load]);
const columns = [
{
key: "product",
label: "PRODUCT",
render: (_: any, row: any) => (
<div>
<div className="font-medium text-foreground">{row.product}</div>
<div className="text-xs text-muted-foreground mt-0.5">{row.sku}</div>
</div>
),
},
{ key: "variant", label: "VARIANT" },
{ key: "integration", label: "INTEGRATION" },
{
key: "errorType",
label: "ERROR TYPE",
render: (val: string) => (
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-amber-500" />
<span className="font-medium text-foreground">{val}</span>
</div>
),
},
{
key: "message",
label: "MESSAGE",
render: (val: string) => <div className="text-sm text-muted-foreground line-clamp-2 max-w-md">{val}</div>
},
{ key: "date", label: "DATE" },
{
key: "severity",
label: "SEVERITY",
render: (val: string) => {
let variant: BadgeVariant = "neutral";
if (val === "Critical") variant = "error";
if (val === "High") variant = "warning";
if (val === "Medium") variant = "approval";
return <StatusBadge status={variant} label={val} />;
},
},
{
key: "status",
label: "STATUS",
render: (val: string) => {
let variant: BadgeVariant = "neutral";
if (val === "Open") variant = "error"; // red
if (val === "Retrying") variant = "warning"; // orange
if (val === "Resolved") variant = "success"; // green
return <StatusBadge status={variant} label={val} />;
},
},
{ key: "product", label: "PRODUCT", render: (_: any, r: any) => <div><div className="font-medium">{r.product?.name || "Unknown product"}</div><div className="text-xs text-muted-foreground">{r.product?.code || r.product_id}</div></div> },
{ key: "channel", label: "CHANNEL", render: (_: any, r: any) => r.channel?.name || "—" },
{ key: "error_code", label: "ERROR", render: (v: string) => <span className="font-mono text-xs">{v || "UNKNOWN"}</span> },
{ key: "error_message", label: "MESSAGE" },
{ key: "attempt_count", label: "ATTEMPTS" },
{ key: "status", label: "STATUS", render: (v: string) => <StatusBadge status={v === "retrying" ? "warning" : "error"} label={v} /> },
{ key: "updated_at", label: "UPDATED", render: (v: string) => new Date(v).toLocaleString() }
];
return (
<div className="space-y-6">
{/* Summary Banner */}
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 flex items-center gap-4">
<div className="p-3 bg-surface rounded-lg">
<AlertTriangle className="w-6 h-6 text-amber-600" />
</div>
<div>
<div className="font-semibold text-amber-900">5 Active Errors 3 require immediate attention</div>
<div className="text-sm text-amber-700 mt-0.5">Review and resolve to maintain sync health</div>
</div>
</div>
<DataTable
columns={columns}
data={MOCK_ERRORS}
actionConfig={{
onView: (row) => navigate(`${row.id}/view`),
}}
searchPlaceholder="Search errors..."
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">
<option>All Integrations</option>
<option>Amazon India</option>
<option>Shopify Main Store</option>
</select>
<select className="h-9 px-3 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<option>All Severities</option>
<option>Critical</option>
<option>High</option>
<option>Medium</option>
</select>
<select className="h-9 px-3 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<option>All Statuses</option>
<option>Open</option>
<option>Retrying</option>
<option>Resolved</option>
</select>
</div>
}
/>
</div>
);
return <DataTable columns={columns} data={items} rowIdKey="id" resultLabel="errors" searchPlaceholder="Search errors..." toolbarLeft={<Button variant="outline" onClick={load} disabled={loading}><RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`}/>Refresh Errors</Button>} />;
}
@@ -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>
);
};
@@ -1,169 +1,25 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Plus, Download } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { RefreshCw } from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { DataTable } from "../../../components/customs/DataTable";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
const MOCK_RULES = [
{
id: "1",
name: "Electronics to Amazon",
integration: "Amazon India",
family: "Consumer Electronics",
category: "All Categories",
workflowState: "Approved",
products: 2840,
status: "active",
lastEvaluated: "2025-06-09 14:32"
},
{
id: "2",
name: "Fashion to Shopify",
integration: "Shopify Main Store",
family: "Apparel & Fashion",
category: "Clothing",
workflowState: "Published",
products: 5120,
status: "active",
lastEvaluated: "2025-06-09 14:00"
},
{
id: "3",
name: "Retail Products to POS",
integration: "Retail POS Network",
family: "All Families",
category: "All Categories",
workflowState: "Approved",
products: 3240,
status: "active",
lastEvaluated: "2025-06-09 12:00"
},
{
id: "4",
name: "Warehouse Items to WMS",
integration: "Warehouse WMS",
family: "All Families",
category: "All Categories",
workflowState: "Any",
products: 0,
status: "inactive",
lastEvaluated: "2025-06-08 08:00"
},
{
id: "5",
name: "UAE Electronics",
integration: "Amazon UAE",
family: "Consumer Electronics",
category: "Smartphones",
workflowState: "Published",
products: 840,
status: "active",
lastEvaluated: "2025-06-09 13:15"
},
];
import { channelsApi } from "../../channels/api/channels.api";
import { notify } from "../../../services/toast";
export default function PublishingRulesList() {
const navigate = useNavigate();
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: "", name: "" });
const [rows, setRows] = useState<any[]>([]); const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const channels = await channelsApi.getAll();
const mappings = await Promise.all(channels.map(async channel => ({ channel, mappings: await channelsApi.getMappings(channel.id) })));
setRows(mappings.map(({ channel, mappings }) => ({ id: channel.id, name: channel.name, code: channel.code, status: channel.status, mappedFields: mappings.length, requiredFields: mappings.filter((m:any) => m.is_required).length, transformations: [...new Set(mappings.map((m:any) => m.transformation_rule).filter((v:string) => v && v !== 'none'))].join(', ') || 'None' })));
} catch { notify.error("Unable to load channel mapping rules"); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const columns = [
{
key: "name",
label: "RULE NAME",
sortable: true,
render: (val: string, row: any) => (
<div>
<div className="font-semibold text-foreground">{val}</div>
<div className="text-xs text-muted-foreground mt-0.5">{row.integration}</div>
</div>
),
},
{ key: "family", label: "FAMILY" },
{ key: "category", label: "CATEGORY" },
{
key: "workflowState",
label: "WORKFLOW STATE",
render: (val: string) => {
let variant: any = "neutral";
let label = val;
if (val === "Approved") variant = "success";
if (val === "Published") variant = "published";
if (val === "Any") variant = "warning";
return <StatusBadge status={variant} label={label} />;
},
},
{
key: "products",
label: "PRODUCTS",
render: (val: number) => <span className="font-semibold text-foreground">{val.toLocaleString()}</span>
},
{
key: "status",
label: "STATUS",
render: (val: string) => {
const variant = val === "active" ? "active" : "error"; // active = green, inactive = red
const label = val === "active" ? "Active" : "Inactive";
return <StatusBadge status={variant} label={label} />;
},
},
{ key: "lastEvaluated", label: "LAST EVALUATED" },
{ key: "name", label: "CHANNEL" }, { key: "code", label: "CODE" }, { key: "mappedFields", label: "MAPPED FIELDS" },
{ key: "requiredFields", label: "REQUIRED" }, { key: "transformations", label: "TRANSFORMATIONS" }, { key: "status", label: "STATUS" }
];
const handleDeleteConfirm = () => {
console.log("Deleted rule:", deleteModal.id);
setDeleteModal({ isOpen: false, id: "", name: "" });
};
return (
<div className="space-y-6">
<DataTable
columns={columns}
data={MOCK_RULES}
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 rules..."
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 Integrations</option>
<option>Amazon India</option>
<option>Shopify Main Store</option>
<option>Warehouse WMS</option>
</select>
</div>
}
toolbarRight={
<div className="flex gap-2">
<Button variant="outline">
<Download className="w-4 h-4 mr-2" />
Export Rules
</Button>
<Button
className="bg-primary hover:bg-primary-hover text-white"
onClick={() => navigate("/integrations/new-rule")}
>
<Plus className="w-4 h-4 mr-2" />
Create New Rule
</Button>
</div>
}
/>
<ConfirmationModal
isOpen={deleteModal.isOpen}
title="Delete Publishing Rule"
description="Are you sure you want to delete this publishing rule? This action cannot be undone."
itemName={deleteModal.name}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</div>
);
return <DataTable columns={columns} data={rows} rowIdKey="id" resultLabel="channel rule sets" searchPlaceholder="Search channel rules..." toolbarLeft={<Button variant="outline" onClick={load} disabled={loading}><RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`}/>Refresh Rules</Button>} />;
}
@@ -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,31 +9,40 @@ 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";
import type { Integration } from "../types/integrations.types";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { StatsCard } from "../../../components/customs/StatsCard";
import { channelsApi } from "../../channels/api/channels.api";
// 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";
// ──────────────────────────────────────────────
import ApiAccessTab from "../components/ApiAccessTab";
const INTEGRATION_META: Record<string, { label: string; icon: any; color: string; bg: string }> = {
shopify: { label: "Shopify", icon: ShoppingCart, color: "text-green-600", bg: "bg-green-50" },
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" },
erp: { label: "ERP", icon: Server, color: "text-red-600", bg: "bg-red-50" },
@@ -47,21 +56,40 @@ 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(() => {
fetchItems();
fetchChannels();
Promise.all([channelsApi.getAllJobs(), channelsApi.getErrors()])
.then(([jobs, errors]) => setOperations({ jobs, errors }))
.catch(() => setOperations({ jobs: [], errors: [] }));
}, [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 +103,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 +142,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 +153,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 +233,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 +241,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>
);
}
+123 -200
View File
@@ -3,9 +3,8 @@ import { useNavigate, useParams, useLocation } from "react-router-dom";
import { useFormik } from "formik";
import * as Yup from "yup";
import {
Zap, ShoppingCart, ShoppingBag, Server, Warehouse, Check,
Store, Globe, Smartphone, Monitor, Code2, Eye, EyeOff, Settings2,
Calendar, Clock, Wifi, ChevronDown, Info
Zap, ShoppingCart, Check, Code2, Eye, EyeOff, Settings2,
Info
} from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { useIntegration } from "../hook/useIntegration";
@@ -23,21 +22,13 @@ const STEPS = [
];
const INTEGRATION_TYPES = [
{ id: "ecommerce", label: "E-Commerce", sub: "Shopify, WooCommerce, Magento", icon: ShoppingCart },
{ id: "marketplace", label: "Marketplace", sub: "Amazon, eBay, Flipkart, Noon", icon: ShoppingBag },
{ id: "erp", label: "ERP", sub: "SAP, Oracle, Microsoft Dynamics", icon: Server },
{ id: "wms", label: "WMS", sub: "Warehouse management systems", icon: Warehouse },
{ id: "pos", label: "POS", sub: "Retail point-of-sale systems", icon: Store },
{ id: "b2b_portal", label: "B2B Portal", sub: "B2B client storefronts and portals", icon: Globe },
{ id: "mobile_app", label: "Mobile App", sub: "iOS/Android application platforms", icon: Smartphone },
{ id: "website", label: "Website", sub: "Marketing and catalog websites", icon: Monitor },
{ id: "custom_api", label: "Custom API", sub: "Any REST or GraphQL endpoint", icon: Code2 },
{ id: "custom_api", label: "Generic REST API", sub: "Send mapped products to any HTTPS website or application", icon: Code2 },
{ id: "shopify", label: "Shopify", sub: "Separate Shopify credential flow; publishing is the next phase", icon: ShoppingCart },
];
function IntegrationSummary({ name, channel, channelName, type, environment, syncDirection, syncFrequency, disabled }: {
function IntegrationSummary({ name, channel, channelName, type, environment, syncDirection, syncFrequency }: {
name: string; channel: string; channelName: string; type: string;
environment: string; syncDirection: string; syncFrequency: string;
disabled?: boolean;
}) {
return (
<div className="w-60 shrink-0">
@@ -75,9 +66,9 @@ function IntegrationSummary({ name, channel, channelName, type, environment, syn
</div>
</div>
<div className="mt-5 pt-5 border-t border-border">
<button type="button" disabled={disabled} className="w-full flex items-center justify-center gap-2 py-2.5 px-4 border border-warning/20 bg-warning/10 text-warning rounded-lg text-sm font-medium hover:bg-warning/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
<button type="button" disabled className="w-full flex items-center justify-center gap-2 py-2.5 px-4 border border-warning/20 bg-warning/10 text-warning rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed">
<Zap className="w-4 h-4" />
Test Connection
Use Test Connection above
</button>
</div>
</div>
@@ -96,6 +87,12 @@ const integrationSchema = Yup.object({
syncFrequency: Yup.string().required(),
autoRetry: Yup.boolean(),
retryAttempts: Yup.number().min(1).max(10),
storeUrl: Yup.string().when('integrationType', { is: 'shopify', then: schema => schema.required('Permanent .myshopify.com domain is required').matches(/^https:\/\/[a-z0-9][a-z0-9-]*\.myshopify\.com\/?$/i, 'Use https://your-store.myshopify.com') }),
clientId: Yup.string().when('integrationType', { is: 'shopify', then: schema => schema.required('Shopify Client ID is required') }),
clientSecret: Yup.string(),
endpoint: Yup.string().when('integrationType', { is: 'custom_api', then: schema => schema.required('Delivery endpoint is required').url('Enter a complete HTTPS URL').matches(/^https:\/\//i, 'External endpoints must use HTTPS') }),
testEndpoint: Yup.string().url('Enter a complete URL').matches(/^(https:\/\/|$)/i, 'External endpoints must use HTTPS'),
customApiHeaderName: Yup.string().matches(/^[A-Za-z0-9-]*$/, 'Header names may contain letters, numbers and hyphens only'),
});
export default function NewIntegration() {
@@ -107,6 +104,8 @@ export default function NewIntegration() {
const [activeStep, setActiveStep] = useState('general');
const [showToken, setShowToken] = useState(false);
const [loadingItem, setLoadingItem] = useState(false);
const [testingConnection, setTestingConnection] = useState(false);
const [connectionMessage, setConnectionMessage] = useState("");
const { createItem, updateItem } = useIntegration();
const { items: channels, fetchItems: fetchChannels } = useChannel();
@@ -119,17 +118,17 @@ export default function NewIntegration() {
initialValues: {
name: "",
channel: "",
integrationType: "ecommerce",
integrationType: "custom_api",
environment: "production",
status: "Connected",
description: "",
storeUrl: "",
accessToken: "",
apiVersion: "2025-01",
apiVersion: "2026-07",
webhookSecret: "",
shopIdentifier: "",
syncDirection: "pim_to_channel",
syncFrequency: "hourly",
syncFrequency: "manual",
autoRetry: true,
retryAttempts: 3,
@@ -142,7 +141,7 @@ export default function NewIntegration() {
clientSecret: "",
// ERP/WMS specific
authMethod: "bearer",
authMethod: "apikey",
authToken: "",
username: "",
password: "",
@@ -159,17 +158,35 @@ export default function NewIntegration() {
gatewayUrl: "",
// Custom API specific
customApiUrl: "",
customApiHeaderKey: "",
endpoint: "",
testEndpoint: "",
method: "POST",
customApiHeaderName: "X-API-Key",
customApiHeaderValue: "",
},
validationSchema: integrationSchema,
onSubmit: async (values, { setSubmitting }) => {
onSubmit: async (values, { setSubmitting, setFieldError }) => {
if (!isEdit && values.integrationType === 'shopify' && !values.clientSecret) {
setFieldError('clientSecret', 'Shopify Client Secret is required');
setActiveStep('connection');
setSubmitting(false);
return;
}
if (!isEdit && values.integrationType === 'custom_api' && values.authMethod === 'bearer' && !values.authToken) {
setFieldError('authToken', 'Bearer token is required'); setActiveStep('connection'); setSubmitting(false); return;
}
if (!isEdit && values.integrationType === 'custom_api' && values.authMethod === 'apikey' && !values.customApiHeaderValue) {
setFieldError('customApiHeaderValue', 'API key value is required'); setActiveStep('connection'); setSubmitting(false); return;
}
const submission = values.integrationType === 'custom_api' ? {
...values,
clearSecretFields: values.authMethod === 'none' ? ['authToken', 'customApiHeaderValue'] : values.authMethod === 'bearer' ? ['customApiHeaderValue'] : ['authToken']
} : values;
try {
if (isEdit && id) {
await updateItem(id, values as any);
await updateItem(id, submission as any);
} else {
await createItem(values as any);
await createItem(submission as any);
}
navigate("..");
} catch {
@@ -194,7 +211,7 @@ export default function NewIntegration() {
description: item.description || "",
storeUrl: item.storeUrl || "",
accessToken: item.accessToken || "",
apiVersion: item.apiVersion || "2025-01",
apiVersion: item.apiVersion || "2026-07",
webhookSecret: item.webhookSecret || "",
shopIdentifier: item.shopIdentifier || "",
syncDirection: item.syncDirection,
@@ -220,8 +237,10 @@ export default function NewIntegration() {
appId: item.appId || "",
bundleIdentifier: item.bundleIdentifier || "",
gatewayUrl: item.gatewayUrl || "",
customApiUrl: item.customApiUrl || "",
customApiHeaderKey: item.customApiHeaderKey || "",
endpoint: item.endpoint || item.customApiUrl || "",
testEndpoint: item.testEndpoint || "",
method: item.method || "POST",
customApiHeaderName: item.customApiHeaderName || item.customApiHeaderKey || "X-API-Key",
customApiHeaderValue: item.customApiHeaderValue || "",
});
}
@@ -233,11 +252,19 @@ export default function NewIntegration() {
const handleChannelChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const val = e.target.value;
formik.setFieldValue("channel", val);
// Auto-match integrationType to channelType from Channel Registry
const selectedChan = channels.find(c => c.code === val || c.id === val);
if (selectedChan && selectedChan.channelType) {
formik.setFieldValue("integrationType", selectedChan.channelType);
};
const handleTestConnection = async () => {
if (!id) return;
setTestingConnection(true);
setConnectionMessage("");
try {
const result = await integrationsService.testConnection(id);
setConnectionMessage(result.message);
} catch (error: any) {
setConnectionMessage(error?.response?.data?.message || "Connection test failed");
} finally {
setTestingConnection(false);
}
};
@@ -283,10 +310,12 @@ export default function NewIntegration() {
{!isView && (
<button
type="button"
onClick={handleTestConnection}
disabled={!id || testingConnection}
className="flex items-center gap-2 py-2 px-4 border border-warning/20 bg-warning/10 text-warning rounded-lg text-sm font-medium hover:bg-warning/20 transition-colors"
>
<Zap className="w-4 h-4" />
Test Connection
{testingConnection ? "Testing…" : id ? "Test Connection" : "Save Before Testing"}
</button>
)}
<Button variant="outline" type="button" onClick={() => navigate('..')}>
@@ -344,6 +373,18 @@ export default function NewIntegration() {
<CardHeader title="General Information" subtitle="Name, channel, environment and status" />
<div className="p-6 space-y-5">
<div>
<label className={labelClass}>How will products leave the PIM? *</label>
<div className="grid grid-cols-2 gap-3">
{INTEGRATION_TYPES.map((type) => {
const Icon = type.icon;
const selected = formik.values.integrationType === type.id;
return <button key={type.id} type="button" onClick={() => formik.setFieldValue('integrationType', type.id)} className={`rounded-xl border-2 p-4 text-left transition-colors ${selected ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/40'}`}><div className="flex items-center gap-2"><Icon className={`h-5 w-5 ${selected ? 'text-primary' : 'text-muted-foreground'}`} /><span className="font-semibold">{type.label}</span></div><p className="mt-2 text-xs text-muted-foreground">{type.sub}</p></button>;
})}
</div>
<div className="mt-3 rounded-lg bg-surface-muted p-3 text-xs text-muted-foreground"><strong>Channel</strong> decides which fields and transformations are sent. <strong>Integration</strong> stores where to send them and how the destination authenticates PIM.</div>
</div>
<div>
<label className={labelClass}>Integration Name *</label>
<input
@@ -351,7 +392,7 @@ export default function NewIntegration() {
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Amazon India Production"
placeholder="e.g. Main Website REST API"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
/>
{formik.touched.name && formik.errors.name && (
@@ -371,7 +412,7 @@ export default function NewIntegration() {
>
<option value="">Select a channel from Channel Registry...</option>
{channels.map((chan) => (
<option key={chan.id} value={chan.code || chan.id}>
<option key={chan.id} value={chan.id}>
{chan.name} ({chan.code})
</option>
))}
@@ -396,21 +437,7 @@ export default function NewIntegration() {
</select>
</div>
</div>
<div>
<label className={labelClass}>Status</label>
<div className="relative">
<select
name="status"
value={formik.values.status}
onChange={formik.handleChange}
className={inputClass()}
>
<option value="Connected">Connected</option>
<option value="Pending">Pending</option>
<option value="Disconnected">Disconnected</option>
</select>
</div>
</div>
<div><label className={labelClass}>Connection Status</label><div className="rounded-lg border border-border bg-surface-muted px-3 py-2.5 text-sm">Pending until credentials are tested successfully</div></div>
</div>
<div>
@@ -429,8 +456,8 @@ export default function NewIntegration() {
<div className="flex items-start gap-3 p-4 bg-blue-50 border border-blue-100 rounded-xl text-sm text-blue-700">
<Info className="w-4 h-4 text-blue-500 mt-0.5 shrink-0" />
<p>
{formik.values.integrationType === "ecommerce" || formik.values.integrationType === "website" || formik.values.integrationType === "b2b_portal" ? (
"Provide your platform URL, access credentials, and store configuration identifiers."
{formik.values.integrationType === "shopify" ? (
"Enter the permanent .myshopify.com domain plus the Client ID and Client Secret from Shopify Dev Dashboard → Apps → your app → Settings. The PIM obtains short-lived access tokens automatically."
) : formik.values.integrationType === "marketplace" ? (
"Marketplace connection requires Seller ID, Marketplace ID, and Selling Partner API credentials."
) : formik.values.integrationType === "erp" || formik.values.integrationType === "wms" ? (
@@ -440,16 +467,16 @@ export default function NewIntegration() {
) : formik.values.integrationType === "mobile_app" ? (
"Enter application identifiers, bundle settings, and push sync settings."
) : (
"Set up custom REST or GraphQL webhook integrations using key-value headers."
"Your website or application must provide an HTTPS product endpoint and tell you how it authenticates requests. The PIM will send mapped product JSON to that endpoint; it does not host or create the destination API."
)}
</p>
</div>
{/* Ecommerce / Website / B2B Portal Fields */}
{(formik.values.integrationType === "ecommerce" || formik.values.integrationType === "website" || formik.values.integrationType === "b2b_portal") && (
{/* Shopify Fields */}
{formik.values.integrationType === "shopify" && (
<>
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Store / Website URL *</label>
<label className="block text-sm font-medium text-foreground mb-1.5">Permanent Shopify Store Domain *</label>
<input
name="storeUrl"
value={formik.values.storeUrl}
@@ -457,18 +484,25 @@ export default function NewIntegration() {
placeholder="https://your-store.myshopify.com"
className={inputClass()}
/>
<p className="mt-1 text-xs text-muted-foreground">Use the permanent <strong>.myshopify.com</strong> domainnot the storefront domain and not the PIM URL.</p>
{formik.touched.storeUrl && formik.errors.storeUrl && <p className="mt-1 text-xs text-red-500">{formik.errors.storeUrl}</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Access Token *</label>
<label className="block text-sm font-medium text-foreground mb-1.5">Shopify Client ID *</label>
<input name="clientId" value={formik.values.clientId} onChange={formik.handleChange} placeholder="Dev Dashboard → App → Settings" className={inputClass()} />
{formik.touched.clientId && formik.errors.clientId && <p className="mt-1 text-xs text-red-500">{formik.errors.clientId}</p>}
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Shopify Client Secret *</label>
<div className="relative">
<input
name="accessToken"
name="clientSecret"
type={showToken ? "text" : "password"}
value={formik.values.accessToken}
value={formik.values.clientSecret}
onChange={formik.handleChange}
placeholder="shpat_xxxxxxxxxxx"
placeholder="Dev Dashboard → App → Settings"
className={inputClass() + " pr-10"}
/>
<button
@@ -479,44 +513,11 @@ export default function NewIntegration() {
{showToken ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">API Version / Edition</label>
<select
name="apiVersion"
value={formik.values.apiVersion}
onChange={formik.handleChange}
className={inputClass()}
>
<option value="2025-01">2025-01 (Latest)</option>
<option value="2024-10">2024-10</option>
<option value="2024-07">2024-07</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Webhook Secret Key</label>
<input
name="webhookSecret"
value={formik.values.webhookSecret}
onChange={formik.handleChange}
placeholder="Optional webhook secret"
className={inputClass()}
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Shop Identifier</label>
<input
name="shopIdentifier"
value={formik.values.shopIdentifier}
onChange={formik.handleChange}
placeholder="e.g., store_main"
className={inputClass()}
/>
{formik.errors.clientSecret && <p className="mt-1 text-xs text-red-500">{formik.errors.clientSecret}</p>}
</div>
</div>
<div className="rounded-lg border border-border bg-surface-muted p-3 text-sm"><strong>Admin API version:</strong> 2026-07, managed by the PIM. No access token, separate webhook secret, or shop identifier is required for the first connection test.</div>
{connectionMessage && <div className="rounded-lg border border-border p-3 text-sm">{connectionMessage}</div>}
</>
)}
@@ -797,40 +798,38 @@ export default function NewIntegration() {
{/* Custom API Fields */}
{formik.values.integrationType === "custom_api" && (
<>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-900">
<p className="font-semibold">Before filling this form, ask your website developer for:</p>
<ol className="mt-2 list-decimal space-y-1 pl-5"><li>The HTTPS URL that accepts one product as JSON.</li><li>Whether it needs no authentication, a Bearer token, or an API-key header.</li><li>A safe test URL if testing must not create a real product.</li></ol>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Base API Gateway URL *</label>
<label className="block text-sm font-medium text-foreground mb-1.5">Product Delivery URL *</label>
<input
name="customApiUrl"
value={formik.values.customApiUrl}
name="endpoint"
value={formik.values.endpoint}
onChange={formik.handleChange}
placeholder="https://api.externalpartner.com/v2"
onBlur={formik.handleBlur}
placeholder="https://your-site.com/api/pim/products"
className={inputClass()}
/>
<p className="mt-1 text-xs text-muted-foreground">PIM sends one mapped product to this URL during a syndication job.</p>
{formik.touched.endpoint && formik.errors.endpoint && <p className="mt-1 text-xs text-red-500">{formik.errors.endpoint}</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Custom Header Name</label>
<input
name="customApiHeaderKey"
value={formik.values.customApiHeaderKey}
onChange={formik.handleChange}
placeholder="X-Partner-Token"
className={inputClass()}
/>
<label className={labelClass}>HTTP Method</label>
<select name="method" value={formik.values.method} onChange={formik.handleChange} className={inputClass()}><option value="POST">POST create/upsert</option><option value="PUT">PUT replace/upsert</option><option value="PATCH">PATCH partial update</option></select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Custom Header Value</label>
<input
name="customApiHeaderValue"
type="password"
value={formik.values.customApiHeaderValue}
onChange={formik.handleChange}
placeholder="token-value"
className={inputClass()}
/>
<label className={labelClass}>Authentication</label>
<select name="authMethod" value={formik.values.authMethod} onChange={formik.handleChange} className={inputClass()}><option value="apikey">API-key header</option><option value="bearer">Bearer token</option><option value="none">No authentication</option></select>
</div>
</div>
{formik.values.authMethod === 'apikey' && <div className="grid grid-cols-2 gap-4"><div><label className={labelClass}>API-key Header Name *</label><input name="customApiHeaderName" value={formik.values.customApiHeaderName} onChange={formik.handleChange} placeholder="X-API-Key" className={inputClass()} />{formik.errors.customApiHeaderName && <p className="mt-1 text-xs text-red-500">{formik.errors.customApiHeaderName}</p>}</div><div><label className={labelClass}>API-key Value *</label><input name="customApiHeaderValue" type={showToken ? 'text' : 'password'} value={formik.values.customApiHeaderValue} onChange={formik.handleChange} placeholder={isEdit ? 'Leave blank to keep current secret' : 'Provided by your website developer'} className={inputClass()} />{formik.errors.customApiHeaderValue && <p className="mt-1 text-xs text-red-500">{formik.errors.customApiHeaderValue}</p>}</div></div>}
{formik.values.authMethod === 'bearer' && <div><label className={labelClass}>Bearer Token *</label><input name="authToken" type={showToken ? 'text' : 'password'} value={formik.values.authToken} onChange={formik.handleChange} placeholder={isEdit ? 'Leave blank to keep current token' : 'Provided by your website developer'} className={inputClass()} />{formik.errors.authToken && <p className="mt-1 text-xs text-red-500">{formik.errors.authToken}</p>}</div>}
<div><label className={labelClass}>Safe Connection-Test URL <span className="font-normal text-muted-foreground">(optional)</span></label><input name="testEndpoint" value={formik.values.testEndpoint} onChange={formik.handleChange} onBlur={formik.handleBlur} placeholder="https://your-site.com/api/pim/test" className={inputClass()} /><p className="mt-1 text-xs text-muted-foreground">Test Connection sends a small <code>pim.connection.test</code> JSON request here. If blank, it uses the Product Delivery URL.</p>{formik.touched.testEndpoint && formik.errors.testEndpoint && <p className="mt-1 text-xs text-red-500">{formik.errors.testEndpoint}</p>}</div>
<div className="rounded-lg border border-border bg-surface-muted p-4 text-sm"><p className="font-semibold">What happens after Save</p><ol className="mt-2 list-decimal space-y-1 pl-5"><li>The secret is encrypted and the Integration stays Pending.</li><li>Test Connection verifies the URL and credentials.</li><li>Your Channel mapping builds the JSON body.</li><li>Trigger Sync queues products; the worker sends them with retries and idempotency.</li><li>Jobs and Error Center show every product result.</li></ol></div>
{connectionMessage && <div className="rounded-lg border border-border p-3 text-sm">{connectionMessage}</div>}
</>
)}
</div>
@@ -844,59 +843,12 @@ export default function NewIntegration() {
<div>
<label className="block text-sm font-medium text-foreground mb-3">Sync Direction</label>
<div className="grid grid-cols-3 gap-3">
{[
{ id: "pim_to_channel", label: "PIM → Channel", sub: "Publish product data to the external system", icon: "↑" },
{ id: "channel_to_pim", label: "Channel → PIM", sub: "Import data from external system into PIM", icon: "↓" },
{ id: "bidirectional", label: "Bidirectional", sub: "Sync data in both directions", icon: "↕" },
].map((d) => {
const isSelected = formik.values.syncDirection === d.id;
return (
<button
key={d.id}
type="button"
onClick={() => formik.setFieldValue("syncDirection", d.id)}
className={`flex flex-col items-center text-center p-4 rounded-xl border-2 transition-all gap-2 ${
isSelected ? "border-primary bg-primary/5" : "border-border hover:border-border"
}`}
>
<span className={`text-xl font-bold ${isSelected ? "text-primary" : "text-muted-foreground"}`}>{d.icon}</span>
<div>
<div className={`text-sm font-semibold ${isSelected ? "text-primary-dark" : "text-foreground"}`}>{d.label}</div>
<div className="text-[11px] text-muted-foreground mt-1">{d.sub}</div>
</div>
</button>
);
})}
</div>
<div className="rounded-xl border-2 border-primary bg-primary/5 p-4"><div className="text-sm font-semibold text-primary-dark">PIM External System</div><div className="mt-1 text-xs text-muted-foreground">This implemented flow publishes PIM products outward. Import and bidirectional sync will appear only after their conflict and ownership rules exist.</div></div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-3">Sync Frequency</label>
<div className="grid grid-cols-4 gap-3">
{[
{ id: "manual", label: "Manual", icon: Settings2 },
{ id: "hourly", label: "Hourly", icon: Clock },
{ id: "daily", label: "Daily", icon: Calendar },
{ id: "realtime", label: "Realtime", icon: Wifi },
].map((f) => {
const Icon = f.icon;
const isSelected = formik.values.syncFrequency === f.id;
return (
<button
key={f.id}
type="button"
onClick={() => formik.setFieldValue("syncFrequency", f.id)}
className={`flex flex-col items-center text-center py-4 px-2 rounded-xl border-2 transition-all gap-2 ${
isSelected ? "border-primary bg-primary/5" : "border-border hover:border-border"
}`}
>
<Icon className={`w-5 h-5 ${isSelected ? "text-primary" : "text-muted-foreground"}`} />
<span className={`text-sm font-medium ${isSelected ? "text-primary-dark" : "text-foreground"}`}>{f.label}</span>
</button>
);
})}
</div>
<div className="rounded-xl border-2 border-primary bg-primary/5 p-4"><div className="flex items-center gap-2 text-sm font-semibold text-primary-dark"><Settings2 className="h-4 w-4" />Manual trigger</div><div className="mt-1 text-xs text-muted-foreground">Use Trigger Sync from Channel Registry. Hourly, daily and realtime schedules are hidden until a scheduler is implemented.</div></div>
</div>
<div className="border border-border rounded-xl p-4 space-y-4">
@@ -943,37 +895,9 @@ export default function NewIntegration() {
{activeStep === 'advanced' && (
<div className="space-y-4">
<div className="bg-surface rounded-xl border border-border shadow-sm overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between px-6 py-4 text-left hover:bg-background transition-colors"
>
<div>
<p className="font-medium text-foreground">Localisation & Defaults</p>
<p className="text-xs text-muted-foreground mt-0.5">Configure default locale, currency, and timezone for this integration</p>
</div>
<ChevronDown className="w-4 h-4 text-muted-foreground" />
</button>
</div>
{[
{ label: "Field Mapping", desc: "Map PIM attribute fields to external system field schemas" },
{ label: "Category Mapping", desc: "Map PIM category taxonomy to external system categories" },
{ label: "Transformation Rules", desc: "Define data transformation and enrichment rules during sync" },
].map((item) => (
<div key={item.label} className="bg-surface rounded-xl border border-border shadow-sm p-5 flex items-center gap-4">
<div className="w-9 h-9 bg-surface-muted rounded-lg flex items-center justify-center">
<Settings2 className="w-4 h-4 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="font-medium text-foreground">{item.label}</p>
<span className="px-2 py-0.5 text-[10px] font-bold uppercase bg-amber-50 text-amber-600 border border-amber-200 rounded">Coming Soon</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5">{item.desc}</p>
</div>
</div>
))}
<div className="rounded-xl border border-green-200 bg-green-50 p-5"><p className="font-medium text-green-900">Field mapping and transformations are available now</p><p className="mt-1 text-xs text-green-800">Configure them on the selected Channel. Saved mappings are already used by CSV export and syndication payload generation.</p><button type="button" onClick={() => navigate('/channels')} className="mt-3 text-sm font-semibold text-green-900 underline">Open Channel Registry</button></div>
<div className="rounded-xl border border-border bg-surface p-5"><p className="font-medium">Localisation defaults later</p><p className="mt-1 text-xs text-muted-foreground">Not required to verify Shopify credentials or publish a first basic product. Add locale, currency and Shopify Markets behavior afterward.</p></div>
<div className="rounded-xl border border-border bg-surface p-5"><p className="font-medium">Category mapping later</p><p className="mt-1 text-xs text-muted-foreground">Not required for the first product. Shopify collections and taxonomy mapping belong in the next publishing phase.</p></div>
</div>
)}
@@ -992,7 +916,6 @@ export default function NewIntegration() {
{/* Right: Integration Summary */}
<IntegrationSummary
disabled={isView}
name={formik.values.name}
channel={formik.values.channel}
channelName={selectedChanName}
@@ -1,31 +1,91 @@
import type { Integration, IntegrationCreateRequest, IntegrationUpdateRequest } from '../types/integrations.types';
import apiClient from '../../../api/axiosInstance';
import type { Integration, IntegrationCreateRequest, IntegrationUpdateRequest, TestConnectionResult, SyncJob, SyncItem } from '../types/integrations.types';
interface ApiResponse<T> {
type ApiResponse<T> = {
success: boolean;
data: T;
message?: string;
}
data: T;
};
export const integrationsService = {
getAll: async (): Promise<Integration[]> => {
const res = await apiClient.get<ApiResponse<Integration[]>>('/api/v1/integrations');
return res.data || [];
export const integrationService = {
getAll: async (params?: Record<string, any>): Promise<Integration[]> => {
const res = await apiClient.get<ApiResponse<Integration[]>>('/api/v1/integrations', { params });
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;
getById: async (id: string): Promise<Integration> => {
const res = await apiClient.get<ApiResponse<Integration>>();
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;
create: async (data: IntegrationCreateRequest): Promise<Integration> => {
const res = await apiClient.post<ApiResponse<Integration>>('/api/v1/integrations', 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;
update: async (id: string, data: IntegrationUpdateRequest): Promise<Integration> => {
const res = await apiClient.put<ApiResponse<Integration>>(, 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;
const res = await apiClient.delete<ApiResponse<any>>();
return res.success || true;
},
getCredentials: async (id: string): Promise<Record<string, string>> => {
const res = await apiClient.get<ApiResponse<Record<string, string>>>();
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>>(, {
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>>();
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>>(, options || {});
const raw: any = res.data;
return raw?.data || raw;
},
getSyncJobs: async (id: string): Promise<SyncJob[]> => {
const res = await apiClient.get<ApiResponse<SyncJob[]>>();
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[]>>();
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[]>>();
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>>();
const raw: any = res.data;
return raw?.data || raw;
}
};
@@ -2,17 +2,26 @@ 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;
integrationType?: string;
environment?: string;
status: string;
health_status?: string;
healthStatus?: string;
sync_mode?: string;
syncMode?: string;
sync_frequency?: string;
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;
@@ -42,22 +51,94 @@ export interface Integration {
// Custom API specific fields
customApiUrl?: string;
customApiHeaderKey?: string;
endpoint?: string;
testEndpoint?: string;
method?: string;
customApiHeaderName?: string;
customApiHeaderValue?: string;
clearSecretFields?: string[];
// Sync settings
syncDirection: string; // pim_to_channel, channel_to_pim, bidirectional
syncFrequency: string; // manual, hourly, daily, realtime
autoRetry: boolean;
retryAttempts: number;
syncDirection?: string;
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,153 @@
import { useEffect, useState } from "react";
import { Building2, Users, Package, Image, Activity } 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 { notify } from "../../../services/toast";
export default function PlatformOverview() {
const navigate = useNavigate();
const [metrics, setMetrics] = useState<any>(null);
const [tenants, setTenants] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
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();
}, []);
return (
<PageWrapper>
<Breadcrumb
items={[{ label: "SaaS Dashboard" }]}
actions={
<Button
variant="primary"
icon={<Building2 className="w-4 h-4" />}
onClick={() => navigate("/platform/tenants")}
>
Manage Tenants
</Button>
}
/>
{/* 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>
{/* Recent tenant health preview. Full lifecycle management lives in Tenants. */}
<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">Recent Tenant Health</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>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-muted-foreground">Loading SaaS platform tenants...</td>
</tr>
) : tenants.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-muted-foreground">No tenants provisioned yet.</td>
</tr>
) : (
tenants.slice(0, 5).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>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</PageWrapper>
);
}
@@ -0,0 +1,381 @@
import { useEffect, useState } from "react";
import { Plus, Building2, Search, CheckCircle, XCircle, Copy, Check } from "lucide-react";
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 { 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) => {
const tenant = (tenants || []).find((item: any) => String(item.id) === String(tenantId));
const confirmed = window.confirm(
`Enter Support Mode for ${tenant?.tenant_name || `Tenant #${tenantId}`}?\n\n` +
'You will operate inside this tenant workspace. All support actions are tenant-scoped and audited.'
);
if (!confirmed) return;
try {
await impersonateTenant(tenantId);
setImpersonatingTenantId(tenantId);
window.location.assign("/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"
>
Exit 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 Access
</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,21 +5,28 @@ 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[];
}
export const VariantsTab: React.FC<VariantsTabProps> = ({
productId,
productType,
parentSku,
family
family,
readOnly,
productAttributes = {},
availableAttributes = []
}) => {
const {
variants,
@@ -37,6 +44,24 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
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 +69,180 @@ 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>();
const axesKeys = useMemo(() => variantAxes.map(a => a.code), [variantAxes]);
// 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]);
// Filter out unconfigured simple master variants (0 attributes) and ensure strict parent productId matching
const configuredVariants = useMemo(() => {
if (!Array.isArray(variants) || !productId) return [];
return variants.filter(v => {
if (!v) return false;
const vParentId = v.parentProductId || (v as any).product_id || (v as any).productId;
if (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 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') {
@@ -101,7 +272,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
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,
@@ -117,7 +288,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
});
setShowGenerator(false);
} catch (err) {
// toast notification is done inside the hook
// handled in hook
}
};
@@ -141,14 +312,13 @@ 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);
setSelectedIds(new Set());
} catch (err) {}
@@ -174,6 +344,101 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
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 +448,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,14 +516,16 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
{/* View settings panel */}
<div className="flex items-center justify-between bg-surface rounded-xl border border-border p-4 shadow-xs">
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => setShowGenerator(true)}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Add / Generate Variants
</button>
{!readOnly && (
<button
type="button"
onClick={() => setShowGenerator(true)}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Add / Generate Variants
</button>
)}
<button
type="button"
onClick={() => fetchByProduct(productId)}
@@ -271,7 +576,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 +585,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 +597,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
onUpdate={handleSingleUpdate}
onDelete={deleteVariant}
onArchive={archiveVariant}
readOnly={readOnly}
/>
)}
</div>
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { familyService } from '../../family/services/family.service';
import { attributeSetsService } from '../../attribute-sets/services/attribute-sets.service';
export const useProductFamilyConfiguration = (familyId?: string) => {
const [family, setFamily] = useState<any | null>(null);
@@ -30,7 +31,14 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
setFamily(blueprint);
setAllowedBrands(blueprint.allowedBrands || []);
setCategory(blueprint.category || null);
setAttributeSet(blueprint.attributeSet || null);
let setObj = blueprint.attributeSet || blueprint.attribute_set;
const setId = blueprint.attribute_set_id || blueprint.attributeSetId || (setObj ? setObj.id : null);
if ((!setObj || !setObj.groups) && setId) {
const fetchedSet = await attributeSetsService.getById(setId).catch(() => null);
if (fetchedSet) setObj = fetchedSet;
}
setAttributeSet(setObj || null);
const groupsData = blueprint.groups || blueprint.attributeGroups || [];
let flatAttrs: any[] = [];
@@ -58,10 +66,30 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
}
setAttributes(flatAttrs);
// Hydrate variant axes to ensure full attribute objects with .code and .name exist
const allAttrsMap = new Map<string, any>(flatAttrs.map(a => [a.id, a]));
// Hydrate variant axes to ensure full attribute objects (including
// selectable options) are used. Family blueprints commonly expose only
// axis UUIDs, while the complete attributes live inside the hydrated
// Attribute Set groups.
const setAttrs: any[] = [];
if (Array.isArray(setObj?.groups)) {
setObj.groups.forEach((group: any) => {
if (Array.isArray(group.attributes)) setAttrs.push(...group.attributes);
});
}
const allAttrsMap = new Map<string, any>();
[...flatAttrs, ...setAttrs].forEach((raw: any) => {
const attr = raw?.attribute || raw;
[raw?.id, raw?._id, raw?.attribute_id, attr?.id, attr?._id, attr?.code]
.filter(Boolean)
.forEach((key: any) => allAttrsMap.set(String(key), attr));
});
const resolvedVariantAxes = Array.isArray(blueprint.variantAxes)
? blueprint.variantAxes.map((va: any) => typeof va === 'string' ? (allAttrsMap.get(va) || { id: va, code: va, name: va }) : va)
? blueprint.variantAxes.map((va: any) => {
const key = typeof va === 'string'
? va
: (va?.attribute_id || va?.id || va?._id || va?.code);
return (key && allAttrsMap.get(String(key))) || va;
})
: [];
const normalizedBlueprint = {
@@ -73,7 +101,11 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
setFamily(normalizedBlueprint);
setAllowedBrands(blueprint.allowedBrands || []);
setCategory(blueprint.category || null);
setAttributeSet(blueprint.attributeSet || null);
// Preserve the hydrated attribute set when the family blueprint only
// exposes attributeSetId/attribute_set_id instead of an embedded object.
// Re-reading blueprint.attributeSet here used to overwrite the fetched
// set with null, so product creation lost the family's inheritance.
setAttributeSet(setObj || null);
setWorkflow(blueprint.workflow || (blueprint.workflowCode ? { code: blueprint.workflowCode } : null));
setAssetFamily(blueprint.assetRequirements || blueprint.assetFamily || null);
+4
View File
@@ -14,6 +14,10 @@ export const useVariant = () => {
const [generating, setGenerating] = useState(false);
const fetchByProduct = useCallback(async (productId: string) => {
if (!productId || productId === 'new' || productId === 'null' || productId === 'undefined') {
setVariants([]);
return;
}
setLoading(true);
try {
const data = await variantService.getByProduct(productId);
File diff suppressed because it is too large Load Diff
+4 -4
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());
@@ -66,7 +66,7 @@ export default function ProductList() {
// Stats for KPI cards
const stats = useMemo(() => {
const published = products.filter(p => p.status === "published").length;
const published = products.filter(p => p.status === "published" || p.status === "active").length;
const pending = products.filter(p => p.status === "pending").length;
const draft = products.filter(p => p.status === "draft").length;
const avgCompleteness = products.length > 0
@@ -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
}}
/>
@@ -2,13 +2,14 @@
import { Routes, Route } from 'react-router-dom';
import ProductList from '../pages/ProductList';
import NewProduct from '../pages/NewProduct';
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
export const ProductRoutes = () => {
return (
<Routes>
<Route index element={<ProductList />} /> {/* /products */}
<Route path="new" element={<NewProduct />} /> {/* /products/new */}
<Route path=":id/edit" element={<NewProduct />} /> {/* /products/123/edit */}
<Route path="new" element={<ProtectedRoute node="products.items" action="create"><NewProduct /></ProtectedRoute>} /> {/* /products/new */}
<Route path=":id/edit" element={<ProtectedRoute node="products.items" action="edit"><NewProduct /></ProtectedRoute>} /> {/* /products/123/edit */}
<Route path=":id" element={<NewProduct />} /> {/* /products/123 */}
</Routes>
);
+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={<ProtectedRoute node="settings.roles" action="create"><NewRoleForm /></ProtectedRoute>} />
<Route path="/:id/edit" element={<ProtectedRoute node="settings.roles" action="edit"><NewRoleForm /></ProtectedRoute>} />
</Routes>
</ProtectedRoute>
);
};

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