diff --git a/src/api/axiosInstance.ts b/src/api/axiosInstance.ts index 8a172ad..0cf61ee 100644 --- a/src/api/axiosInstance.ts +++ b/src/api/axiosInstance.ts @@ -1,6 +1,6 @@ import axios, { type InternalAxiosRequestConfig } from 'axios'; -const API_BASE_URL = 'http://localhost:5000'; +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5002'; const axiosInstance = axios.create({ baseURL: API_BASE_URL, @@ -17,6 +17,10 @@ axiosInstance.interceptors.request.use( if (token) { config.headers.Authorization = `Bearer ${token}`; } + const impersonatedTenantId = localStorage.getItem('impersonatedTenantId'); + if (impersonatedTenantId) { + config.headers['X-Impersonated-Tenant-Id'] = impersonatedTenantId; + } return config; }, (error: unknown) => Promise.reject(error) @@ -25,10 +29,13 @@ axiosInstance.interceptors.request.use( // Response interceptor axiosInstance.interceptors.response.use( (response) => response, - (error: { response?: { status?: number } }) => { - if (error.response?.status === 401) { + (error: { config?: { url?: string }; response?: { status?: number } }) => { + const isLoginEndpoint = error.config?.url?.includes('/auth/login'); + if (error.response?.status === 401 && !isLoginEndpoint) { localStorage.removeItem('accessToken'); - window.location.href = '/login'; + if (window.location.pathname !== '/login') { + window.location.href = '/login'; + } } return Promise.reject(error); } diff --git a/src/authentication/components/ProtectedRoute.tsx b/src/authentication/components/ProtectedRoute.tsx index af2f7af..fd5e129 100644 --- a/src/authentication/components/ProtectedRoute.tsx +++ b/src/authentication/components/ProtectedRoute.tsx @@ -31,4 +31,20 @@ export const ProtectedRoute: React.FC<{ return <>{children}; }; +export const PlatformGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { isAuthenticated, user } = useAppSelector((state) => state.auth); + const location = useLocation(); + + if (!isAuthenticated) { + return ; + } + + const isPlatformUser = user?.type === 'platform' || user?.user_type === 'platform'; + if (!isPlatformUser) { + return ; + } + + return <>{children}; +}; + export default AuthGuard; diff --git a/src/authentication/pages/Login.tsx b/src/authentication/pages/Login.tsx index cf3d35b..241d115 100644 --- a/src/authentication/pages/Login.tsx +++ b/src/authentication/pages/Login.tsx @@ -5,6 +5,7 @@ import { setCredentials } from "../../store/slices/authSlice"; import { authService } from "../services/authService"; import { Eye, EyeOff, Lock, Mail, ArrowRight } from "lucide-react"; import { AuthLayout } from "../components/AuthLayout"; +import { notify } from "../../services/toast"; export const Login = () => { const [email, setEmail] = useState(""); @@ -50,8 +51,9 @@ export const Login = () => { 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); } diff --git a/src/components/customs/DataTable.tsx b/src/components/customs/DataTable.tsx index 3242441..dd9eb45 100644 --- a/src/components/customs/DataTable.tsx +++ b/src/components/customs/DataTable.tsx @@ -7,6 +7,7 @@ import { import { ActionMenu, type CustomAction } from "./ActionMenu"; export interface DataTableColumn { + id?: string; key: string; label: string; sortable?: boolean; @@ -300,9 +301,10 @@ export function DataTable = any>({ {columns.map((col) => { const align = col.align || "center"; + const colKey = col.id || col.key; return ( 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 = any>({ {columns.map(col => { const align = col.align || "center"; + const colKey = col.id || col.key; return ( diff --git a/src/components/customs/PermissionGuard.tsx b/src/components/customs/PermissionGuard.tsx new file mode 100644 index 0000000..a83851d --- /dev/null +++ b/src/components/customs/PermissionGuard.tsx @@ -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 = ({ + 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}; +}; diff --git a/src/components/customs/Select.tsx b/src/components/customs/Select.tsx index 8c6883e..8c02f5b 100644 --- a/src/components/customs/Select.tsx +++ b/src/components/customs/Select.tsx @@ -101,6 +101,7 @@ export function Select({ - - } - /> + + + + + + } + /> {/* Stats Cards */}
@@ -1613,5 +1616,6 @@ export default function AssetList() { onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })} /> + ); } diff --git a/src/features/attribute-sets/pages/AttributeSetList.tsx b/src/features/attribute-sets/pages/AttributeSetList.tsx index 7b1ed62..ce50299 100644 --- a/src/features/attribute-sets/pages/AttributeSetList.tsx +++ b/src/features/attribute-sets/pages/AttributeSetList.tsx @@ -53,7 +53,9 @@ export default function AttributeSetList() { render: (val: string) => {val || '—'}, }, { - key: 'groups', label: 'GROUPS', + id: 'col_groups_count', + key: 'groups', + label: 'GROUPS', render: (val: any) => ( {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)) { diff --git a/src/features/attributes/pages/AttributeList.tsx b/src/features/attributes/pages/AttributeList.tsx index 8c3fe11..1ae536d 100644 --- a/src/features/attributes/pages/AttributeList.tsx +++ b/src/features/attributes/pages/AttributeList.tsx @@ -13,7 +13,11 @@ import { StatsCard } from "../../../components/customs/StatsCard"; import { ConfirmationModal } from "../../../components/modals/ConfirmationModal"; import { useState } from "react"; +import { usePermissions } from "../../../hooks/usePermission"; +import { Can } from "../../../components/customs/Can"; + export default function AttributeList() { + const { canCreate, canEdit, canDelete } = usePermissions("products.attributes"); const navigate = useNavigate(); const { attributes, fetchAttributes, deleteAttribute } = useAttribute(); const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" }); @@ -183,9 +187,11 @@ export default function AttributeList() { } onClick={() => navigate("/attributes/new")}> - Create Attribute - + + + } /> @@ -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 }} />
diff --git a/src/features/attributes/pages/NewAttribute.tsx b/src/features/attributes/pages/NewAttribute.tsx index 5585d95..0dceb81 100644 --- a/src/features/attributes/pages/NewAttribute.tsx +++ b/src/features/attributes/pages/NewAttribute.tsx @@ -45,6 +45,22 @@ export default function NewAttribute() { const { createAttribute, updateAttribute, fetchAttributes, getAttributeById } = useAttribute(); + const [optionsList, setOptionsList] = useState>([]); + const [newOptionInput, setNewOptionInput] = useState(""); + + const handleAddOption = () => { + if (!newOptionInput.trim()) return; + const label = newOptionInput.trim(); + const code = label.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, ''); + if (optionsList.some((o) => o.code === code)) return; + setOptionsList((prev) => [...prev, { code, label }]); + setNewOptionInput(""); + }; + + const handleRemoveOption = (index: number) => { + setOptionsList((prev) => prev.filter((_, i) => i !== index)); + }; + const formik = useFormik({ initialValues: { code: "", @@ -97,6 +113,9 @@ export default function NewAttribute() { apiVisible: values.apiVisible, isRequiredForCompleteness: values.isRequiredForCompleteness, }; + if (values.dataType === "select" || values.dataType === "multiselect") { + payload.options = optionsList; + } if (values.description?.trim()) payload.description = values.description.trim(); try { if (isEdit && id) { @@ -305,6 +324,40 @@ export default function NewAttribute() { + {(formik.values.dataType === "select" || formik.values.dataType === "multiselect") && ( +
+ +
+ 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} + /> + +
+ {optionsList.length > 0 ? ( +
+ {optionsList.map((opt, idx) => ( + + {opt.label} ({opt.code}) + {!isView && ( + + )} + + ))} +
+ ) : ( +

No options added yet. Type an option label above and click Add Option.

+ )} +
+ )} +
diff --git a/src/features/brands/pages/BrandList.tsx b/src/features/brands/pages/BrandList.tsx index bf6b998..3e5da2b 100644 --- a/src/features/brands/pages/BrandList.tsx +++ b/src/features/brands/pages/BrandList.tsx @@ -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() { } onClick={() => navigate("/brands/new")}> - Create Brand - + + + } /> @@ -49,8 +55,8 @@ export default function BrandList() { brands={brands} onRowClick={(row) => navigate(`/brands/${row.id}/edit`)} onView={(row) => navigate(`/brands/${row.id}/view`)} - onEdit={(row) => navigate(`/brands/${row.id}/edit`)} - onDelete={(row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })} + onEdit={canEdit ? ((row) => navigate(`/brands/${row.id}/edit`)) : undefined} + onDelete={canDelete ? ((row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })) : undefined} />
diff --git a/src/features/categories/pages/CategoryList.tsx b/src/features/categories/pages/CategoryList.tsx index 7cef61c..abcdcba 100644 --- a/src/features/categories/pages/CategoryList.tsx +++ b/src/features/categories/pages/CategoryList.tsx @@ -11,6 +11,8 @@ import { StatsCard } from "../../../components/customs/StatsCard"; import { ConfirmationModal } from "../../../components/modals/ConfirmationModal"; import { CategoryTaxonomyTree } from "../components/CategoryTaxonomyTree"; +import { Can } from "../../../components/customs/Can"; + type ViewMode = "table" | "tree"; export default function CategoryList() { @@ -31,10 +33,10 @@ export default function CategoryList() { }, [fetchCategories]); const stats = { - total: categories.length || 0, - active: categories.filter((c) => c.status === "active").length, - products: 33008, - families: 406, + total: categories.length || 0, + active: categories.filter((c) => c.status === "active").length, + products: categories.reduce((sum, c) => sum + (Number(c.productCount) || 0), 0), + families: categories.reduce((sum, c) => sum + (Number(c.familyCount) || 0), 0), }; const handleDeleteConfirm = async () => { @@ -56,13 +58,15 @@ export default function CategoryList() { } - onClick={() => navigate("/categories/new")} - > - Add Root Category - + + + } /> diff --git a/src/features/categories/pages/NewCategory.tsx b/src/features/categories/pages/NewCategory.tsx index 9380d59..2cdd110 100644 --- a/src/features/categories/pages/NewCategory.tsx +++ b/src/features/categories/pages/NewCategory.tsx @@ -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)} > - {allowedParentOptions.map((cat) => ( + {allowedParentOptions.filter(Boolean).map((cat) => ( ))} - {!isEdit && ( + {!isEdit && Boolean(parentIdParam) && (

Locked to parent context. Click inline tree actions to create subcategories.

diff --git a/src/features/channels/api/channels.api.ts b/src/features/channels/api/channels.api.ts index f8a52b3..53d5e9f 100644 --- a/src/features/channels/api/channels.api.ts +++ b/src/features/channels/api/channels.api.ts @@ -34,6 +34,41 @@ export const channelsApi = { const res = await apiClient.delete>(`${BASE_URL}/${id}`); return res.success; }, + + getMappings: async (channelId: string): Promise => { + const res = await apiClient.get>(`${BASE_URL}/${channelId}/mappings`); + return res.data || []; + }, + + updateMappings: async (channelId: string, mappings: any[]): Promise => { + const res = await apiClient.put>(`${BASE_URL}/${channelId}/mappings`, { mappings }); + return res.data; + }, + + triggerSyndication: async (channelId: string): Promise => { + const res = await apiClient.post>(`${BASE_URL}/${channelId}/syndicate`); + return res.data; + }, + + getJobs: async (channelId: string): Promise => { + const res = await apiClient.get>(`${BASE_URL}/${channelId}/jobs`); + return res.data || []; + }, + + previewPayload: async (channelId: string): Promise => { + const res = await apiClient.post>(`${BASE_URL}/${channelId}/preview`); + return res.data; + }, + + syndicateAll: async (): Promise => { + const res = await apiClient.post>(`${BASE_URL}/syndicate-all`); + return res.data || []; + }, + + testConnection: async (channelId: string): Promise => { + const res = await apiClient.post>(`${BASE_URL}/${channelId}/test-connection`); + return res.data; + }, }; export default channelsApi; diff --git a/src/features/channels/components/ChannelMappingTab.tsx b/src/features/channels/components/ChannelMappingTab.tsx new file mode 100644 index 0000000..095ad26 --- /dev/null +++ b/src/features/channels/components/ChannelMappingTab.tsx @@ -0,0 +1,228 @@ +import { useState, useEffect } from "react"; +import { Save, Plus, Trash2, ArrowRight, Eye } from "lucide-react"; +import { Button } from "../../../components/customs/Button"; +import { channelsApi } from "../api/channels.api"; +import { notify } from "../../../services/toast"; +import { PayloadPreviewModal } from "./PayloadPreviewModal"; + +const COMMON_PIM_ATTRIBUTES = [ + { code: "name", label: "Product Title / Name (name)" }, + { code: "code", label: "Product Code / SKU (code)" }, + { code: "description", label: "Product Description (description)" }, + { code: "status", label: "Publication Status (status)" }, + { code: "created_at", label: "Creation Timestamp (created_at)" }, +]; + +const COMMON_CHANNEL_FIELDS = [ + { code: "title", label: "Storefront Title (title)" }, + { code: "body_html", label: "HTML Body Description (body_html)" }, + { code: "variant_sku", label: "Variant SKU (variant_sku)" }, + { code: "price", label: "Variant Price (price)" }, + { code: "vendor", label: "Brand / Vendor (vendor)" }, + { code: "product_type", label: "Product Category / Type (product_type)" }, +]; + +const TRANSFORMATION_RULES = [ + { value: "none", label: "Direct Pass-through" }, + { value: "uppercase", label: "UPPERCASE" }, + { value: "lowercase", label: "lowercase" }, + { value: "currency_format", label: "Currency Format (0.00)" }, + { value: "strip_html", label: "Strip HTML Tags" }, + { value: "default_if_null", label: "Fallback Default Value" }, +]; + +export function ChannelMappingTab({ channelId }: { channelId: string }) { + const [mappings, setMappings] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [previewing, setPreviewing] = useState(false); + const [previewData, setPreviewData] = useState(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 ( +
+
+
+ ); + } + + return ( +
+
+
+

Channel Field Mapping Matrix

+

Map central PIM attributes to target storefront fields and apply transformation pipelines.

+
+
+ + + +
+
+ + setPreviewData(null)} + previewData={previewData} + /> + +
+ + + + + + + + + + + + + {mappings.map((rule, idx) => ( + + + + + + + + + ))} + +
PIM Central AttributePipelineTarget Storefront FieldTransformation RuleRequiredAction
+ + + + + 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" + /> + + + + handleChange(idx, "is_required", e.target.checked)} + className="rounded border-border text-primary focus:ring-primary h-4 w-4" + /> + + +
+
+
+ ); +} diff --git a/src/features/channels/components/PayloadPreviewModal.tsx b/src/features/channels/components/PayloadPreviewModal.tsx new file mode 100644 index 0000000..9b0fa94 --- /dev/null +++ b/src/features/channels/components/PayloadPreviewModal.tsx @@ -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 ( +
+
+ {/* Header */} +
+
+
+ +
+
+

Transformed Payload Preview

+

Real-time adapter transformation preview for channel: {previewData.channel?.name}

+
+
+ +
+ + {/* Content */} +
+ {/* Left Column: PIM Raw Data */} +
+
+ PIM Central Product (Raw JSON) +
+
+              {JSON.stringify(previewData.pimProductRaw, null, 2)}
+            
+
+ + {/* Right Column: Adapter Storefront Output */} +
+
+ Adapter Storefront Payload ({previewData.channel?.code}) + +
+
+              {JSON.stringify(previewData.adapterOutput, null, 2)}
+            
+
+
+ + {/* Footer */} +
+ +
+
+
+ ); +} diff --git a/src/features/channels/components/SyndicationHistoryTab.tsx b/src/features/channels/components/SyndicationHistoryTab.tsx new file mode 100644 index 0000000..6bdb84c --- /dev/null +++ b/src/features/channels/components/SyndicationHistoryTab.tsx @@ -0,0 +1,159 @@ +import { useState, useEffect } from "react"; +import { Play, RefreshCw, AlertCircle, CheckCircle2, Clock, Eye } from "lucide-react"; +import { Button } from "../../../components/customs/Button"; +import { channelsApi } from "../api/channels.api"; +import { notify } from "../../../services/toast"; + +export function SyndicationHistoryTab({ channelId }: { channelId: string }) { + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + const [selectedErrorLog, setSelectedErrorLog] = useState(null); + + useEffect(() => { + loadJobs(); + }, [channelId]); + + const loadJobs = async () => { + if (!channelId || channelId === "demo-channel-id") { + setJobs([]); + setLoading(false); + return; + } + setLoading(true); + try { + const data = await channelsApi.getJobs(channelId); + setJobs(data || []); + } catch { + setJobs([]); + } finally { + setLoading(false); + } + }; + + const handleTriggerSync = async () => { + setSyncing(true); + try { + await channelsApi.triggerSyndication(channelId); + notify.success("Syndication job triggered and processed successfully!"); + await loadJobs(); + } catch { + notify.error("Failed to trigger syndication job"); + } finally { + setSyncing(false); + } + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case 'completed': + return Completed; + case 'failed': + return Failed; + case 'running': + return Running; + default: + return Pending; + } + }; + + return ( +
+
+
+

Syndication Execution History

+

Real-time execution runs, success metrics, and error log inspection for this channel.

+
+
+ + +
+
+ +
+ + + + + + + + + + + + + + {jobs.length === 0 ? ( + + + + ) : ( + jobs.map((job) => ( + + + + + + + + + + )) + )} + +
Job IDStatusTotal ProductsSuccessFailedStarted AtLog Details
+ No syndication runs recorded yet. Click "Trigger Instant Sync" to start your first job run. +
{job.id.slice(0, 8)}...{getStatusBadge(job.status)}{job.total_products}{job.success_count}{job.failed_count}{new Date(job.started_at || job.created_at).toLocaleString()} + {job.error_log && job.error_log.length > 0 ? ( + + ) : ( + + )} +
+
+ + {/* Error Log Inspection Modal */} + {selectedErrorLog && ( +
+
+
+

+ Syndication Error Logs +

+ +
+
+ {selectedErrorLog.map((err, idx) => ( +
+
Product SKU: {err.sku || 'N/A'} (ID: {err.productId})
+
{err.error}
+
+ ))} +
+
+ +
+
+
+ )} +
+ ); +} diff --git a/src/features/channels/pages/ChannelList.tsx b/src/features/channels/pages/ChannelList.tsx index 608d207..7552704 100644 --- a/src/features/channels/pages/ChannelList.tsx +++ b/src/features/channels/pages/ChannelList.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Plus, RefreshCw, Radio, CheckCircle, Layers, TrendingUp, ShoppingCart, ShoppingBag, Server, Warehouse, Store, Smartphone, Globe, Monitor } from "lucide-react"; +import { Plus, RefreshCw, Radio, CheckCircle, Layers, TrendingUp, ShoppingCart, ShoppingBag, Server, Warehouse, Store, Smartphone, Globe, Monitor, Play } from "lucide-react"; import { PageWrapper } from "../../../components/layouts/PageWrapper"; import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute"; import { Breadcrumb } from "../../../components/layouts/Breadcrumb"; @@ -11,6 +11,11 @@ import { StatsCard } from "../../../components/customs/StatsCard"; import { useChannel } from "../hook/useChannel"; import type { Channel } from "../types/channels.types"; import { ConfirmationModal } from "../../../components/modals/ConfirmationModal"; +import { channelsApi } from "../api/channels.api"; +import { notify } from "../../../services/toast"; + +import { Can } from "../../../components/customs/Can"; +import { usePermissions } from "../../../hooks/usePermission"; const CHANNEL_TYPES_META: Record = { ecommerce: { label: "Ecommerce", icon: ShoppingCart, typeColor: "text-blue-600", typeBg: "bg-blue-50" }, @@ -24,6 +29,7 @@ const CHANNEL_TYPES_META: Record val || 0 }, { key: "products", label: "Products", render: (val: any) => val ? val.toLocaleString() : 0 }, + { + key: "syndicate", + label: "Syndication", + render: (_: any, row: Channel) => ( + + ), + }, { key: "createdAt", label: "Updated", @@ -109,20 +140,38 @@ export default function ChannelList() { return ( - + + - + + + } /> @@ -167,8 +216,8 @@ export default function ChannelList() { data={items} actionConfig={{ onView: (row) => navigate(`${row.id}/view`), - onEdit: (row) => navigate(`${row.id}/edit`), - onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name }) + onEdit: canEdit ? ((row) => navigate(`${row.id}/edit`)) : undefined, + onDelete: canDelete ? ((row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })) : undefined }} />
diff --git a/src/features/channels/pages/NewChannel.tsx b/src/features/channels/pages/NewChannel.tsx index 05b9b37..13e53f5 100644 --- a/src/features/channels/pages/NewChannel.tsx +++ b/src/features/channels/pages/NewChannel.tsx @@ -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() { )} - {/* Step 3 — Summary */} + {/* Step 3 — Field Mapping Matrix */} + {activeStep === "mapping" && ( +
+ +
+ )} + + {/* Step 4 — Syndication History */} + {activeStep === "syndication" && ( +
+ +
+ )} + + {/* Step 5 — Summary */} {activeStep === "summary" && (
diff --git a/src/features/dashboard/components/chart.tsx b/src/features/dashboard/components/chart.tsx index dcb1e2f..99a1ee1 100644 --- a/src/features/dashboard/components/chart.tsx +++ b/src/features/dashboard/components/chart.tsx @@ -43,7 +43,7 @@ export function ChartContainer({ className={cn("flex justify-center text-xs", className)} {...props} > - + {children}
diff --git a/src/features/dashboard/pages/Dashboard.tsx b/src/features/dashboard/pages/Dashboard.tsx index c40a8de..369648d 100644 --- a/src/features/dashboard/pages/Dashboard.tsx +++ b/src/features/dashboard/pages/Dashboard.tsx @@ -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(0); + const [channelCount, setChannelCount] = useState(0); + const [publishedCount, setPublishedCount] = useState(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 (
-

Welcome back, John

+

+ Welcome back, {user?.first_name || user?.user_name || (user?.user_type === 'platform' ? 'Platform Super Admin' : 'Tenant Administrator')} +

Here's what's happening with your product catalog today.

- {/* KPI InfoCards */} } - trend="+12.5%" - trendDirection="up" - subtitle="vs. last month" + subtitle="Tenant Workspace Total" /> } subtitle="Requires attention" /> } - trend="+18.2%" - trendDirection="up" - subtitle="vs. last month" + subtitle="Ready for syndication" /> } - trend="+4.3%" - trendDirection="up" subtitle="Publishing enabled" /> diff --git a/src/features/family/pages/FamilyList.tsx b/src/features/family/pages/FamilyList.tsx index 67ba4d3..dd3fac0 100644 --- a/src/features/family/pages/FamilyList.tsx +++ b/src/features/family/pages/FamilyList.tsx @@ -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() { } onClick={() => navigate("/families/new")}> - Create Family - + + + } /> diff --git a/src/features/platform/pages/PlatformOverview.tsx b/src/features/platform/pages/PlatformOverview.tsx new file mode 100644 index 0000000..048085e --- /dev/null +++ b/src/features/platform/pages/PlatformOverview.tsx @@ -0,0 +1,211 @@ +import { useEffect, useState } from "react"; +import { Building2, Users, Package, Image, ShieldCheck, Activity, UserCheck, Play, StopCircle } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { PageWrapper } from "../../../components/layouts/PageWrapper"; +import { Breadcrumb } from "../../../components/layouts/Breadcrumb"; +import { Button } from "../../../components/customs/Button"; +import { tenantService } from "../../tenants/services/tenant.service"; +import { useTenant } from "../../tenants/hooks/useTenant"; +import { notify } from "../../../services/toast"; + +export default function PlatformOverview() { + const navigate = useNavigate(); + const { impersonateTenant, stopImpersonation } = useTenant(); + const [metrics, setMetrics] = useState(null); + const [tenants, setTenants] = useState([]); + const [loading, setLoading] = useState(true); + const [impersonatingTenantId, setImpersonatingTenantId] = useState( + localStorage.getItem("impersonatedTenantId") + ); + + const loadData = async () => { + try { + setLoading(true); + const [metricRes, tenantRes] = await Promise.all([ + tenantService.getPlatformMetrics(), + tenantService.getPlatformTenants() + ]); + setMetrics(metricRes); + setTenants(tenantRes); + } catch (err) { + notify.error("Failed to load platform dashboard data"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadData(); + }, []); + + const handleStartImpersonate = async (tenantId: string) => { + try { + await impersonateTenant(tenantId); + setImpersonatingTenantId(tenantId); + navigate("/products"); + } catch (err) { + // Handled in hook + } + }; + + const handleStopImpersonate = () => { + stopImpersonation(); + setImpersonatingTenantId(null); + }; + + return ( + + } + onClick={() => navigate("/platform/tenants")} + > + Provision New Tenant + + } + /> + + {/* Support Impersonation Banner */} + {impersonatingTenantId && ( +
+
+
+ +
+
+

Support Impersonation Mode Active

+

+ Currently troubleshooting Tenant ID: {impersonatingTenantId}. Requests are safely scoped to this tenant context. +

+
+
+ +
+ )} + + {/* Metrics Cards Grid */} +
+
+
+ +
+
+

Total SaaS Tenants

+

{loading ? "..." : metrics?.tenants?.total || 0}

+ {metrics?.tenants?.active || 0} Active +
+
+ +
+
+ +
+
+

Platform Accounts

+

{loading ? "..." : metrics?.users?.total || 0}

+ Across all tenants +
+
+ +
+
+ +
+
+

Total Products

+

{loading ? "..." : metrics?.data?.total_products || 0}

+ Catalog items +
+
+ +
+
+ +
+
+

Cloudinary DAM Assets

+

{loading ? "..." : metrics?.data?.total_assets || 0}

+ Images & Raw Docs +
+
+
+ + {/* Tenants Table Preview */} +
+
+
+ +

Tenant Provisioning Registry

+
+ +
+ +
+ + + + + + + + + + + + + + {loading ? ( + + + + ) : tenants.length === 0 ? ( + + + + ) : ( + tenants.map((t) => ( + + + + + + + + + + )) + )} + +
Tenant CodeOrganization NameContact EmailProductsAssetsStatusSupport Action
Loading SaaS platform tenants...
No tenants provisioned yet.
{t.tenant_code}{t.tenant_name}{t.contact_email || "N/A"}{t.total_products || 0}{t.total_assets || 0} + + {t.status ? "Active" : "Suspended"} + + + {String(t.id) === String(impersonatingTenantId) ? ( + Active Session + ) : ( + + )} +
+
+
+
+ ); +} diff --git a/src/features/platform/pages/PlatformTenantsPage.tsx b/src/features/platform/pages/PlatformTenantsPage.tsx new file mode 100644 index 0000000..54efad5 --- /dev/null +++ b/src/features/platform/pages/PlatformTenantsPage.tsx @@ -0,0 +1,377 @@ +import { useEffect, useState } from "react"; +import { Plus, Building2, Search, Play, StopCircle, CheckCircle, XCircle, Copy, Check } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { PageWrapper } from "../../../components/layouts/PageWrapper"; +import { Breadcrumb } from "../../../components/layouts/Breadcrumb"; +import { Button } from "../../../components/customs/Button"; +import { useTenant } from "../../tenants/hooks/useTenant"; +import { useFormik } from "formik"; +import * as Yup from "yup"; +import { notify } from "../../../services/toast"; + +const inputClass = (error?: boolean) => + `w-full border ${error ? 'border-danger focus:ring-danger' : 'border-primary/10 focus:ring-primary-light'} rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 bg-surface text-foreground placeholder-muted-foreground`; + +export default function PlatformTenantsPage() { + const navigate = useNavigate(); + const { tenants, fetchPlatformTenants, provisionTenant, updatePlatformStatus, impersonateTenant, stopImpersonation } = useTenant(); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [isProvisionModalOpen, setIsProvisionModalOpen] = useState(false); + const [impersonatingTenantId, setImpersonatingTenantId] = useState( + 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(null); + const [copied, setCopied] = useState(false); + + const formik = useFormik({ + initialValues: { + tenant_name: "", + domain: "", + contact_email: "", + admin_name: "", + admin_email: "", + admin_password: "" + }, + validationSchema: Yup.object().shape({ + tenant_name: Yup.string().required("Organization name is required"), + contact_email: Yup.string().email("Invalid email").required("Contact email is required"), + admin_email: Yup.string().email("Invalid admin email"), + admin_password: Yup.string().min(6, "Password must be at least 6 characters") + }), + onSubmit: async (values, { setSubmitting, resetForm }) => { + try { + const result = await provisionTenant(values); + resetForm(); + setIsProvisionModalOpen(false); + setProvisionedSuccessData({ + tenant: result.tenant || result.data?.tenant, + admin: result.admin || result.data?.admin, + rawPassword: values.admin_password + }); + fetchPlatformTenants(); + } catch (err) { + // Error handled in hook + } finally { + setSubmitting(false); + } + } + }); + + const handleToggleStatus = async (id: string, currentStatus: boolean) => { + try { + await updatePlatformStatus(id, !currentStatus); + } catch (err) { + // Handled in hook + } + }; + + const handleStartImpersonate = async (tenantId: string) => { + try { + await impersonateTenant(tenantId); + setImpersonatingTenantId(tenantId); + navigate("/products"); + } catch (err) { + // Handled in hook + } + }; + + const filteredTenants = (tenants || []).filter(t => + t.tenant_name?.toLowerCase().includes(searchTerm.toLowerCase()) || + t.tenant_code?.toLowerCase().includes(searchTerm.toLowerCase()) || + t.contact_email?.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + return ( + + } + onClick={() => setIsProvisionModalOpen(true)} + > + Provision New Tenant + + } + /> + + {/* Filter & Search Bar */} +
+
+ + 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" + /> +
+
+ + {/* Tenants Table */} +
+
+ + + + + + + + + + + + + + + {loading ? ( + + + + ) : filteredTenants.length === 0 ? ( + + + + ) : ( + filteredTenants.map((t: any) => ( + + + + + + + + + + + )) + )} + +
Tenant CodeOrganizationDomainContact EmailProductsAssetsStatusActions
Loading tenants...
No matching tenants found.
{t.tenant_code}{t.tenant_name}{t.domain || "N/A"}{t.contact_email}{t.total_products || 0}{t.total_assets || 0} + + {t.status ? "Active" : "Suspended"} + + + + + {String(t.id) === String(impersonatingTenantId) ? ( + + ) : ( + + )} +
+
+
+ + {/* Provision Tenant Modal */} + {isProvisionModalOpen && ( +
+
+
+

+ Provision New Tenant Account +

+ +
+ +
+
+ + + {formik.touched.tenant_name && formik.errors.tenant_name && ( +

{formik.errors.tenant_name}

+ )} +
+ +
+
+ + +
+
+ + + {formik.touched.contact_email && formik.errors.contact_email && ( +

{formik.errors.contact_email}

+ )} +
+
+ +
+

Initial Tenant Admin Credentials

+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+
+ )} + + {/* Provisioning Success Modal with Copy Credentials */} + {provisionedSuccessData && ( +
+
+
+
+ +
+
+

Tenant Provisioned!

+

Share these setup credentials with your client

+
+
+ +
+
+ Organization: + {provisionedSuccessData.tenant?.tenant_name} +
+
+ Tenant Code: + {provisionedSuccessData.tenant?.tenant_code} +
+
+ Admin Email: + {provisionedSuccessData.admin?.email || provisionedSuccessData.tenant?.contact_email} +
+
+ Admin Password: + {provisionedSuccessData.rawPassword || '••••••••'} +
+
+ +
+ + +
+
+
+ )} +
+ ); +} diff --git a/src/features/product/pages/ProductList.tsx b/src/features/product/pages/ProductList.tsx index 20d5347..8ec9b1f 100644 --- a/src/features/product/pages/ProductList.tsx +++ b/src/features/product/pages/ProductList.tsx @@ -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>(new Set()); @@ -184,8 +184,8 @@ export default function ProductList() { pageSizeOptions={[5, 10, 25, 50]} actionConfig={{ onView: (row) => navigate(`/products/${row.id}/edit`), - onEdit: (row) => navigate(`/products/${row.id}/edit`), - onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name }) + onEdit: canEdit ? ((row) => navigate(`/products/${row.id}/edit`)) : undefined, + onDelete: canDelete ? ((row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })) : undefined }} /> diff --git a/src/features/roles/pages/NewRoleForm.tsx b/src/features/roles/pages/NewRoleForm.tsx index 053f0d3..19727ba 100644 --- a/src/features/roles/pages/NewRoleForm.tsx +++ b/src/features/roles/pages/NewRoleForm.tsx @@ -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([]); const [permissions, setPermissions] = useState>({}); @@ -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() {

{formik.errors.role_name}

)} -
- - -

Leave empty for a global platform role.

-
+ {isPlatformAdmin && ( +
+ + +

Leave empty for a global platform role.

+
+ )}
diff --git a/src/features/roles/routes/role.routes.tsx b/src/features/roles/routes/role.routes.tsx index 6c0a7db..faa6a01 100644 --- a/src/features/roles/routes/role.routes.tsx +++ b/src/features/roles/routes/role.routes.tsx @@ -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 ( - - } /> - } /> - } /> - + + + } /> + } /> + } /> + + ); }; diff --git a/src/features/settings/pages/SettingList.tsx b/src/features/settings/pages/SettingList.tsx index 16100f1..042319d 100644 --- a/src/features/settings/pages/SettingList.tsx +++ b/src/features/settings/pages/SettingList.tsx @@ -1,6 +1,8 @@ -import { useState, useEffect } from "react"; -import { User, Bell, Shield, Plug, Key, Palette, Globe, Database, MessageSquare, Webhook, Box, ChevronRight } from "lucide-react"; +import { useState, useEffect, useEffect } from "react"; +import { User, Bell, Shield, Plug, Key, Palette, Globe, Database, MessageSquare, Webhook, Box, ChevronRight, Save } from "lucide-react"; import { useNavigate } from "react-router-dom"; +import { useSelector } from "react-redux"; +import type { RootState } from "../../../store"; import { PageWrapper } from "../../../components/layouts/PageWrapper"; import { Button } from "../../../components/customs/Button"; import { Breadcrumb } from "../../../components/layouts/Breadcrumb"; @@ -8,12 +10,18 @@ import { Radio } from "../../../components/customs/Radio"; import { usePermissions } from "../../../hooks/usePermission"; import { fileServerService, type FileServerConfig } from "../services/fileServer.service"; +import { settingsService } from "../services/settings.service"; +import { notify } from "../../../services/toast"; +import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute"; +import { Can } from "../../../components/customs/Can"; export default function SettingList() { const navigate = useNavigate(); const { canView: canViewFileServer } = usePermissions('settings.file_server'); - const [activeTab, setActiveTab] = useState("Integrations"); + const user = useSelector((state: RootState) => state.auth.user); + + const [activeTab, setActiveTab] = useState("General"); const [requireApproval, setRequireApproval] = useState(true); const [autoPublish, setAutoPublish] = useState(false); @@ -79,6 +87,55 @@ export default function SettingList() { setSaveStatus({ type: 'error', message: err.response?.data?.message || err.message || 'Failed to save settings.' }); } }; + const [saving, setSaving] = useState(false); + + const [orgName, setOrgName] = useState(user?.tenant?.name || "Organization"); + const [subdomain, setSubdomain] = useState(user?.tenant?.tenant_code || user?.tenant?.domain || "org"); + + useEffect(() => { + if (user?.tenant?.name) { + setOrgName(user.tenant.name); + } + if (user?.tenant?.tenant_code || user?.tenant?.domain) { + setSubdomain(user.tenant.tenant_code || user.tenant.domain || ""); + } + }, [user]); + + // Load category settings from API when tab changes + useEffect(() => { + const loadSettings = async () => { + try { + const cat = activeTab.toLowerCase(); + const data = await settingsService.getCategorySettings(cat); + if (data) { + if (data.orgName) setOrgName(data.orgName); + if (data.subdomain) setSubdomain(data.subdomain); + if (data.requireApproval !== undefined) setRequireApproval(data.requireApproval); + if (data.autoPublish !== undefined) setAutoPublish(data.autoPublish); + } + } catch (err) { + // Silently fallback to defaults + } + }; + loadSettings(); + }, [activeTab]); + + const handleSaveGeneral = async () => { + setSaving(true); + try { + await settingsService.updateCategorySettings("general", { + orgName, + subdomain, + requireApproval, + autoPublish + }); + notify.success("General settings saved successfully!"); + } catch (err: any) { + notify.error(err?.message || "Failed to save settings"); + } finally { + setSaving(false); + } + }; const horizontalTabs = [ { id: "General", icon: User }, @@ -92,8 +149,9 @@ export default function SettingList() { return ( - - + + + {/* Horizontal Tabs Header */}
@@ -133,12 +191,22 @@ export default function SettingList() {
- + setOrgName(e.target.value)} + className="w-full px-3 py-2.5 text-sm border border-primary/10 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-light focus:border-transparent bg-surface" + />
- +
- + setSubdomain(e.target.value)} + className="flex-1 px-3 py-2.5 text-sm border border-primary/10 rounded-l-lg border-r-0 focus:outline-none focus:ring-2 focus:ring-primary-light focus:border-transparent bg-surface z-10" + />
.pim-platform.com
@@ -195,8 +263,13 @@ export default function SettingList() {
- - + + + +
)} @@ -548,5 +621,6 @@ export default function SettingList() { )}
+
); } diff --git a/src/features/settings/services/settings.service.ts b/src/features/settings/services/settings.service.ts index deb645d..09c0d76 100644 --- a/src/features/settings/services/settings.service.ts +++ b/src/features/settings/services/settings.service.ts @@ -1,55 +1,12 @@ -import type { Setting, SettingCreateRequest, SettingUpdateRequest } from '../types/settings.types'; - -const STORAGE_KEY = 'pim_settings'; - -const getStored = (): Setting[] => { - const stored = localStorage.getItem(STORAGE_KEY); - if (!stored) return []; - return JSON.parse(stored); -}; - -const setStored = (items: Setting[]) => { - localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); -}; +import axiosInstance from '../../../api/axiosInstance'; export const settingsService = { - getAll: async (): Promise => { - return new Promise((resolve) => setTimeout(() => resolve(getStored()), 300)); - }, - getById: async (id: string): Promise => { - return new Promise((resolve) => setTimeout(() => resolve(getStored().find(p => p.id === id)), 200)); - }, - create: async (req: SettingCreateRequest): Promise => { - return new Promise((resolve) => { - setTimeout(() => { - const list = getStored(); - const newItem: Setting = { ...req, id: String(Date.now()), createdAt: new Date().toISOString() }; - list.push(newItem); - setStored(list); - resolve(newItem); - }, 300); - }); - }, - update: async (id: string, req: SettingUpdateRequest): Promise => { - return new Promise((resolve, reject) => { - setTimeout(() => { - const list = getStored(); - const index = list.findIndex(p => p.id === id); - if (index === -1) { reject(new Error('Not found')); return; } - const updated = { ...list[index], ...req }; - list[index] = updated; - setStored(list); - resolve(updated); - }, 300); - }); - }, - delete: async (id: string): Promise => { - return new Promise((resolve) => { - setTimeout(() => { - const list = getStored().filter(p => p.id !== id); - setStored(list); - resolve(true); - }, 300); - }); + getCategorySettings: async (category: string) => { + const response = await axiosInstance.get(`/settings/by-category/${category}`); + return response.data.data; }, + updateCategorySettings: async (category: string, data: Record) => { + const response = await axiosInstance.put(`/settings/by-category/${category}`, data); + return response.data; + } }; diff --git a/src/features/tenants/hooks/useTenant.ts b/src/features/tenants/hooks/useTenant.ts index 84f48c3..fa472cb 100644 --- a/src/features/tenants/hooks/useTenant.ts +++ b/src/features/tenants/hooks/useTenant.ts @@ -70,14 +70,80 @@ export function useTenant() { } }; + const fetchPlatformTenants = useCallback(async () => { + try { + setLoading(true); + setError(null); + const data = await tenantService.getPlatformTenants(); + setTenants(data); + return data; + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch platform tenants'); + notify.error(err); + } finally { + setLoading(false); + } + }, []); + + const provisionTenant = async (data: any) => { + try { + setLoading(true); + const result = await tenantService.provisionTenant(data); + notify.success('Tenant provisioned successfully with Admin credentials'); + return result; + } catch (err) { + notify.error(err); + throw err; + } finally { + setLoading(false); + } + }; + + const updatePlatformStatus = async (id: string, status: boolean) => { + try { + setLoading(true); + const updated = await tenantService.updatePlatformStatus(id, status); + setTenants(prev => prev.map(t => t.id === id ? { ...t, status: updated.status } : t)); + notify.success(`Tenant status updated to ${updated.status ? 'Active' : 'Suspended'}`); + return updated; + } catch (err) { + notify.error(err); + throw err; + } finally { + setLoading(false); + } + }; + + const impersonateTenant = async (tenantId: string) => { + try { + const result = await tenantService.impersonateTenant(tenantId); + localStorage.setItem('impersonatedTenantId', tenantId); + notify.success(result.message || 'Support impersonation active'); + return result; + } catch (err) { + notify.error(err); + throw err; + } + }; + + const stopImpersonation = () => { + localStorage.removeItem('impersonatedTenantId'); + notify.info('Support impersonation ended'); + }; + return { tenants, loading, error, fetchTenants, + fetchPlatformTenants, getTenant, createTenant, + provisionTenant, updateTenant, - deleteTenant + updatePlatformStatus, + deleteTenant, + impersonateTenant, + stopImpersonation }; } diff --git a/src/features/tenants/services/tenant.service.ts b/src/features/tenants/services/tenant.service.ts index a0a4a63..f29e5e7 100644 --- a/src/features/tenants/services/tenant.service.ts +++ b/src/features/tenants/services/tenant.service.ts @@ -25,5 +25,31 @@ export const tenantService = { delete: async (id: string) => { const response = await api.delete(`/api/v1/tenants/${id}`); return (response as any).data; + }, + + // Platform Admin Endpoints + getPlatformTenants: async () => { + const response = await api.get('/api/v1/platform/tenants'); + return (response as any).data; + }, + + provisionTenant: async (data: any) => { + const response = await api.post('/api/v1/platform/tenants', data); + return (response as any).data; + }, + + updatePlatformStatus: async (id: string, status: boolean) => { + const response = await api.patch(`/api/v1/platform/tenants/${id}/status`, { status }); + return (response as any).data; + }, + + getPlatformMetrics: async () => { + const response = await api.get('/api/v1/platform/metrics'); + return (response as any).data; + }, + + impersonateTenant: async (tenantId: string) => { + const response = await api.post(`/api/v1/platform/impersonate/${tenantId}`); + return (response as any).data; } }; diff --git a/src/features/tenants/types/tenant.types.ts b/src/features/tenants/types/tenant.types.ts index a8d2da2..ae6a832 100644 --- a/src/features/tenants/types/tenant.types.ts +++ b/src/features/tenants/types/tenant.types.ts @@ -25,3 +25,18 @@ export interface UpdateTenantDTO { mobile?: string; status?: boolean; } + +export interface PlatformTenant extends Tenant { + total_products?: number; + total_assets?: number; + total_users?: number; +} + +export interface ProvisionTenantDTO { + tenant_name: string; + domain?: string; + contact_email: string; + admin_name?: string; + admin_email?: string; + admin_password?: string; +} diff --git a/src/features/units/components/UnitTable.tsx b/src/features/units/components/UnitTable.tsx index fdace20..0386486 100644 --- a/src/features/units/components/UnitTable.tsx +++ b/src/features/units/components/UnitTable.tsx @@ -27,7 +27,7 @@ export const UnitTable: React.FC = ({ key: "conversionFactor", label: "CONVERSION", render: (value: any, row: Unit) => - value !== undefined ? `${value} ${row.baseUnit || ''}` : "—" + (value !== undefined && value !== null && value !== '') ? `${value} ${row.baseUnit || ''}`.trim() : "—" }, { key: "status", diff --git a/src/features/users/pages/UserList.tsx b/src/features/users/pages/UserList.tsx index 2532d2d..e24c88b 100644 --- a/src/features/users/pages/UserList.tsx +++ b/src/features/users/pages/UserList.tsx @@ -14,6 +14,8 @@ import { tenantService } from "../../tenants/services/tenant.service"; import { notify } from "../../../services/toast"; import type { DBRole, PermissionNode } from "../services/roles.service"; import type { Tenant } from "../../tenants/types/tenant.types"; +import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute"; +import { Can } from "../../../components/customs/Can"; export default function UserList() { const navigate = useNavigate(); @@ -79,21 +81,18 @@ export default function UserList() { const [newRoleStatus, setNewRoleStatus] = useState(true); const isSuperAdminUser = (user: any) => { - if (!user) return false; - if (user.email === 'admin@admin.com') return true; - const userRoles = user.roles || []; + if (user?.user_type === 'platform' || user?.type === 'platform') return true; + const userRoles = user?.roles || []; return userRoles.some((r: any) => r.role_code === 'SUPER_ADMIN' || r.role_code === 'SUPERADMIN' || - r.role_name?.toLowerCase().includes('super admin') || - r.is_system_role + r.role_name?.toLowerCase().includes('super admin') ); }; const isSuperAdminRole = (role: any) => { if (!role) return false; return Boolean( - role.is_system_role || role.role_code === 'SUPER_ADMIN' || role.role_code === 'SUPERADMIN' || role.role_name?.toLowerCase().includes('super admin') @@ -378,16 +377,19 @@ export default function UserList() { ]; return ( - - navigate("new")} className="bg-primary hover:bg-primary-hover text-white"> - - Invite Member - - } - /> + + + + + + } + /> {/* KPI Cards */}
@@ -907,6 +909,7 @@ export default function UserList() {
)} -
+
+ ); } diff --git a/src/hooks/usePermission.ts b/src/hooks/usePermission.ts index ee128cf..3c991fb 100644 --- a/src/hooks/usePermission.ts +++ b/src/hooks/usePermission.ts @@ -8,16 +8,17 @@ import { hasPermission } from '../utils/permissionUtils'; * Usage: const { canView, canCreate } = usePermissions('products.items'); */ export const usePermissions = (nodeCode: PermissionNodes | string) => { - // Extract permissions from the Redux store + // Extract permissions and user from the Redux store const permissions = useSelector((state: RootState) => state.auth.permissions); + const user = useSelector((state: RootState) => state.auth.user); return { - canView: hasPermission(permissions, nodeCode, 'view'), - canCreate: hasPermission(permissions, nodeCode, 'create'), - canEdit: hasPermission(permissions, nodeCode, 'edit'), - canDelete: hasPermission(permissions, nodeCode, 'delete'), - canAlter: hasPermission(permissions, nodeCode, 'alter'), - canImport: hasPermission(permissions, nodeCode, 'import'), - canExport: hasPermission(permissions, nodeCode, 'export'), + canView: hasPermission(permissions, nodeCode, 'view', user), + canCreate: hasPermission(permissions, nodeCode, 'create', user), + canEdit: hasPermission(permissions, nodeCode, 'edit', user), + canDelete: hasPermission(permissions, nodeCode, 'delete', user), + canAlter: hasPermission(permissions, nodeCode, 'alter', user), + canImport: hasPermission(permissions, nodeCode, 'import', user), + canExport: hasPermission(permissions, nodeCode, 'export', user), }; }; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 8e4e0c5..4b52913 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -18,7 +18,7 @@ export function getAssetUrl(url?: string): string { return url; } const cleanUrl = url.startsWith('/') ? url : `/${url}`; - const baseUrl = (import.meta as any).env?.VITE_API_BASE_URL || 'http://localhost:5000'; + const baseUrl = (import.meta as any).env?.VITE_API_BASE_URL || (import.meta as any).env?.VITE_API_URL || 'http://localhost:5002'; return `${baseUrl}${cleanUrl}`; } diff --git a/src/routes/AppRoutes.tsx b/src/routes/AppRoutes.tsx index 6b894d7..e078576 100644 --- a/src/routes/AppRoutes.tsx +++ b/src/routes/AppRoutes.tsx @@ -1,6 +1,7 @@ // src/routes/AppRoutes.tsx import { Routes, Route, Navigate } from 'react-router-dom'; -import { AuthGuard } from '../authentication/components/ProtectedRoute'; +import { AuthGuard, PlatformGuard } from '../authentication/components/ProtectedRoute'; +import { ProtectedRoute } from '../components/layouts/ProtectedRoute'; import MainLayout from '../components/layouts/MainLayout'; import { Login, SignUp, ForgotPassword, ResetPassword, AcceptInvite } from '../authentication/routes'; @@ -31,6 +32,8 @@ import { SettingRoutes } from '../features/settings/routes/settings.routes'; import { TenantRoutes } from '../features/tenants/routes/tenant.routes'; import { RoleRoutes } from '../features/roles/routes/role.routes'; import { NotificationRoutes } from '../features/notifications/routes/notifications.routes'; +import PlatformOverview from '../features/platform/pages/PlatformOverview'; +import PlatformTenantsPage from '../features/platform/pages/PlatformTenantsPage'; const AppRoutes = () => { return ( @@ -47,7 +50,12 @@ const AppRoutes = () => { {/* Protected Routes with Layout */} }> - } /> + {/* Platform Control Center (Platform Super Admins Only) */} + } /> + } /> + } /> + + } /> {/* Catalog */} } /> @@ -75,8 +83,8 @@ const AppRoutes = () => { {/* Users */} } /> - } /> - } /> + } /> + } /> {/* Notifications */} } /> diff --git a/src/routes/sidebar.config.ts b/src/routes/sidebar.config.ts index 171e2c4..a5bd364 100644 --- a/src/routes/sidebar.config.ts +++ b/src/routes/sidebar.config.ts @@ -19,7 +19,10 @@ import { Settings, List, Layers2, - Bell + Bell, + ShieldCheck, + Activity, + Building2 } from 'lucide-react'; import React from 'react'; @@ -36,10 +39,21 @@ export interface SidebarItem { href: string; icon: React.ElementType; permission?: string; + platformOnly?: boolean; children?: SidebarSubItem[]; } export const sidebarConfig: SidebarItem[] = [ + { + label: 'Platform Control', + href: '/platform', + icon: ShieldCheck, + platformOnly: true, + children: [ + { label: 'SaaS Overview & Metrics', href: '/platform/overview', icon: Activity }, + { label: 'Tenant Provisioning', href: '/platform/tenants', icon: Building2 }, + ] + }, { label: 'Dashboard', href: '/dashboard', @@ -95,24 +109,27 @@ export const sidebarConfig: SidebarItem[] = [ label: 'Asset Management', href: '/assets', icon: Image, + permission: 'media.assets', children: [ - { label: 'Asset Manager', href: '/assets', icon: Folder }, - { label: 'Asset Types', href: '/asset-types', icon: LayoutGrid }, - { label: 'Asset Families', href: '/asset-families', icon: Layers }, + { label: 'Asset Manager', href: '/assets', icon: Folder, permission: 'media.assets' }, + { label: 'Asset Types', href: '/asset-types', icon: LayoutGrid, permission: 'media.taxonomy' }, + { label: 'Asset Families', href: '/asset-families', icon: Layers, permission: 'media.taxonomy' }, ] }, { label: 'Workflow & Approvals', href: '/workflow', - icon: Workflow + icon: Workflow, + permission: 'products.items' }, { label: 'Channels & Integration', href: '/channels', icon: Radio, + permission: 'channels.syndication', children: [ - { label: 'Channel Registry', href: '/channels', icon: Radio }, - { label: 'Channel Types', href: '/channel-types', icon: Layers2, permission: 'channels.types' }, + { label: 'Channel Registry', href: '/channels', icon: Radio, permission: 'channels.syndication' }, + { label: 'Channel Types', href: '/channel-types', icon: Layers2, permission: 'channels.syndication' }, { label: 'Integration Hub', href: '/integrations', icon: Plug, permission: 'settings.integrations' }, ] }, @@ -122,7 +139,6 @@ export const sidebarConfig: SidebarItem[] = [ icon: Users, children: [ { label: 'Users', href: '/users', icon: Users, permission: 'settings.users' }, - { label: 'Tenants', href: '/users/tenants', icon: Database, permission: 'settings.tenants' }, { label: 'Roles', href: '/users/roles', icon: Users, permission: 'settings.roles' } ] }, diff --git a/src/services/socket.service.ts b/src/services/socket.service.ts index 8fe57c4..dfebb15 100644 --- a/src/services/socket.service.ts +++ b/src/services/socket.service.ts @@ -1,6 +1,6 @@ import { io, Socket } from 'socket.io-client'; -const API_BASE_URL = 'http://localhost:5000'; +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5002'; class SocketServiceClass { private socket: Socket | null = null; diff --git a/src/utils/permissionUtils.ts b/src/utils/permissionUtils.ts index 54c7413..75a5159 100644 --- a/src/utils/permissionUtils.ts +++ b/src/utils/permissionUtils.ts @@ -12,8 +12,14 @@ import type { PermissionPayload, PermissionNodes, PermissionAction } from '../ty export const hasPermission = ( permissions: PermissionPayload | undefined | null, nodeCode: PermissionNodes | string, - action: PermissionAction + action: PermissionAction, + user?: any ): boolean => { + // Platform superadmin or tenant admin role bypass + if (user?.user_type === 'platform' || user?.role_code === 'TENANT_ADMIN' || user?.roles?.some((r: any) => r.role_code === 'SUPER_ADMIN' || r.role_code === 'TENANT_ADMIN')) { + return true; + } + if (!permissions) return false; // Superadmin wildcard check diff --git a/vite.config.ts b/vite.config.ts index f9cba25..bbf7277 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ server: { proxy: { '/uploads': { - target: 'http://localhost:5000', + target: 'http://localhost:5002', changeOrigin: true, }, },