resolved merge conflict

This commit is contained in:
Mohammed-Fardeen-02
2026-08-19 12:47:05 +05:30
43 changed files with 1740 additions and 219 deletions
+11 -4
View File
@@ -1,6 +1,6 @@
import axios, { type InternalAxiosRequestConfig } from 'axios';
const API_BASE_URL = 'http://localhost:5000';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5002';
const axiosInstance = axios.create({
baseURL: API_BASE_URL,
@@ -17,6 +17,10 @@ axiosInstance.interceptors.request.use(
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
const impersonatedTenantId = localStorage.getItem('impersonatedTenantId');
if (impersonatedTenantId) {
config.headers['X-Impersonated-Tenant-Id'] = impersonatedTenantId;
}
return config;
},
(error: unknown) => Promise.reject(error)
@@ -25,10 +29,13 @@ axiosInstance.interceptors.request.use(
// Response interceptor
axiosInstance.interceptors.response.use(
(response) => response,
(error: { response?: { status?: number } }) => {
if (error.response?.status === 401) {
(error: { config?: { url?: string }; response?: { status?: number } }) => {
const isLoginEndpoint = error.config?.url?.includes('/auth/login');
if (error.response?.status === 401 && !isLoginEndpoint) {
localStorage.removeItem('accessToken');
window.location.href = '/login';
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
return Promise.reject(error);
}
@@ -31,4 +31,20 @@ export const ProtectedRoute: React.FC<{
return <>{children}</>;
};
export const PlatformGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated, user } = useAppSelector((state) => state.auth);
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
const isPlatformUser = user?.type === 'platform' || user?.user_type === 'platform';
if (!isPlatformUser) {
return <Navigate to="/dashboard" replace />;
}
return <>{children}</>;
};
export default AuthGuard;
+3 -1
View File
@@ -5,6 +5,7 @@ import { setCredentials } from "../../store/slices/authSlice";
import { authService } from "../services/authService";
import { Eye, EyeOff, Lock, Mail, ArrowRight } from "lucide-react";
import { AuthLayout } from "../components/AuthLayout";
import { notify } from "../../services/toast";
export const Login = () => {
const [email, setEmail] = useState("");
@@ -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);
}
+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}</>;
};
+1
View File
@@ -101,6 +101,7 @@ export function Select({
<button
ref={buttonRef}
id={id}
name={name}
type="button"
disabled={disabled}
onClick={handleToggle}
+1 -1
View File
@@ -27,7 +27,7 @@ export function Header() {
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");
(user?.type === 'platform' || user?.user_type === 'platform' ? "Platform Super Admin" : "Member");
const tenantName = user?.tenant?.name || user?.tenant_name ||
(user?.type === 'platform' || !user?.tenant_id ? "Platform Core" : `Tenant #${user.tenant_id}`);
+20 -9
View File
@@ -18,6 +18,7 @@ interface NavItem {
href: string;
badge?: string;
permission?: string;
platformOnly?: boolean;
children?: NavItem[];
}
@@ -62,6 +63,8 @@ export function Sidebar() {
}, [collapsed]);
const permissions = useSelector((state: RootState) => state.auth.permissions);
const user = useSelector((state: RootState) => state.auth.user);
const isPlatformUser = user?.type === 'platform' || user?.user_type === 'platform';
const toggleSection = (href: string) => {
setExpandedSections(prev =>
@@ -83,7 +86,11 @@ export function Sidebar() {
const filterNavItems = (items: NavItem[]): NavItem[] => {
return items.reduce<NavItem[]>((acc, item) => {
if (item.permission && !hasPermission(permissions, item.permission, "view")) {
if (item.platformOnly && !isPlatformUser) {
return acc;
}
if (item.permission && !hasPermission(permissions, item.permission, "view", user)) {
return acc;
}
@@ -212,14 +219,18 @@ export function Sidebar() {
{/* Footer */}
{!collapsed && (
<div className="p-2 border-t border-border mt-auto">
<div className="px-2 py-1 rounded-lg bg-background hover:bg-primary/5 transition-colors cursor-pointer">
<div className="px-2 py-1.5 rounded-lg bg-background hover:bg-primary/5 transition-colors cursor-pointer">
<div className="flex items-center gap-2">
<div className="w-7 h-6 rounded-full bg-gradient-to-br from-primary to-primary-hover flex items-center justify-center text-white text-xs font-semibold shadow-sm flex-shrink-0">
AC
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary-hover flex items-center justify-center text-white text-xs font-semibold shadow-sm flex-shrink-0">
{isPlatformUser ? 'PA' : (user?.tenant?.name?.substring(0, 2).toUpperCase() || 'TN')}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-foreground truncate">Acme Corp</div>
<div className="text-xs text-muted-foreground truncate">Enterprise Plan</div>
<div className="text-sm font-semibold text-foreground truncate">
{isPlatformUser ? 'Platform Super Admin' : (user?.tenant?.name || 'Tenant Workspace')}
</div>
<div className="text-xs text-muted-foreground truncate">
{isPlatformUser ? 'Global SaaS Mode' : `${user?.tenant?.plan_name || 'Active'} Plan`}
</div>
</div>
</div>
</div>
@@ -230,10 +241,10 @@ export function Sidebar() {
{collapsed && (
<div className="p-2 border-t border-border mt-auto flex justify-center">
<div
title="Acme Corp"
className="w-8 h-8 rounded-full bg-gradient-to-br from-primary to-primary-hover flex items-center justify-center text-white text-xs font-semibold shadow-sm cursor-pointer hover:opacity-90 transition-opacity"
title={isPlatformUser ? 'Platform Admin' : (user?.tenant?.name || 'Tenant Workspace')}
className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary-hover flex items-center justify-center text-white text-xs font-semibold shadow-sm cursor-pointer hover:opacity-90 transition-opacity"
>
AC
{isPlatformUser ? 'PA' : (user?.tenant?.name?.substring(0, 2).toUpperCase() || 'TN')}
</div>
</div>
)}
+19 -15
View File
@@ -21,6 +21,8 @@ import type { Product } from "../../product/types/product.types";
import { notify } from "../../../services/toast";
import apiClient from "../../../api/axiosInstance";
import { getAssetUrl } from "../../../lib/utils";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { Can } from "../../../components/customs/Can";
export default function AssetList() {
const navigate = useNavigate();
@@ -386,21 +388,22 @@ export default function AssetList() {
};
return (
<PageWrapper>
<Breadcrumb
items={[{ label: "Home" }, { label: "Asset Manager" }]}
actions={
<>
<Button
className="bg-primary hover:bg-primary-hover text-white flex items-center shadow-md rounded-lg px-4 py-2"
onClick={() => navigate("/assets/new")}
>
<Upload className="w-4 h-4 mr-2" />
Upload Asset
</Button>
</>
}
/>
<ProtectedRoute node="media.assets">
<PageWrapper>
<Breadcrumb
items={[{ label: "Home" }, { label: "Asset Manager" }]}
actions={
<Can node="media.assets" action="create">
<Button
className="bg-primary hover:bg-primary-hover text-white flex items-center shadow-md rounded-lg px-4 py-2"
onClick={() => navigate("/assets/new")}
>
<Upload className="w-4 h-4 mr-2" />
Upload Asset
</Button>
</Can>
}
/>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
@@ -1613,5 +1616,6 @@ export default function AssetList() {
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</PageWrapper>
</ProtectedRoute>
);
}
@@ -53,7 +53,9 @@ export default function AttributeSetList() {
render: (val: string) => <span className="text-sm text-muted-foreground">{val || '—'}</span>,
},
{
key: 'groups', label: 'GROUPS',
id: 'col_groups_count',
key: 'groups',
label: 'GROUPS',
render: (val: any) => (
<span className="px-2.5 py-1 text-xs font-medium rounded bg-blue-50 text-blue-700">
{Array.isArray(val) ? val.length : 0} groups
@@ -61,7 +63,9 @@ export default function AttributeSetList() {
),
},
{
key: 'groups', label: 'ATTRIBUTES',
id: 'col_attributes_count',
key: 'groups',
label: 'ATTRIBUTES',
render: (groups: any) => {
let count = 0;
if (Array.isArray(groups)) {
@@ -13,7 +13,11 @@ import { StatsCard } from "../../../components/customs/StatsCard";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { useState } from "react";
import { usePermissions } from "../../../hooks/usePermission";
import { Can } from "../../../components/customs/Can";
export default function AttributeList() {
const { canCreate, canEdit, canDelete } = usePermissions("products.attributes");
const navigate = useNavigate();
const { attributes, fetchAttributes, deleteAttribute } = useAttribute();
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" });
@@ -183,9 +187,11 @@ export default function AttributeList() {
<Breadcrumb
items={[{ label: "Home" }, { label: "Attributes" }]}
actions={
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("/attributes/new")}>
Create Attribute
</Button>
<Can node="products.attributes" action="create">
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("/attributes/new")}>
Create Attribute
</Button>
</Can>
}
/>
@@ -235,8 +241,8 @@ export default function AttributeList() {
}
actionConfig={{
onView: (row) => navigate(`/attributes/${row.id}/view`),
onEdit: (row) => navigate(`/attributes/${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
onEdit: canEdit ? ((row) => navigate(`/attributes/${row.id}/edit`)) : undefined,
onDelete: canDelete ? ((row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })) : undefined
}}
/>
</div>
@@ -45,6 +45,22 @@ export default function NewAttribute() {
const { createAttribute, updateAttribute, fetchAttributes, getAttributeById } = useAttribute();
const [optionsList, setOptionsList] = useState<Array<{ code: string; label: string }>>([]);
const [newOptionInput, setNewOptionInput] = useState("");
const handleAddOption = () => {
if (!newOptionInput.trim()) return;
const label = newOptionInput.trim();
const code = label.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
if (optionsList.some((o) => o.code === code)) return;
setOptionsList((prev) => [...prev, { code, label }]);
setNewOptionInput("");
};
const handleRemoveOption = (index: number) => {
setOptionsList((prev) => prev.filter((_, i) => i !== index));
};
const formik = useFormik({
initialValues: {
code: "",
@@ -97,6 +113,9 @@ export default function NewAttribute() {
apiVisible: values.apiVisible,
isRequiredForCompleteness: values.isRequiredForCompleteness,
};
if (values.dataType === "select" || values.dataType === "multiselect") {
payload.options = optionsList;
}
if (values.description?.trim()) payload.description = values.description.trim();
try {
if (isEdit && id) {
@@ -305,6 +324,40 @@ export default function NewAttribute() {
</Select>
</div>
{(formik.values.dataType === "select" || formik.values.dataType === "multiselect") && (
<div className="bg-primary/5 p-4 rounded-lg border border-primary/20 space-y-3">
<label className={labelClass}>Selectable Options <span className="text-red-400">*</span></label>
<div className="flex gap-2">
<Input
value={newOptionInput}
onChange={(e: any) => setNewOptionInput(e.target.value)}
onKeyDown={(e: any) => { if (e.key === "Enter") { e.preventDefault(); handleAddOption(); } }}
placeholder="Type option label (e.g. Red, Blue, Black) and press Enter"
disabled={isView}
/>
<Button type="button" variant="secondary" onClick={handleAddOption} disabled={isView || !newOptionInput.trim()}>
Add Option
</Button>
</div>
{optionsList.length > 0 ? (
<div className="flex flex-wrap gap-2 pt-1">
{optionsList.map((opt, idx) => (
<span key={opt.code} className="inline-flex items-center gap-1.5 px-3 py-1 bg-surface border border-border rounded-full text-xs font-medium text-foreground">
{opt.label} <code className="text-[10px] text-muted-foreground">({opt.code})</code>
{!isView && (
<button type="button" onClick={() => handleRemoveOption(idx)} className="text-muted-foreground hover:text-red-500 font-bold ml-1">
×
</button>
)}
</span>
))}
</div>
) : (
<p className="text-xs text-amber-600 italic">No options added yet. Type an option label above and click Add Option.</p>
)}
</div>
)}
<div>
<label className={labelClass}>Status</label>
<RadioGroup className="mt-1">
+11 -5
View File
@@ -9,7 +9,11 @@ import { useBrand } from "../hook/useBrand";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { Can } from "../../../components/customs/Can";
import { usePermissions } from "../../../hooks/usePermission";
export default function BrandList() {
const { canEdit, canDelete } = usePermissions("masters.brands");
const navigate = useNavigate();
const { brands, fetchBrands, deleteBrand } = useBrand();
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" });
@@ -38,9 +42,11 @@ export default function BrandList() {
<Breadcrumb
items={[{ label: "Home" }, { label: "Brands" }]}
actions={
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("/brands/new")}>
Create Brand
</Button>
<Can node="masters.brands" action="create">
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("/brands/new")}>
Create Brand
</Button>
</Can>
}
/>
@@ -49,8 +55,8 @@ export default function BrandList() {
brands={brands}
onRowClick={(row) => navigate(`/brands/${row.id}/edit`)}
onView={(row) => navigate(`/brands/${row.id}/view`)}
onEdit={(row) => navigate(`/brands/${row.id}/edit`)}
onDelete={(row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })}
onEdit={canEdit ? ((row) => navigate(`/brands/${row.id}/edit`)) : undefined}
onDelete={canDelete ? ((row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })) : undefined}
/>
</div>
+15 -11
View File
@@ -11,6 +11,8 @@ import { StatsCard } from "../../../components/customs/StatsCard";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { CategoryTaxonomyTree } from "../components/CategoryTaxonomyTree";
import { Can } from "../../../components/customs/Can";
type ViewMode = "table" | "tree";
export default function CategoryList() {
@@ -31,10 +33,10 @@ export default function CategoryList() {
}, [fetchCategories]);
const stats = {
total: categories.length || 0,
active: categories.filter((c) => c.status === "active").length,
products: 33008,
families: 406,
total: categories.length || 0,
active: categories.filter((c) => c.status === "active").length,
products: categories.reduce((sum, c) => sum + (Number(c.productCount) || 0), 0),
families: categories.reduce((sum, c) => sum + (Number(c.familyCount) || 0), 0),
};
const handleDeleteConfirm = async () => {
@@ -56,13 +58,15 @@ export default function CategoryList() {
<Breadcrumb
items={[{ label: "Home" }, { label: "Categories" }]}
actions={
<Button
variant="primary"
icon={<Plus className="w-4 h-4" />}
onClick={() => navigate("/categories/new")}
>
Add Root Category
</Button>
<Can node="products.categories" action="create">
<Button
variant="primary"
icon={<Plus className="w-4 h-4" />}
onClick={() => navigate("/categories/new")}
>
Add Root Category
</Button>
</Can>
}
/>
@@ -113,8 +113,11 @@ export default function NewCategory() {
}, [isEdit, id, categories, parentIdParam]);
const allowedParentOptions = useMemo(() => {
if (!isEdit) return categories;
return categories.filter((cat) => cat.id !== id && !cat.path?.startsWith(categories.find(c => c.id === id)?.path + "/"));
const validCategories = Array.isArray(categories) ? categories.filter((cat) => cat && cat.id) : [];
if (!isEdit) return validCategories;
const currentCat = validCategories.find((c) => c.id === id);
const currentPath = currentCat?.path || "";
return validCategories.filter((cat) => cat.id !== id && (!currentPath || !cat.path?.startsWith(currentPath + "/")));
}, [categories, isEdit, id]);
return (
@@ -204,16 +207,16 @@ export default function NewCategory() {
name="parentId"
value={formik.values.parentId}
onChange={formik.handleChange}
disabled={!isEdit} // Disabled (Read-only) during creation, enabled during Edit
disabled={Boolean(parentIdParam)}
>
<option value="">None (Root Level)</option>
{allowedParentOptions.map((cat) => (
{allowedParentOptions.filter(Boolean).map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name} ({cat.code})
</option>
))}
</Select>
{!isEdit && (
{!isEdit && Boolean(parentIdParam) && (
<p className="text-[10px] text-muted-foreground mt-1">
Locked to parent context. Click inline tree actions to create subcategories.
</p>
+35
View File
@@ -34,6 +34,41 @@ export const channelsApi = {
const res = await apiClient.delete<ApiResponse<any>>(`${BASE_URL}/${id}`);
return res.success;
},
getMappings: async (channelId: string): Promise<any[]> => {
const res = await apiClient.get<ApiResponse<any[]>>(`${BASE_URL}/${channelId}/mappings`);
return res.data || [];
},
updateMappings: async (channelId: string, mappings: any[]): Promise<any> => {
const res = await apiClient.put<ApiResponse<any>>(`${BASE_URL}/${channelId}/mappings`, { mappings });
return res.data;
},
triggerSyndication: async (channelId: string): Promise<any> => {
const res = await apiClient.post<ApiResponse<any>>(`${BASE_URL}/${channelId}/syndicate`);
return res.data;
},
getJobs: async (channelId: string): Promise<any[]> => {
const res = await apiClient.get<ApiResponse<any[]>>(`${BASE_URL}/${channelId}/jobs`);
return res.data || [];
},
previewPayload: async (channelId: string): Promise<any> => {
const res = await apiClient.post<ApiResponse<any>>(`${BASE_URL}/${channelId}/preview`);
return res.data;
},
syndicateAll: async (): Promise<any[]> => {
const res = await apiClient.post<ApiResponse<any[]>>(`${BASE_URL}/syndicate-all`);
return res.data || [];
},
testConnection: async (channelId: string): Promise<any> => {
const res = await apiClient.post<ApiResponse<any>>(`${BASE_URL}/${channelId}/test-connection`);
return res.data;
},
};
export default channelsApi;
@@ -0,0 +1,228 @@
import { useState, useEffect } from "react";
import { Save, Plus, Trash2, ArrowRight, Eye } from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { channelsApi } from "../api/channels.api";
import { notify } from "../../../services/toast";
import { PayloadPreviewModal } from "./PayloadPreviewModal";
const COMMON_PIM_ATTRIBUTES = [
{ code: "name", label: "Product Title / Name (name)" },
{ code: "code", label: "Product Code / SKU (code)" },
{ code: "description", label: "Product Description (description)" },
{ code: "status", label: "Publication Status (status)" },
{ code: "created_at", label: "Creation Timestamp (created_at)" },
];
const COMMON_CHANNEL_FIELDS = [
{ code: "title", label: "Storefront Title (title)" },
{ code: "body_html", label: "HTML Body Description (body_html)" },
{ code: "variant_sku", label: "Variant SKU (variant_sku)" },
{ code: "price", label: "Variant Price (price)" },
{ code: "vendor", label: "Brand / Vendor (vendor)" },
{ code: "product_type", label: "Product Category / Type (product_type)" },
];
const TRANSFORMATION_RULES = [
{ value: "none", label: "Direct Pass-through" },
{ value: "uppercase", label: "UPPERCASE" },
{ value: "lowercase", label: "lowercase" },
{ value: "currency_format", label: "Currency Format (0.00)" },
{ value: "strip_html", label: "Strip HTML Tags" },
{ value: "default_if_null", label: "Fallback Default Value" },
];
export function ChannelMappingTab({ channelId }: { channelId: string }) {
const [mappings, setMappings] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [previewing, setPreviewing] = useState(false);
const [previewData, setPreviewData] = useState<any>(null);
useEffect(() => {
loadMappings();
}, [channelId]);
const loadMappings = async () => {
setLoading(true);
const defaultBaseline = [
{ pim_attribute_code: "name", channel_field_code: "title", transformation_rule: "none", default_value: "", is_required: true },
{ pim_attribute_code: "code", channel_field_code: "variant_sku", transformation_rule: "uppercase", default_value: "", is_required: true },
{ pim_attribute_code: "status", channel_field_code: "published_status", transformation_rule: "lowercase", default_value: "published", is_required: false },
];
if (!channelId || channelId === "demo-channel-id") {
setMappings(defaultBaseline);
setLoading(false);
return;
}
try {
const data = await channelsApi.getMappings(channelId);
if (data && data.length > 0) {
setMappings(data);
} else {
setMappings(defaultBaseline);
}
} catch {
setMappings(defaultBaseline);
} finally {
setLoading(false);
}
};
const handleAddRule = () => {
setMappings((prev) => [
...prev,
{ pim_attribute_code: "name", channel_field_code: "custom_field", transformation_rule: "none", default_value: "", is_required: false },
]);
};
const handleRemoveRule = (index: number) => {
setMappings((prev) => prev.filter((_, i) => i !== index));
};
const handleChange = (index: number, field: string, value: any) => {
setMappings((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], [field]: value };
return updated;
});
};
const handleSave = async () => {
setSaving(true);
try {
await channelsApi.updateMappings(channelId, mappings);
notify.success("Attribute mapping rules saved successfully!");
await loadMappings();
} catch {
notify.error("Failed to save mapping rules");
} finally {
setSaving(false);
}
};
const handlePreview = async () => {
setPreviewing(true);
try {
const data = await channelsApi.previewPayload(channelId);
setPreviewData(data);
} catch {
notify.error("Failed to generate transformation preview");
} finally {
setPreviewing(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-48">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-semibold text-foreground">Channel Field Mapping Matrix</h3>
<p className="text-xs text-muted-foreground mt-0.5">Map central PIM attributes to target storefront fields and apply transformation pipelines.</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" loading={previewing} onClick={handlePreview}>
<Eye className="w-4 h-4 mr-2" /> Preview Transformed Payload
</Button>
<Button variant="outline" onClick={handleAddRule}>
<Plus className="w-4 h-4 mr-2" /> Add Rule
</Button>
<Button variant="primary" loading={saving} onClick={handleSave}>
<Save className="w-4 h-4 mr-2" /> Save Mappings
</Button>
</div>
</div>
<PayloadPreviewModal
isOpen={Boolean(previewData)}
onClose={() => setPreviewData(null)}
previewData={previewData}
/>
<div className="border border-border rounded-lg overflow-hidden bg-surface shadow-sm">
<table className="w-full text-left text-sm">
<thead className="bg-surface-muted border-b border-border text-xs text-muted-foreground uppercase">
<tr>
<th className="px-4 py-3">PIM Central Attribute</th>
<th className="px-4 py-3 text-center">Pipeline</th>
<th className="px-4 py-3">Target Storefront Field</th>
<th className="px-4 py-3">Transformation Rule</th>
<th className="px-4 py-3 text-center">Required</th>
<th className="px-4 py-3 text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{mappings.map((rule, idx) => (
<tr key={idx} className="hover:bg-surface-muted/50">
<td className="px-4 py-3">
<select
value={rule.pim_attribute_code}
onChange={(e) => handleChange(idx, "pim_attribute_code", e.target.value)}
className="w-full px-3 py-1.5 border border-border rounded-md bg-surface text-foreground text-xs focus:ring-1 focus:ring-primary"
>
{COMMON_PIM_ATTRIBUTES.map((attr) => (
<option key={attr.code} value={attr.code}>
{attr.label} ({attr.code})
</option>
))}
</select>
</td>
<td className="px-4 py-3 text-center">
<ArrowRight className="w-4 h-4 mx-auto text-muted-foreground" />
</td>
<td className="px-4 py-3">
<input
type="text"
value={rule.channel_field_code}
onChange={(e) => handleChange(idx, "channel_field_code", e.target.value)}
placeholder="e.g. title or body_html"
className="w-full px-3 py-1.5 border border-border rounded-md bg-surface text-foreground text-xs font-mono focus:ring-1 focus:ring-primary"
/>
</td>
<td className="px-4 py-3">
<select
value={rule.transformation_rule}
onChange={(e) => handleChange(idx, "transformation_rule", e.target.value)}
className="w-full px-3 py-1.5 border border-border rounded-md bg-surface text-foreground text-xs focus:ring-1 focus:ring-primary"
>
{TRANSFORMATION_RULES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</td>
<td className="px-4 py-3 text-center">
<input
type="checkbox"
checked={Boolean(rule.is_required)}
onChange={(e) => handleChange(idx, "is_required", e.target.checked)}
className="rounded border-border text-primary focus:ring-primary h-4 w-4"
/>
</td>
<td className="px-4 py-3 text-right">
<button
type="button"
onClick={() => handleRemoveRule(idx)}
className="p-1.5 text-danger hover:bg-danger/10 rounded transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,84 @@
import { Button } from "../../../components/customs/Button";
import { Code, Check, Copy } from "lucide-react";
import { useState } from "react";
interface PayloadPreviewModalProps {
isOpen: boolean;
onClose: () => void;
previewData: any;
}
export function PayloadPreviewModal({ isOpen, onClose, previewData }: PayloadPreviewModalProps) {
const [copied, setCopied] = useState(false);
if (!isOpen || !previewData) return null;
const handleCopy = () => {
navigator.clipboard.writeText(JSON.stringify(previewData.adapterOutput, null, 2));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="bg-surface border border-border rounded-xl shadow-2xl max-w-4xl w-full flex flex-col max-h-[85vh] overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-border bg-gradient-to-r from-primary/10 to-surface flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
<Code className="w-4 h-4" />
</div>
<div>
<h3 className="font-semibold text-foreground text-sm">Transformed Payload Preview</h3>
<p className="text-xs text-muted-foreground">Real-time adapter transformation preview for channel: <span className="font-semibold text-primary">{previewData.channel?.name}</span></p>
</div>
</div>
<button
onClick={onClose}
className="text-muted-foreground hover:text-foreground text-sm font-bold p-1 rounded"
>
</button>
</div>
{/* Content */}
<div className="p-6 grid grid-cols-2 gap-4 flex-1 overflow-y-auto">
{/* Left Column: PIM Raw Data */}
<div className="flex flex-col border border-border rounded-lg bg-surface-muted overflow-hidden">
<div className="px-3 py-2 bg-border/40 text-xs font-semibold text-muted-foreground uppercase tracking-wider border-b border-border">
PIM Central Product (Raw JSON)
</div>
<pre className="p-4 text-xs font-mono text-foreground overflow-auto flex-1 max-h-96">
{JSON.stringify(previewData.pimProductRaw, null, 2)}
</pre>
</div>
{/* Right Column: Adapter Storefront Output */}
<div className="flex flex-col border border-primary/20 rounded-lg bg-primary/5/30 overflow-hidden">
<div className="px-3 py-2 bg-primary/10 text-xs font-semibold text-primary uppercase tracking-wider border-b border-primary/20 flex justify-between items-center">
<span>Adapter Storefront Payload ({previewData.channel?.code})</span>
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center gap-1 text-[11px] text-primary hover:underline"
>
{copied ? <Check className="w-3 h-3 text-success" /> : <Copy className="w-3 h-3" />}
{copied ? "Copied!" : "Copy Payload"}
</button>
</div>
<pre className="p-4 text-xs font-mono text-primary-dark overflow-auto flex-1 max-h-96">
{JSON.stringify(previewData.adapterOutput, null, 2)}
</pre>
</div>
</div>
{/* Footer */}
<div className="px-6 py-3 border-t border-border bg-surface-muted flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
Close Inspector
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,159 @@
import { useState, useEffect } from "react";
import { Play, RefreshCw, AlertCircle, CheckCircle2, Clock, Eye } from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { channelsApi } from "../api/channels.api";
import { notify } from "../../../services/toast";
export function SyndicationHistoryTab({ channelId }: { channelId: string }) {
const [jobs, setJobs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [selectedErrorLog, setSelectedErrorLog] = useState<any[] | null>(null);
useEffect(() => {
loadJobs();
}, [channelId]);
const loadJobs = async () => {
if (!channelId || channelId === "demo-channel-id") {
setJobs([]);
setLoading(false);
return;
}
setLoading(true);
try {
const data = await channelsApi.getJobs(channelId);
setJobs(data || []);
} catch {
setJobs([]);
} finally {
setLoading(false);
}
};
const handleTriggerSync = async () => {
setSyncing(true);
try {
await channelsApi.triggerSyndication(channelId);
notify.success("Syndication job triggered and processed successfully!");
await loadJobs();
} catch {
notify.error("Failed to trigger syndication job");
} finally {
setSyncing(false);
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'completed':
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-success/10 text-success border border-success/20"><CheckCircle2 className="w-3.5 h-3.5" /> Completed</span>;
case 'failed':
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-danger/10 text-danger border border-danger/20"><AlertCircle className="w-3.5 h-3.5" /> Failed</span>;
case 'running':
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-warning/10 text-warning border border-warning/20 animate-pulse"><Clock className="w-3.5 h-3.5" /> Running</span>;
default:
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-muted text-muted-foreground">Pending</span>;
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-semibold text-foreground">Syndication Execution History</h3>
<p className="text-xs text-muted-foreground mt-0.5">Real-time execution runs, success metrics, and error log inspection for this channel.</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={loadJobs} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} /> Refresh
</Button>
<Button variant="primary" loading={syncing} onClick={handleTriggerSync}>
<Play className="w-4 h-4 mr-2" /> Trigger Instant Sync
</Button>
</div>
</div>
<div className="border border-border rounded-lg overflow-hidden bg-surface shadow-sm">
<table className="w-full text-left text-sm">
<thead className="bg-surface-muted border-b border-border text-xs text-muted-foreground uppercase">
<tr>
<th className="px-4 py-3">Job ID</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3 text-center">Total Products</th>
<th className="px-4 py-3 text-center">Success</th>
<th className="px-4 py-3 text-center">Failed</th>
<th className="px-4 py-3">Started At</th>
<th className="px-4 py-3 text-right">Log Details</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{jobs.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground text-xs">
No syndication runs recorded yet. Click "Trigger Instant Sync" to start your first job run.
</td>
</tr>
) : (
jobs.map((job) => (
<tr key={job.id} className="hover:bg-surface-muted/50">
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{job.id.slice(0, 8)}...</td>
<td className="px-4 py-3">{getStatusBadge(job.status)}</td>
<td className="px-4 py-3 text-center font-medium">{job.total_products}</td>
<td className="px-4 py-3 text-center text-success font-semibold">{job.success_count}</td>
<td className="px-4 py-3 text-center text-danger font-semibold">{job.failed_count}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{new Date(job.started_at || job.created_at).toLocaleString()}</td>
<td className="px-4 py-3 text-right">
{job.error_log && job.error_log.length > 0 ? (
<button
type="button"
onClick={() => setSelectedErrorLog(job.error_log)}
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-danger bg-danger/10 hover:bg-danger/20 rounded transition-colors"
>
<Eye className="w-3.5 h-3.5" /> View Errors ({job.error_log.length})
</button>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Error Log Inspection Modal */}
{selectedErrorLog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-surface border border-border rounded-xl shadow-xl max-w-2xl w-full p-6 space-y-4">
<div className="flex items-center justify-between border-b border-border pb-3">
<h4 className="text-base font-semibold text-danger flex items-center gap-2">
<AlertCircle className="w-5 h-5" /> Syndication Error Logs
</h4>
<button
onClick={() => setSelectedErrorLog(null)}
className="text-muted-foreground hover:text-foreground text-sm font-bold"
>
</button>
</div>
<div className="max-h-80 overflow-y-auto space-y-2">
{selectedErrorLog.map((err, idx) => (
<div key={idx} className="p-3 bg-danger/5 border border-danger/20 rounded-lg text-xs font-mono">
<div className="font-semibold text-danger">Product SKU: {err.sku || 'N/A'} (ID: {err.productId})</div>
<div className="text-muted-foreground mt-1">{err.error}</div>
</div>
))}
</div>
<div className="flex justify-end pt-2">
<Button variant="outline" onClick={() => setSelectedErrorLog(null)}>
Close Inspector
</Button>
</div>
</div>
</div>
)}
</div>
);
}
+57 -8
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Plus, RefreshCw, Radio, CheckCircle, Layers, TrendingUp, ShoppingCart, ShoppingBag, Server, Warehouse, Store, Smartphone, Globe, Monitor } from "lucide-react";
import { Plus, RefreshCw, Radio, CheckCircle, Layers, TrendingUp, ShoppingCart, ShoppingBag, Server, Warehouse, Store, Smartphone, Globe, Monitor, Play } from "lucide-react";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
@@ -11,6 +11,11 @@ import { StatsCard } from "../../../components/customs/StatsCard";
import { useChannel } from "../hook/useChannel";
import type { Channel } from "../types/channels.types";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { channelsApi } from "../api/channels.api";
import { notify } from "../../../services/toast";
import { Can } from "../../../components/customs/Can";
import { usePermissions } from "../../../hooks/usePermission";
const CHANNEL_TYPES_META: Record<string, { label: string, icon: any, typeColor: string, typeBg: string }> = {
ecommerce: { label: "Ecommerce", icon: ShoppingCart, typeColor: "text-blue-600", typeBg: "bg-blue-50" },
@@ -24,6 +29,7 @@ const CHANNEL_TYPES_META: Record<string, { label: string, icon: any, typeColor:
};
export default function ChannelList() {
const { canEdit, canDelete } = usePermissions("channels.syndication");
const navigate = useNavigate();
const { items, fetchItems, loading, deleteItem } = useChannel();
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: "", name: "" });
@@ -80,6 +86,31 @@ export default function ChannelList() {
},
{ key: "families", label: "Families", render: (val: any) => val || 0 },
{ key: "products", label: "Products", render: (val: any) => val ? val.toLocaleString() : 0 },
{
key: "syndicate",
label: "Syndication",
render: (_: any, row: Channel) => (
<button
type="button"
onClick={async () => {
try {
notify.info(`Triggering syndication for ${row.name}...`);
const res = await channelsApi.triggerSyndication(row.id);
if (res.status === 'completed') {
notify.success(`Syndication for ${row.name} completed! (${res.success_count} synced)`);
} else {
notify.warning(`Syndication for ${row.name} completed with ${res.failed_count} errors`);
}
} catch {
notify.error("Failed to trigger syndication");
}
}}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-semibold text-primary bg-primary/10 hover:bg-primary/20 rounded transition-colors"
>
<Play className="w-3 h-3" /> Trigger Sync
</button>
),
},
{
key: "createdAt",
label: "Updated",
@@ -109,20 +140,38 @@ export default function ChannelList() {
return (
<ProtectedRoute node="settings.integrations">
<ProtectedRoute node="channels.syndication">
<PageWrapper>
<Breadcrumb
items={[{ label: "Home" }, { label: "Channel Registry" }]}
actions={
<>
<Button
variant="outline"
className="bg-surface border-border text-foreground hover:bg-surface-muted"
onClick={async () => {
try {
notify.info("Triggering bulk syndication across all active channels...");
const results = await channelsApi.syndicateAll();
notify.success(`Bulk syndication complete! Triggered sync for ${results.length} active channels.`);
} catch {
notify.error("Failed to trigger bulk channel syndication");
}
}}
>
<Play className="w-4 h-4 mr-2 text-primary" />
Syndicate All Channels
</Button>
<Button variant="outline" className="bg-surface border-border text-muted-foreground hover:bg-surface-muted" onClick={fetchItems}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button onClick={() => navigate("new")} className="bg-primary hover:bg-primary-hover text-white">
<Plus className="w-4 h-4 mr-2" />
Add Channel
</Button>
<Can node="channels.syndication" action="create">
<Button onClick={() => navigate("new")} className="bg-primary hover:bg-primary-hover text-white">
<Plus className="w-4 h-4 mr-2" />
Add Channel
</Button>
</Can>
</>
}
/>
@@ -167,8 +216,8 @@ export default function ChannelList() {
data={items}
actionConfig={{
onView: (row) => navigate(`${row.id}/view`),
onEdit: (row) => navigate(`${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
onEdit: canEdit ? ((row) => navigate(`${row.id}/edit`)) : undefined,
onDelete: canDelete ? ((row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })) : undefined
}}
/>
</div>
+23 -4
View File
@@ -28,10 +28,15 @@ const CHANNEL_TYPES = [
{ id: "website", label: "Website", icon: Monitor, color: "text-primary", bg: "bg-primary/5" },
];
import { ChannelMappingTab } from "../components/ChannelMappingTab";
import { SyndicationHistoryTab } from "../components/SyndicationHistoryTab";
const STEPS = [
{ id: "basic", label: "Basic Information", step: 1 },
{ id: "availability",label: "Availability", step: 2 },
{ id: "summary", label: "Summary", step: 3 },
{ id: "basic", label: "Basic Information", step: 1 },
{ id: "availability", label: "Availability", step: 2 },
{ id: "mapping", label: "Field Mapping Matrix", step: 3 },
{ id: "syndication", label: "Syndication History", step: 4 },
{ id: "summary", label: "Summary", step: 5 },
];
const channelSchema = Yup.object({
@@ -330,7 +335,21 @@ export default function NewChannel() {
</div>
)}
{/* Step 3 — Summary */}
{/* Step 3 — Field Mapping Matrix */}
{activeStep === "mapping" && (
<div className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden p-6">
<ChannelMappingTab channelId={id || "demo-channel-id"} />
</div>
)}
{/* Step 4 — Syndication History */}
{activeStep === "syndication" && (
<div className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden p-6">
<SyndicationHistoryTab channelId={id || "demo-channel-id"} />
</div>
)}
{/* Step 5 — Summary */}
{activeStep === "summary" && (
<div className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader title="Summary" subtitle="Review your channel configuration before saving" />
+1 -1
View File
@@ -43,7 +43,7 @@ export function ChartContainer({
className={cn("flex justify-center text-xs", className)}
{...props}
>
<RechartsPrimitive.ResponsiveContainer width="100%" height="100%">
<RechartsPrimitive.ResponsiveContainer width="100%" height="100%" minWidth={100} minHeight={100}>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
+37 -15
View File
@@ -61,45 +61,67 @@ const recentActivity = [
// ── Dashboard Component ───────────────────────────────────────────────────────
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { productApi } from "../../product/api/product.api";
import { channelsApi } from "../../channels/api/channels.api";
export default function Dashboard() {
const user = useSelector((state: any) => state.auth?.user);
const [productCount, setProductCount] = useState<number>(0);
const [channelCount, setChannelCount] = useState<number>(0);
const [publishedCount, setPublishedCount] = useState<number>(0);
useEffect(() => {
async function loadStats() {
try {
const [products, channels] = await Promise.all([
productApi.getAll().catch(() => []),
channelsApi.getAll().catch(() => [])
]);
setProductCount(products.length || 0);
setPublishedCount(products.filter((p: any) => p.status === 'published' || p.status === 'active').length || 0);
setChannelCount(channels.length || 0);
} catch {
// handled
}
}
loadStats();
}, []);
return (
<PageWrapper>
<div className="mb-6">
<h2 className="text-xl font-bold text-foreground mb-1">Welcome back, John</h2>
<h2 className="text-xl font-bold text-foreground mb-1">
Welcome back, {user?.first_name || user?.user_name || (user?.user_type === 'platform' ? 'Platform Super Admin' : 'Tenant Administrator')}
</h2>
<p className="text-sm text-muted-foreground">Here's what's happening with your product catalog today.</p>
</div>
{/* KPI InfoCards */}
<InfoCardGrid cols={4} className="mb-6">
<InfoCard
label="Total Products"
value="12,459"
value={productCount.toLocaleString()}
icon={<Box className="w-5 h-5 text-primary-light" />}
trend="+12.5%"
trendDirection="up"
subtitle="vs. last month"
subtitle="Tenant Workspace Total"
/>
<InfoCard
label="Pending Approvals"
value="8"
value="0"
icon={<AlertCircle className="w-5 h-5 text-primary-light" />}
subtitle="Requires attention"
/>
<InfoCard
label="Published This Month"
value="2,380"
label="Published Products"
value={publishedCount.toLocaleString()}
icon={<CheckCircle2 className="w-5 h-5 text-primary-light" />}
trend="+18.2%"
trendDirection="up"
subtitle="vs. last month"
subtitle="Ready for syndication"
/>
<InfoCard
label="Active Channels"
value="24"
value={channelCount.toLocaleString()}
icon={<Radio className="w-5 h-5 text-primary-light" />}
trend="+4.3%"
trendDirection="up"
subtitle="Publishing enabled"
/>
</InfoCardGrid>
+7 -3
View File
@@ -10,6 +10,8 @@ import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { StatsCard } from "../../../components/customs/StatsCard";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { Can } from "../../../components/customs/Can";
export default function FamilyList() {
const navigate = useNavigate();
const { families, fetchFamilies, deleteFamily } = useFamily();
@@ -49,9 +51,11 @@ export default function FamilyList() {
<Breadcrumb
items={[{ label: "Home" }, { label: "Product Families" }]}
actions={
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("/families/new")}>
Create Family
</Button>
<Can node="products.families" action="create">
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("/families/new")}>
Create Family
</Button>
</Can>
}
/>
@@ -0,0 +1,211 @@
import { useEffect, useState } from "react";
import { Building2, Users, Package, Image, ShieldCheck, Activity, UserCheck, Play, StopCircle } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { Button } from "../../../components/customs/Button";
import { tenantService } from "../../tenants/services/tenant.service";
import { useTenant } from "../../tenants/hooks/useTenant";
import { notify } from "../../../services/toast";
export default function PlatformOverview() {
const navigate = useNavigate();
const { impersonateTenant, stopImpersonation } = useTenant();
const [metrics, setMetrics] = useState<any>(null);
const [tenants, setTenants] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [impersonatingTenantId, setImpersonatingTenantId] = useState<string | null>(
localStorage.getItem("impersonatedTenantId")
);
const loadData = async () => {
try {
setLoading(true);
const [metricRes, tenantRes] = await Promise.all([
tenantService.getPlatformMetrics(),
tenantService.getPlatformTenants()
]);
setMetrics(metricRes);
setTenants(tenantRes);
} catch (err) {
notify.error("Failed to load platform dashboard data");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleStartImpersonate = async (tenantId: string) => {
try {
await impersonateTenant(tenantId);
setImpersonatingTenantId(tenantId);
navigate("/products");
} catch (err) {
// Handled in hook
}
};
const handleStopImpersonate = () => {
stopImpersonation();
setImpersonatingTenantId(null);
};
return (
<PageWrapper>
<Breadcrumb
items={[{ label: "Platform Operator" }, { label: "SaaS Control Center Overview" }]}
actions={
<Button
variant="primary"
icon={<Building2 className="w-4 h-4" />}
onClick={() => navigate("/platform/tenants")}
>
Provision New Tenant
</Button>
}
/>
{/* Support Impersonation Banner */}
{impersonatingTenantId && (
<div className="mb-6 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-amber-500 text-white font-bold">
<ShieldCheck className="w-5 h-5" />
</div>
<div>
<h4 className="font-semibold text-foreground text-sm">Support Impersonation Mode Active</h4>
<p className="text-xs text-muted-foreground">
Currently troubleshooting Tenant ID: <span className="font-mono font-bold text-amber-500">{impersonatingTenantId}</span>. Requests are safely scoped to this tenant context.
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
icon={<StopCircle className="w-4 h-4 text-danger" />}
onClick={handleStopImpersonate}
>
End Support Mode
</Button>
</div>
)}
{/* Metrics Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5 mb-8">
<div className="bg-surface rounded-xl p-5 border border-border shadow-sm flex items-center gap-4">
<div className="p-3 rounded-xl bg-primary/10 text-primary">
<Building2 className="w-6 h-6" />
</div>
<div>
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Total SaaS Tenants</p>
<h3 className="text-2xl font-bold text-foreground mt-0.5">{loading ? "..." : metrics?.tenants?.total || 0}</h3>
<span className="text-xs text-emerald-500 font-medium">{metrics?.tenants?.active || 0} Active</span>
</div>
</div>
<div className="bg-surface rounded-xl p-5 border border-border shadow-sm flex items-center gap-4">
<div className="p-3 rounded-xl bg-blue-500/10 text-blue-500">
<Users className="w-6 h-6" />
</div>
<div>
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Platform Accounts</p>
<h3 className="text-2xl font-bold text-foreground mt-0.5">{loading ? "..." : metrics?.users?.total || 0}</h3>
<span className="text-xs text-muted-foreground">Across all tenants</span>
</div>
</div>
<div className="bg-surface rounded-xl p-5 border border-border shadow-sm flex items-center gap-4">
<div className="p-3 rounded-xl bg-violet-500/10 text-violet-500">
<Package className="w-6 h-6" />
</div>
<div>
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Total Products</p>
<h3 className="text-2xl font-bold text-foreground mt-0.5">{loading ? "..." : metrics?.data?.total_products || 0}</h3>
<span className="text-xs text-muted-foreground">Catalog items</span>
</div>
</div>
<div className="bg-surface rounded-xl p-5 border border-border shadow-sm flex items-center gap-4">
<div className="p-3 rounded-xl bg-amber-500/10 text-amber-500">
<Image className="w-6 h-6" />
</div>
<div>
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Cloudinary DAM Assets</p>
<h3 className="text-2xl font-bold text-foreground mt-0.5">{loading ? "..." : metrics?.data?.total_assets || 0}</h3>
<span className="text-xs text-muted-foreground">Images & Raw Docs</span>
</div>
</div>
</div>
{/* Tenants Table Preview */}
<div className="bg-surface rounded-xl border border-border shadow-sm overflow-hidden mb-8">
<div className="px-6 py-4 border-b border-border bg-gradient-to-r from-primary/5 to-surface flex items-center justify-between">
<div className="flex items-center gap-2">
<Activity className="w-4 h-4 text-primary" />
<h3 className="font-semibold text-foreground text-sm">Tenant Provisioning Registry</h3>
</div>
<Button variant="ghost" size="sm" onClick={() => navigate("/platform/tenants")}>
Manage All Tenants
</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse text-sm">
<thead>
<tr className="border-b border-border bg-background/50 text-xs text-muted-foreground uppercase tracking-wider">
<th className="px-6 py-3">Tenant Code</th>
<th className="px-6 py-3">Organization Name</th>
<th className="px-6 py-3">Contact Email</th>
<th className="px-6 py-3">Products</th>
<th className="px-6 py-3">Assets</th>
<th className="px-6 py-3">Status</th>
<th className="px-6 py-3 text-right">Support Action</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-muted-foreground">Loading SaaS platform tenants...</td>
</tr>
) : tenants.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-muted-foreground">No tenants provisioned yet.</td>
</tr>
) : (
tenants.map((t) => (
<tr key={t.id} className="hover:bg-primary/5 transition-colors">
<td className="px-6 py-4 font-mono text-xs font-semibold text-primary">{t.tenant_code}</td>
<td className="px-6 py-4 font-medium text-foreground">{t.tenant_name}</td>
<td className="px-6 py-4 text-muted-foreground">{t.contact_email || "N/A"}</td>
<td className="px-6 py-4 font-semibold text-foreground">{t.total_products || 0}</td>
<td className="px-6 py-4 font-semibold text-foreground">{t.total_assets || 0}</td>
<td className="px-6 py-4">
<span className={`px-2.5 py-1 rounded-full text-xs font-semibold ${t.status ? 'bg-emerald-500/10 text-emerald-500' : 'bg-red-500/10 text-red-500'}`}>
{t.status ? "Active" : "Suspended"}
</span>
</td>
<td className="px-6 py-4 text-right">
{String(t.id) === String(impersonatingTenantId) ? (
<span className="text-xs text-amber-500 font-semibold">Active Session</span>
) : (
<button
onClick={() => handleStartImpersonate(String(t.id))}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-primary/10 text-primary hover:bg-primary hover:text-white transition-colors"
>
<Play className="w-3.5 h-3.5" /> Support Assist
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</PageWrapper>
);
}
@@ -0,0 +1,377 @@
import { useEffect, useState } from "react";
import { Plus, Building2, Search, Play, StopCircle, CheckCircle, XCircle, Copy, Check } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { Button } from "../../../components/customs/Button";
import { useTenant } from "../../tenants/hooks/useTenant";
import { useFormik } from "formik";
import * as Yup from "yup";
import { notify } from "../../../services/toast";
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-danger focus:ring-danger' : 'border-primary/10 focus:ring-primary-light'} rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 bg-surface text-foreground placeholder-muted-foreground`;
export default function PlatformTenantsPage() {
const navigate = useNavigate();
const { tenants, fetchPlatformTenants, provisionTenant, updatePlatformStatus, impersonateTenant, stopImpersonation } = useTenant();
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [isProvisionModalOpen, setIsProvisionModalOpen] = useState(false);
const [impersonatingTenantId, setImpersonatingTenantId] = useState<string | null>(
localStorage.getItem("impersonatedTenantId")
);
const loadData = async () => {
try {
setLoading(true);
await fetchPlatformTenants();
} catch {
notify.error("Failed to load platform tenants");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const [provisionedSuccessData, setProvisionedSuccessData] = useState<any | null>(null);
const [copied, setCopied] = useState(false);
const formik = useFormik({
initialValues: {
tenant_name: "",
domain: "",
contact_email: "",
admin_name: "",
admin_email: "",
admin_password: ""
},
validationSchema: Yup.object().shape({
tenant_name: Yup.string().required("Organization name is required"),
contact_email: Yup.string().email("Invalid email").required("Contact email is required"),
admin_email: Yup.string().email("Invalid admin email"),
admin_password: Yup.string().min(6, "Password must be at least 6 characters")
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
try {
const result = await provisionTenant(values);
resetForm();
setIsProvisionModalOpen(false);
setProvisionedSuccessData({
tenant: result.tenant || result.data?.tenant,
admin: result.admin || result.data?.admin,
rawPassword: values.admin_password
});
fetchPlatformTenants();
} catch (err) {
// Error handled in hook
} finally {
setSubmitting(false);
}
}
});
const handleToggleStatus = async (id: string, currentStatus: boolean) => {
try {
await updatePlatformStatus(id, !currentStatus);
} catch (err) {
// Handled in hook
}
};
const handleStartImpersonate = async (tenantId: string) => {
try {
await impersonateTenant(tenantId);
setImpersonatingTenantId(tenantId);
navigate("/products");
} catch (err) {
// Handled in hook
}
};
const filteredTenants = (tenants || []).filter(t =>
t.tenant_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
t.tenant_code?.toLowerCase().includes(searchTerm.toLowerCase()) ||
t.contact_email?.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<PageWrapper>
<Breadcrumb
items={[{ label: "Platform Operator" }, { label: "Tenant Provisioning & Management" }]}
actions={
<Button
variant="primary"
icon={<Plus className="w-4 h-4" />}
onClick={() => setIsProvisionModalOpen(true)}
>
Provision New Tenant
</Button>
}
/>
{/* Filter & Search Bar */}
<div className="mb-6 flex items-center justify-between gap-4">
<div className="relative flex-1 max-w-md">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Search by code, name, or email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2 border border-border rounded-lg text-sm bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
{/* Tenants Table */}
<div className="bg-surface rounded-xl border border-border shadow-sm overflow-hidden mb-8">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse text-sm">
<thead>
<tr className="border-b border-border bg-background/50 text-xs text-muted-foreground uppercase tracking-wider">
<th className="px-6 py-3">Tenant Code</th>
<th className="px-6 py-3">Organization</th>
<th className="px-6 py-3">Domain</th>
<th className="px-6 py-3">Contact Email</th>
<th className="px-6 py-3">Products</th>
<th className="px-6 py-3">Assets</th>
<th className="px-6 py-3">Status</th>
<th className="px-6 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading ? (
<tr>
<td colSpan={8} className="px-6 py-8 text-center text-muted-foreground">Loading tenants...</td>
</tr>
) : filteredTenants.length === 0 ? (
<tr>
<td colSpan={8} className="px-6 py-8 text-center text-muted-foreground">No matching tenants found.</td>
</tr>
) : (
filteredTenants.map((t: any) => (
<tr key={t.id} className="hover:bg-primary/5 transition-colors">
<td className="px-6 py-4 font-mono text-xs font-semibold text-primary">{t.tenant_code}</td>
<td className="px-6 py-4 font-medium text-foreground">{t.tenant_name}</td>
<td className="px-6 py-4 text-muted-foreground">{t.domain || "N/A"}</td>
<td className="px-6 py-4 text-muted-foreground">{t.contact_email}</td>
<td className="px-6 py-4 font-semibold">{t.total_products || 0}</td>
<td className="px-6 py-4 font-semibold">{t.total_assets || 0}</td>
<td className="px-6 py-4">
<span className={`px-2.5 py-1 rounded-full text-xs font-semibold ${t.status ? 'bg-emerald-500/10 text-emerald-500' : 'bg-red-500/10 text-red-500'}`}>
{t.status ? "Active" : "Suspended"}
</span>
</td>
<td className="px-6 py-4 text-right flex items-center justify-end gap-2">
<button
onClick={() => handleToggleStatus(String(t.id), t.status)}
className={`p-1.5 rounded-lg text-xs font-medium transition-colors ${
t.status ? 'bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white' : 'bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500 hover:text-white'
}`}
title={t.status ? "Suspend Tenant" : "Activate Tenant"}
>
{t.status ? <XCircle className="w-4 h-4" /> : <CheckCircle className="w-4 h-4" />}
</button>
{String(t.id) === String(impersonatingTenantId) ? (
<button
onClick={() => { stopImpersonation(); setImpersonatingTenantId(null); }}
className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-amber-500 text-white hover:bg-amber-600 transition-colors"
>
End Support
</button>
) : (
<button
onClick={() => handleStartImpersonate(String(t.id))}
className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-primary/10 text-primary hover:bg-primary hover:text-white transition-colors"
>
Support Assist
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Provision Tenant Modal */}
{isProvisionModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-surface rounded-xl border border-border shadow-xl w-full max-w-lg overflow-hidden">
<div className="px-6 py-4 border-b border-border bg-gradient-to-r from-primary/5 to-surface flex items-center justify-between">
<h3 className="font-semibold text-foreground text-sm flex items-center gap-2">
<Building2 className="w-4 h-4 text-primary" /> Provision New Tenant Account
</h3>
<button
onClick={() => setIsProvisionModalOpen(false)}
className="text-muted-foreground hover:text-foreground text-sm font-semibold"
>
</button>
</div>
<form onSubmit={formik.handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-xs font-semibold text-foreground mb-1">Organization Name *</label>
<input
type="text"
name="tenant_name"
value={formik.values.tenant_name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
className={inputClass(formik.touched.tenant_name && Boolean(formik.errors.tenant_name))}
placeholder="e.g. IKEA Global"
/>
{formik.touched.tenant_name && formik.errors.tenant_name && (
<p className="text-xs text-danger mt-1">{formik.errors.tenant_name}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-foreground mb-1">Domain (Optional)</label>
<input
type="text"
name="domain"
value={formik.values.domain}
onChange={formik.handleChange}
className={inputClass()}
placeholder="ikea.com"
/>
</div>
<div>
<label className="block text-xs font-semibold text-foreground mb-1">Contact Email *</label>
<input
type="email"
name="contact_email"
value={formik.values.contact_email}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
className={inputClass(formik.touched.contact_email && Boolean(formik.errors.contact_email))}
placeholder="support@ikea.com"
/>
{formik.touched.contact_email && formik.errors.contact_email && (
<p className="text-xs text-danger mt-1">{formik.errors.contact_email}</p>
)}
</div>
</div>
<div className="pt-2 border-t border-border">
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">Initial Tenant Admin Credentials</h4>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-foreground mb-1">Admin Name</label>
<input
type="text"
name="admin_name"
value={formik.values.admin_name}
onChange={formik.handleChange}
className={inputClass()}
placeholder="John Admin"
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1">Admin Email</label>
<input
type="email"
name="admin_email"
value={formik.values.admin_email}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
className={inputClass(formik.touched.admin_email && Boolean(formik.errors.admin_email))}
placeholder="admin@ikea.com"
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1">Admin Password</label>
<input
type="password"
name="admin_password"
value={formik.values.admin_password}
onChange={formik.handleChange}
className={inputClass(formik.touched.admin_password && Boolean(formik.errors.admin_password))}
placeholder="••••••••"
/>
</div>
</div>
</div>
<div className="pt-4 flex items-center justify-end gap-3 border-t border-border">
<Button variant="outline" type="button" onClick={() => setIsProvisionModalOpen(false)}>
Cancel
</Button>
<Button variant="primary" type="submit" loading={formik.isSubmitting}>
Provision Tenant
</Button>
</div>
</form>
</div>
</div>
)}
{/* Provisioning Success Modal with Copy Credentials */}
{provisionedSuccessData && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="w-full max-w-md bg-surface border border-primary/20 rounded-2xl p-6 shadow-2xl space-y-5">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-success/10 text-success flex items-center justify-center">
<CheckCircle className="w-6 h-6" />
</div>
<div>
<h3 className="text-lg font-bold text-foreground">Tenant Provisioned!</h3>
<p className="text-xs text-muted-foreground">Share these setup credentials with your client</p>
</div>
</div>
<div className="bg-primary/5 border border-primary/10 rounded-xl p-4 space-y-3 font-mono text-xs text-foreground">
<div className="flex justify-between border-b border-primary/10 pb-2">
<span className="text-muted-foreground font-sans">Organization:</span>
<span className="font-semibold text-primary">{provisionedSuccessData.tenant?.tenant_name}</span>
</div>
<div className="flex justify-between border-b border-primary/10 pb-2">
<span className="text-muted-foreground font-sans">Tenant Code:</span>
<span className="font-semibold">{provisionedSuccessData.tenant?.tenant_code}</span>
</div>
<div className="flex justify-between border-b border-primary/10 pb-2">
<span className="text-muted-foreground font-sans">Admin Email:</span>
<span className="font-semibold">{provisionedSuccessData.admin?.email || provisionedSuccessData.tenant?.contact_email}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground font-sans">Admin Password:</span>
<span className="font-semibold text-danger">{provisionedSuccessData.rawPassword || '••••••••'}</span>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<Button
variant="primary"
className="w-full flex items-center justify-center gap-2"
onClick={() => {
const textToCopy = `Organization: ${provisionedSuccessData.tenant?.tenant_name}\nTenant Code: ${provisionedSuccessData.tenant?.tenant_code}\nAdmin Email: ${provisionedSuccessData.admin?.email || provisionedSuccessData.tenant?.contact_email}\nPassword: ${provisionedSuccessData.rawPassword || 'Admin@123'}\nLogin URL: http://localhost:5173/login`;
navigator.clipboard.writeText(textToCopy);
setCopied(true);
notify.success("Provisioning credentials copied to clipboard!");
setTimeout(() => setCopied(false), 2000);
}}
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
{copied ? "Copied to Clipboard!" : "Copy Client Credentials"}
</Button>
<Button variant="outline" onClick={() => setProvisionedSuccessData(null)}>
Done
</Button>
</div>
</div>
</div>
)}
</PageWrapper>
);
}
+3 -3
View File
@@ -42,7 +42,7 @@ function ProductThumb({ name: _name }: { name: string }) {
// ── Main page ──────────────────────────────────────────────────────────────────
export default function ProductList() {
const { canImport, canExport } = usePermissions("products.items");
const { canEdit, canDelete, canImport, canExport } = usePermissions("products.items");
const navigate = useNavigate();
const { products, fetchProducts, deleteProduct, loading } = useProduct();
const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -184,8 +184,8 @@ export default function ProductList() {
pageSizeOptions={[5, 10, 25, 50]}
actionConfig={{
onView: (row) => navigate(`/products/${row.id}/edit`),
onEdit: (row) => navigate(`/products/${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
onEdit: canEdit ? ((row) => navigate(`/products/${row.id}/edit`)) : undefined,
onDelete: canDelete ? ((row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })) : undefined
}}
/>
+24 -16
View File
@@ -44,11 +44,17 @@ function CardHeader({ title, subtitle }: { title: string; subtitle?: string }) {
);
}
import { useSelector } from "react-redux";
import type { RootState } from "../../../store";
export default function NewRoleForm() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const isEdit = Boolean(id);
const user = useSelector((state: RootState) => state.auth.user);
const isPlatformAdmin = user?.user_type === 'platform' || user?.type === 'platform';
const { nodes, fetchNodes, createRole, updateRole, nodesLoading, nodesError } = useRole();
const [tenants, setTenants] = useState<Tenant[]>([]);
const [permissions, setPermissions] = useState<Record<string, RolePermission>>({});
@@ -85,7 +91,7 @@ export default function NewRoleForm() {
const setValuesRef = useRef<((values: any) => void) | null>(null);
const formik = useFormik({
initialValues: { role_name: "", description: "", tenant_id: "" },
initialValues: { role_name: "", description: "", tenant_id: isPlatformAdmin ? "" : String(user?.tenant_id || user?.tenant?.id || "") },
validationSchema: roleSchema,
onSubmit: async (values, { setSubmitting }) => {
const permList = Object.values(permissions).filter(
@@ -456,21 +462,23 @@ export default function NewRoleForm() {
<p className={errorClass}>{formik.errors.role_name}</p>
)}
</div>
<div>
<label className={labelClass}>Assign to Tenant</label>
<select
name="tenant_id"
value={formik.values.tenant_id}
onChange={formik.handleChange}
className={inputClass()}
>
<option value="">No Tenant (Platform Role)</option>
{tenants.map((t) => (
<option key={t.id} value={t.id}>{t.tenant_name}</option>
))}
</select>
<p className="mt-1 text-xs text-subtle-foreground">Leave empty for a global platform role.</p>
</div>
{isPlatformAdmin && (
<div>
<label className={labelClass}>Assign to Tenant</label>
<select
name="tenant_id"
value={formik.values.tenant_id}
onChange={formik.handleChange}
className={inputClass()}
>
<option value="">No Tenant (Platform Role)</option>
{tenants.map((t: any) => (
<option key={t.id} value={t.id}>{t.name || t.tenant_name || `Tenant #${t.id}`}</option>
))}
</select>
<p className="mt-1 text-xs text-subtle-foreground">Leave empty for a global platform role.</p>
</div>
)}
</div>
<div>
<label className={labelClass}>Description</label>
+8 -5
View File
@@ -1,13 +1,16 @@
import { Routes, Route } from 'react-router-dom';
import RoleList from '../pages/RoleList';
import NewRoleForm from '../pages/NewRoleForm';
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
export const RoleRoutes = () => {
return (
<Routes>
<Route path="/" element={<RoleList />} />
<Route path="/new" element={<NewRoleForm />} />
<Route path="/:id/edit" element={<NewRoleForm />} />
</Routes>
<ProtectedRoute node="settings.roles">
<Routes>
<Route path="/" element={<RoleList />} />
<Route path="/new" element={<NewRoleForm />} />
<Route path="/:id/edit" element={<NewRoleForm />} />
</Routes>
</ProtectedRoute>
);
};
+84 -10
View File
@@ -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 (
<PageWrapper>
<Breadcrumb items={[{ label: "Home" }, { label: "Settings" }]} />
<ProtectedRoute node="settings.general">
<PageWrapper>
<Breadcrumb items={[{ label: "Home" }, { label: "Settings" }]} />
{/* Horizontal Tabs Header */}
<div className="flex items-center gap-2 border-b border-primary/10 mb-6 overflow-x-auto pb-px">
@@ -133,12 +191,22 @@ export default function SettingList() {
<div className="p-6 space-y-5">
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Organization Name</label>
<input type="text" defaultValue="Acme Corporation" className="w-full px-3 py-2.5 text-sm border border-primary/10 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-light focus:border-transparent bg-surface" />
<input
type="text"
value={orgName}
onChange={(e) => setOrgName(e.target.value)}
className="w-full px-3 py-2.5 text-sm border border-primary/10 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-light focus:border-transparent bg-surface"
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Subdomain</label>
<label className="block text-sm font-medium text-foreground mb-1.5">Subdomain / Tenant Code</label>
<div className="flex items-stretch">
<input type="text" defaultValue="acme" className="flex-1 px-3 py-2.5 text-sm border border-primary/10 rounded-l-lg border-r-0 focus:outline-none focus:ring-2 focus:ring-primary-light focus:border-transparent bg-surface z-10" />
<input
type="text"
value={subdomain}
onChange={(e) => setSubdomain(e.target.value)}
className="flex-1 px-3 py-2.5 text-sm border border-primary/10 rounded-l-lg border-r-0 focus:outline-none focus:ring-2 focus:ring-primary-light focus:border-transparent bg-surface z-10"
/>
<div className="px-4 py-2.5 bg-primary/5/50 border border-primary/10 rounded-r-lg text-sm text-muted-foreground flex items-center">.pim-platform.com</div>
</div>
</div>
@@ -195,8 +263,13 @@ export default function SettingList() {
</div>
<div className="flex justify-end gap-3 px-6 py-4 border-t border-primary/5 bg-gradient-to-r from-surface to-primary/5/30">
<Button variant="ghost" className="text-muted-foreground hover:text-foreground hover:bg-background">Cancel</Button>
<Button className="bg-primary hover:bg-primary-hover text-white">Save Changes</Button>
<Button variant="ghost" className="text-muted-foreground hover:text-foreground hover:bg-background" onClick={() => window.location.reload()}>Cancel</Button>
<Can node="settings.general" action="edit">
<Button className="bg-primary hover:bg-primary-hover text-white flex items-center gap-2" loading={saving} onClick={handleSaveGeneral}>
<Save className="w-4 h-4" />
Save Changes
</Button>
</Can>
</div>
</div>
)}
@@ -548,5 +621,6 @@ export default function SettingList() {
)}
</div>
</PageWrapper>
</ProtectedRoute>
);
}
@@ -1,55 +1,12 @@
import type { Setting, SettingCreateRequest, SettingUpdateRequest } from '../types/settings.types';
const STORAGE_KEY = 'pim_settings';
const getStored = (): Setting[] => {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return [];
return JSON.parse(stored);
};
const setStored = (items: Setting[]) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
};
import axiosInstance from '../../../api/axiosInstance';
export const settingsService = {
getAll: async (): Promise<Setting[]> => {
return new Promise((resolve) => setTimeout(() => resolve(getStored()), 300));
},
getById: async (id: string): Promise<Setting | undefined> => {
return new Promise((resolve) => setTimeout(() => resolve(getStored().find(p => p.id === id)), 200));
},
create: async (req: SettingCreateRequest): Promise<Setting> => {
return new Promise((resolve) => {
setTimeout(() => {
const list = getStored();
const newItem: Setting = { ...req, id: String(Date.now()), createdAt: new Date().toISOString() };
list.push(newItem);
setStored(list);
resolve(newItem);
}, 300);
});
},
update: async (id: string, req: SettingUpdateRequest): Promise<Setting> => {
return new Promise((resolve, reject) => {
setTimeout(() => {
const list = getStored();
const index = list.findIndex(p => p.id === id);
if (index === -1) { reject(new Error('Not found')); return; }
const updated = { ...list[index], ...req };
list[index] = updated;
setStored(list);
resolve(updated);
}, 300);
});
},
delete: async (id: string): Promise<boolean> => {
return new Promise((resolve) => {
setTimeout(() => {
const list = getStored().filter(p => p.id !== id);
setStored(list);
resolve(true);
}, 300);
});
getCategorySettings: async (category: string) => {
const response = await axiosInstance.get(`/settings/by-category/${category}`);
return response.data.data;
},
updateCategorySettings: async (category: string, data: Record<string, any>) => {
const response = await axiosInstance.put(`/settings/by-category/${category}`, data);
return response.data;
}
};
+67 -1
View File
@@ -70,14 +70,80 @@ export function useTenant() {
}
};
const fetchPlatformTenants = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await tenantService.getPlatformTenants();
setTenants(data);
return data;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch platform tenants');
notify.error(err);
} finally {
setLoading(false);
}
}, []);
const provisionTenant = async (data: any) => {
try {
setLoading(true);
const result = await tenantService.provisionTenant(data);
notify.success('Tenant provisioned successfully with Admin credentials');
return result;
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
}
};
const updatePlatformStatus = async (id: string, status: boolean) => {
try {
setLoading(true);
const updated = await tenantService.updatePlatformStatus(id, status);
setTenants(prev => prev.map(t => t.id === id ? { ...t, status: updated.status } : t));
notify.success(`Tenant status updated to ${updated.status ? 'Active' : 'Suspended'}`);
return updated;
} catch (err) {
notify.error(err);
throw err;
} finally {
setLoading(false);
}
};
const impersonateTenant = async (tenantId: string) => {
try {
const result = await tenantService.impersonateTenant(tenantId);
localStorage.setItem('impersonatedTenantId', tenantId);
notify.success(result.message || 'Support impersonation active');
return result;
} catch (err) {
notify.error(err);
throw err;
}
};
const stopImpersonation = () => {
localStorage.removeItem('impersonatedTenantId');
notify.info('Support impersonation ended');
};
return {
tenants,
loading,
error,
fetchTenants,
fetchPlatformTenants,
getTenant,
createTenant,
provisionTenant,
updateTenant,
deleteTenant
updatePlatformStatus,
deleteTenant,
impersonateTenant,
stopImpersonation
};
}
@@ -25,5 +25,31 @@ export const tenantService = {
delete: async (id: string) => {
const response = await api.delete(`/api/v1/tenants/${id}`);
return (response as any).data;
},
// Platform Admin Endpoints
getPlatformTenants: async () => {
const response = await api.get('/api/v1/platform/tenants');
return (response as any).data;
},
provisionTenant: async (data: any) => {
const response = await api.post('/api/v1/platform/tenants', data);
return (response as any).data;
},
updatePlatformStatus: async (id: string, status: boolean) => {
const response = await api.patch(`/api/v1/platform/tenants/${id}/status`, { status });
return (response as any).data;
},
getPlatformMetrics: async () => {
const response = await api.get('/api/v1/platform/metrics');
return (response as any).data;
},
impersonateTenant: async (tenantId: string) => {
const response = await api.post(`/api/v1/platform/impersonate/${tenantId}`);
return (response as any).data;
}
};
@@ -25,3 +25,18 @@ export interface UpdateTenantDTO {
mobile?: string;
status?: boolean;
}
export interface PlatformTenant extends Tenant {
total_products?: number;
total_assets?: number;
total_users?: number;
}
export interface ProvisionTenantDTO {
tenant_name: string;
domain?: string;
contact_email: string;
admin_name?: string;
admin_email?: string;
admin_password?: string;
}
+1 -1
View File
@@ -27,7 +27,7 @@ export const UnitTable: React.FC<UnitTableProps> = ({
key: "conversionFactor",
label: "CONVERSION",
render: (value: any, row: Unit) =>
value !== undefined ? `${value} ${row.baseUnit || ''}` : "—"
(value !== undefined && value !== null && value !== '') ? `${value} ${row.baseUnit || ''}`.trim() : "—"
},
{
key: "status",
+20 -17
View File
@@ -14,6 +14,8 @@ import { tenantService } from "../../tenants/services/tenant.service";
import { notify } from "../../../services/toast";
import type { DBRole, PermissionNode } from "../services/roles.service";
import type { Tenant } from "../../tenants/types/tenant.types";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { Can } from "../../../components/customs/Can";
export default function UserList() {
const navigate = useNavigate();
@@ -79,21 +81,18 @@ export default function UserList() {
const [newRoleStatus, setNewRoleStatus] = useState(true);
const isSuperAdminUser = (user: any) => {
if (!user) return false;
if (user.email === 'admin@admin.com') return true;
const userRoles = user.roles || [];
if (user?.user_type === 'platform' || user?.type === 'platform') return true;
const userRoles = user?.roles || [];
return userRoles.some((r: any) =>
r.role_code === 'SUPER_ADMIN' ||
r.role_code === 'SUPERADMIN' ||
r.role_name?.toLowerCase().includes('super admin') ||
r.is_system_role
r.role_name?.toLowerCase().includes('super admin')
);
};
const isSuperAdminRole = (role: any) => {
if (!role) return false;
return Boolean(
role.is_system_role ||
role.role_code === 'SUPER_ADMIN' ||
role.role_code === 'SUPERADMIN' ||
role.role_name?.toLowerCase().includes('super admin')
@@ -378,16 +377,19 @@ export default function UserList() {
];
return (
<PageWrapper>
<Breadcrumb
items={[{ label: "Home" }, { label: "Users & Roles" }]}
actions={
<Button onClick={() => navigate("new")} className="bg-primary hover:bg-primary-hover text-white">
<Plus className="w-4 h-4 mr-2" />
Invite Member
</Button>
}
/>
<ProtectedRoute node="settings.users">
<PageWrapper>
<Breadcrumb
items={[{ label: "Home" }, { label: "Users & Roles" }]}
actions={
<Can node="settings.users" action="create">
<Button onClick={() => navigate("new")} className="bg-primary hover:bg-primary-hover text-white">
<Plus className="w-4 h-4 mr-2" />
Invite Member
</Button>
</Can>
}
/>
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
@@ -907,6 +909,7 @@ export default function UserList() {
</div>
</div>
)}
</PageWrapper>
</PageWrapper>
</ProtectedRoute>
);
}
+9 -8
View File
@@ -8,16 +8,17 @@ import { hasPermission } from '../utils/permissionUtils';
* Usage: const { canView, canCreate } = usePermissions('products.items');
*/
export const usePermissions = (nodeCode: PermissionNodes | string) => {
// Extract permissions from the Redux store
// Extract permissions and user from the Redux store
const permissions = useSelector((state: RootState) => state.auth.permissions);
const user = useSelector((state: RootState) => state.auth.user);
return {
canView: hasPermission(permissions, nodeCode, 'view'),
canCreate: hasPermission(permissions, nodeCode, 'create'),
canEdit: hasPermission(permissions, nodeCode, 'edit'),
canDelete: hasPermission(permissions, nodeCode, 'delete'),
canAlter: hasPermission(permissions, nodeCode, 'alter'),
canImport: hasPermission(permissions, nodeCode, 'import'),
canExport: hasPermission(permissions, nodeCode, 'export'),
canView: hasPermission(permissions, nodeCode, 'view', user),
canCreate: hasPermission(permissions, nodeCode, 'create', user),
canEdit: hasPermission(permissions, nodeCode, 'edit', user),
canDelete: hasPermission(permissions, nodeCode, 'delete', user),
canAlter: hasPermission(permissions, nodeCode, 'alter', user),
canImport: hasPermission(permissions, nodeCode, 'import', user),
canExport: hasPermission(permissions, nodeCode, 'export', user),
};
};
+1 -1
View File
@@ -18,7 +18,7 @@ export function getAssetUrl(url?: string): string {
return url;
}
const cleanUrl = url.startsWith('/') ? url : `/${url}`;
const baseUrl = (import.meta as any).env?.VITE_API_BASE_URL || 'http://localhost:5000';
const baseUrl = (import.meta as any).env?.VITE_API_BASE_URL || (import.meta as any).env?.VITE_API_URL || 'http://localhost:5002';
return `${baseUrl}${cleanUrl}`;
}
+12 -4
View File
@@ -1,6 +1,7 @@
// src/routes/AppRoutes.tsx
import { Routes, Route, Navigate } from 'react-router-dom';
import { AuthGuard } from '../authentication/components/ProtectedRoute';
import { AuthGuard, PlatformGuard } from '../authentication/components/ProtectedRoute';
import { ProtectedRoute } from '../components/layouts/ProtectedRoute';
import MainLayout from '../components/layouts/MainLayout';
import { Login, SignUp, ForgotPassword, ResetPassword, AcceptInvite } from '../authentication/routes';
@@ -31,6 +32,8 @@ import { SettingRoutes } from '../features/settings/routes/settings.routes';
import { TenantRoutes } from '../features/tenants/routes/tenant.routes';
import { RoleRoutes } from '../features/roles/routes/role.routes';
import { NotificationRoutes } from '../features/notifications/routes/notifications.routes';
import PlatformOverview from '../features/platform/pages/PlatformOverview';
import PlatformTenantsPage from '../features/platform/pages/PlatformTenantsPage';
const AppRoutes = () => {
return (
@@ -47,7 +50,12 @@ const AppRoutes = () => {
{/* Protected Routes with Layout */}
<Route element={<AuthGuard><MainLayout /></AuthGuard>}>
<Route path="/dashboard" element={<DashboardRoutes />} />
{/* Platform Control Center (Platform Super Admins Only) */}
<Route path="/platform" element={<PlatformGuard><Navigate to="/platform/overview" replace /></PlatformGuard>} />
<Route path="/platform/overview" element={<PlatformGuard><PlatformOverview /></PlatformGuard>} />
<Route path="/platform/tenants" element={<PlatformGuard><PlatformTenantsPage /></PlatformGuard>} />
<Route path="/dashboard/*" element={<DashboardRoutes />} />
{/* Catalog */}
<Route path="/products/*" element={<ProductRoutes />} />
@@ -75,8 +83,8 @@ const AppRoutes = () => {
{/* Users */}
<Route path="/users/tenants/*" element={<TenantRoutes />} />
<Route path="/users/roles/*" element={<RoleRoutes />} />
<Route path="/users/*" element={<UserRoutes />} />
<Route path="/users/roles/*" element={<ProtectedRoute node="settings.roles"><RoleRoutes /></ProtectedRoute>} />
<Route path="/users/*" element={<ProtectedRoute node="settings.users"><UserRoutes /></ProtectedRoute>} />
{/* Notifications */}
<Route path="/notifications/*" element={<NotificationRoutes />} />
+24 -8
View File
@@ -19,7 +19,10 @@ import {
Settings,
List,
Layers2,
Bell
Bell,
ShieldCheck,
Activity,
Building2
} from 'lucide-react';
import React from 'react';
@@ -36,10 +39,21 @@ export interface SidebarItem {
href: string;
icon: React.ElementType;
permission?: string;
platformOnly?: boolean;
children?: SidebarSubItem[];
}
export const sidebarConfig: SidebarItem[] = [
{
label: 'Platform Control',
href: '/platform',
icon: ShieldCheck,
platformOnly: true,
children: [
{ label: 'SaaS Overview & Metrics', href: '/platform/overview', icon: Activity },
{ label: 'Tenant Provisioning', href: '/platform/tenants', icon: Building2 },
]
},
{
label: 'Dashboard',
href: '/dashboard',
@@ -95,24 +109,27 @@ export const sidebarConfig: SidebarItem[] = [
label: 'Asset Management',
href: '/assets',
icon: Image,
permission: 'media.assets',
children: [
{ label: 'Asset Manager', href: '/assets', icon: Folder },
{ label: 'Asset Types', href: '/asset-types', icon: LayoutGrid },
{ label: 'Asset Families', href: '/asset-families', icon: Layers },
{ label: 'Asset Manager', href: '/assets', icon: Folder, permission: 'media.assets' },
{ label: 'Asset Types', href: '/asset-types', icon: LayoutGrid, permission: 'media.taxonomy' },
{ label: 'Asset Families', href: '/asset-families', icon: Layers, permission: 'media.taxonomy' },
]
},
{
label: 'Workflow & Approvals',
href: '/workflow',
icon: Workflow
icon: Workflow,
permission: 'products.items'
},
{
label: 'Channels & Integration',
href: '/channels',
icon: Radio,
permission: 'channels.syndication',
children: [
{ label: 'Channel Registry', href: '/channels', icon: Radio },
{ label: 'Channel Types', href: '/channel-types', icon: Layers2, permission: 'channels.types' },
{ label: 'Channel Registry', href: '/channels', icon: Radio, permission: 'channels.syndication' },
{ label: 'Channel Types', href: '/channel-types', icon: Layers2, permission: 'channels.syndication' },
{ label: 'Integration Hub', href: '/integrations', icon: Plug, permission: 'settings.integrations' },
]
},
@@ -122,7 +139,6 @@ export const sidebarConfig: SidebarItem[] = [
icon: Users,
children: [
{ label: 'Users', href: '/users', icon: Users, permission: 'settings.users' },
{ label: 'Tenants', href: '/users/tenants', icon: Database, permission: 'settings.tenants' },
{ label: 'Roles', href: '/users/roles', icon: Users, permission: 'settings.roles' }
]
},
+1 -1
View File
@@ -1,6 +1,6 @@
import { io, Socket } from 'socket.io-client';
const API_BASE_URL = 'http://localhost:5000';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5002';
class SocketServiceClass {
private socket: Socket | null = null;
+7 -1
View File
@@ -12,8 +12,14 @@ import type { PermissionPayload, PermissionNodes, PermissionAction } from '../ty
export const hasPermission = (
permissions: PermissionPayload | undefined | null,
nodeCode: PermissionNodes | string,
action: PermissionAction
action: PermissionAction,
user?: any
): boolean => {
// Platform superadmin or tenant admin role bypass
if (user?.user_type === 'platform' || user?.role_code === 'TENANT_ADMIN' || user?.roles?.some((r: any) => r.role_code === 'SUPER_ADMIN' || r.role_code === 'TENANT_ADMIN')) {
return true;
}
if (!permissions) return false;
// Superadmin wildcard check
+1 -1
View File
@@ -7,7 +7,7 @@ export default defineConfig({
server: {
proxy: {
'/uploads': {
target: 'http://localhost:5000',
target: 'http://localhost:5002',
changeOrigin: true,
},
},