resolved merge conflict
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}</>;
|
||||
};
|
||||
@@ -101,6 +101,7 @@ export function Select({
|
||||
<button
|
||||
ref={buttonRef}
|
||||
id={id}
|
||||
name={name}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={handleToggle}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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
@@ -7,7 +7,7 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/uploads': {
|
||||
target: 'http://localhost:5000',
|
||||
target: 'http://localhost:5002',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user