Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
452896be0c |
@@ -1,6 +0,0 @@
|
||||
# Public browser configuration. Do not place SSO module secrets here.
|
||||
VITE_API_URL=http://localhost:5002
|
||||
|
||||
# Use / for a dedicated PIM hostname, or /pim_frontend/ when hosted below
|
||||
# https://id-ms.maskantech.in/pim_frontend/.
|
||||
VITE_BASE_PATH=/
|
||||
@@ -31,8 +31,7 @@ axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: { config?: { url?: string }; response?: { status?: number } }) => {
|
||||
const isLoginEndpoint = error.config?.url?.includes('/auth/login');
|
||||
const isSsoExchangeEndpoint = error.config?.url?.includes('/auth/sso/exchange');
|
||||
if (error.response?.status === 401 && !isLoginEndpoint && !isSsoExchangeEndpoint) {
|
||||
if (error.response?.status === 401 && !isLoginEndpoint) {
|
||||
localStorage.removeItem('accessToken');
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/authentication/components/ProtectedRoute.tsx
|
||||
import React from 'react';
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAppSelector } from '../../store';
|
||||
import { usePermissions } from '../../hooks/usePermission';
|
||||
import { AccessDenied } from '../../components/customs/AccessDenied';
|
||||
@@ -47,20 +47,4 @@ export const PlatformGuard: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prevents Global SaaS administrators from opening tenant operational modules.
|
||||
* A platform administrator must explicitly enter audited Support Mode first.
|
||||
*/
|
||||
export const TenantWorkspaceGuard: React.FC = () => {
|
||||
const { user } = useAppSelector((state) => state.auth);
|
||||
const isPlatformUser = user?.type === 'platform' || user?.user_type === 'platform';
|
||||
const isSupportMode = Boolean(localStorage.getItem('impersonatedTenantId'));
|
||||
|
||||
if (isPlatformUser && !isSupportMode) {
|
||||
return <Navigate to="/platform/overview" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
export default AuthGuard;
|
||||
|
||||
@@ -7,24 +7,6 @@ import { Eye, EyeOff, Lock, Mail, ArrowRight } from "lucide-react";
|
||||
import { AuthLayout } from "../components/AuthLayout";
|
||||
import { notify } from "../../services/toast";
|
||||
|
||||
const GoogleIcon = () => (
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden="true">
|
||||
<path fill="#4285F4" d="M21.6 12.2c0-.7-.1-1.4-.2-2H12v3.9h5.4a4.6 4.6 0 0 1-2 3v2.5h3.3c1.9-1.8 2.9-4.4 2.9-7.4Z" />
|
||||
<path fill="#34A853" d="M12 22c2.7 0 5-.9 6.7-2.4l-3.3-2.5c-.9.6-2.1 1-3.4 1a5.9 5.9 0 0 1-5.5-4.1H3.1v2.6A10 10 0 0 0 12 22Z" />
|
||||
<path fill="#FBBC05" d="M6.5 14a6 6 0 0 1 0-3.9V7.4H3.1a10 10 0 0 0 0 9.2L6.5 14Z" />
|
||||
<path fill="#EA4335" d="M12 5.9c1.5 0 2.8.5 3.8 1.5l2.9-2.8A9.7 9.7 0 0 0 3.1 7.4l3.4 2.7A5.9 5.9 0 0 1 12 5.9Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MicrosoftIcon = () => (
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden="true">
|
||||
<path fill="#F25022" d="M2 2h9.5v9.5H2z" />
|
||||
<path fill="#7FBA00" d="M12.5 2H22v9.5h-9.5z" />
|
||||
<path fill="#00A4EF" d="M2 12.5h9.5V22H2z" />
|
||||
<path fill="#FFB900" d="M12.5 12.5H22V22h-9.5z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Login = () => {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -193,11 +175,11 @@ export const Login = () => {
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<button className="w-full inline-flex justify-center py-2 px-4 border border-border rounded-xl shadow-sm bg-surface text-sm font-medium text-muted-foreground hover:bg-background transition-colors">
|
||||
<GoogleIcon />
|
||||
<img className="h-4 w-4" src="https://www.svgrepo.com/show/475656/google-color.svg" alt="Google" />
|
||||
<span className="ml-2">Google</span>
|
||||
</button>
|
||||
<button className="w-full inline-flex justify-center py-2 px-4 border border-border rounded-xl shadow-sm bg-surface text-sm font-medium text-muted-foreground hover:bg-background transition-colors">
|
||||
<MicrosoftIcon />
|
||||
<img className="h-4 w-4" src="https://www.svgrepo.com/show/475662/microsoft.svg" alt="Microsoft" />
|
||||
<span className="ml-2">Microsoft</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,89 +1,55 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { AlertCircle, Loader2, ShieldCheck } from 'lucide-react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { authService } from '../services/authService';
|
||||
import { setCredentials } from '../../store/slices/authSlice';
|
||||
import { AuthLayout } from '../components/AuthLayout';
|
||||
|
||||
type SSOExchangeResponse = {
|
||||
data?: { user: unknown; accessToken: string; refreshToken?: string; permissions?: Record<string, unknown> };
|
||||
user?: unknown;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
permissions?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
let activeGrant: string | null = null;
|
||||
let activeExchange: Promise<unknown> | null = null;
|
||||
|
||||
function exchangeGrantOnce(grant: string) {
|
||||
if (activeGrant !== grant || !activeExchange) {
|
||||
activeGrant = grant;
|
||||
activeExchange = authService.exchangeSaaSToken(grant);
|
||||
}
|
||||
return activeExchange;
|
||||
}
|
||||
import { Package } from 'lucide-react';
|
||||
import './Login.css';
|
||||
|
||||
export const SSOCallback = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialError = searchParams.get('error');
|
||||
const initialGrant = searchParams.get('grant');
|
||||
const validInitialGrant = Boolean(initialGrant && /^[a-f0-9]{32}$/i.test(initialGrant));
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const [error, setError] = useState<string>(() => initialError
|
||||
? `SSO Error: ${initialError}`
|
||||
: validInitialGrant ? '' : 'The SSO link is missing, invalid, or already used. Please open PIM again from the SaaS portal.');
|
||||
const [status, setStatus] = useState<string>(() => initialError || !validInitialGrant ? 'SSO login failed' : 'Processing SSO login...');
|
||||
const [error, setError] = useState<string>('');
|
||||
const [status, setStatus] = useState<string>('Processing SSO login...');
|
||||
|
||||
useEffect(() => {
|
||||
const grant = searchParams.get('grant');
|
||||
const callbackPath = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/sso/callback`;
|
||||
window.history.replaceState({}, document.title, callbackPath);
|
||||
if (searchParams.get('error') || !grant || !/^[a-f0-9]{32}$/i.test(grant)) return;
|
||||
let cancelled = false;
|
||||
exchangeGrantOnce(grant).then((response) => {
|
||||
if (cancelled) return;
|
||||
const result = response as SSOExchangeResponse;
|
||||
const session = result.data ?? result;
|
||||
const { user, accessToken, refreshToken, permissions } = session;
|
||||
if (!accessToken) throw new Error('PIM session token was not returned');
|
||||
localStorage.removeItem('impersonatedTenantId');
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
if (refreshToken) localStorage.setItem('refreshToken', refreshToken);
|
||||
dispatch(setCredentials({ user, accessToken, refreshToken, permissions: permissions ?? {} }));
|
||||
const tenantName = (user as { tenant?: { name?: string } } | undefined)?.tenant?.name;
|
||||
setStatus(`Welcome to ${tenantName || 'PIM'}. Redirecting...`);
|
||||
navigate('/dashboard', { replace: true });
|
||||
}).catch((requestError: unknown) => {
|
||||
if (cancelled) return;
|
||||
const message = (requestError as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
setError(message || 'SSO login failed. Please open PIM again from the SaaS portal.');
|
||||
const errorParam = searchParams.get('error');
|
||||
if (errorParam) {
|
||||
setError(`SSO Error: ${errorParam}`);
|
||||
setStatus('SSO login failed');
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [searchParams, navigate, dispatch]);
|
||||
setTimeout(() => navigate('/login'), 3000);
|
||||
return;
|
||||
}
|
||||
setStatus('Login successful! Redirecting...');
|
||||
setTimeout(() => navigate('/dashboard'), 1000);
|
||||
}, [searchParams, navigate]);
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div className="flex flex-col items-center text-center py-8">
|
||||
<div className={`w-14 h-14 rounded-full flex items-center justify-center mb-4 ${error ? 'bg-danger/10' : 'bg-primary/10'}`}>
|
||||
{error
|
||||
? <AlertCircle className="w-7 h-7 text-danger" />
|
||||
: <ShieldCheck className="w-7 h-7 text-primary" />}
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-foreground">Secure SaaS Sign-in</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">{status}</p>
|
||||
{error ? (
|
||||
<div className="w-full mt-6 p-4 rounded-xl bg-danger/10 border border-danger/20 text-danger text-sm text-left">
|
||||
{error}
|
||||
<div className="login-container">
|
||||
<div className="login-background" />
|
||||
<div className="login-right-panel" style={{ width: '100%', maxWidth: '500px', margin: '0 auto' }}>
|
||||
<div className="login-card">
|
||||
<div className="login-card-header" style={{ textAlign: 'center' }}>
|
||||
<div className="login-logo-badge" style={{ margin: '0 auto 1rem' }}>
|
||||
<Package className="login-logo-icon" />
|
||||
</div>
|
||||
<h2 className="login-card-title">SSO Authentication</h2>
|
||||
<p className="login-card-subtitle">{status}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin mt-8" aria-label="Completing secure sign-in" />
|
||||
)}
|
||||
{error ? (
|
||||
<div className="error-message" style={{ marginTop: '1rem' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '2rem 0' }}>
|
||||
<div style={{
|
||||
border: '3px solid #f3f3f3', borderTop: '3px solid #3498db',
|
||||
borderRadius: '50%', width: '40px', height: '40px',
|
||||
animation: 'spin 1s linear infinite', margin: '0 auto'
|
||||
}} />
|
||||
<style>{`@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -40,9 +40,8 @@ export const authService = {
|
||||
return response;
|
||||
},
|
||||
|
||||
exchangeSaaSToken: async (grant: string): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post<LoginResponse>('/api/v1/auth/sso/exchange', { grant });
|
||||
return response;
|
||||
exchangeSaaSToken: async (_code: string): Promise<LoginResponse> => {
|
||||
throw new Error('SSO not implemented');
|
||||
},
|
||||
|
||||
acceptInvite: async (email: string, tempPassword: string, newPassword: string): Promise<LoginResponse> => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Search, X, Check, ChevronDown } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface Option {
|
||||
@@ -18,6 +19,7 @@ interface SelectProps {
|
||||
placeholder?: string;
|
||||
children?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
searchable?: boolean;
|
||||
}
|
||||
|
||||
export function Select({
|
||||
@@ -31,11 +33,14 @@ export function Select({
|
||||
placeholder = "Select...",
|
||||
children,
|
||||
disabled,
|
||||
searchable,
|
||||
}: SelectProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const [rect, setRect] = React.useState<DOMRect | null>(null);
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const options = React.useMemo(() => {
|
||||
const opts: Option[] = [];
|
||||
@@ -53,6 +58,19 @@ export function Select({
|
||||
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
|
||||
// By default, enable search if there are more than 5 options or if explicitly requested
|
||||
const isSearchEnabled = searchable !== undefined ? searchable : options.length > 5;
|
||||
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
if (!isSearchEnabled || !searchQuery.trim()) {
|
||||
return options;
|
||||
}
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
return options.filter((opt) =>
|
||||
opt.label.toLowerCase().includes(q) || opt.value.toLowerCase().includes(q)
|
||||
);
|
||||
}, [options, isSearchEnabled, searchQuery]);
|
||||
|
||||
// Recalculate position on scroll/resize while open
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -68,6 +86,17 @@ export function Select({
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Auto-focus search input when opened
|
||||
React.useEffect(() => {
|
||||
if (isOpen && isSearchEnabled) {
|
||||
setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, 50);
|
||||
} else if (!isOpen) {
|
||||
setSearchQuery("");
|
||||
}
|
||||
}, [isOpen, isSearchEnabled]);
|
||||
|
||||
// Close on outside click
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -77,6 +106,7 @@ export function Select({
|
||||
dropdownRef.current?.contains(e.target as Node)
|
||||
) return;
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
onBlur?.({ target: { name } } as any);
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
@@ -94,10 +124,11 @@ export function Select({
|
||||
const handleSelect = (val: string) => {
|
||||
onChange?.({ target: { name, value: val } });
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div className="relative w-full font-sans">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
id={id}
|
||||
@@ -115,18 +146,12 @@ export function Select({
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={cn(!selectedOption && "text-muted-foreground")}>
|
||||
<span className={cn(!selectedOption && "text-muted-foreground", "truncate pr-2")}>
|
||||
{selectedOption ? selectedOption.label : placeholder}
|
||||
</span>
|
||||
<svg
|
||||
className={cn("h-4 w-4 text-muted-foreground transition-transform", isOpen && "rotate-180")}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m19 9-7 7-7-7" />
|
||||
</svg>
|
||||
<ChevronDown
|
||||
className={cn("h-4 w-4 text-muted-foreground transition-transform shrink-0", isOpen && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && rect && createPortal(
|
||||
@@ -136,30 +161,60 @@ export function Select({
|
||||
position: "fixed",
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
width: Math.max(rect.width, 220),
|
||||
zIndex: 9999,
|
||||
}}
|
||||
className="bg-surface text-foreground border border-border shadow-lg rounded-xl p-1 max-h-60 overflow-y-auto"
|
||||
className="bg-surface text-foreground border border-border shadow-xl rounded-xl overflow-hidden flex flex-col max-h-64 font-sans animate-in fade-in zoom-in-95 duration-100"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<div
|
||||
key={opt.value}
|
||||
onMouseDown={(e) => { e.preventDefault(); handleSelect(opt.value); }}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-lg px-3 py-2 text-sm select-none transition-colors flex items-center justify-between",
|
||||
opt.value === value
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "hover:bg-primary/5 hover:text-primary text-foreground"
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
{opt.value === value && (
|
||||
<svg className="h-4 w-4 fill-current text-primary shrink-0" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{isSearchEnabled && (
|
||||
<div className="p-2 border-b border-border bg-surface-muted flex items-center gap-2 shrink-0">
|
||||
<Search className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="w-full px-2 py-1 text-xs border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary bg-background text-foreground font-sans"
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground shrink-0 cursor-pointer"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
<div className="p-1 overflow-y-auto flex-1 space-y-0.5">
|
||||
{filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((opt) => (
|
||||
<div
|
||||
key={opt.value}
|
||||
onMouseDown={(e) => { e.preventDefault(); handleSelect(opt.value); }}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-lg px-3 py-2 text-xs select-none transition-colors flex items-center justify-between",
|
||||
opt.value === value
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "hover:bg-background text-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate pr-2">{opt.label}</span>
|
||||
{opt.value === value && (
|
||||
<Check className="h-3.5 w-3.5 text-primary shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-4 text-center text-xs text-muted-foreground">
|
||||
No options found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { notificationService } from "../../features/notifications";
|
||||
import { tenantService } from "../../features/tenants/services/tenant.service";
|
||||
import { notify } from "../../services/toast";
|
||||
import { hasPermission } from "../../utils/permissionUtils";
|
||||
|
||||
export function Header() {
|
||||
const { title, subtitle } = useHeader();
|
||||
@@ -17,7 +16,7 @@ export function Header() {
|
||||
const { language, setLanguage } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const { user, permissions } = useAppSelector((state) => state.auth);
|
||||
const { user } = useAppSelector((state) => state.auth);
|
||||
|
||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||
const [headerUnread, setHeaderUnread] = useState(0);
|
||||
@@ -28,7 +27,6 @@ export function Header() {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isPlatformUser = user?.type === 'platform' || user?.user_type === 'platform';
|
||||
const canViewNotifications = hasPermission(permissions, 'notifications', 'view', user);
|
||||
|
||||
// Derive real-time display values from Redux user state
|
||||
const userName = user?.name || user?.user_name || (user?.email ? user.email.split('@')[0] : "User");
|
||||
@@ -40,7 +38,7 @@ export function Header() {
|
||||
|
||||
// Load platform tenants if superadmin
|
||||
const loadPlatformTenants = useCallback(async () => {
|
||||
if (isPlatformUser && impersonatedTenantId) {
|
||||
if (isPlatformUser) {
|
||||
try {
|
||||
const tenants = await tenantService.getPlatformTenants();
|
||||
if (Array.isArray(tenants)) {
|
||||
@@ -50,42 +48,51 @@ export function Header() {
|
||||
// Fallback silently if not available
|
||||
}
|
||||
}
|
||||
}, [isPlatformUser, impersonatedTenantId]);
|
||||
}, [isPlatformUser]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPlatformTenants();
|
||||
}, [loadPlatformTenants]);
|
||||
|
||||
useEffect(() => {
|
||||
const syncImpersonation = (event: Event) => {
|
||||
const tenantId = (event as CustomEvent<string | null>).detail;
|
||||
setImpersonatedTenantId(tenantId || localStorage.getItem('impersonatedTenantId'));
|
||||
};
|
||||
window.addEventListener('pim:impersonation-changed', syncImpersonation);
|
||||
return () => window.removeEventListener('pim:impersonation-changed', syncImpersonation);
|
||||
}, []);
|
||||
|
||||
const activeTenantObj = platformTenants.find(t => String(t.id) === String(impersonatedTenantId));
|
||||
|
||||
const tenantName = impersonatedTenantId
|
||||
? `${activeTenantObj?.name || activeTenantObj?.tenant_name || 'Tenant'} (#${impersonatedTenantId}) [Support Mode]`
|
||||
? `${activeTenantObj?.name || 'Tenant'} (#${impersonatedTenantId}) [Support Mode]`
|
||||
: (user?.tenant?.name || user?.tenant_name || (isPlatformUser ? "Platform Core" : `Tenant #${user?.tenant_id}`));
|
||||
|
||||
const handleWorkspaceChange = async (targetTenantId: string) => {
|
||||
if (!targetTenantId) {
|
||||
// Switch back to Global Platform Control
|
||||
localStorage.removeItem('impersonatedTenantId');
|
||||
setImpersonatedTenantId(null);
|
||||
notify.info("Switched to Global SaaS Control Tower");
|
||||
navigate("/platform/overview");
|
||||
window.location.reload();
|
||||
} else {
|
||||
// Impersonate selected tenant
|
||||
try {
|
||||
await tenantService.impersonateTenant(targetTenantId);
|
||||
localStorage.setItem('impersonatedTenantId', targetTenantId);
|
||||
setImpersonatedTenantId(targetTenantId);
|
||||
const selected = platformTenants.find(t => String(t.id) === String(targetTenantId));
|
||||
notify.success(`Entered Support Mode: ${selected?.name || `Tenant #${targetTenantId}`}`);
|
||||
navigate("/products");
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
notify.error("Unable to switch workspace context.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleExitSupportMode = () => {
|
||||
localStorage.removeItem('impersonatedTenantId');
|
||||
setImpersonatedTenantId(null);
|
||||
window.dispatchEvent(new CustomEvent('pim:impersonation-changed', { detail: null }));
|
||||
notify.info("Support impersonation session ended");
|
||||
navigate("/platform/tenants");
|
||||
navigate("/platform/overview");
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!canViewNotifications) {
|
||||
setHeaderUnread(0);
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
const sync = async () => {
|
||||
const count = await notificationService.getUnreadCount();
|
||||
@@ -109,7 +116,7 @@ export function Header() {
|
||||
socketService.off("notification:unread-count", handleUnreadCount);
|
||||
});
|
||||
};
|
||||
}, [canViewNotifications]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@@ -136,7 +143,7 @@ export function Header() {
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="w-4 h-4 text-amber-950 animate-bounce" />
|
||||
<span>
|
||||
SUPPORT IMPERSONATION ACTIVE: You are viewing and operating inside workspace <strong>"{activeTenantObj?.name || activeTenantObj?.tenant_name || 'Selected Tenant'}"</strong> (Tenant #{impersonatedTenantId}).
|
||||
SUPPORT IMPERSONATION ACTIVE: You are viewing and operating inside workspace <strong>"{activeTenantObj?.name || 'Selected Tenant'}"</strong> (Tenant #{impersonatedTenantId}).
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@@ -173,6 +180,33 @@ export function Header() {
|
||||
|
||||
{/* Right Side Controls */}
|
||||
<div className="flex items-center gap-2 sm:gap-4">
|
||||
{/* Platform SuperAdmin Workspace Switcher */}
|
||||
{isPlatformUser && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl text-xs font-semibold border border-primary/30 bg-primary/5 text-primary shadow-2xs">
|
||||
<Building2 className="w-4 h-4 text-primary shrink-0" />
|
||||
<div className="flex flex-col text-left">
|
||||
<span className="text-[9px] font-black uppercase text-primary/70 tracking-wider">Active Workspace</span>
|
||||
<select
|
||||
value={impersonatedTenantId || ''}
|
||||
onChange={(e) => handleWorkspaceChange(e.target.value)}
|
||||
className="bg-transparent outline-none cursor-pointer font-bold text-foreground text-xs pr-1"
|
||||
aria-label="Select workspace context"
|
||||
>
|
||||
<option value="" className="bg-surface text-foreground font-semibold">
|
||||
🌐 Global Platform Console
|
||||
</option>
|
||||
<optgroup label="Tenants / Organizations">
|
||||
{platformTenants.map(t => (
|
||||
<option key={t.id} value={t.id} className="bg-surface text-foreground font-medium">
|
||||
🏢 {t.name} (#{t.id})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Standard Non-Platform Tenant Badge */}
|
||||
{!isPlatformUser && (
|
||||
<div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium border border-border bg-surface-muted text-foreground">
|
||||
@@ -200,7 +234,7 @@ export function Header() {
|
||||
</div>
|
||||
|
||||
{/* Notifications Bell */}
|
||||
{canViewNotifications && <div className="relative">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => navigate("/notifications")}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-muted transition-colors relative cursor-pointer"
|
||||
@@ -213,7 +247,7 @@ export function Header() {
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* Profile Card Trigger & Popover */}
|
||||
<div className="relative" ref={menuRef}>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { AccessDenied } from '../customs/AccessDenied';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
node: PermissionNodes | string;
|
||||
action?: 'view' | 'create' | 'edit' | 'delete' | 'alter' | 'import' | 'export';
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode; // Optional custom fallback
|
||||
}
|
||||
@@ -23,22 +22,12 @@ interface ProtectedRouteProps {
|
||||
*/
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
node,
|
||||
action = 'view',
|
||||
children,
|
||||
fallback = <AccessDenied />
|
||||
}) => {
|
||||
const permissions = usePermissions(node);
|
||||
const allowed = {
|
||||
view: permissions.canView,
|
||||
create: permissions.canCreate,
|
||||
edit: permissions.canEdit,
|
||||
delete: permissions.canDelete,
|
||||
alter: permissions.canAlter,
|
||||
import: permissions.canImport,
|
||||
export: permissions.canExport,
|
||||
}[action];
|
||||
const { canView } = usePermissions(node);
|
||||
|
||||
if (!allowed) {
|
||||
if (!canView) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ interface NavItem {
|
||||
children?: NavItem[];
|
||||
}
|
||||
|
||||
import { sidebarConfig as tenantNavItems, platformSidebarConfig } from "../../routes/sidebar.config";
|
||||
import { sidebarConfig as navItems } from "../../routes/sidebar.config";
|
||||
|
||||
export function Sidebar() {
|
||||
const navigate = useNavigate();
|
||||
@@ -65,18 +65,6 @@ export function Sidebar() {
|
||||
const permissions = useSelector((state: RootState) => state.auth.permissions);
|
||||
const user = useSelector((state: RootState) => state.auth.user);
|
||||
const isPlatformUser = user?.type === 'platform' || user?.user_type === 'platform';
|
||||
const [impersonatedTenantId, setImpersonatedTenantId] = useState<string | null>(
|
||||
localStorage.getItem('impersonatedTenantId')
|
||||
);
|
||||
const isSupportMode = isPlatformUser && Boolean(impersonatedTenantId);
|
||||
|
||||
React.useEffect(() => {
|
||||
const syncImpersonation = (event: Event) => {
|
||||
setImpersonatedTenantId((event as CustomEvent<string | null>).detail || localStorage.getItem('impersonatedTenantId'));
|
||||
};
|
||||
window.addEventListener('pim:impersonation-changed', syncImpersonation);
|
||||
return () => window.removeEventListener('pim:impersonation-changed', syncImpersonation);
|
||||
}, []);
|
||||
|
||||
const toggleSection = (href: string) => {
|
||||
setExpandedSections(prev =>
|
||||
@@ -119,10 +107,7 @@ export function Sidebar() {
|
||||
}, []);
|
||||
};
|
||||
|
||||
const navigationSource = isPlatformUser && !isSupportMode
|
||||
? platformSidebarConfig
|
||||
: tenantNavItems.filter(item => !item.platformOnly);
|
||||
const filteredNavItems = filterNavItems(navigationSource);
|
||||
const filteredNavItems = filterNavItems(navItems);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -241,10 +226,10 @@ export function Sidebar() {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-foreground truncate">
|
||||
{isSupportMode ? `Tenant #${impersonatedTenantId}` : isPlatformUser ? 'Platform Super Admin' : (user?.tenant?.name || 'Tenant Workspace')}
|
||||
{isPlatformUser ? 'Platform Super Admin' : (user?.tenant?.name || 'Tenant Workspace')}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{isSupportMode ? 'Audited Support Mode' : isPlatformUser ? 'Global SaaS Mode' : `${user?.tenant?.plan_name || 'Active'} Plan`}
|
||||
{isPlatformUser ? 'Global SaaS Mode' : `${user?.tenant?.plan_name || 'Active'} Plan`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useFormik } from "formik";
|
||||
import {
|
||||
X, Image as ImageIcon, Video, FileText, Award, Megaphone,
|
||||
HelpCircle, Plus, AlertCircle, Check, Loader2, Save
|
||||
} from "lucide-react";
|
||||
import { Input } from "../../../components/customs/Input";
|
||||
import { TextArea } from "../../../components/customs/TextArea";
|
||||
import { Select } from "../../../components/customs/Select";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useAssetType } from "../hook/useAssetType";
|
||||
import { assetTypeSchema } from "../validation/asset-types.schema";
|
||||
import { notify } from "../../../services/toast";
|
||||
import type { AssetType, AssetTypeCreateRequest } from "../types/asset-types.types";
|
||||
|
||||
const CATEGORIES = [
|
||||
{
|
||||
id: 'image',
|
||||
label: 'Image',
|
||||
icon: ImageIcon,
|
||||
desc: 'jpg, jpeg, png...',
|
||||
color: 'text-blue-600',
|
||||
bg: 'bg-blue-50',
|
||||
bgSelected: 'bg-blue-600',
|
||||
borderSelected: 'border-blue-500',
|
||||
ringSelected: 'ring-blue-500',
|
||||
bgSelectedCard: 'bg-blue-50/70',
|
||||
hoverBorder: 'hover:border-blue-300',
|
||||
hoverBg: 'hover:bg-blue-50/50'
|
||||
},
|
||||
{
|
||||
id: 'video',
|
||||
label: 'Video',
|
||||
icon: Video,
|
||||
desc: 'mp4, mov, avi...',
|
||||
color: 'text-orange-600',
|
||||
bg: 'bg-orange-50',
|
||||
bgSelected: 'bg-orange-600',
|
||||
borderSelected: 'border-orange-500',
|
||||
ringSelected: 'ring-orange-500',
|
||||
bgSelectedCard: 'bg-orange-50/70',
|
||||
hoverBorder: 'hover:border-orange-300',
|
||||
hoverBg: 'hover:bg-orange-50/50'
|
||||
},
|
||||
{
|
||||
id: 'document',
|
||||
label: 'Document',
|
||||
icon: FileText,
|
||||
desc: 'pdf, docx, doc...',
|
||||
color: 'text-red-600',
|
||||
bg: 'bg-red-50',
|
||||
bgSelected: 'bg-red-600',
|
||||
borderSelected: 'border-red-500',
|
||||
ringSelected: 'ring-red-500',
|
||||
bgSelectedCard: 'bg-red-50/70',
|
||||
hoverBorder: 'hover:border-red-300',
|
||||
hoverBg: 'hover:bg-red-50/50'
|
||||
},
|
||||
{
|
||||
id: 'certificate',
|
||||
label: 'Certificate',
|
||||
icon: Award,
|
||||
desc: 'pdf, jpg, png',
|
||||
color: 'text-emerald-600',
|
||||
bg: 'bg-emerald-50',
|
||||
bgSelected: 'bg-emerald-600',
|
||||
borderSelected: 'border-emerald-500',
|
||||
ringSelected: 'ring-emerald-500',
|
||||
bgSelectedCard: 'bg-emerald-50/70',
|
||||
hoverBorder: 'hover:border-emerald-300',
|
||||
hoverBg: 'hover:bg-emerald-50/50'
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
label: 'Marketing',
|
||||
icon: Megaphone,
|
||||
desc: 'jpg, png, svg...',
|
||||
color: 'text-purple-600',
|
||||
bg: 'bg-purple-50',
|
||||
bgSelected: 'bg-purple-600',
|
||||
borderSelected: 'border-purple-500',
|
||||
ringSelected: 'ring-purple-500',
|
||||
bgSelectedCard: 'bg-purple-50/70',
|
||||
hoverBorder: 'hover:border-purple-300',
|
||||
hoverBg: 'hover:bg-purple-50/50'
|
||||
},
|
||||
{
|
||||
id: 'other',
|
||||
label: 'Other',
|
||||
icon: HelpCircle,
|
||||
desc: 'pdf, zip, csv...',
|
||||
color: 'text-muted-foreground',
|
||||
bg: 'bg-background',
|
||||
bgSelected: 'bg-surface-active',
|
||||
borderSelected: 'border-border',
|
||||
ringSelected: 'ring-ring',
|
||||
bgSelectedCard: 'bg-background',
|
||||
hoverBorder: 'hover:border-border',
|
||||
hoverBg: 'hover:bg-background/50'
|
||||
},
|
||||
] as const;
|
||||
|
||||
const POPULAR_EXTENSIONS = [
|
||||
// Images
|
||||
{ ext: 'jpg', category: 'image', label: 'JPG' },
|
||||
{ ext: 'jpeg', category: 'image', label: 'JPEG' },
|
||||
{ ext: 'png', category: 'image', label: 'PNG' },
|
||||
{ ext: 'webp', category: 'image', label: 'WEBP' },
|
||||
{ ext: 'gif', category: 'image', label: 'GIF' },
|
||||
{ ext: 'svg', category: 'image', label: 'SVG' },
|
||||
// Videos
|
||||
{ ext: 'mp4', category: 'video', label: 'MP4' },
|
||||
{ ext: 'mov', category: 'video', label: 'MOV' },
|
||||
{ ext: 'avi', category: 'video', label: 'AVI' },
|
||||
{ ext: 'webm', category: 'video', label: 'WEBM' },
|
||||
// Documents
|
||||
{ ext: 'pdf', category: 'document', label: 'PDF' },
|
||||
{ ext: 'doc', category: 'document', label: 'DOC' },
|
||||
{ ext: 'docx', category: 'document', label: 'DOCX' },
|
||||
{ ext: 'xls', category: 'document', label: 'XLS' },
|
||||
{ ext: 'xlsx', category: 'document', label: 'XLSX' },
|
||||
{ ext: 'ppt', category: 'document', label: 'PPT' },
|
||||
{ ext: 'pptx', category: 'document', label: 'PPTX' },
|
||||
{ ext: 'txt', category: 'document', label: 'TXT' },
|
||||
// Other
|
||||
{ ext: 'zip', category: 'other', label: 'ZIP' },
|
||||
{ ext: 'rar', category: 'other', label: 'RAR' },
|
||||
{ ext: 'csv', category: 'other', label: 'CSV' },
|
||||
{ ext: 'json', category: 'other', label: 'JSON' },
|
||||
];
|
||||
|
||||
const STEPS = [
|
||||
{ id: 'basic', label: 'Basic Info', step: 1 },
|
||||
{ id: 'category', label: 'Asset Category', step: 2 },
|
||||
{ id: 'validation', label: 'Validation Rules', step: 3 },
|
||||
{ id: 'preview', label: 'Preview', step: 4 },
|
||||
];
|
||||
|
||||
const labelClass = 'block text-xs font-semibold text-foreground mb-1.5';
|
||||
const errorClass = 'text-xs text-red-500 mt-1';
|
||||
|
||||
interface CreateAssetTypeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (created: AssetType) => void;
|
||||
}
|
||||
|
||||
export function CreateAssetTypeModal({ isOpen, onClose, onSuccess }: CreateAssetTypeModalProps) {
|
||||
const { createItem, loading } = useAssetType();
|
||||
const [newFileType, setNewFileType] = useState('');
|
||||
const [activeStep, setActiveStep] = useState('basic');
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: "",
|
||||
code: "",
|
||||
description: "",
|
||||
status: "active" as "active" | "inactive",
|
||||
isRequired: false,
|
||||
category: "" as typeof CATEGORIES[number]['id'] | "",
|
||||
validation: {
|
||||
allowedFileTypes: [] as string[],
|
||||
maxFileSize: 10,
|
||||
minUploadCount: 0,
|
||||
maxUploadCount: 1,
|
||||
}
|
||||
},
|
||||
validationSchema: assetTypeSchema,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
try {
|
||||
const created = await createItem(values as AssetTypeCreateRequest);
|
||||
if (created) {
|
||||
onSuccess(created);
|
||||
onClose();
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Handled in hook notify
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Sync validation errors and switch steps if submit is attempted with errors
|
||||
useEffect(() => {
|
||||
if (formik.submitCount > 0 && !formik.isSubmitting) {
|
||||
const errors = formik.errors;
|
||||
const errorKeys = Object.keys(errors);
|
||||
|
||||
if (errorKeys.length > 0) {
|
||||
const messages: string[] = [];
|
||||
|
||||
if (errors.name) messages.push(errors.name);
|
||||
if (errors.code) messages.push(errors.code);
|
||||
if (errors.category) {
|
||||
messages.push(errors.category);
|
||||
setActiveStep('category');
|
||||
} else if (errors.name || errors.code) {
|
||||
setActiveStep('basic');
|
||||
} else if (errors.validation) {
|
||||
setActiveStep('validation');
|
||||
const valErrors = errors.validation as any;
|
||||
if (valErrors?.maxFileSize) messages.push(valErrors.maxFileSize);
|
||||
if (valErrors?.allowedFileTypes) messages.push(valErrors.allowedFileTypes);
|
||||
}
|
||||
|
||||
notify.error(`Please resolve validation errors: ${messages.join('; ')}`);
|
||||
formik.setSubmitting(false);
|
||||
}
|
||||
}
|
||||
}, [formik.submitCount, formik.isSubmitting, formik.errors]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
formik.handleChange(e);
|
||||
if (!formik.touched.code) {
|
||||
const generatedCode = e.target.value.toLowerCase().replace(/[^a-z0-9]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
|
||||
formik.setFieldValue('code', generatedCode);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddFileType = () => {
|
||||
const trimmed = newFileType.trim().toLowerCase().replace(/^\./, '');
|
||||
if (trimmed && !formik.values.validation.allowedFileTypes.includes(trimmed)) {
|
||||
formik.setFieldValue('validation.allowedFileTypes', [...formik.values.validation.allowedFileTypes, trimmed]);
|
||||
setNewFileType('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeFileType = (type: string) => {
|
||||
formik.setFieldValue('validation.allowedFileTypes', formik.values.validation.allowedFileTypes.filter(t => t !== type));
|
||||
};
|
||||
|
||||
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-xs animate-in fade-in duration-150 p-4 font-sans">
|
||||
<div className="bg-surface rounded-2xl border border-border shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200">
|
||||
|
||||
{/* Modal Header */}
|
||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between bg-surface-muted/30">
|
||||
<div>
|
||||
<h3 className="font-bold text-foreground text-base">Create New Asset Type</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Configure media classifications and validation rules</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 hover:bg-background rounded-lg text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator Bar */}
|
||||
<div className="px-6 py-3 border-b border-border bg-surface flex items-center justify-between gap-2 overflow-x-auto">
|
||||
{STEPS.map((s, idx) => {
|
||||
const isActive = activeStep === s.id;
|
||||
const isDone = idx < activeIndex;
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-all shrink-0 cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary font-bold border border-primary/20'
|
||||
: isDone
|
||||
? 'text-foreground hover:bg-surface-muted'
|
||||
: 'text-muted-foreground hover:bg-surface-muted/50'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold ${
|
||||
isActive
|
||||
? 'bg-primary text-white'
|
||||
: isDone
|
||||
? 'bg-emerald-500 text-white'
|
||||
: 'bg-surface-muted text-muted-foreground border border-border'
|
||||
}`}>
|
||||
{isDone ? <Check className="w-3 h-3" /> : s.step}
|
||||
</span>
|
||||
<span>{s.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<form id="inline-asset-type-form" onSubmit={formik.handleSubmit} className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
|
||||
{/* Step 1 — Basic Information */}
|
||||
{activeStep === 'basic' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Asset Type Name <span className="text-red-500">*</span></label>
|
||||
<Input
|
||||
name="name"
|
||||
value={formik.values.name}
|
||||
onChange={handleNameChange}
|
||||
onBlur={formik.handleBlur}
|
||||
placeholder="e.g. Primary Image"
|
||||
aria-invalid={formik.touched.name && Boolean(formik.errors.name)}
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Asset Code <span className="text-red-500">*</span></label>
|
||||
<Input
|
||||
name="code"
|
||||
value={formik.values.code}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
placeholder="e.g. primary_image"
|
||||
aria-invalid={formik.touched.code && Boolean(formik.errors.code)}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">Unique identifier (slug)</p>
|
||||
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Description</label>
|
||||
<TextArea
|
||||
name="description"
|
||||
value={formik.values.description}
|
||||
onChange={formik.handleChange}
|
||||
rows={2}
|
||||
placeholder="Describe the purpose and usage guidelines for this asset type..."
|
||||
className="resize-none text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Status</label>
|
||||
<Select
|
||||
name="status"
|
||||
value={formik.values.status}
|
||||
onChange={formik.handleChange}
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Required Asset</label>
|
||||
<label className="flex items-center gap-3 p-2 border border-border rounded-lg cursor-pointer hover:bg-background transition-colors h-9">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isRequired"
|
||||
checked={formik.values.isRequired}
|
||||
onChange={formik.handleChange}
|
||||
className="w-4 h-4 text-primary rounded border-border focus:ring-primary"
|
||||
/>
|
||||
<div className="text-xs font-medium text-foreground">Mark as required by default</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2 — Asset Category */}
|
||||
{activeStep === 'category' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">Select the media category. This determines default validation rules and file type presets.</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{CATEGORIES.map(cat => (
|
||||
<div
|
||||
key={cat.id}
|
||||
onClick={() => formik.setFieldValue('category', cat.id)}
|
||||
className={`
|
||||
cursor-pointer p-3.5 rounded-xl border transition-all flex flex-col items-center justify-center gap-1.5 text-center
|
||||
${formik.values.category === cat.id
|
||||
? `${cat.bgSelectedCard} ${cat.borderSelected} shadow-xs ring-1 ${cat.ringSelected}`
|
||||
: `bg-surface border-border ${cat.hoverBorder} ${cat.hoverBg}`
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center transition-all ${formik.values.category === cat.id ? `${cat.bgSelected} text-white` : `${cat.bg} ${cat.color}`}`}>
|
||||
<cat.icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className={`font-semibold text-xs ${formik.values.category === cat.id ? 'text-foreground font-bold' : 'text-foreground'}`}>{cat.label}</div>
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5">{cat.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{formik.touched.category && formik.errors.category && (
|
||||
<p className={errorClass}>{formik.errors.category}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3 — Validation Rules */}
|
||||
{activeStep === 'validation' && (
|
||||
<div className="space-y-4">
|
||||
{/* Selected formats badges list */}
|
||||
<div>
|
||||
<label className={labelClass}>Allowed File Types</label>
|
||||
<div className="min-h-[38px] p-2.5 border border-border rounded-lg flex flex-wrap gap-1.5 bg-background">
|
||||
{formik.values.validation.allowedFileTypes.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground py-0.5 px-1">No file types selected yet. Check boxes below or add custom formats.</span>
|
||||
) : (
|
||||
formik.values.validation.allowedFileTypes.map(type => (
|
||||
<span key={type} className="inline-flex items-center gap-1 px-2 py-0.5 bg-surface border border-border rounded-md text-xs font-semibold text-foreground shadow-2xs">
|
||||
.{type}
|
||||
<button type="button" onClick={() => removeFileType(type)} className="text-muted-foreground hover:text-red-500 ml-1 transition-colors cursor-pointer"><X className="w-3 h-3" /></button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Format selection */}
|
||||
<div>
|
||||
<label className={labelClass}>Select Formats</label>
|
||||
<div className="bg-background border border-border rounded-xl p-3 space-y-3 max-h-48 overflow-y-auto">
|
||||
{['image', 'video', 'document', 'other'].map(group => {
|
||||
const exts = POPULAR_EXTENSIONS.filter(e => e.category === group);
|
||||
const groupLabel = group === 'image' ? 'Image' : group === 'video' ? 'Video' : group === 'document' ? 'Document' : 'Data & Other';
|
||||
|
||||
return (
|
||||
<div key={group} className="space-y-1.5">
|
||||
<div className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider">{groupLabel}</div>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2">
|
||||
{exts.map(item => {
|
||||
const isChecked = formik.values.validation.allowedFileTypes.includes(item.ext);
|
||||
return (
|
||||
<label
|
||||
key={item.ext}
|
||||
className={`
|
||||
flex items-center gap-1.5 px-2 py-1 border rounded-md cursor-pointer transition-all select-none text-xs
|
||||
${isChecked
|
||||
? 'bg-primary/10 border-primary text-primary font-bold shadow-2xs'
|
||||
: 'bg-surface border-border text-foreground hover:border-primary/20'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(e) => {
|
||||
const current = formik.values.validation.allowedFileTypes || [];
|
||||
if (e.target.checked) {
|
||||
formik.setFieldValue('validation.allowedFileTypes', [...current, item.ext]);
|
||||
} else {
|
||||
formik.setFieldValue('validation.allowedFileTypes', current.filter(t => t !== item.ext));
|
||||
}
|
||||
}}
|
||||
className="w-3 h-3 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
|
||||
/>
|
||||
<span className="text-[11px]">.{item.ext}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Extension Input */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-muted-foreground mb-1">Add Custom Extension</label>
|
||||
<div className="flex gap-2 max-w-xs">
|
||||
<Input
|
||||
value={newFileType}
|
||||
onChange={(e) => setNewFileType(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddFileType(); } }}
|
||||
placeholder="e.g. psd"
|
||||
className="text-xs h-8"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddFileType}
|
||||
className="px-3 py-1 bg-surface hover:bg-background border border-border rounded-md text-xs font-semibold text-foreground flex items-center justify-center transition-colors cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Constraints */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>Max Size (MB)</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="validation.maxFileSize"
|
||||
value={formik.values.validation.maxFileSize}
|
||||
onChange={formik.handleChange}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Min Uploads</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="validation.minUploadCount"
|
||||
value={formik.values.validation.minUploadCount}
|
||||
onChange={formik.handleChange}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Max Uploads</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="validation.maxUploadCount"
|
||||
value={formik.values.validation.maxUploadCount}
|
||||
onChange={formik.handleChange}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4 — Preview */}
|
||||
{activeStep === 'preview' && (
|
||||
<div className="space-y-4">
|
||||
<div className="border border-border rounded-xl overflow-hidden shadow-xs">
|
||||
<div className="bg-primary p-3.5 flex items-center justify-between text-white">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 bg-surface/20 rounded-lg flex items-center justify-center backdrop-blur-sm">
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-sm">{formik.values.name || 'Asset Type Name'}</div>
|
||||
<div className="text-[11px] text-primary-light font-mono">{formik.values.code || 'asset_code'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 py-0.5 bg-surface/20 rounded-full text-xs font-medium flex items-center gap-1.5 backdrop-blur-sm">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${formik.values.status === 'active' ? 'bg-green-400' : 'bg-muted-foreground'}`} />
|
||||
{formik.values.status === 'active' ? 'Active' : 'Inactive'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-background p-4 grid grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<div className="font-semibold text-muted-foreground uppercase mb-1.5 text-[10px]">Category & Formats</div>
|
||||
<div className="mb-2 font-medium capitalize text-foreground">{formik.values.category || 'Not specified'}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{formik.values.validation.allowedFileTypes.length === 0 ? (
|
||||
<span className="text-muted-foreground text-[11px]">All file types permitted</span>
|
||||
) : (
|
||||
formik.values.validation.allowedFileTypes.map(type => (
|
||||
<span key={type} className="px-1.5 py-0.5 bg-surface-muted text-foreground rounded text-[10px] font-mono">.{type}</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-muted-foreground uppercase mb-1.5 text-[10px]">Constraints</div>
|
||||
<div className="space-y-1 text-[11px] text-muted-foreground">
|
||||
<div className="flex justify-between"><span>Max Size:</span> <span className="font-medium text-foreground">{formik.values.validation.maxFileSize} MB</span></div>
|
||||
<div className="flex justify-between"><span>Uploads:</span> <span className="font-medium text-foreground">Min {formik.values.validation.minUploadCount}, Max {formik.values.validation.maxUploadCount}</span></div>
|
||||
<div className="flex justify-between"><span>Required:</span> <span className="font-medium text-foreground">{formik.values.isRequired ? 'Yes' : 'No (Optional)'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="px-6 py-3.5 border-t border-border bg-surface-muted/30 flex items-center justify-between">
|
||||
<Button variant="outline" size="sm" type="button" onClick={onClose} disabled={formik.isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{activeIndex > 0 && (
|
||||
<Button variant="outline" size="sm" type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
{activeIndex < STEPS.length - 1 ? (
|
||||
<Button variant="primary" size="sm" type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)}>
|
||||
Next
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
type="submit"
|
||||
form="inline-asset-type-form"
|
||||
icon={<Save className="w-3.5 h-3.5" />}
|
||||
loading={formik.isSubmitting || loading}
|
||||
>
|
||||
Create Asset Type
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,3 +2,5 @@ export * from './types/asset-types.types';
|
||||
export * from './services/asset-types.service';
|
||||
export * from './hook/useAssetType';
|
||||
export * from './routes/asset-types.routes';
|
||||
export * from './components/CreateAssetTypeModal';
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import AssetList from '../pages/AssetList';
|
||||
import NewAsset from '../pages/NewAsset';
|
||||
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
|
||||
|
||||
export const AssetRoutes = () => (
|
||||
<Routes>
|
||||
<Route index element={<AssetList />} />
|
||||
<Route path="new" element={<ProtectedRoute node="media.assets" action="create"><NewAsset /></ProtectedRoute>} />
|
||||
<Route path=":id/edit" element={<ProtectedRoute node="media.assets" action="edit"><NewAsset /></ProtectedRoute>} />
|
||||
<Route path="new" element={<NewAsset />} />
|
||||
<Route path=":id/edit" element={<NewAsset />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -28,8 +28,6 @@ export interface AssetAnalytics {
|
||||
export interface AssetMapping {
|
||||
id: string;
|
||||
asset_id: string;
|
||||
product_id?: string;
|
||||
productId?: string;
|
||||
role: string;
|
||||
display_order: number;
|
||||
is_primary: boolean;
|
||||
|
||||
@@ -139,6 +139,38 @@ export default function NewAttributeSet() {
|
||||
|
||||
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
|
||||
|
||||
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit || isView) {
|
||||
setHighestVisitedStep(STEPS.length);
|
||||
}
|
||||
}, [isEdit, isView]);
|
||||
|
||||
const isBasicValid = Boolean(formik.values.name?.trim() && formik.values.code?.trim() && !formik.errors.name && !formik.errors.code);
|
||||
|
||||
const isStepAccessible = useCallback((stepNum: number) => {
|
||||
if (isEdit || isView) return true;
|
||||
if (stepNum === 1) return true;
|
||||
if (!isBasicValid) return false;
|
||||
return stepNum <= highestVisitedStep + 1;
|
||||
}, [isEdit, isView, isBasicValid, highestVisitedStep]);
|
||||
|
||||
const handleNextStep = useCallback(() => {
|
||||
if (activeStep === 'basic') {
|
||||
if (!isBasicValid) {
|
||||
formik.setFieldTouched('name', true);
|
||||
formik.setFieldTouched('code', true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (activeIndex < STEPS.length - 1) {
|
||||
const nextStepObj = STEPS[activeIndex + 1];
|
||||
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
|
||||
setActiveStep(nextStepObj.id);
|
||||
}
|
||||
}, [activeStep, isBasicValid, activeIndex, formik]);
|
||||
|
||||
return (
|
||||
<ProtectedRoute node="products.attributes">
|
||||
<PageWrapper>
|
||||
@@ -181,17 +213,23 @@ export default function NewAttributeSet() {
|
||||
const isActive = activeStep === s.id;
|
||||
const isDone = idx < activeIndex;
|
||||
const isLast = idx === STEPS.length - 1;
|
||||
const accessible = isStepAccessible(s.step);
|
||||
return (
|
||||
<div key={s.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center" style={{ width: 24 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${
|
||||
isActive ? "bg-primary ring-2 ring-primary/20" :
|
||||
isDone ? "bg-emerald-500" :
|
||||
"bg-surface border-2 border-border hover:border-primary/30"
|
||||
}`}
|
||||
} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
{isDone
|
||||
? <Check className="w-3 h-3 text-white" />
|
||||
@@ -204,8 +242,13 @@ export default function NewAttributeSet() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""}`}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
<span className={`text-xs font-medium leading-tight block ${
|
||||
isActive ? "text-primary-dark" : isDone ? "text-muted-foreground" : "text-muted-foreground hover:text-muted-foreground"
|
||||
@@ -452,7 +495,7 @@ export default function NewAttributeSet() {
|
||||
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm font-medium text-muted-foreground hover:bg-background transition-colors">Back</button>
|
||||
)}
|
||||
{activeIndex < STEPS.length - 1 && (
|
||||
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
|
||||
<button type="button" onClick={handleNextStep} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -193,6 +193,39 @@ export default function NewAttribute() {
|
||||
|
||||
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
|
||||
|
||||
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit || isView) {
|
||||
setHighestVisitedStep(STEPS.length);
|
||||
}
|
||||
}, [isEdit, isView]);
|
||||
|
||||
const isGeneralValid = Boolean(formik.values.name?.trim() && formik.values.code?.trim() && formik.values.type && !formik.errors.name && !formik.errors.code && !formik.errors.type);
|
||||
|
||||
const isStepAccessible = useCallback((stepNum: number) => {
|
||||
if (isEdit || isView) return true;
|
||||
if (stepNum === 1) return true;
|
||||
if (!isGeneralValid) return false;
|
||||
return stepNum <= highestVisitedStep + 1;
|
||||
}, [isEdit, isView, isGeneralValid, highestVisitedStep]);
|
||||
|
||||
const handleNextStep = useCallback(() => {
|
||||
if (activeStep === 'general') {
|
||||
if (!isGeneralValid) {
|
||||
formik.setFieldTouched('name', true);
|
||||
formik.setFieldTouched('code', true);
|
||||
formik.setFieldTouched('type', true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (activeIndex < STEPS.length - 1) {
|
||||
const nextStepObj = STEPS[activeIndex + 1];
|
||||
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
|
||||
setActiveStep(nextStepObj.id);
|
||||
}
|
||||
}, [activeStep, isGeneralValid, activeIndex, formik]);
|
||||
|
||||
return (
|
||||
<ProtectedRoute node="products.attributes">
|
||||
<div className="h-screen flex flex-col overflow-hidden bg-background/50">
|
||||
@@ -247,16 +280,22 @@ export default function NewAttribute() {
|
||||
const isActive = activeStep === s.id;
|
||||
const isDone = s.step < activeIndex + 1;
|
||||
const isLast = idx === STEPS.length - 1;
|
||||
const accessible = isStepAccessible(s.step);
|
||||
return (
|
||||
<div key={s.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center" style={{ width: 24 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${isActive ? "bg-primary ring-2 ring-primary/20" :
|
||||
isDone ? "bg-emerald-500" :
|
||||
"bg-surface border-2 border-border hover:border-primary/30"
|
||||
}`}
|
||||
} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
{isDone
|
||||
? <Check className="w-3 h-3 text-white" />
|
||||
@@ -269,8 +308,13 @@ export default function NewAttribute() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""}`}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
<span className={`text-xs font-medium leading-tight block ${isActive ? "text-primary-dark" : isDone ? "text-muted-foreground" : "text-muted-foreground hover:text-muted-foreground"
|
||||
}`}>{s.label}</span>
|
||||
@@ -578,7 +622,7 @@ export default function NewAttribute() {
|
||||
<Button variant="outline" type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} icon={<ChevronLeft className="w-4 h-4" />}>Back</Button>
|
||||
)}
|
||||
{activeIndex < STEPS.length - 1 && (
|
||||
<Button variant="primary" type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)}>Next <ChevronRight className="w-4 h-4" /></Button>
|
||||
<Button variant="primary" type="button" onClick={handleNextStep}>Next <ChevronRight className="w-4 h-4" /></Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -2,15 +2,14 @@ import { Routes, Route } from 'react-router-dom';
|
||||
import AttributeList from '../pages/AttributeList';
|
||||
import NewAttribute from '../pages/NewAttribute';
|
||||
import AttributeGroupList from '../../attribute-groups/pages/AttributeGroupList';
|
||||
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
|
||||
|
||||
export const AttributeRoutes = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<AttributeList />} />
|
||||
<Route path="groups" element={<AttributeGroupList />} />
|
||||
<Route path="new" element={<ProtectedRoute node="products.attributes" action="create"><NewAttribute /></ProtectedRoute>} />
|
||||
<Route path=":id/edit" element={<ProtectedRoute node="products.attributes" action="edit"><NewAttribute /></ProtectedRoute>} />
|
||||
<Route path="new" element={<NewAttribute />} />
|
||||
<Route path=":id/edit" element={<NewAttribute />} />
|
||||
<Route path=":id/view" element={<NewAttribute />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -7,8 +7,8 @@ import type { Brand } from '../types/brand.types';
|
||||
interface BrandTableProps {
|
||||
brands: Brand[];
|
||||
onView: (row: Brand) => void;
|
||||
onEdit?: (row: Brand) => void;
|
||||
onDelete?: (row: Brand) => void;
|
||||
onEdit: (row: Brand) => void;
|
||||
onDelete: (row: Brand) => void;
|
||||
onRowClick?: (row: Brand) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import BrandList from '../pages/BrandList';
|
||||
import NewBrandForm from '../pages/NewBrandForm';
|
||||
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
|
||||
|
||||
export const BrandRoutes = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<BrandList />} />
|
||||
<Route path="new" element={<ProtectedRoute node="masters.brands" action="create"><NewBrandForm /></ProtectedRoute>} />
|
||||
<Route path=":id/edit" element={<ProtectedRoute node="masters.brands" action="edit"><NewBrandForm /></ProtectedRoute>} />
|
||||
<Route path="new" element={<NewBrandForm />} />
|
||||
<Route path=":id/edit" element={<NewBrandForm />} />
|
||||
<Route path=":id/view" element={<NewBrandForm />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -101,7 +101,6 @@ function TreeNodeRow({
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isRoot = node.depth === 0;
|
||||
const isActive = node.category.status === "active";
|
||||
const isSharedBaseline = node.category.tenant_id == null;
|
||||
const linePrefix = node.depth === 0 ? "" : prefix + (isLast ? "└── " : "├── ");
|
||||
const childPrefix = node.depth === 0 ? "" : prefix + (isLast ? " " : "│ ");
|
||||
const descendants = countDescendants(node);
|
||||
@@ -221,26 +220,22 @@ function TreeNodeRow({
|
||||
>
|
||||
<Plus className="w-4 h-4 md:w-3.5 md:h-3.5" />
|
||||
</button>
|
||||
{!isSharedBaseline && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(node.category)}
|
||||
className="p-2 md:p-1 rounded hover:bg-primary/10 text-muted-foreground hover:text-primary transition-colors flex items-center justify-center min-h-[32px] min-w-[32px] md:min-h-0 md:min-w-0"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="w-4 h-4 md:w-3.5 md:h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(node.category)}
|
||||
className="p-2 md:p-1 rounded hover:bg-red-50 text-muted-foreground hover:text-red-500 transition-colors flex items-center justify-center min-h-[32px] min-w-[32px] md:min-h-0 md:min-w-0"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 md:w-3.5 md:h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(node.category)}
|
||||
className="p-2 md:p-1 rounded hover:bg-primary/10 text-muted-foreground hover:text-primary transition-colors flex items-center justify-center min-h-[32px] min-w-[32px] md:min-h-0 md:min-w-0"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="w-4 h-4 md:w-3.5 md:h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(node.category)}
|
||||
className="p-2 md:p-1 rounded hover:bg-red-50 text-muted-foreground hover:text-red-500 transition-colors flex items-center justify-center min-h-[32px] min-w-[32px] md:min-h-0 md:min-w-0"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 md:w-3.5 md:h-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,13 +2,12 @@ import { Routes, Route } from 'react-router-dom';
|
||||
import CategoryList from '../pages/CategoryList';
|
||||
import NewCategory from '../pages/NewCategory';
|
||||
import CategoryView from '../pages/CategoryView';
|
||||
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
|
||||
|
||||
export const CategoryRoutes = () => (
|
||||
<Routes>
|
||||
<Route index element={<CategoryList />} />
|
||||
<Route path="new" element={<ProtectedRoute node="products.categories" action="create"><NewCategory /></ProtectedRoute>} />
|
||||
<Route path=":id/edit" element={<ProtectedRoute node="products.categories" action="edit"><NewCategory /></ProtectedRoute>} />
|
||||
<Route path="new" element={<NewCategory />} />
|
||||
<Route path=":id/edit" element={<NewCategory />} />
|
||||
<Route path=":id/view" element={<CategoryView />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -11,9 +11,6 @@ export interface Category {
|
||||
lastUpdated: string;
|
||||
createdBy: string;
|
||||
path?: string;
|
||||
tenant_id?: string | number | null;
|
||||
productCount?: number;
|
||||
familyCount?: number;
|
||||
}
|
||||
|
||||
export type CategoryCreateRequest = Omit<Category, 'id' | 'lastUpdated' | 'createdBy' | 'parentName'>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Layers2,
|
||||
CheckCircle,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
Monitor
|
||||
} from "lucide-react";
|
||||
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
@@ -23,6 +25,7 @@ import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { useChannelType } from "../hook/useChannelType";
|
||||
import type { ChannelType } from "../types/channel-types.types";
|
||||
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
|
||||
import { StatsCard } from "../../../components/customs/StatsCard";
|
||||
|
||||
const ICON_MAP: Record<string, any> = {
|
||||
@@ -38,7 +41,15 @@ const ICON_MAP: Record<string, any> = {
|
||||
};
|
||||
|
||||
export default function ChannelTypeList() {
|
||||
const { items, fetchItems, loading } = useChannelType();
|
||||
const navigate = useNavigate();
|
||||
const { items, fetchItems, loading, deleteItem } = useChannelType();
|
||||
|
||||
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({
|
||||
isOpen: false,
|
||||
id: "",
|
||||
name: "",
|
||||
});
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
@@ -101,6 +112,19 @@ export default function ChannelTypeList() {
|
||||
},
|
||||
];
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteModal.id) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteItem(deleteModal.id);
|
||||
setDeleteModal({ isOpen: false, id: "", name: "" });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<Breadcrumb
|
||||
@@ -115,6 +139,10 @@ export default function ChannelTypeList() {
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={() => navigate("new")} className="bg-primary hover:bg-primary-hover text-white">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Channel Type
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -157,6 +185,20 @@ export default function ChannelTypeList() {
|
||||
rowIdKey="id"
|
||||
resultLabel="channel types"
|
||||
statusKey="status"
|
||||
actionConfig={{
|
||||
onEdit: (row) => navigate(`${row.id}/edit`),
|
||||
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name }),
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={deleteModal.isOpen}
|
||||
title="Delete Channel Type"
|
||||
description="Are you sure you want to delete this channel type? Channels using this type may be affected."
|
||||
itemName={deleteModal.name}
|
||||
loading={isDeleting}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
|
||||
/>
|
||||
</PageWrapper>
|
||||
);
|
||||
|
||||
@@ -1,34 +1,68 @@
|
||||
import apiClient from '../../../api/axiosInstance';
|
||||
import type { ChannelType, ChannelTypeCreateRequest, ChannelTypeUpdateRequest } from '../types/channel-types.types';
|
||||
|
||||
interface ApiResponse<T> { success: boolean; data: T; message?: string }
|
||||
const base = '/api/v1/channel-types';
|
||||
const normalize = (raw: any): ChannelType => ({
|
||||
...raw,
|
||||
createdAt: raw.createdAt || raw.created_at,
|
||||
updatedAt: raw.updatedAt || raw.updated_at,
|
||||
channelCount: raw.channelCount ?? raw.channels?.length ?? 0
|
||||
});
|
||||
const STORAGE_KEY = 'pim_channel_types';
|
||||
|
||||
const INITIAL_DATA: ChannelType[] = [
|
||||
{ id: '1', name: 'E-Commerce', code: 'ecommerce', description: 'Online retail storefronts and shopping platforms', icon: 'ShoppingCart', status: 'active', channelCount: 4, createdAt: '2025-01-10', author: 'Admin User' },
|
||||
{ id: '2', name: 'Marketplace', code: 'marketplace', description: 'Third-party marketplace listings like Amazon and eBay', icon: 'ShoppingBag', status: 'active', channelCount: 3, createdAt: '2025-01-12', author: 'Sarah Chen' },
|
||||
{ id: '3', name: 'ERP System', code: 'erp', description: 'Enterprise resource planning system integrations', icon: 'Server', status: 'active', channelCount: 2, createdAt: '2025-01-15', author: 'Michael Torres' },
|
||||
{ id: '4', name: 'Warehouse (WMS)', code: 'wms', description: 'Warehouse management system for stock and logistics', icon: 'Warehouse', status: 'active', channelCount: 1, createdAt: '2025-01-18', author: 'Emma Wilson' },
|
||||
{ id: '5', name: 'Point of Sale', code: 'pos', description: 'In-store point of sale and retail terminal systems', icon: 'Store', status: 'active', channelCount: 2, createdAt: '2025-02-01', author: 'Admin User' },
|
||||
{ id: '6', name: 'B2B Portal', code: 'b2b_portal', description: 'Business-to-business buyer portals and wholesale platforms', icon: 'Globe', status: 'active', channelCount: 1, createdAt: '2025-02-10', author: 'Sarah Chen' },
|
||||
{ id: '7', name: 'Mobile App', code: 'mobile_app', description: 'Native mobile applications for iOS and Android', icon: 'Smartphone', status: 'inactive', channelCount: 0, createdAt: '2025-02-20', author: 'Michael Torres' },
|
||||
{ id: '8', name: 'Corporate Website', code: 'website', description: 'Company marketing websites and product catalogues', icon: 'Monitor', status: 'active', channelCount: 2, createdAt: '2025-03-01', author: 'Emma Wilson' },
|
||||
];
|
||||
|
||||
const getStored = (): ChannelType[] => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(INITIAL_DATA));
|
||||
return INITIAL_DATA;
|
||||
}
|
||||
return JSON.parse(stored);
|
||||
};
|
||||
|
||||
const setStored = (items: ChannelType[]) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||
};
|
||||
|
||||
export const channelTypesService = {
|
||||
getAll: async (): Promise<ChannelType[]> => {
|
||||
const response = await apiClient.get<ApiResponse<any[]>>(base);
|
||||
return (response.data || []).map(normalize);
|
||||
},
|
||||
getById: async (id: string): Promise<ChannelType | undefined> => {
|
||||
const response = await apiClient.get<ApiResponse<any>>(`${base}/${id}`);
|
||||
return response.data ? normalize(response.data) : undefined;
|
||||
},
|
||||
create: async (req: ChannelTypeCreateRequest): Promise<ChannelType> => {
|
||||
const response = await apiClient.post<ApiResponse<any>>(base, req);
|
||||
return normalize(response.data);
|
||||
},
|
||||
update: async (id: string, req: ChannelTypeUpdateRequest): Promise<ChannelType> => {
|
||||
const response = await apiClient.put<ApiResponse<any>>(`${base}/${id}`, req);
|
||||
return normalize(response.data);
|
||||
},
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
const response = await apiClient.delete<ApiResponse<unknown>>(`${base}/${id}`);
|
||||
return response.success;
|
||||
}
|
||||
getAll: async (): Promise<ChannelType[]> =>
|
||||
new Promise((resolve) => setTimeout(() => resolve(getStored()), 300)),
|
||||
|
||||
getById: async (id: string): Promise<ChannelType | undefined> =>
|
||||
new Promise((resolve) => setTimeout(() => resolve(getStored().find(p => p.id === id)), 200)),
|
||||
|
||||
create: async (req: ChannelTypeCreateRequest): Promise<ChannelType> =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored();
|
||||
const newItem: ChannelType = { ...req, id: String(Date.now()), createdAt: new Date().toISOString() };
|
||||
list.push(newItem);
|
||||
setStored(list);
|
||||
resolve(newItem);
|
||||
}, 300);
|
||||
}),
|
||||
|
||||
update: async (id: string, req: ChannelTypeUpdateRequest): Promise<ChannelType> =>
|
||||
new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored();
|
||||
const index = list.findIndex(p => p.id === id);
|
||||
if (index === -1) { reject(new Error('Not found')); return; }
|
||||
const updated = { ...list[index], ...req };
|
||||
list[index] = updated;
|
||||
setStored(list);
|
||||
resolve(updated);
|
||||
}, 300);
|
||||
}),
|
||||
|
||||
delete: async (id: string): Promise<boolean> =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored().filter(p => p.id !== id);
|
||||
setStored(list);
|
||||
resolve(true);
|
||||
}, 300);
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import apiClient, { axiosInstance } from '../../../api/axiosInstance';
|
||||
import apiClient from '../../../api/axiosInstance';
|
||||
import type { Channel, ChannelCreateRequest, ChannelUpdateRequest } from '../types/channels.types';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
@@ -8,33 +8,26 @@ interface ApiResponse<T> {
|
||||
}
|
||||
|
||||
const BASE_URL = '/api/v1/channels';
|
||||
const normalizeChannel = (raw: any): Channel => ({
|
||||
...raw,
|
||||
channelType: raw.channelType || raw.type_id || '',
|
||||
allowPublishing: raw.allowPublishing ?? raw.metadata?.allowPublishing ?? true,
|
||||
createdAt: raw.createdAt || raw.created_at,
|
||||
updatedAt: raw.updatedAt || raw.updated_at
|
||||
});
|
||||
|
||||
export const channelsApi = {
|
||||
getAll: async (): Promise<Channel[]> => {
|
||||
const res = await apiClient.get<ApiResponse<Channel[]>>(BASE_URL);
|
||||
return (res.data || []).map(normalizeChannel);
|
||||
return res.data || [];
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Channel | undefined> => {
|
||||
const res = await apiClient.get<ApiResponse<Channel>>(`${BASE_URL}/${id}`);
|
||||
return res.data ? normalizeChannel(res.data) : undefined;
|
||||
return res.data;
|
||||
},
|
||||
|
||||
create: async (req: ChannelCreateRequest): Promise<Channel> => {
|
||||
const res = await apiClient.post<ApiResponse<Channel>>(BASE_URL, req);
|
||||
return normalizeChannel(res.data);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
update: async (id: string, req: ChannelUpdateRequest): Promise<Channel> => {
|
||||
const res = await apiClient.put<ApiResponse<Channel>>(`${BASE_URL}/${id}`, req);
|
||||
return normalizeChannel(res.data);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
remove: async (id: string): Promise<boolean> => {
|
||||
@@ -76,43 +69,6 @@ export const channelsApi = {
|
||||
const res = await apiClient.post<ApiResponse<any>>(`${BASE_URL}/${channelId}/test-connection`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
getAllJobs: async (): Promise<any[]> => {
|
||||
const res = await apiClient.get<ApiResponse<any[]>>(`${BASE_URL}/operations/jobs`);
|
||||
return res.data || [];
|
||||
},
|
||||
|
||||
getErrors: async (): Promise<any[]> => {
|
||||
const res = await apiClient.get<ApiResponse<any[]>>(`${BASE_URL}/operations/errors?includeRetrying=true`);
|
||||
return res.data || [];
|
||||
},
|
||||
|
||||
getQueueHealth: async (): Promise<any> => {
|
||||
const res = await apiClient.get<ApiResponse<any>>(`${BASE_URL}/queue/health`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
getOperationsAudit: async (): Promise<any[]> => {
|
||||
const res = await apiClient.get<ApiResponse<any[]>>(`${BASE_URL}/operations/audit`);
|
||||
return res.data || [];
|
||||
},
|
||||
|
||||
cancelJob: async (jobId: string): Promise<any> => {
|
||||
const res = await apiClient.post<ApiResponse<any>>(`${BASE_URL}/jobs/${jobId}/cancel`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
retryJob: async (jobId: string): Promise<any> => {
|
||||
const res = await apiClient.post<ApiResponse<any>>(`${BASE_URL}/jobs/${jobId}/retry`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
downloadCsv: async (channelId: string): Promise<{ blob: Blob; filename: string }> => {
|
||||
const response = await axiosInstance.get(`${BASE_URL}/${channelId}/export.csv`, { responseType: 'blob' });
|
||||
const disposition = String(response.headers['content-disposition'] || '');
|
||||
const filename = disposition.match(/filename="?([^";]+)"?/i)?.[1] || 'channel-products.csv';
|
||||
return { blob: response.data, filename };
|
||||
},
|
||||
};
|
||||
|
||||
export default channelsApi;
|
||||
|
||||
@@ -13,6 +13,15 @@ const COMMON_PIM_ATTRIBUTES = [
|
||||
{ code: "created_at", label: "Creation Timestamp (created_at)" },
|
||||
];
|
||||
|
||||
export const COMMON_CHANNEL_FIELDS = [
|
||||
{ code: "title", label: "Storefront Title (title)" },
|
||||
{ code: "body_html", label: "HTML Body Description (body_html)" },
|
||||
{ code: "variant_sku", label: "Variant SKU (variant_sku)" },
|
||||
{ code: "price", label: "Variant Price (price)" },
|
||||
{ code: "vendor", label: "Brand / Vendor (vendor)" },
|
||||
{ code: "product_type", label: "Product Category / Type (product_type)" },
|
||||
];
|
||||
|
||||
const TRANSFORMATION_RULES = [
|
||||
{ value: "none", label: "Direct Pass-through" },
|
||||
{ value: "uppercase", label: "UPPERCASE" },
|
||||
|
||||
@@ -35,7 +35,7 @@ export function SyndicationHistoryTab({ channelId }: { channelId: string }) {
|
||||
setSyncing(true);
|
||||
try {
|
||||
await channelsApi.triggerSyndication(channelId);
|
||||
notify.success("Syndication job queued successfully");
|
||||
notify.success("Syndication job triggered and processed successfully!");
|
||||
await loadJobs();
|
||||
} catch {
|
||||
notify.error("Failed to trigger syndication job");
|
||||
@@ -52,13 +52,6 @@ export function SyndicationHistoryTab({ channelId }: { channelId: string }) {
|
||||
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-danger/10 text-danger border border-danger/20"><AlertCircle className="w-3.5 h-3.5" /> Failed</span>;
|
||||
case 'running':
|
||||
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-warning/10 text-warning border border-warning/20 animate-pulse"><Clock className="w-3.5 h-3.5" /> Running</span>;
|
||||
case 'retrying':
|
||||
case 'queued':
|
||||
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-warning/10 text-warning border border-warning/20"><Clock className="w-3.5 h-3.5" /> {status}</span>;
|
||||
case 'partial':
|
||||
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-danger/10 text-danger border border-danger/20"><AlertCircle className="w-3.5 h-3.5" /> Partial</span>;
|
||||
case 'cancelled':
|
||||
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-muted text-muted-foreground">Cancelled</span>;
|
||||
default:
|
||||
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-muted text-muted-foreground">Pending</span>;
|
||||
}
|
||||
@@ -148,8 +141,8 @@ export function SyndicationHistoryTab({ channelId }: { channelId: string }) {
|
||||
<div className="max-h-80 overflow-y-auto space-y-2">
|
||||
{selectedErrorLog.map((err, idx) => (
|
||||
<div key={idx} className="p-3 bg-danger/5 border border-danger/20 rounded-lg text-xs font-mono">
|
||||
<div className="font-semibold text-danger">Product: {err.sku || err.product_code || 'N/A'} (ID: {err.productId || err.product_id || 'N/A'})</div>
|
||||
<div className="text-muted-foreground mt-1">{err.error || err.error_message || err.message}</div>
|
||||
<div 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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Plus, RefreshCw, Radio, CheckCircle, Clock, TrendingUp, ShoppingCart, ShoppingBag, Server, Warehouse, Store, Smartphone, Globe, Monitor, Play, CircleHelp, Download } 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";
|
||||
@@ -16,7 +16,6 @@ import { notify } from "../../../services/toast";
|
||||
|
||||
import { Can } from "../../../components/customs/Can";
|
||||
import { usePermissions } from "../../../hooks/usePermission";
|
||||
import { useChannelType } from "../../channel-types/hook/useChannelType";
|
||||
|
||||
const CHANNEL_TYPES_META: Record<string, { label: string, icon: any, typeColor: string, typeBg: string }> = {
|
||||
ecommerce: { label: "Ecommerce", icon: ShoppingCart, typeColor: "text-blue-600", typeBg: "bg-blue-50" },
|
||||
@@ -33,20 +32,12 @@ export default function ChannelList() {
|
||||
const { canEdit, canDelete } = usePermissions("channels.syndication");
|
||||
const navigate = useNavigate();
|
||||
const { items, fetchItems, loading, deleteItem } = useChannel();
|
||||
const { items: channelTypes, fetchItems: fetchChannelTypes } = useChannelType();
|
||||
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: "", name: "" });
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [operations, setOperations] = useState<{ ready: number; published: number }>({ ready: 0, published: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
fetchChannelTypes();
|
||||
Promise.all([channelsApi.getQueueHealth(), channelsApi.getAllJobs()])
|
||||
.then(([health, jobs]) => setOperations({ ready: health.ready || 0, published: jobs.reduce((sum, job) => sum + Number(job.success_count || 0), 0) }))
|
||||
.catch(() => setOperations({ ready: 0, published: 0 }));
|
||||
}, [fetchItems, fetchChannelTypes]);
|
||||
|
||||
const typeCodeFor = (value?: string) => channelTypes.find(type => type.id === value)?.code || value || '';
|
||||
}, [fetchItems]);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
@@ -54,7 +45,7 @@ export default function ChannelList() {
|
||||
label: "Channel Name",
|
||||
sortable: true,
|
||||
render: (_: any, row: Channel) => {
|
||||
const meta = CHANNEL_TYPES_META[typeCodeFor(row.channelType)] || { label: "Unassigned", icon: CircleHelp, typeColor: "text-muted-foreground", typeBg: "bg-surface-muted" };
|
||||
const meta = CHANNEL_TYPES_META[row.channelType || ""] || CHANNEL_TYPES_META.website;
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -78,7 +69,7 @@ export default function ChannelList() {
|
||||
key: "channelType",
|
||||
label: "Type",
|
||||
render: (val: string) => {
|
||||
const meta = CHANNEL_TYPES_META[typeCodeFor(val)] || { label: "Unassigned", icon: CircleHelp, typeColor: "text-muted-foreground", typeBg: "bg-surface-muted" };
|
||||
const meta = CHANNEL_TYPES_META[val] || CHANNEL_TYPES_META.website;
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium border border-transparent ${meta.typeBg} ${meta.typeColor}`}>
|
||||
@@ -97,15 +88,19 @@ export default function ChannelList() {
|
||||
{ key: "products", label: "Products", render: (val: any) => val ? val.toLocaleString() : 0 },
|
||||
{
|
||||
key: "syndicate",
|
||||
label: "Actions",
|
||||
label: "Syndication",
|
||||
render: (_: any, row: Channel) => (
|
||||
<div className="flex items-center gap-1.5"><button
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
notify.info(`Triggering syndication for ${row.name}...`);
|
||||
const res = await channelsApi.triggerSyndication(row.id);
|
||||
notify.success(`Syndication for ${row.name} queued (${res.total_products || 0} products)`);
|
||||
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");
|
||||
}
|
||||
@@ -113,20 +108,7 @@ export default function ChannelList() {
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-semibold text-primary bg-primary/10 hover:bg-primary/20 rounded transition-colors"
|
||||
>
|
||||
<Play className="w-3 h-3" /> Trigger Sync
|
||||
</button><button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const { blob, filename } = await channelsApi.downloadCsv(row.id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url; anchor.download = filename; anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
notify.success(`Downloaded ${filename}`);
|
||||
} catch { notify.error("Configure Channel mappings before downloading CSV"); }
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-semibold text-foreground bg-surface-muted hover:bg-border rounded transition-colors"
|
||||
><Download className="w-3 h-3"/> Download CSV</button></div>
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -171,7 +153,7 @@ export default function ChannelList() {
|
||||
try {
|
||||
notify.info("Triggering bulk syndication across all active channels...");
|
||||
const results = await channelsApi.syndicateAll();
|
||||
notify.success(`Queued syndication for ${results.length} active channels.`);
|
||||
notify.success(`Bulk syndication complete! Triggered sync for ${results.length} active channels.`);
|
||||
} catch {
|
||||
notify.error("Failed to trigger bulk channel syndication");
|
||||
}
|
||||
@@ -197,29 +179,29 @@ export default function ChannelList() {
|
||||
{/* Stats Cards */}
|
||||
<StatsCard
|
||||
title="Total Channels"
|
||||
value={items.length}
|
||||
value="9"
|
||||
subtitle="Active and inactive"
|
||||
color="purple"
|
||||
icon={<Radio className="w-5 h-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Active Channels"
|
||||
value={items.filter(item => item.status === 'active').length}
|
||||
value="8"
|
||||
subtitle="Serving traffic"
|
||||
color="green"
|
||||
icon={<CheckCircle className="w-5 h-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Ready Queue Items"
|
||||
value={operations.ready}
|
||||
subtitle="Awaiting worker execution"
|
||||
title="Families Assigned"
|
||||
value="183"
|
||||
subtitle="Across all storefronts"
|
||||
color="blue"
|
||||
icon={<Clock className="w-5 h-5" />}
|
||||
icon={<Layers className="w-5 h-5" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Products Published"
|
||||
value={operations.published}
|
||||
subtitle="Persisted successful deliveries"
|
||||
value="69,905"
|
||||
subtitle="Synced items"
|
||||
color="orange"
|
||||
icon={<TrendingUp className="w-5 h-5" />}
|
||||
/>
|
||||
|
||||
@@ -125,6 +125,39 @@ export default function NewChannel() {
|
||||
|
||||
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
|
||||
|
||||
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit || isView) {
|
||||
setHighestVisitedStep(STEPS.length);
|
||||
}
|
||||
}, [isEdit, isView]);
|
||||
|
||||
const isBasicValid = Boolean(formik.values.name?.trim() && formik.values.code?.trim() && formik.values.channelType && !formik.errors.name && !formik.errors.code && !formik.errors.channelType);
|
||||
|
||||
const isStepAccessible = useCallback((stepNum: number) => {
|
||||
if (isEdit || isView) return true;
|
||||
if (stepNum === 1) return true;
|
||||
if (!isBasicValid) return false;
|
||||
return stepNum <= highestVisitedStep + 1;
|
||||
}, [isEdit, isView, isBasicValid, highestVisitedStep]);
|
||||
|
||||
const handleNextStep = useCallback(() => {
|
||||
if (activeStep === 'basic') {
|
||||
if (!isBasicValid) {
|
||||
formik.setFieldTouched('name', true);
|
||||
formik.setFieldTouched('code', true);
|
||||
formik.setFieldTouched('channelType', true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (activeIndex < STEPS.length - 1) {
|
||||
const nextStepObj = STEPS[activeIndex + 1];
|
||||
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
|
||||
setActiveStep(nextStepObj.id);
|
||||
}
|
||||
}, [activeStep, isBasicValid, activeIndex, formik]);
|
||||
|
||||
return (
|
||||
<ProtectedRoute node="settings.integrations">
|
||||
<PageWrapper>
|
||||
@@ -169,17 +202,23 @@ export default function NewChannel() {
|
||||
const isActive = activeStep === s.id;
|
||||
const isDone = idx < activeIndex;
|
||||
const isLast = idx === STEPS.length - 1;
|
||||
const accessible = isStepAccessible(s.step);
|
||||
return (
|
||||
<div key={s.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center" style={{ width: 24 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${
|
||||
isActive ? "bg-primary ring-2 ring-primary/20" :
|
||||
isDone ? "bg-success" :
|
||||
"bg-surface border-2 border-border hover:border-primary/30"
|
||||
}`}
|
||||
} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
{isDone
|
||||
? <Check className="w-3 h-3 text-white" />
|
||||
@@ -192,8 +231,13 @@ export default function NewChannel() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""}`}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""} ${!accessible ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
<span className={`text-xs font-medium leading-tight block ${
|
||||
isActive ? "text-primary" : isDone ? "text-muted-foreground" : "text-muted-foreground hover:text-muted-foreground"
|
||||
@@ -426,7 +470,7 @@ export default function NewChannel() {
|
||||
<Button variant="outline" type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)}>Back</Button>
|
||||
)}
|
||||
{activeIndex < STEPS.length - 1 && (
|
||||
<Button variant="primary" type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)}>Next</Button>
|
||||
<Button variant="primary" type="button" onClick={handleNextStep}>Next</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import ChannelList from '../pages/ChannelList';
|
||||
import NewChannel from '../pages/NewChannel';
|
||||
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
|
||||
|
||||
export const ChannelRoutes = () => (
|
||||
<Routes>
|
||||
<Route index element={<ChannelList />} />
|
||||
<Route path="new" element={<ProtectedRoute node="channels.syndication" action="create"><NewChannel /></ProtectedRoute>} />
|
||||
<Route path=":id/edit" element={<ProtectedRoute node="channels.syndication" action="edit"><NewChannel /></ProtectedRoute>} />
|
||||
<Route path="new" element={<NewChannel />} />
|
||||
<Route path=":id/edit" element={<NewChannel />} />
|
||||
<Route path=":id/view" element={<NewChannel />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -93,7 +93,7 @@ export default function Dashboard() {
|
||||
<PageWrapper>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-bold text-foreground mb-1">
|
||||
Welcome back, {user?.first_name || user?.user_name || user?.name || (user?.user_type === 'platform' || user?.type === 'platform' ? 'Platform Super Admin' : 'Tenant Administrator')}
|
||||
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>
|
||||
|
||||
+2389
-1124
File diff suppressed because it is too large
Load Diff
@@ -2,14 +2,13 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import FamilyList from '../pages/FamilyList';
|
||||
import NewFamily from '../pages/NewFamily';
|
||||
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
|
||||
|
||||
export const FamilyRoutes = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<FamilyList />} />
|
||||
<Route path="new" element={<ProtectedRoute node="products.families" action="create"><NewFamily /></ProtectedRoute>} />
|
||||
<Route path=":id/edit" element={<ProtectedRoute node="products.families" action="edit"><NewFamily /></ProtectedRoute>} />
|
||||
<Route path="new" element={<NewFamily />} />
|
||||
<Route path=":id/edit" element={<NewFamily />} />
|
||||
<Route path=":id/view" element={<NewFamily />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface Family {
|
||||
description?: string;
|
||||
category?: string;
|
||||
categoryId?: string | null;
|
||||
productType?: 'simple' | 'variant' | string;
|
||||
attributes: string[]; // List of attribute codes
|
||||
attributeGroups?: number;
|
||||
variantAxes: string[]; // List of attribute codes used as variant axes
|
||||
@@ -17,6 +18,7 @@ export interface Family {
|
||||
createdBy: string;
|
||||
channels?: string[];
|
||||
assetRequirements?: string[];
|
||||
directAssetTypes?: string[];
|
||||
completenessRules?: Record<string, number>;
|
||||
workflowCode?: string;
|
||||
attributeSetId?: string;
|
||||
|
||||
@@ -20,6 +20,8 @@ export const familySchema = Yup.object().shape({
|
||||
})
|
||||
),
|
||||
status: Yup.string().oneOf(['active', 'inactive', 'draft']),
|
||||
productType: Yup.string().oneOf(['simple', 'variant']).optional(),
|
||||
allowedBrands: Yup.array().of(Yup.string()).optional(),
|
||||
allowedUnits: Yup.array().of(Yup.string()).optional(),
|
||||
directAssetTypes: Yup.array().of(Yup.string()).optional(),
|
||||
});
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Copy, KeyRound, Plus, ShieldCheck, Trash2 } from "lucide-react";
|
||||
import apiClient from "../../../api/axiosInstance";
|
||||
import { useAppSelector } from "../../../store";
|
||||
|
||||
type ApiKeyRecord = {
|
||||
id: string; name: string; prefix: string; scopes: string[]; status: string;
|
||||
expiresAt: string; lastUsedAt?: string | null; createdAt: string; apiKey?: string;
|
||||
};
|
||||
|
||||
type ApiResponse<T> = { success: boolean; data: T };
|
||||
|
||||
export default function ApiAccessTab() {
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const isPlatformUser = user?.type === "platform" || user?.user_type === "platform";
|
||||
const isSupportMode = isPlatformUser && Boolean(localStorage.getItem("impersonatedTenantId"));
|
||||
const [keys, setKeys] = useState<ApiKeyRecord[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [expiresInDays, setExpiresInDays] = useState(90);
|
||||
const [createdKey, setCreatedKey] = useState<ApiKeyRecord | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
const response = await apiClient.get<ApiResponse<ApiKeyRecord[]>>("/api/v1/api-keys");
|
||||
setKeys(response.data || []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSupportMode) load().catch((cause: any) => setError(cause?.response?.data?.message || "Could not load API keys."));
|
||||
}, [isSupportMode]);
|
||||
|
||||
if (isSupportMode) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-5 text-sm text-amber-950">
|
||||
<div className="flex items-center gap-2 font-semibold"><ShieldCheck className="h-4 w-4" />API-key management is protected in Support Mode</div>
|
||||
<p className="mt-2">Platform support can inspect tenant data for troubleshooting, but cannot create, view, or revoke credentials on the tenant's behalf.</p>
|
||||
<p className="mt-2">A real tenant administrator must sign in to this workspace and open <strong>Integration Hub → API Access</strong> to manage product API keys.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const createKey = async () => {
|
||||
setBusy(true); setError("");
|
||||
try {
|
||||
const response = await apiClient.post<ApiResponse<ApiKeyRecord>>("/api/v1/api-keys", { name, expiresInDays });
|
||||
setCreatedKey(response.data); setName(""); await load();
|
||||
} catch (cause: any) {
|
||||
setError(cause?.response?.data?.message || "Could not create API key.");
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const revoke = async (id: string) => {
|
||||
if (!window.confirm("Revoke this API key? Applications using it will immediately lose access.")) return;
|
||||
await apiClient.delete(`/api/v1/api-keys/${id}`); await load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-900">
|
||||
<div className="flex items-center gap-2 font-semibold"><ShieldCheck className="h-4 w-4" />What this key can do</div>
|
||||
<p className="mt-2">It can only read products belonging to your tenant through <code>GET /api/v1/external/products</code>. It cannot create, edit, delete, publish, or access another tenant.</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<h3 className="font-semibold text-foreground">Create a read-only product key</h3>
|
||||
<div className="mt-3 flex flex-wrap gap-3">
|
||||
<input aria-label="API key name" value={name} onChange={(event) => setName(event.target.value)} placeholder="Example: Website product reader" maxLength={120} className="min-w-64 rounded-lg border border-border bg-surface px-3 py-2 text-sm" />
|
||||
<select aria-label="API key expiry" value={expiresInDays} onChange={(event) => setExpiresInDays(Number(event.target.value))} className="rounded-lg border border-border bg-surface px-3 py-2 text-sm">
|
||||
<option value={30}>30 days</option><option value={90}>90 days</option><option value={180}>180 days</option><option value={365}>365 days</option>
|
||||
</select>
|
||||
<button disabled={busy || !name.trim()} onClick={createKey} className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50"><Plus className="h-4 w-4" />Create key</button>
|
||||
</div>
|
||||
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
|
||||
{createdKey?.apiKey && (
|
||||
<div className="rounded-lg border-2 border-amber-300 bg-amber-50 p-4">
|
||||
<p className="font-semibold text-amber-950">Copy this key now — it will never be shown again.</p>
|
||||
<div className="mt-3 flex gap-2"><code className="min-w-0 flex-1 overflow-x-auto rounded bg-white p-3 text-sm">{createdKey.apiKey}</code><button aria-label="Copy API key" onClick={() => navigator.clipboard.writeText(createdKey.apiKey!)} className="rounded-lg border border-amber-300 bg-white px-3"><Copy className="h-4 w-4" /></button></div>
|
||||
<button onClick={() => setCreatedKey(null)} className="mt-3 text-sm font-medium text-amber-900 underline">I have saved it securely</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<table className="w-full text-left text-sm"><thead className="bg-surface-muted"><tr><th className="p-3">Name</th><th className="p-3">Prefix</th><th className="p-3">Scope</th><th className="p-3">Expires</th><th className="p-3">Last used</th><th className="p-3">Status</th><th className="p-3"></th></tr></thead>
|
||||
<tbody>{keys.map((key) => <tr key={key.id} className="border-t border-border"><td className="p-3 font-medium"><span className="inline-flex items-center gap-2"><KeyRound className="h-4 w-4" />{key.name}</span></td><td className="p-3 font-mono text-xs">{key.prefix}…</td><td className="p-3">products:read</td><td className="p-3">{new Date(key.expiresAt).toLocaleDateString()}</td><td className="p-3">{key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleString() : "Never"}</td><td className="p-3 capitalize">{key.status}</td><td className="p-3 text-right">{key.status === "active" && <button aria-label={`Revoke ${key.name}`} onClick={() => revoke(key.id)} className="text-red-600"><Trash2 className="h-4 w-4" /></button>}</td></tr>)}</tbody>
|
||||
</table>
|
||||
{keys.length === 0 && <p className="p-6 text-center text-muted-foreground">No API keys yet.</p>}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-4 text-sm"><h3 className="font-semibold">Example request</h3><pre className="mt-2 overflow-x-auto rounded bg-slate-950 p-3 text-slate-100">{`curl https://YOUR-PIM/api/v1/external/products \\\n -H "X-API-Key: pim_live_your_key"`}</pre></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,115 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Clock, RefreshCw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Download, Filter, Clock } from "lucide-react";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { SearchBar } from "../../../components/customs/SearchBar";
|
||||
import { channelsApi } from "../../channels/api/channels.api";
|
||||
import { notify } from "../../../services/toast";
|
||||
|
||||
const MOCK_LOGS = [
|
||||
{
|
||||
time: "2025-06-09 14:35",
|
||||
event: "Started Sync",
|
||||
integration: "Amazon India",
|
||||
description: "Manual full catalogue sync initiated — 4,240 products by Sarah Chen",
|
||||
user: "Sarah Chen"
|
||||
},
|
||||
{
|
||||
time: "2025-06-09 12:00",
|
||||
event: "Scheduled Sync",
|
||||
integration: "Retail POS Network",
|
||||
description: "Daily catalogue sync triggered by schedule",
|
||||
user: "System"
|
||||
},
|
||||
{
|
||||
time: "2025-06-08 16:22",
|
||||
event: "Updated Credentials",
|
||||
integration: "Shopify Main Store",
|
||||
description: "Access token rotated. Previous token expires 2025-07-01",
|
||||
user: "Michael Torres"
|
||||
},
|
||||
{
|
||||
time: "2025-06-08 09:00",
|
||||
event: "Created Integration",
|
||||
integration: "WooCommerce EU",
|
||||
description: "New integration created in staging environment",
|
||||
user: "Admin User"
|
||||
},
|
||||
];
|
||||
|
||||
export default function AuditLogsList() {
|
||||
const [logs, setLogs] = useState<any[]>([]); const [search, setSearch] = useState(""); const [loading, setLoading] = useState(false);
|
||||
const load = useCallback(async () => { setLoading(true); try { setLogs(await channelsApi.getOperationsAudit()); } catch { notify.error("Unable to load operations audit"); } finally { setLoading(false); } }, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
const shown = logs.filter(log => `${log.action} ${log.resource} ${JSON.stringify(log.details || {})}`.toLowerCase().includes(search.toLowerCase()));
|
||||
return <div className="space-y-4">
|
||||
<div className="flex gap-3"><SearchBar value={search} onChange={setSearch} placeholder="Search audit events..." className="flex-1"/><Button variant="outline" onClick={load} disabled={loading}><RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`}/>Refresh</Button></div>
|
||||
<div className="border border-border rounded-xl divide-y divide-border bg-surface">
|
||||
{shown.length ? shown.map(log => <div key={log.id} className="p-5 flex gap-4"><div className="mt-1.5 w-2.5 h-2.5 rounded-full bg-primary shrink-0"/><div><div className="font-semibold">{log.action}</div><div className="text-sm text-muted-foreground mt-1">{log.resource}{log.resource_id ? ` · ${log.resource_id}` : ""}</div><div className="flex items-center gap-1 text-xs text-muted-foreground mt-2"><Clock className="w-3.5 h-3.5"/>{new Date(log.createdAt || log.created_at).toLocaleString()}</div></div></div>) : <div className="p-10 text-center text-sm text-muted-foreground">No tenant-owned channel or integration audit events found.</div>}
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// Optional: Filter logs based on search
|
||||
const filteredLogs = MOCK_LOGS.filter(log =>
|
||||
log.event.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
log.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
log.integration.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
{/* SearchBar + Action Buttons */}
|
||||
<div className="flex items-center gap-3">
|
||||
<SearchBar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search logs..."
|
||||
className="flex-1"
|
||||
/>
|
||||
|
||||
<Button variant="outline" className="border-border bg-surface px-4">
|
||||
<Filter className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="outline" className="border-border bg-surface px-4">
|
||||
<Download className="w-4 h-4" /> Export
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Vertical Timeline */}
|
||||
<div className="bg-surface rounded-2xl border border-border shadow-sm overflow-hidden">
|
||||
<div className="divide-y divide-border">
|
||||
{filteredLogs.length > 0 ? (
|
||||
filteredLogs.map((log, index) => (
|
||||
<div key={index} className="p-6 flex gap-5 hover:bg-background transition-colors group">
|
||||
{/* Purple Dot */}
|
||||
<div className="mt-1.5 w-3 h-3 rounded-full bg-primary flex-shrink-0 ring-4 ring-primary/10" />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="font-semibold text-foreground text-[15px]">{log.event}</div>
|
||||
<span className="px-3 py-0.5 text-xs font-medium bg-primary-light text-primary-dark rounded-full">
|
||||
{log.integration}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-sm text-muted-foreground leading-relaxed">
|
||||
{log.description}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{log.time}
|
||||
</div>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<span>by {log.user}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-12 text-center text-muted-foreground">
|
||||
No matching logs found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-5 border-t border-border bg-background text-center">
|
||||
<button className="text-sm text-primary font-medium hover:text-primary-dark flex items-center gap-1.5 mx-auto">
|
||||
Load More Activity
|
||||
<span className="text-base leading-none">↓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,171 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { channelsApi } from "../../channels/api/channels.api";
|
||||
import { notify } from "../../../services/toast";
|
||||
import { StatusBadge, type BadgeVariant } from "../../../components/customs/StatusBadge";
|
||||
|
||||
const MOCK_ERRORS = [
|
||||
{
|
||||
id: "1",
|
||||
product: "Samsung Galaxy S25 Ultra",
|
||||
variant: "256GB Black",
|
||||
sku: "P-8841",
|
||||
integration: "Amazon India",
|
||||
errorType: "Validation Error",
|
||||
message: "Required attribute 'bullet_points' missing for Amazon listing compliance",
|
||||
date: "2025-06-09 14:32",
|
||||
severity: "High",
|
||||
status: "Open"
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
product: "Sony WH-1000XM5",
|
||||
variant: "Black",
|
||||
sku: "P-6621",
|
||||
integration: "Amazon India",
|
||||
errorType: "Image Rejected",
|
||||
message: "Primary image does not meet Amazon image guidelines (minimum 1000px)",
|
||||
date: "2025-06-09 14:28",
|
||||
severity: "High",
|
||||
status: "Open"
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
product: "Nike Air Max 270",
|
||||
variant: "UK 8 White",
|
||||
sku: "P-4412",
|
||||
integration: "Shopify Main Store",
|
||||
errorType: "SKU Conflict",
|
||||
message: "SKU 'NAM270-W8' already exists in Shopify with different product ID",
|
||||
date: "2025-06-09 14:01",
|
||||
severity: "Critical",
|
||||
status: "Retrying"
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
product: "Adidas Ultraboost 24",
|
||||
variant: "UK 9 Blue",
|
||||
sku: "P-4520",
|
||||
integration: "Shopify Main Store",
|
||||
errorType: "Rate Limit",
|
||||
message: "Shopify API rate limit exceeded (40/s). Request queued for retry.",
|
||||
date: "2025-06-09 13:58",
|
||||
severity: "Medium",
|
||||
status: "Resolved"
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
product: "WMS Inventory Batch",
|
||||
variant: "-",
|
||||
sku: "BATCH-284",
|
||||
integration: "Warehouse WMS",
|
||||
errorType: "Connection Timeout",
|
||||
message: "Connection to warehouse endpoint timed out after 30s. Host: wms.internal:8080",
|
||||
date: "2025-06-08 08:03",
|
||||
severity: "Critical",
|
||||
status: "Open"
|
||||
},
|
||||
];
|
||||
|
||||
export default function ErrorCenterList() {
|
||||
const [items, setItems] = useState<any[]>([]); const [loading, setLoading] = useState(false);
|
||||
const load = useCallback(async () => { setLoading(true); try { setItems(await channelsApi.getErrors()); } catch { notify.error("Unable to load syndication errors"); } finally { setLoading(false); } }, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const columns = [
|
||||
{ key: "product", label: "PRODUCT", render: (_: any, r: any) => <div><div className="font-medium">{r.product?.name || "Unknown product"}</div><div className="text-xs text-muted-foreground">{r.product?.code || r.product_id}</div></div> },
|
||||
{ key: "channel", label: "CHANNEL", render: (_: any, r: any) => r.channel?.name || "—" },
|
||||
{ key: "error_code", label: "ERROR", render: (v: string) => <span className="font-mono text-xs">{v || "UNKNOWN"}</span> },
|
||||
{ key: "error_message", label: "MESSAGE" },
|
||||
{ key: "attempt_count", label: "ATTEMPTS" },
|
||||
{ key: "status", label: "STATUS", render: (v: string) => <StatusBadge status={v === "retrying" ? "warning" : "error"} label={v} /> },
|
||||
{ key: "updated_at", label: "UPDATED", render: (v: string) => new Date(v).toLocaleString() }
|
||||
{
|
||||
key: "product",
|
||||
label: "PRODUCT",
|
||||
render: (_: any, row: any) => (
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{row.product}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{row.sku}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "variant", label: "VARIANT" },
|
||||
{ key: "integration", label: "INTEGRATION" },
|
||||
{
|
||||
key: "errorType",
|
||||
label: "ERROR TYPE",
|
||||
render: (val: string) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
||||
<span className="font-medium text-foreground">{val}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "message",
|
||||
label: "MESSAGE",
|
||||
render: (val: string) => <div className="text-sm text-muted-foreground line-clamp-2 max-w-md">{val}</div>
|
||||
},
|
||||
{ key: "date", label: "DATE" },
|
||||
{
|
||||
key: "severity",
|
||||
label: "SEVERITY",
|
||||
render: (val: string) => {
|
||||
let variant: BadgeVariant = "neutral";
|
||||
if (val === "Critical") variant = "error";
|
||||
if (val === "High") variant = "warning";
|
||||
if (val === "Medium") variant = "approval";
|
||||
|
||||
return <StatusBadge status={variant} label={val} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "STATUS",
|
||||
render: (val: string) => {
|
||||
let variant: BadgeVariant = "neutral";
|
||||
if (val === "Open") variant = "error"; // red
|
||||
if (val === "Retrying") variant = "warning"; // orange
|
||||
if (val === "Resolved") variant = "success"; // green
|
||||
|
||||
return <StatusBadge status={variant} label={val} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
return <DataTable columns={columns} data={items} rowIdKey="id" resultLabel="errors" searchPlaceholder="Search errors..." toolbarLeft={<Button variant="outline" onClick={load} disabled={loading}><RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`}/>Refresh Errors</Button>} />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Banner */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 flex items-center gap-4">
|
||||
<div className="p-3 bg-surface rounded-lg">
|
||||
<AlertTriangle className="w-6 h-6 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-amber-900">5 Active Errors • 3 require immediate attention</div>
|
||||
<div className="text-sm text-amber-700 mt-0.5">Review and resolve to maintain sync health</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={MOCK_ERRORS}
|
||||
actionConfig={{
|
||||
onView: (row) => navigate(`${row.id}/view`),
|
||||
}}
|
||||
searchPlaceholder="Search errors..."
|
||||
toolbarLeft={
|
||||
<div className="flex gap-2">
|
||||
<select className="h-9 px-3 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
|
||||
<option>All Integrations</option>
|
||||
<option>Amazon India</option>
|
||||
<option>Shopify Main Store</option>
|
||||
</select>
|
||||
<select className="h-9 px-3 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
|
||||
<option>All Severities</option>
|
||||
<option>Critical</option>
|
||||
<option>High</option>
|
||||
<option>Medium</option>
|
||||
</select>
|
||||
<select className="h-9 px-3 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
|
||||
<option>All Statuses</option>
|
||||
<option>Open</option>
|
||||
<option>Retrying</option>
|
||||
<option>Resolved</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,169 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Plus, Download } from "lucide-react";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { channelsApi } from "../../channels/api/channels.api";
|
||||
import { notify } from "../../../services/toast";
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
|
||||
|
||||
const MOCK_RULES = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Electronics to Amazon",
|
||||
integration: "Amazon India",
|
||||
family: "Consumer Electronics",
|
||||
category: "All Categories",
|
||||
workflowState: "Approved",
|
||||
products: 2840,
|
||||
status: "active",
|
||||
lastEvaluated: "2025-06-09 14:32"
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Fashion to Shopify",
|
||||
integration: "Shopify Main Store",
|
||||
family: "Apparel & Fashion",
|
||||
category: "Clothing",
|
||||
workflowState: "Published",
|
||||
products: 5120,
|
||||
status: "active",
|
||||
lastEvaluated: "2025-06-09 14:00"
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Retail Products to POS",
|
||||
integration: "Retail POS Network",
|
||||
family: "All Families",
|
||||
category: "All Categories",
|
||||
workflowState: "Approved",
|
||||
products: 3240,
|
||||
status: "active",
|
||||
lastEvaluated: "2025-06-09 12:00"
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "Warehouse Items to WMS",
|
||||
integration: "Warehouse WMS",
|
||||
family: "All Families",
|
||||
category: "All Categories",
|
||||
workflowState: "Any",
|
||||
products: 0,
|
||||
status: "inactive",
|
||||
lastEvaluated: "2025-06-08 08:00"
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "UAE Electronics",
|
||||
integration: "Amazon UAE",
|
||||
family: "Consumer Electronics",
|
||||
category: "Smartphones",
|
||||
workflowState: "Published",
|
||||
products: 840,
|
||||
status: "active",
|
||||
lastEvaluated: "2025-06-09 13:15"
|
||||
},
|
||||
];
|
||||
|
||||
export default function PublishingRulesList() {
|
||||
const [rows, setRows] = useState<any[]>([]); const [loading, setLoading] = useState(false);
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const channels = await channelsApi.getAll();
|
||||
const mappings = await Promise.all(channels.map(async channel => ({ channel, mappings: await channelsApi.getMappings(channel.id) })));
|
||||
setRows(mappings.map(({ channel, mappings }) => ({ id: channel.id, name: channel.name, code: channel.code, status: channel.status, mappedFields: mappings.length, requiredFields: mappings.filter((m:any) => m.is_required).length, transformations: [...new Set(mappings.map((m:any) => m.transformation_rule).filter((v:string) => v && v !== 'none'))].join(', ') || 'None' })));
|
||||
} catch { notify.error("Unable to load channel mapping rules"); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
const navigate = useNavigate();
|
||||
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: "", name: "" });
|
||||
|
||||
const columns = [
|
||||
{ key: "name", label: "CHANNEL" }, { key: "code", label: "CODE" }, { key: "mappedFields", label: "MAPPED FIELDS" },
|
||||
{ key: "requiredFields", label: "REQUIRED" }, { key: "transformations", label: "TRANSFORMATIONS" }, { key: "status", label: "STATUS" }
|
||||
{
|
||||
key: "name",
|
||||
label: "RULE NAME",
|
||||
sortable: true,
|
||||
render: (val: string, row: any) => (
|
||||
<div>
|
||||
<div className="font-semibold text-foreground">{val}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{row.integration}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "family", label: "FAMILY" },
|
||||
{ key: "category", label: "CATEGORY" },
|
||||
{
|
||||
key: "workflowState",
|
||||
label: "WORKFLOW STATE",
|
||||
render: (val: string) => {
|
||||
let variant: any = "neutral";
|
||||
let label = val;
|
||||
|
||||
if (val === "Approved") variant = "success";
|
||||
if (val === "Published") variant = "published";
|
||||
if (val === "Any") variant = "warning";
|
||||
|
||||
return <StatusBadge status={variant} label={label} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "products",
|
||||
label: "PRODUCTS",
|
||||
render: (val: number) => <span className="font-semibold text-foreground">{val.toLocaleString()}</span>
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "STATUS",
|
||||
render: (val: string) => {
|
||||
const variant = val === "active" ? "active" : "error"; // active = green, inactive = red
|
||||
const label = val === "active" ? "Active" : "Inactive";
|
||||
return <StatusBadge status={variant} label={label} />;
|
||||
},
|
||||
},
|
||||
{ key: "lastEvaluated", label: "LAST EVALUATED" },
|
||||
];
|
||||
return <DataTable columns={columns} data={rows} rowIdKey="id" resultLabel="channel rule sets" searchPlaceholder="Search channel rules..." toolbarLeft={<Button variant="outline" onClick={load} disabled={loading}><RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`}/>Refresh Rules</Button>} />;
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
console.log("Deleted rule:", deleteModal.id);
|
||||
setDeleteModal({ isOpen: false, id: "", name: "" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={MOCK_RULES}
|
||||
actionConfig={{
|
||||
onView: (row) => navigate(`${row.id}/view`),
|
||||
onEdit: (row) => navigate(`${row.id}/edit`),
|
||||
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
|
||||
}}
|
||||
searchPlaceholder="Search rules..."
|
||||
toolbarLeft={
|
||||
<div className="flex gap-2">
|
||||
<select className="h-9 px-3 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary bg-surface">
|
||||
<option>All Integrations</option>
|
||||
<option>Amazon India</option>
|
||||
<option>Shopify Main Store</option>
|
||||
<option>Warehouse WMS</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
toolbarRight={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export Rules
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-primary hover:bg-primary-hover text-white"
|
||||
onClick={() => navigate("/integrations/new-rule")}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create New Rule
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={deleteModal.isOpen}
|
||||
title="Delete Publishing Rule"
|
||||
description="Are you sure you want to delete this publishing rule? This action cannot be undone."
|
||||
itemName={deleteModal.name}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ import { useChannel } from "../../channels/hook/useChannel";
|
||||
import type { Integration } from "../types/integrations.types";
|
||||
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
|
||||
import { StatsCard } from "../../../components/customs/StatsCard";
|
||||
import { channelsApi } from "../../channels/api/channels.api";
|
||||
|
||||
// Import Custom Feature Components & Pre-Built Templates
|
||||
import { IntegrationHealthBadge } from "../components/IntegrationHealthBadge";
|
||||
@@ -39,10 +38,8 @@ import PublishingRulesList from "../components/PublishingRulesTab";
|
||||
import SyncJobsList from "../components/SyncJobsTab";
|
||||
import ErrorCenterList from "../components/ErrorCenterTab";
|
||||
import AuditLogsList from "../components/AuditLogsTab";
|
||||
import ApiAccessTab from "../components/ApiAccessTab";
|
||||
|
||||
const INTEGRATION_META: Record<string, { label: string; icon: any; color: string; bg: string }> = {
|
||||
shopify: { label: "Shopify", icon: ShoppingCart, color: "text-green-600", bg: "bg-green-50" },
|
||||
ecommerce: { label: "E-Commerce", icon: ShoppingCart, color: "text-blue-600", bg: "bg-blue-50" },
|
||||
marketplace: { label: "Marketplace", icon: ShoppingBag, color: "text-orange-600", bg: "bg-orange-50" },
|
||||
erp: { label: "ERP", icon: Server, color: "text-red-600", bg: "bg-red-50" },
|
||||
@@ -78,9 +75,6 @@ export default function IntegrationList() {
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
fetchChannels();
|
||||
Promise.all([channelsApi.getAllJobs(), channelsApi.getErrors()])
|
||||
.then(([jobs, errors]) => setOperations({ jobs, errors }))
|
||||
.catch(() => setOperations({ jobs: [], errors: [] }));
|
||||
}, [fetchItems, fetchChannels]);
|
||||
|
||||
// Automatically open Shopify modal into Step 3 when returning from OAuth redirect
|
||||
|
||||
@@ -3,8 +3,9 @@ import { useNavigate, useParams, useLocation } from "react-router-dom";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import {
|
||||
Zap, ShoppingCart, Check, Code2, Eye, EyeOff, Settings2,
|
||||
Info
|
||||
Zap, ShoppingCart, ShoppingBag, Server, Warehouse, Check,
|
||||
Store, Globe, Smartphone, Monitor, Code2, Eye, EyeOff, Settings2,
|
||||
Calendar, Clock, Wifi, ChevronDown, Info
|
||||
} from "lucide-react";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useIntegration } from "../hook/useIntegration";
|
||||
@@ -22,13 +23,21 @@ const STEPS = [
|
||||
];
|
||||
|
||||
const INTEGRATION_TYPES = [
|
||||
{ id: "custom_api", label: "Generic REST API", sub: "Send mapped products to any HTTPS website or application", icon: Code2 },
|
||||
{ id: "shopify", label: "Shopify", sub: "Separate Shopify credential flow; publishing is the next phase", icon: ShoppingCart },
|
||||
{ id: "ecommerce", label: "E-Commerce", sub: "Shopify, WooCommerce, Magento", icon: ShoppingCart },
|
||||
{ id: "marketplace", label: "Marketplace", sub: "Amazon, eBay, Flipkart, Noon", icon: ShoppingBag },
|
||||
{ id: "erp", label: "ERP", sub: "SAP, Oracle, Microsoft Dynamics", icon: Server },
|
||||
{ id: "wms", label: "WMS", sub: "Warehouse management systems", icon: Warehouse },
|
||||
{ id: "pos", label: "POS", sub: "Retail point-of-sale systems", icon: Store },
|
||||
{ id: "b2b_portal", label: "B2B Portal", sub: "B2B client storefronts and portals", icon: Globe },
|
||||
{ id: "mobile_app", label: "Mobile App", sub: "iOS/Android application platforms", icon: Smartphone },
|
||||
{ id: "website", label: "Website", sub: "Marketing and catalog websites", icon: Monitor },
|
||||
{ id: "custom_api", label: "Custom API", sub: "Any REST or GraphQL endpoint", icon: Code2 },
|
||||
];
|
||||
|
||||
function IntegrationSummary({ name, channel, channelName, type, environment, syncDirection, syncFrequency }: {
|
||||
function IntegrationSummary({ name, channel, channelName, type, environment, syncDirection, syncFrequency, disabled }: {
|
||||
name: string; channel: string; channelName: string; type: string;
|
||||
environment: string; syncDirection: string; syncFrequency: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-60 shrink-0">
|
||||
@@ -66,9 +75,9 @@ function IntegrationSummary({ name, channel, channelName, type, environment, syn
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 pt-5 border-t border-border">
|
||||
<button type="button" disabled className="w-full flex items-center justify-center gap-2 py-2.5 px-4 border border-warning/20 bg-warning/10 text-warning rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<button type="button" disabled={disabled} className="w-full flex items-center justify-center gap-2 py-2.5 px-4 border border-warning/20 bg-warning/10 text-warning rounded-lg text-sm font-medium hover:bg-warning/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<Zap className="w-4 h-4" />
|
||||
Use Test Connection above
|
||||
Test Connection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,12 +96,6 @@ const integrationSchema = Yup.object({
|
||||
syncFrequency: Yup.string().required(),
|
||||
autoRetry: Yup.boolean(),
|
||||
retryAttempts: Yup.number().min(1).max(10),
|
||||
storeUrl: Yup.string().when('integrationType', { is: 'shopify', then: schema => schema.required('Permanent .myshopify.com domain is required').matches(/^https:\/\/[a-z0-9][a-z0-9-]*\.myshopify\.com\/?$/i, 'Use https://your-store.myshopify.com') }),
|
||||
clientId: Yup.string().when('integrationType', { is: 'shopify', then: schema => schema.required('Shopify Client ID is required') }),
|
||||
clientSecret: Yup.string(),
|
||||
endpoint: Yup.string().when('integrationType', { is: 'custom_api', then: schema => schema.required('Delivery endpoint is required').url('Enter a complete HTTPS URL').matches(/^https:\/\//i, 'External endpoints must use HTTPS') }),
|
||||
testEndpoint: Yup.string().url('Enter a complete URL').matches(/^(https:\/\/|$)/i, 'External endpoints must use HTTPS'),
|
||||
customApiHeaderName: Yup.string().matches(/^[A-Za-z0-9-]*$/, 'Header names may contain letters, numbers and hyphens only'),
|
||||
});
|
||||
|
||||
export default function NewIntegration() {
|
||||
@@ -104,8 +107,6 @@ export default function NewIntegration() {
|
||||
const [activeStep, setActiveStep] = useState('general');
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [loadingItem, setLoadingItem] = useState(false);
|
||||
const [testingConnection, setTestingConnection] = useState(false);
|
||||
const [connectionMessage, setConnectionMessage] = useState("");
|
||||
|
||||
const { createItem, updateItem } = useIntegration();
|
||||
const { items: channels, fetchItems: fetchChannels } = useChannel();
|
||||
@@ -118,17 +119,17 @@ export default function NewIntegration() {
|
||||
initialValues: {
|
||||
name: "",
|
||||
channel: "",
|
||||
integrationType: "custom_api",
|
||||
integrationType: "ecommerce",
|
||||
environment: "production",
|
||||
status: "Connected",
|
||||
description: "",
|
||||
storeUrl: "",
|
||||
accessToken: "",
|
||||
apiVersion: "2026-07",
|
||||
apiVersion: "2025-01",
|
||||
webhookSecret: "",
|
||||
shopIdentifier: "",
|
||||
syncDirection: "pim_to_channel",
|
||||
syncFrequency: "manual",
|
||||
syncFrequency: "hourly",
|
||||
autoRetry: true,
|
||||
retryAttempts: 3,
|
||||
|
||||
@@ -141,7 +142,7 @@ export default function NewIntegration() {
|
||||
clientSecret: "",
|
||||
|
||||
// ERP/WMS specific
|
||||
authMethod: "apikey",
|
||||
authMethod: "bearer",
|
||||
authToken: "",
|
||||
username: "",
|
||||
password: "",
|
||||
@@ -158,35 +159,17 @@ export default function NewIntegration() {
|
||||
gatewayUrl: "",
|
||||
|
||||
// Custom API specific
|
||||
endpoint: "",
|
||||
testEndpoint: "",
|
||||
method: "POST",
|
||||
customApiHeaderName: "X-API-Key",
|
||||
customApiUrl: "",
|
||||
customApiHeaderKey: "",
|
||||
customApiHeaderValue: "",
|
||||
},
|
||||
validationSchema: integrationSchema,
|
||||
onSubmit: async (values, { setSubmitting, setFieldError }) => {
|
||||
if (!isEdit && values.integrationType === 'shopify' && !values.clientSecret) {
|
||||
setFieldError('clientSecret', 'Shopify Client Secret is required');
|
||||
setActiveStep('connection');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
if (!isEdit && values.integrationType === 'custom_api' && values.authMethod === 'bearer' && !values.authToken) {
|
||||
setFieldError('authToken', 'Bearer token is required'); setActiveStep('connection'); setSubmitting(false); return;
|
||||
}
|
||||
if (!isEdit && values.integrationType === 'custom_api' && values.authMethod === 'apikey' && !values.customApiHeaderValue) {
|
||||
setFieldError('customApiHeaderValue', 'API key value is required'); setActiveStep('connection'); setSubmitting(false); return;
|
||||
}
|
||||
const submission = values.integrationType === 'custom_api' ? {
|
||||
...values,
|
||||
clearSecretFields: values.authMethod === 'none' ? ['authToken', 'customApiHeaderValue'] : values.authMethod === 'bearer' ? ['customApiHeaderValue'] : ['authToken']
|
||||
} : values;
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await updateItem(id, submission as any);
|
||||
await updateItem(id, values as any);
|
||||
} else {
|
||||
await createItem(submission as any);
|
||||
await createItem(values as any);
|
||||
}
|
||||
navigate("..");
|
||||
} catch {
|
||||
@@ -211,7 +194,7 @@ export default function NewIntegration() {
|
||||
description: item.description || "",
|
||||
storeUrl: item.storeUrl || "",
|
||||
accessToken: item.accessToken || "",
|
||||
apiVersion: item.apiVersion || "2026-07",
|
||||
apiVersion: item.apiVersion || "2025-01",
|
||||
webhookSecret: item.webhookSecret || "",
|
||||
shopIdentifier: item.shopIdentifier || "",
|
||||
syncDirection: item.syncDirection,
|
||||
@@ -237,10 +220,8 @@ export default function NewIntegration() {
|
||||
appId: item.appId || "",
|
||||
bundleIdentifier: item.bundleIdentifier || "",
|
||||
gatewayUrl: item.gatewayUrl || "",
|
||||
endpoint: item.endpoint || item.customApiUrl || "",
|
||||
testEndpoint: item.testEndpoint || "",
|
||||
method: item.method || "POST",
|
||||
customApiHeaderName: item.customApiHeaderName || item.customApiHeaderKey || "X-API-Key",
|
||||
customApiUrl: item.customApiUrl || "",
|
||||
customApiHeaderKey: item.customApiHeaderKey || "",
|
||||
customApiHeaderValue: item.customApiHeaderValue || "",
|
||||
});
|
||||
}
|
||||
@@ -252,19 +233,11 @@ export default function NewIntegration() {
|
||||
const handleChannelChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const val = e.target.value;
|
||||
formik.setFieldValue("channel", val);
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!id) return;
|
||||
setTestingConnection(true);
|
||||
setConnectionMessage("");
|
||||
try {
|
||||
const result = await integrationsService.testConnection(id);
|
||||
setConnectionMessage(result.message);
|
||||
} catch (error: any) {
|
||||
setConnectionMessage(error?.response?.data?.message || "Connection test failed");
|
||||
} finally {
|
||||
setTestingConnection(false);
|
||||
|
||||
// Auto-match integrationType to channelType from Channel Registry
|
||||
const selectedChan = channels.find(c => c.code === val || c.id === val);
|
||||
if (selectedChan && selectedChan.channelType) {
|
||||
formik.setFieldValue("integrationType", selectedChan.channelType);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -285,6 +258,39 @@ export default function NewIntegration() {
|
||||
|
||||
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
|
||||
|
||||
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit || isView) {
|
||||
setHighestVisitedStep(STEPS.length);
|
||||
}
|
||||
}, [isEdit, isView]);
|
||||
|
||||
const isGeneralValid = Boolean(formik.values.name?.trim() && formik.values.channel && formik.values.integrationType && !formik.errors.name && !formik.errors.channel && !formik.errors.integrationType);
|
||||
|
||||
const isStepAccessible = useCallback((stepNum: number) => {
|
||||
if (isEdit || isView) return true;
|
||||
if (stepNum === 1) return true;
|
||||
if (!isGeneralValid) return false;
|
||||
return stepNum <= highestVisitedStep + 1;
|
||||
}, [isEdit, isView, isGeneralValid, highestVisitedStep]);
|
||||
|
||||
const handleNextStep = useCallback(() => {
|
||||
if (activeStep === 'general') {
|
||||
if (!isGeneralValid) {
|
||||
formik.setFieldTouched('name', true);
|
||||
formik.setFieldTouched('channel', true);
|
||||
formik.setFieldTouched('integrationType', true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (activeIndex < STEPS.length - 1) {
|
||||
const nextStepObj = STEPS[activeIndex + 1];
|
||||
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
|
||||
setActiveStep(nextStepObj.id);
|
||||
}
|
||||
}, [activeStep, isGeneralValid, activeIndex, formik]);
|
||||
|
||||
const selectedType = INTEGRATION_TYPES.find((t) => t.id === formik.values.integrationType);
|
||||
const selectedChanName = channels.find(c => c.code === formik.values.channel || c.id === formik.values.channel)?.name ?? '';
|
||||
|
||||
@@ -310,12 +316,10 @@ export default function NewIntegration() {
|
||||
{!isView && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={!id || testingConnection}
|
||||
className="flex items-center gap-2 py-2 px-4 border border-warning/20 bg-warning/10 text-warning rounded-lg text-sm font-medium hover:bg-warning/20 transition-colors"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
{testingConnection ? "Testing…" : id ? "Test Connection" : "Save Before Testing"}
|
||||
Test Connection
|
||||
</button>
|
||||
)}
|
||||
<Button variant="outline" type="button" onClick={() => navigate('..')}>
|
||||
@@ -347,15 +351,34 @@ export default function NewIntegration() {
|
||||
const isActive = activeStep === s.id;
|
||||
const isDone = idx < activeIndex;
|
||||
const isLast = idx === STEPS.length - 1;
|
||||
const accessible = isStepAccessible(s.step);
|
||||
return (
|
||||
<div key={s.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center" style={{ width: 24 }}>
|
||||
<button type="button" onClick={() => setActiveStep(s.id)} className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${isActive ? 'bg-primary ring-2 ring-primary/20' : isDone ? 'bg-success' : 'bg-surface border-2 border-border hover:border-primary/30'}`}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${isActive ? 'bg-primary ring-2 ring-primary/20' : isDone ? 'bg-success' : 'bg-surface border-2 border-border hover:border-primary/30'} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
{isDone ? <Check className="w-3 h-3 text-white" /> : <span className={`text-[9px] font-bold ${isActive ? 'text-white' : 'text-muted-foreground'}`}>{s.step}</span>}
|
||||
</button>
|
||||
{!isLast && <div className={`w-px flex-1 my-0.5 ${isDone ? 'bg-success/50' : 'bg-surface-muted'}`} style={{ minHeight: 14 }} />}
|
||||
</div>
|
||||
<button type="button" onClick={() => setActiveStep(s.id)} className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span className={`text-xs font-medium leading-tight block ${isActive ? 'text-primary' : isDone ? 'text-muted-foreground' : 'text-muted-foreground hover:text-muted-foreground'}`}>{s.label}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -373,18 +396,6 @@ export default function NewIntegration() {
|
||||
<CardHeader title="General Information" subtitle="Name, channel, environment and status" />
|
||||
<div className="p-6 space-y-5">
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>How will products leave the PIM? *</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{INTEGRATION_TYPES.map((type) => {
|
||||
const Icon = type.icon;
|
||||
const selected = formik.values.integrationType === type.id;
|
||||
return <button key={type.id} type="button" onClick={() => formik.setFieldValue('integrationType', type.id)} className={`rounded-xl border-2 p-4 text-left transition-colors ${selected ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/40'}`}><div className="flex items-center gap-2"><Icon className={`h-5 w-5 ${selected ? 'text-primary' : 'text-muted-foreground'}`} /><span className="font-semibold">{type.label}</span></div><p className="mt-2 text-xs text-muted-foreground">{type.sub}</p></button>;
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 rounded-lg bg-surface-muted p-3 text-xs text-muted-foreground"><strong>Channel</strong> decides which fields and transformations are sent. <strong>Integration</strong> stores where to send them and how the destination authenticates PIM.</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Integration Name *</label>
|
||||
<input
|
||||
@@ -392,7 +403,7 @@ export default function NewIntegration() {
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
placeholder="e.g. Main Website REST API"
|
||||
placeholder="e.g. Amazon India Production"
|
||||
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name && (
|
||||
@@ -412,7 +423,7 @@ export default function NewIntegration() {
|
||||
>
|
||||
<option value="">Select a channel from Channel Registry...</option>
|
||||
{channels.map((chan) => (
|
||||
<option key={chan.id} value={chan.id}>
|
||||
<option key={chan.id} value={chan.code || chan.id}>
|
||||
{chan.name} ({chan.code})
|
||||
</option>
|
||||
))}
|
||||
@@ -437,7 +448,21 @@ export default function NewIntegration() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div><label className={labelClass}>Connection Status</label><div className="rounded-lg border border-border bg-surface-muted px-3 py-2.5 text-sm">Pending until credentials are tested successfully</div></div>
|
||||
<div>
|
||||
<label className={labelClass}>Status</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
name="status"
|
||||
value={formik.values.status}
|
||||
onChange={formik.handleChange}
|
||||
className={inputClass()}
|
||||
>
|
||||
<option value="Connected">Connected</option>
|
||||
<option value="Pending">Pending</option>
|
||||
<option value="Disconnected">Disconnected</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -456,8 +481,8 @@ export default function NewIntegration() {
|
||||
<div className="flex items-start gap-3 p-4 bg-blue-50 border border-blue-100 rounded-xl text-sm text-blue-700">
|
||||
<Info className="w-4 h-4 text-blue-500 mt-0.5 shrink-0" />
|
||||
<p>
|
||||
{formik.values.integrationType === "shopify" ? (
|
||||
"Enter the permanent .myshopify.com domain plus the Client ID and Client Secret from Shopify Dev Dashboard → Apps → your app → Settings. The PIM obtains short-lived access tokens automatically."
|
||||
{formik.values.integrationType === "ecommerce" || formik.values.integrationType === "website" || formik.values.integrationType === "b2b_portal" ? (
|
||||
"Provide your platform URL, access credentials, and store configuration identifiers."
|
||||
) : formik.values.integrationType === "marketplace" ? (
|
||||
"Marketplace connection requires Seller ID, Marketplace ID, and Selling Partner API credentials."
|
||||
) : formik.values.integrationType === "erp" || formik.values.integrationType === "wms" ? (
|
||||
@@ -467,16 +492,16 @@ export default function NewIntegration() {
|
||||
) : formik.values.integrationType === "mobile_app" ? (
|
||||
"Enter application identifiers, bundle settings, and push sync settings."
|
||||
) : (
|
||||
"Your website or application must provide an HTTPS product endpoint and tell you how it authenticates requests. The PIM will send mapped product JSON to that endpoint; it does not host or create the destination API."
|
||||
"Set up custom REST or GraphQL webhook integrations using key-value headers."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Shopify Fields */}
|
||||
{formik.values.integrationType === "shopify" && (
|
||||
{/* Ecommerce / Website / B2B Portal Fields */}
|
||||
{(formik.values.integrationType === "ecommerce" || formik.values.integrationType === "website" || formik.values.integrationType === "b2b_portal") && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Permanent Shopify Store Domain *</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Store / Website URL *</label>
|
||||
<input
|
||||
name="storeUrl"
|
||||
value={formik.values.storeUrl}
|
||||
@@ -484,25 +509,18 @@ export default function NewIntegration() {
|
||||
placeholder="https://your-store.myshopify.com"
|
||||
className={inputClass()}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Use the permanent <strong>.myshopify.com</strong> domain—not the storefront domain and not the PIM URL.</p>
|
||||
{formik.touched.storeUrl && formik.errors.storeUrl && <p className="mt-1 text-xs text-red-500">{formik.errors.storeUrl}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Shopify Client ID *</label>
|
||||
<input name="clientId" value={formik.values.clientId} onChange={formik.handleChange} placeholder="Dev Dashboard → App → Settings" className={inputClass()} />
|
||||
{formik.touched.clientId && formik.errors.clientId && <p className="mt-1 text-xs text-red-500">{formik.errors.clientId}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Shopify Client Secret *</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Access Token *</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
name="clientSecret"
|
||||
name="accessToken"
|
||||
type={showToken ? "text" : "password"}
|
||||
value={formik.values.clientSecret}
|
||||
value={formik.values.accessToken}
|
||||
onChange={formik.handleChange}
|
||||
placeholder="Dev Dashboard → App → Settings"
|
||||
placeholder="shpat_xxxxxxxxxxx"
|
||||
className={inputClass() + " pr-10"}
|
||||
/>
|
||||
<button
|
||||
@@ -513,11 +531,44 @@ export default function NewIntegration() {
|
||||
{showToken ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{formik.errors.clientSecret && <p className="mt-1 text-xs text-red-500">{formik.errors.clientSecret}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">API Version / Edition</label>
|
||||
<select
|
||||
name="apiVersion"
|
||||
value={formik.values.apiVersion}
|
||||
onChange={formik.handleChange}
|
||||
className={inputClass()}
|
||||
>
|
||||
<option value="2025-01">2025-01 (Latest)</option>
|
||||
<option value="2024-10">2024-10</option>
|
||||
<option value="2024-07">2024-07</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Webhook Secret Key</label>
|
||||
<input
|
||||
name="webhookSecret"
|
||||
value={formik.values.webhookSecret}
|
||||
onChange={formik.handleChange}
|
||||
placeholder="Optional webhook secret"
|
||||
className={inputClass()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Shop Identifier</label>
|
||||
<input
|
||||
name="shopIdentifier"
|
||||
value={formik.values.shopIdentifier}
|
||||
onChange={formik.handleChange}
|
||||
placeholder="e.g., store_main"
|
||||
className={inputClass()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-surface-muted p-3 text-sm"><strong>Admin API version:</strong> 2026-07, managed by the PIM. No access token, separate webhook secret, or shop identifier is required for the first connection test.</div>
|
||||
{connectionMessage && <div className="rounded-lg border border-border p-3 text-sm">{connectionMessage}</div>}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -798,38 +849,40 @@ export default function NewIntegration() {
|
||||
{/* Custom API Fields */}
|
||||
{formik.values.integrationType === "custom_api" && (
|
||||
<>
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-900">
|
||||
<p className="font-semibold">Before filling this form, ask your website developer for:</p>
|
||||
<ol className="mt-2 list-decimal space-y-1 pl-5"><li>The HTTPS URL that accepts one product as JSON.</li><li>Whether it needs no authentication, a Bearer token, or an API-key header.</li><li>A safe test URL if testing must not create a real product.</li></ol>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Product Delivery URL *</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Base API Gateway URL *</label>
|
||||
<input
|
||||
name="endpoint"
|
||||
value={formik.values.endpoint}
|
||||
name="customApiUrl"
|
||||
value={formik.values.customApiUrl}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
placeholder="https://your-site.com/api/pim/products"
|
||||
placeholder="https://api.externalpartner.com/v2"
|
||||
className={inputClass()}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">PIM sends one mapped product to this URL during a syndication job.</p>
|
||||
{formik.touched.endpoint && formik.errors.endpoint && <p className="mt-1 text-xs text-red-500">{formik.errors.endpoint}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>HTTP Method</label>
|
||||
<select name="method" value={formik.values.method} onChange={formik.handleChange} className={inputClass()}><option value="POST">POST — create/upsert</option><option value="PUT">PUT — replace/upsert</option><option value="PATCH">PATCH — partial update</option></select>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Custom Header Name</label>
|
||||
<input
|
||||
name="customApiHeaderKey"
|
||||
value={formik.values.customApiHeaderKey}
|
||||
onChange={formik.handleChange}
|
||||
placeholder="X-Partner-Token"
|
||||
className={inputClass()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Authentication</label>
|
||||
<select name="authMethod" value={formik.values.authMethod} onChange={formik.handleChange} className={inputClass()}><option value="apikey">API-key header</option><option value="bearer">Bearer token</option><option value="none">No authentication</option></select>
|
||||
<label className="block text-sm font-medium text-foreground mb-1.5">Custom Header Value</label>
|
||||
<input
|
||||
name="customApiHeaderValue"
|
||||
type="password"
|
||||
value={formik.values.customApiHeaderValue}
|
||||
onChange={formik.handleChange}
|
||||
placeholder="token-value"
|
||||
className={inputClass()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{formik.values.authMethod === 'apikey' && <div className="grid grid-cols-2 gap-4"><div><label className={labelClass}>API-key Header Name *</label><input name="customApiHeaderName" value={formik.values.customApiHeaderName} onChange={formik.handleChange} placeholder="X-API-Key" className={inputClass()} />{formik.errors.customApiHeaderName && <p className="mt-1 text-xs text-red-500">{formik.errors.customApiHeaderName}</p>}</div><div><label className={labelClass}>API-key Value *</label><input name="customApiHeaderValue" type={showToken ? 'text' : 'password'} value={formik.values.customApiHeaderValue} onChange={formik.handleChange} placeholder={isEdit ? 'Leave blank to keep current secret' : 'Provided by your website developer'} className={inputClass()} />{formik.errors.customApiHeaderValue && <p className="mt-1 text-xs text-red-500">{formik.errors.customApiHeaderValue}</p>}</div></div>}
|
||||
{formik.values.authMethod === 'bearer' && <div><label className={labelClass}>Bearer Token *</label><input name="authToken" type={showToken ? 'text' : 'password'} value={formik.values.authToken} onChange={formik.handleChange} placeholder={isEdit ? 'Leave blank to keep current token' : 'Provided by your website developer'} className={inputClass()} />{formik.errors.authToken && <p className="mt-1 text-xs text-red-500">{formik.errors.authToken}</p>}</div>}
|
||||
<div><label className={labelClass}>Safe Connection-Test URL <span className="font-normal text-muted-foreground">(optional)</span></label><input name="testEndpoint" value={formik.values.testEndpoint} onChange={formik.handleChange} onBlur={formik.handleBlur} placeholder="https://your-site.com/api/pim/test" className={inputClass()} /><p className="mt-1 text-xs text-muted-foreground">Test Connection sends a small <code>pim.connection.test</code> JSON request here. If blank, it uses the Product Delivery URL.</p>{formik.touched.testEndpoint && formik.errors.testEndpoint && <p className="mt-1 text-xs text-red-500">{formik.errors.testEndpoint}</p>}</div>
|
||||
<div className="rounded-lg border border-border bg-surface-muted p-4 text-sm"><p className="font-semibold">What happens after Save</p><ol className="mt-2 list-decimal space-y-1 pl-5"><li>The secret is encrypted and the Integration stays Pending.</li><li>Test Connection verifies the URL and credentials.</li><li>Your Channel mapping builds the JSON body.</li><li>Trigger Sync queues products; the worker sends them with retries and idempotency.</li><li>Jobs and Error Center show every product result.</li></ol></div>
|
||||
{connectionMessage && <div className="rounded-lg border border-border p-3 text-sm">{connectionMessage}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -843,12 +896,59 @@ export default function NewIntegration() {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-3">Sync Direction</label>
|
||||
<div className="rounded-xl border-2 border-primary bg-primary/5 p-4"><div className="text-sm font-semibold text-primary-dark">PIM → External System</div><div className="mt-1 text-xs text-muted-foreground">This implemented flow publishes PIM products outward. Import and bidirectional sync will appear only after their conflict and ownership rules exist.</div></div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ id: "pim_to_channel", label: "PIM → Channel", sub: "Publish product data to the external system", icon: "↑" },
|
||||
{ id: "channel_to_pim", label: "Channel → PIM", sub: "Import data from external system into PIM", icon: "↓" },
|
||||
{ id: "bidirectional", label: "Bidirectional", sub: "Sync data in both directions", icon: "↕" },
|
||||
].map((d) => {
|
||||
const isSelected = formik.values.syncDirection === d.id;
|
||||
return (
|
||||
<button
|
||||
key={d.id}
|
||||
type="button"
|
||||
onClick={() => formik.setFieldValue("syncDirection", d.id)}
|
||||
className={`flex flex-col items-center text-center p-4 rounded-xl border-2 transition-all gap-2 ${
|
||||
isSelected ? "border-primary bg-primary/5" : "border-border hover:border-border"
|
||||
}`}
|
||||
>
|
||||
<span className={`text-xl font-bold ${isSelected ? "text-primary" : "text-muted-foreground"}`}>{d.icon}</span>
|
||||
<div>
|
||||
<div className={`text-sm font-semibold ${isSelected ? "text-primary-dark" : "text-foreground"}`}>{d.label}</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-1">{d.sub}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-3">Sync Frequency</label>
|
||||
<div className="rounded-xl border-2 border-primary bg-primary/5 p-4"><div className="flex items-center gap-2 text-sm font-semibold text-primary-dark"><Settings2 className="h-4 w-4" />Manual trigger</div><div className="mt-1 text-xs text-muted-foreground">Use Trigger Sync from Channel Registry. Hourly, daily and realtime schedules are hidden until a scheduler is implemented.</div></div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{[
|
||||
{ id: "manual", label: "Manual", icon: Settings2 },
|
||||
{ id: "hourly", label: "Hourly", icon: Clock },
|
||||
{ id: "daily", label: "Daily", icon: Calendar },
|
||||
{ id: "realtime", label: "Realtime", icon: Wifi },
|
||||
].map((f) => {
|
||||
const Icon = f.icon;
|
||||
const isSelected = formik.values.syncFrequency === f.id;
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
onClick={() => formik.setFieldValue("syncFrequency", f.id)}
|
||||
className={`flex flex-col items-center text-center py-4 px-2 rounded-xl border-2 transition-all gap-2 ${
|
||||
isSelected ? "border-primary bg-primary/5" : "border-border hover:border-border"
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 ${isSelected ? "text-primary" : "text-muted-foreground"}`} />
|
||||
<span className={`text-sm font-medium ${isSelected ? "text-primary-dark" : "text-foreground"}`}>{f.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-xl p-4 space-y-4">
|
||||
@@ -895,9 +995,37 @@ export default function NewIntegration() {
|
||||
|
||||
{activeStep === 'advanced' && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-green-200 bg-green-50 p-5"><p className="font-medium text-green-900">Field mapping and transformations are available now</p><p className="mt-1 text-xs text-green-800">Configure them on the selected Channel. Saved mappings are already used by CSV export and syndication payload generation.</p><button type="button" onClick={() => navigate('/channels')} className="mt-3 text-sm font-semibold text-green-900 underline">Open Channel Registry</button></div>
|
||||
<div className="rounded-xl border border-border bg-surface p-5"><p className="font-medium">Localisation defaults — later</p><p className="mt-1 text-xs text-muted-foreground">Not required to verify Shopify credentials or publish a first basic product. Add locale, currency and Shopify Markets behavior afterward.</p></div>
|
||||
<div className="rounded-xl border border-border bg-surface p-5"><p className="font-medium">Category mapping — later</p><p className="mt-1 text-xs text-muted-foreground">Not required for the first product. Shopify collections and taxonomy mapping belong in the next publishing phase.</p></div>
|
||||
<div className="bg-surface rounded-xl border border-border shadow-sm overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-between px-6 py-4 text-left hover:bg-background transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Localisation & Defaults</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Configure default locale, currency, and timezone for this integration</p>
|
||||
</div>
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{[
|
||||
{ label: "Field Mapping", desc: "Map PIM attribute fields to external system field schemas" },
|
||||
{ label: "Category Mapping", desc: "Map PIM category taxonomy to external system categories" },
|
||||
{ label: "Transformation Rules", desc: "Define data transformation and enrichment rules during sync" },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="bg-surface rounded-xl border border-border shadow-sm p-5 flex items-center gap-4">
|
||||
<div className="w-9 h-9 bg-surface-muted rounded-lg flex items-center justify-center">
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-foreground">{item.label}</p>
|
||||
<span className="px-2 py-0.5 text-[10px] font-bold uppercase bg-amber-50 text-amber-600 border border-amber-200 rounded">Coming Soon</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{item.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -909,13 +1037,14 @@ export default function NewIntegration() {
|
||||
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm font-medium text-muted-foreground hover:bg-background transition-colors">Back</button>
|
||||
)}
|
||||
{activeIndex < STEPS.length - 1 && (
|
||||
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
|
||||
<button type="button" onClick={handleNextStep} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Integration Summary */}
|
||||
<IntegrationSummary
|
||||
disabled={isView}
|
||||
name={formik.values.name}
|
||||
channel={formik.values.channel}
|
||||
channelName={selectedChanName}
|
||||
|
||||
@@ -1,50 +1,57 @@
|
||||
import type {
|
||||
Integration,
|
||||
IntegrationCreateRequest,
|
||||
IntegrationUpdateRequest,
|
||||
TestConnectionResult,
|
||||
SyncJob,
|
||||
SyncItem
|
||||
} from '../types/integrations.types';
|
||||
import apiClient from '../../../api/axiosInstance';
|
||||
import type { Integration, IntegrationCreateRequest, IntegrationUpdateRequest, TestConnectionResult, SyncJob, SyncItem } from '../types/integrations.types';
|
||||
|
||||
type ApiResponse<T> = {
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data: T;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const integrationService = {
|
||||
getAll: async (params?: Record<string, any>): Promise<Integration[]> => {
|
||||
const res = await apiClient.get<ApiResponse<Integration[]>>('/api/v1/integrations', { params });
|
||||
export const integrationsService = {
|
||||
getAll: async (): Promise<Integration[]> => {
|
||||
const res = await apiClient.get<ApiResponse<Integration[]>>('/api/v1/integrations');
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data || []) as Integration[];
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Integration> => {
|
||||
const res = await apiClient.get<ApiResponse<Integration>>();
|
||||
getById: async (id: string): Promise<Integration | undefined> => {
|
||||
const res = await apiClient.get<ApiResponse<Integration>>(`/api/v1/integrations/${id}`);
|
||||
const raw: any = res.data;
|
||||
return (raw?.data || raw) as Integration;
|
||||
},
|
||||
|
||||
create: async (data: IntegrationCreateRequest): Promise<Integration> => {
|
||||
const res = await apiClient.post<ApiResponse<Integration>>('/api/v1/integrations', data);
|
||||
create: async (req: IntegrationCreateRequest): Promise<Integration> => {
|
||||
const res = await apiClient.post<ApiResponse<Integration>>('/api/v1/integrations', req);
|
||||
const raw: any = res.data;
|
||||
return (raw?.data || raw) as Integration;
|
||||
},
|
||||
|
||||
update: async (id: string, data: IntegrationUpdateRequest): Promise<Integration> => {
|
||||
const res = await apiClient.put<ApiResponse<Integration>>(, data);
|
||||
update: async (id: string, req: IntegrationUpdateRequest): Promise<Integration> => {
|
||||
const res = await apiClient.put<ApiResponse<Integration>>(`/api/v1/integrations/${id}`, req);
|
||||
const raw: any = res.data;
|
||||
return (raw?.data || raw) as Integration;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
const res = await apiClient.delete<ApiResponse<any>>();
|
||||
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/integrations/${id}`);
|
||||
return res.success || true;
|
||||
},
|
||||
|
||||
getCredentials: async (id: string): Promise<Record<string, string>> => {
|
||||
const res = await apiClient.get<ApiResponse<Record<string, string>>>();
|
||||
const res = await apiClient.get<ApiResponse<Record<string, string>>>(`/api/v1/integrations/${id}/credentials`);
|
||||
const raw: any = res.data;
|
||||
return (raw?.data || raw) as Record<string, string>;
|
||||
},
|
||||
|
||||
setCredentials: async (id: string, credentialType: string, secretValue: string, expiresAt?: string): Promise<any> => {
|
||||
const res = await apiClient.post<ApiResponse<any>>(, {
|
||||
const res = await apiClient.post<ApiResponse<any>>(`/api/v1/integrations/${id}/credentials`, {
|
||||
credential_type: credentialType,
|
||||
secret_value: secretValue,
|
||||
expires_at: expiresAt
|
||||
@@ -54,37 +61,37 @@ export const integrationService = {
|
||||
},
|
||||
|
||||
testConnection: async (id: string): Promise<TestConnectionResult> => {
|
||||
const res = await apiClient.post<ApiResponse<TestConnectionResult>>();
|
||||
const res = await apiClient.post<ApiResponse<TestConnectionResult>>(`/api/v1/integrations/${id}/test-connection`);
|
||||
const raw: any = res.data;
|
||||
return (raw?.data || raw) as TestConnectionResult;
|
||||
},
|
||||
|
||||
triggerSync: async (id: string, options?: { productId?: string; productIds?: string[] }): Promise<any> => {
|
||||
const res = await apiClient.post<ApiResponse<any>>(, options || {});
|
||||
const res = await apiClient.post<ApiResponse<any>>(`/api/v1/integrations/${id}/sync`, options || {});
|
||||
const raw: any = res.data;
|
||||
return raw?.data || raw;
|
||||
},
|
||||
|
||||
getSyncJobs: async (id: string): Promise<SyncJob[]> => {
|
||||
const res = await apiClient.get<ApiResponse<SyncJob[]>>();
|
||||
const res = await apiClient.get<ApiResponse<SyncJob[]>>(`/api/v1/integrations/${id}/jobs`);
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data || []) as SyncJob[];
|
||||
},
|
||||
|
||||
getAllSyncJobs: async (): Promise<SyncJob[]> => {
|
||||
const res = await apiClient.get<ApiResponse<SyncJob[]>>();
|
||||
const res = await apiClient.get<ApiResponse<SyncJob[]>>(`/api/v1/integrations/jobs/all`);
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data || []) as SyncJob[];
|
||||
},
|
||||
|
||||
getSyncItems: async (jobId: string): Promise<SyncItem[]> => {
|
||||
const res = await apiClient.get<ApiResponse<SyncItem[]>>();
|
||||
const res = await apiClient.get<ApiResponse<SyncItem[]>>(`/api/v1/integrations/jobs/${jobId}/items`);
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data || []) as SyncItem[];
|
||||
},
|
||||
|
||||
startShopifyOAuth: async (id: string): Promise<{ authorizationUrl: string; state: string }> => {
|
||||
const res = await apiClient.post<ApiResponse<any>>();
|
||||
const res = await apiClient.post<ApiResponse<any>>(`/api/v1/integrations/${id}/shopify/oauth/start`);
|
||||
const raw: any = res.data;
|
||||
return raw?.data || raw;
|
||||
}
|
||||
|
||||
@@ -2,15 +2,15 @@ export interface Integration {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
channel: string;
|
||||
integrationType?: string;
|
||||
channel: string; // e.g. shopify, amazon, custom_api
|
||||
integrationType?: string; // e.g. ecommerce, marketplace, erp
|
||||
environment?: string;
|
||||
status: string;
|
||||
health_status?: string;
|
||||
status: string; // active, inactive, pending, error
|
||||
health_status?: string; // healthy, degraded, error
|
||||
healthStatus?: string;
|
||||
sync_mode?: string;
|
||||
sync_mode?: string; // auto, manual, scheduled
|
||||
syncMode?: string;
|
||||
sync_frequency?: string;
|
||||
sync_frequency?: string; // realtime, hourly, daily
|
||||
syncFrequency?: string;
|
||||
last_synced_at?: string;
|
||||
lastSync?: string;
|
||||
@@ -18,55 +18,9 @@ export interface Integration {
|
||||
// E-commerce connection credentials & details
|
||||
storeUrl?: string;
|
||||
accessToken?: string;
|
||||
apiVersion?: string;
|
||||
webhookSecret?: string;
|
||||
shopIdentifier?: string;
|
||||
shopDomain?: string;
|
||||
|
||||
// Marketplace specific fields
|
||||
sellerId?: string;
|
||||
marketplaceId?: string;
|
||||
awsAccessKeyId?: string;
|
||||
awsSecretKey?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
|
||||
// ERP/WMS specific fields
|
||||
authMethod?: string;
|
||||
authToken?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
|
||||
// POS specific fields
|
||||
posTerminalId?: string;
|
||||
posStoreCode?: string;
|
||||
posApiKey?: string;
|
||||
posApiSecret?: string;
|
||||
|
||||
// Mobile App specific fields
|
||||
appId?: string;
|
||||
bundleIdentifier?: string;
|
||||
gatewayUrl?: string;
|
||||
|
||||
// Custom API specific fields
|
||||
customApiUrl?: string;
|
||||
customApiHeaderKey?: string;
|
||||
endpoint?: string;
|
||||
testEndpoint?: string;
|
||||
method?: string;
|
||||
customApiHeaderName?: string;
|
||||
customApiHeaderValue?: string;
|
||||
clearSecretFields?: string[];
|
||||
|
||||
// Sync settings
|
||||
syncDirection?: string;
|
||||
autoRetry?: boolean;
|
||||
retryAttempts?: number;
|
||||
|
||||
// Listing page read-only / metadata fields
|
||||
syncErrors?: number;
|
||||
published?: string | number;
|
||||
author?: string;
|
||||
createdAt?: string;
|
||||
created_at?: string;
|
||||
updatedAt?: string;
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Building2, Users, Package, Image, Activity } from "lucide-react";
|
||||
import { Building2, Users, Package, Image, ShieldCheck, Activity, Play, StopCircle } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { tenantService } from "../../tenants/services/tenant.service";
|
||||
import { useTenant } from "../../tenants/hooks/useTenant";
|
||||
import { notify } from "../../../services/toast";
|
||||
|
||||
export default function PlatformOverview() {
|
||||
const navigate = useNavigate();
|
||||
const { impersonateTenant, stopImpersonation } = useTenant();
|
||||
const [metrics, setMetrics] = useState<any>(null);
|
||||
const [tenants, setTenants] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [impersonatingTenantId, setImpersonatingTenantId] = useState<string | null>(
|
||||
localStorage.getItem("impersonatedTenantId")
|
||||
);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
@@ -33,21 +38,61 @@ export default function PlatformOverview() {
|
||||
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: "SaaS Dashboard" }]}
|
||||
items={[{ label: "Platform Operator" }, { label: "SaaS Control Center Overview" }]}
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Building2 className="w-4 h-4" />}
|
||||
onClick={() => navigate("/platform/tenants")}
|
||||
>
|
||||
Manage 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">
|
||||
@@ -95,12 +140,12 @@ export default function PlatformOverview() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent tenant health preview. Full lifecycle management lives in Tenants. */}
|
||||
{/* 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">Recent Tenant Health</h3>
|
||||
<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
|
||||
@@ -117,19 +162,20 @@ export default function PlatformOverview() {
|
||||
<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={6} className="px-6 py-8 text-center text-muted-foreground">Loading SaaS platform tenants...</td>
|
||||
<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={6} className="px-6 py-8 text-center text-muted-foreground">No tenants provisioned yet.</td>
|
||||
<td colSpan={7} className="px-6 py-8 text-center text-muted-foreground">No tenants provisioned yet.</td>
|
||||
</tr>
|
||||
) : (
|
||||
tenants.slice(0, 5).map((t) => (
|
||||
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>
|
||||
@@ -141,6 +187,18 @@ export default function PlatformOverview() {
|
||||
{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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Building2, Search, CheckCircle, XCircle, Copy, Check } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
@@ -12,6 +13,7 @@ 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("");
|
||||
@@ -81,17 +83,10 @@ export default function PlatformTenantsPage() {
|
||||
};
|
||||
|
||||
const handleStartImpersonate = async (tenantId: string) => {
|
||||
const tenant = (tenants || []).find((item: any) => String(item.id) === String(tenantId));
|
||||
const confirmed = window.confirm(
|
||||
`Enter Support Mode for ${tenant?.tenant_name || `Tenant #${tenantId}`}?\n\n` +
|
||||
'You will operate inside this tenant workspace. All support actions are tenant-scoped and audited.'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await impersonateTenant(tenantId);
|
||||
setImpersonatingTenantId(tenantId);
|
||||
window.location.assign("/products");
|
||||
navigate("/products");
|
||||
} catch (err) {
|
||||
// Handled in hook
|
||||
}
|
||||
@@ -174,8 +169,9 @@ export default function PlatformTenantsPage() {
|
||||
<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'
|
||||
}`}
|
||||
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" />}
|
||||
@@ -186,14 +182,14 @@ export default function PlatformTenantsPage() {
|
||||
onClick={() => { stopImpersonation(); setImpersonatingTenantId(null); }}
|
||||
className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-amber-500 text-white hover:bg-amber-600 transition-colors"
|
||||
>
|
||||
Exit Support
|
||||
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 Access
|
||||
Support Assist
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
@@ -269,7 +265,7 @@ export default function PlatformTenantsPage() {
|
||||
|
||||
<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>
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useAssetType } from '../../asset-types/hook/useAssetType';
|
||||
import { assetFamiliesService } from '../../asset-families/services/asset-families.service';
|
||||
import {
|
||||
Upload, Image as ImageIcon, Video, FileText, Trash2,
|
||||
Check, Loader2, Search, Info, Shield, ArrowUp, ArrowDown, Plus, Box
|
||||
Check, Loader2, Search, Info, Shield, ArrowUp, ArrowDown, Plus, Box,
|
||||
X, ChevronDown
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Loader } from '../../../components/customs/Loader';
|
||||
@@ -18,21 +19,43 @@ interface ProductAssetsTabProps {
|
||||
family: any;
|
||||
readOnly?: boolean;
|
||||
refreshProductData?: () => void;
|
||||
variants?: any[];
|
||||
initialAssets?: AssetMapping[];
|
||||
initialVariantAssets?: any[];
|
||||
onAssetsChange?: (assets: AssetMapping[], variantAssets: any[]) => void;
|
||||
}
|
||||
|
||||
export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
productId,
|
||||
family,
|
||||
readOnly,
|
||||
refreshProductData
|
||||
refreshProductData,
|
||||
variants: variantsProp = [],
|
||||
initialAssets = [],
|
||||
initialVariantAssets = [],
|
||||
onAssetsChange
|
||||
}) => {
|
||||
const [assignedAssets, setAssignedAssets] = useState<AssetMapping[]>([]);
|
||||
const [assignedVariantAssets, setAssignedVariantAssets] = useState<any[]>([]);
|
||||
const [variants, setVariants] = useState<any[]>([]);
|
||||
const [assignedAssets, setAssignedAssets] = useState<AssetMapping[]>(initialAssets);
|
||||
const [assignedVariantAssets, setAssignedVariantAssets] = useState<any[]>(initialVariantAssets);
|
||||
const [variants, setVariants] = useState<any[]>(variantsProp);
|
||||
const [selectedVariantId, setSelectedVariantId] = useState<string>('global');
|
||||
const [isBulkMode, setIsBulkMode] = useState(false);
|
||||
const [selectedVariantIds, setSelectedVariantIds] = useState<string[]>([]);
|
||||
|
||||
// Sync variants from prop
|
||||
useEffect(() => {
|
||||
if (variantsProp && variantsProp.length > 0) {
|
||||
setVariants(variantsProp);
|
||||
}
|
||||
}, [variantsProp]);
|
||||
|
||||
// Sync assets to parent
|
||||
useEffect(() => {
|
||||
if (onAssetsChange) {
|
||||
onAssetsChange(assignedAssets, assignedVariantAssets);
|
||||
}
|
||||
}, [assignedAssets, assignedVariantAssets, onAssetsChange]);
|
||||
|
||||
// Strict product isolation & configured variants filtering
|
||||
const configuredVariants = useMemo(() => {
|
||||
const productSpecific = variants.filter((v: any) => {
|
||||
@@ -100,7 +123,23 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
const { items: allAssetTypes, fetchItems: fetchAssetTypes } = useAssetType();
|
||||
const [selectedAssetTypeId, setSelectedAssetTypeId] = useState<string>('');
|
||||
const [allAssetFamilies, setAllAssetFamilies] = useState<any[]>([]);
|
||||
const [selectedAssetFamilyId, setSelectedAssetFamilyId] = useState<string>('');
|
||||
const [selectedAssetFamilyIds, setSelectedAssetFamilyIds] = useState<string[]>([]);
|
||||
const [isAssetFamilyDropdownOpen, setIsAssetFamilyDropdownOpen] = useState(false);
|
||||
const [assetFamilySearchQuery, setAssetFamilySearchQuery] = useState('');
|
||||
const assetFamilyDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown on click outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (assetFamilyDropdownRef.current && !assetFamilyDropdownRef.current.contains(event.target as Node)) {
|
||||
setIsAssetFamilyDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch asset types and families
|
||||
useEffect(() => {
|
||||
@@ -116,25 +155,42 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
fetchFamilies();
|
||||
}, [fetchAssetTypes]);
|
||||
|
||||
// Filter asset types based on selected asset family
|
||||
const filteredAssetTypes = useMemo(() => {
|
||||
if (!selectedAssetFamilyId) {
|
||||
return allAssetTypes;
|
||||
}
|
||||
const matchedFamily = allAssetFamilies.find(af => af.id === selectedAssetFamilyId);
|
||||
if (!matchedFamily) {
|
||||
return allAssetTypes;
|
||||
}
|
||||
const allowedTypeIds =
|
||||
Array.isArray(matchedFamily.assetTypeIds) && matchedFamily.assetTypeIds.length > 0
|
||||
? matchedFamily.assetTypeIds
|
||||
: (matchedFamily.assetTypes || [])
|
||||
.map((at: any) => at.id || at.assetTypeId)
|
||||
.filter(Boolean);
|
||||
// Filter asset families based on search query
|
||||
const filteredAssetFamilies = useMemo(() => {
|
||||
const activeFamilies = allAssetFamilies.filter(af => af && af.status === 'active');
|
||||
if (!assetFamilySearchQuery.trim()) return activeFamilies;
|
||||
const q = assetFamilySearchQuery.toLowerCase();
|
||||
return activeFamilies.filter(af =>
|
||||
af.name?.toLowerCase().includes(q) || (af.code && String(af.code).toLowerCase().includes(q))
|
||||
);
|
||||
}, [allAssetFamilies, assetFamilySearchQuery]);
|
||||
|
||||
const filtered = allAssetTypes.filter(at => allowedTypeIds.includes(at.id));
|
||||
// Filter asset types based on selected asset families (union of requirements)
|
||||
const filteredAssetTypes = useMemo(() => {
|
||||
if (selectedAssetFamilyIds.length === 0) {
|
||||
return allAssetTypes;
|
||||
}
|
||||
const allowedTypeIds = new Set<string>();
|
||||
selectedAssetFamilyIds.forEach(afId => {
|
||||
const matchedFamily = allAssetFamilies.find(af => String(af.id) === String(afId));
|
||||
if (matchedFamily) {
|
||||
const typeIds =
|
||||
Array.isArray(matchedFamily.assetTypeIds) && matchedFamily.assetTypeIds.length > 0
|
||||
? matchedFamily.assetTypeIds
|
||||
: (matchedFamily.assetTypes || [])
|
||||
.map((at: any) => at.id || at.assetTypeId)
|
||||
.filter(Boolean);
|
||||
typeIds.forEach((tId: string) => allowedTypeIds.add(String(tId)));
|
||||
}
|
||||
});
|
||||
|
||||
if (allowedTypeIds.size === 0) {
|
||||
return allAssetTypes;
|
||||
}
|
||||
|
||||
const filtered = allAssetTypes.filter(at => allowedTypeIds.has(String(at.id)));
|
||||
return filtered.length > 0 ? filtered : allAssetTypes;
|
||||
}, [allAssetTypes, allAssetFamilies, selectedAssetFamilyId]);
|
||||
}, [allAssetTypes, allAssetFamilies, selectedAssetFamilyIds]);
|
||||
|
||||
// Auto-select initial asset type if none selected or if selected is no longer allowed
|
||||
useEffect(() => {
|
||||
@@ -168,8 +224,8 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
Array.isArray(matchedFamily.assetTypeIds) && matchedFamily.assetTypeIds.length > 0
|
||||
? matchedFamily.assetTypeIds
|
||||
: (matchedFamily.assetTypes || [])
|
||||
.map((at: any) => at.id || at.assetTypeId)
|
||||
.filter(Boolean);
|
||||
.map((at: any) => at.id || at.assetTypeId)
|
||||
.filter(Boolean);
|
||||
ids.push(...allowedTypeIds);
|
||||
}
|
||||
});
|
||||
@@ -232,9 +288,6 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
// Load product assets
|
||||
const loadProductAssets = async () => {
|
||||
if (!productId || productId === 'new' || productId === 'null' || productId === 'undefined') {
|
||||
setAssignedAssets([]);
|
||||
setVariants([]);
|
||||
setAssignedVariantAssets([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
@@ -267,7 +320,9 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
setSelectedVariantIds([]);
|
||||
setIsBulkMode(false);
|
||||
setScopeFilter('all');
|
||||
loadProductAssets();
|
||||
if (productId) {
|
||||
loadProductAssets();
|
||||
}
|
||||
}, [productId]);
|
||||
|
||||
// Load general assets library for selection modal
|
||||
@@ -289,18 +344,6 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
}
|
||||
}, [showPicker]);
|
||||
|
||||
if (!productId) {
|
||||
return (
|
||||
<div className="p-8 text-center bg-surface rounded-xl border border-border shadow-sm">
|
||||
<Info className="w-10 h-10 text-primary mx-auto mb-3 animate-bounce" />
|
||||
<h3 className="font-semibold text-foreground mb-1">Save Product to Upload Assets</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||
Please fill in the required fields in the General step and click **Save** first to create the product, then you can assign assets.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle multiple files upload sequentially
|
||||
const handleMultipleFilesUpload = async (files: FileList | File[]) => {
|
||||
if (!selectedAssetType) {
|
||||
@@ -368,26 +411,69 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
|
||||
// Map this asset to current product or variant(s)
|
||||
if (isBulkMode && selectedVariantIds.length > 0) {
|
||||
await assetsService.bulkAssignVariantAsset(productId, {
|
||||
asset_id: newAsset.id,
|
||||
role,
|
||||
variant_ids: selectedVariantIds,
|
||||
is_primary: false
|
||||
});
|
||||
if (productId) {
|
||||
await assetsService.bulkAssignVariantAsset(productId, {
|
||||
asset_id: newAsset.id,
|
||||
role,
|
||||
variant_ids: selectedVariantIds,
|
||||
is_primary: false
|
||||
});
|
||||
} else {
|
||||
const newVarMappings = selectedVariantIds.map((vId, idx) => ({
|
||||
id: `temp-vmap-${Date.now()}-${idx}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
variantId: vId,
|
||||
variant_id: vId,
|
||||
asset_id: newAsset.id,
|
||||
role,
|
||||
is_primary: false,
|
||||
display_order: assignedVariantAssets.filter(m => m.variantId === vId).length,
|
||||
asset: newAsset,
|
||||
variant: configuredVariants.find(v => v.id === vId)
|
||||
}));
|
||||
setAssignedVariantAssets(prev => [...prev, ...newVarMappings]);
|
||||
}
|
||||
} else if (selectedVariantId !== 'global') {
|
||||
await assetsService.assignVariantAsset(selectedVariantId, {
|
||||
asset_id: newAsset.id,
|
||||
role,
|
||||
is_primary: !assignedVariantAssets.some(m => m.variantId === selectedVariantId && m.is_primary) && successCount === 0,
|
||||
display_order: assignedVariantAssets.filter(m => m.variantId === selectedVariantId).length + successCount
|
||||
});
|
||||
if (productId && !selectedVariantId.startsWith('temp-')) {
|
||||
await assetsService.assignVariantAsset(selectedVariantId, {
|
||||
asset_id: newAsset.id,
|
||||
role,
|
||||
is_primary: !assignedVariantAssets.some(m => m.variantId === selectedVariantId && m.is_primary) && successCount === 0,
|
||||
display_order: assignedVariantAssets.filter(m => m.variantId === selectedVariantId).length + successCount
|
||||
});
|
||||
} else {
|
||||
const newVarMapping = {
|
||||
id: `temp-vmap-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
variantId: selectedVariantId,
|
||||
variant_id: selectedVariantId,
|
||||
asset_id: newAsset.id,
|
||||
role,
|
||||
is_primary: !assignedVariantAssets.some(m => m.variantId === selectedVariantId && m.is_primary) && successCount === 0,
|
||||
display_order: assignedVariantAssets.filter(m => m.variantId === selectedVariantId).length + successCount,
|
||||
asset: newAsset,
|
||||
variant: configuredVariants.find(v => v.id === selectedVariantId)
|
||||
};
|
||||
setAssignedVariantAssets(prev => [...prev, newVarMapping]);
|
||||
}
|
||||
} else {
|
||||
await assetsService.assignProductAsset(productId, {
|
||||
asset_id: newAsset.id,
|
||||
role,
|
||||
is_primary: assignedAssets.length === 0 && successCount === 0,
|
||||
display_order: assignedAssets.length + successCount
|
||||
});
|
||||
if (productId) {
|
||||
await assetsService.assignProductAsset(productId, {
|
||||
asset_id: newAsset.id,
|
||||
role,
|
||||
is_primary: assignedAssets.length === 0 && successCount === 0,
|
||||
display_order: assignedAssets.length + successCount
|
||||
});
|
||||
} else {
|
||||
const newMapping: AssetMapping = {
|
||||
id: `temp-map-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
product_id: '',
|
||||
asset_id: newAsset.id,
|
||||
role,
|
||||
is_primary: assignedAssets.length === 0 && successCount === 0,
|
||||
display_order: assignedAssets.length + successCount,
|
||||
asset: newAsset
|
||||
};
|
||||
setAssignedAssets(prev => [...prev, newMapping]);
|
||||
}
|
||||
}
|
||||
|
||||
successCount++;
|
||||
@@ -399,7 +485,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(`Successfully uploaded and assigned ${successCount} assets.`);
|
||||
loadProductAssets();
|
||||
if (productId) loadProductAssets();
|
||||
refreshProductData?.();
|
||||
}
|
||||
setUploading(false);
|
||||
@@ -475,30 +561,73 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
}
|
||||
|
||||
if (isBulkMode && selectedVariantIds.length > 0) {
|
||||
await assetsService.bulkAssignVariantAsset(productId, {
|
||||
asset_id: asset.id,
|
||||
role,
|
||||
variant_ids: selectedVariantIds,
|
||||
is_primary: false
|
||||
});
|
||||
if (productId) {
|
||||
await assetsService.bulkAssignVariantAsset(productId, {
|
||||
asset_id: asset.id,
|
||||
role,
|
||||
variant_ids: selectedVariantIds,
|
||||
is_primary: false
|
||||
});
|
||||
} else {
|
||||
const newVarMappings = selectedVariantIds.map((vId, idx) => ({
|
||||
id: `temp-vmap-${Date.now()}-${idx}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
variantId: vId,
|
||||
variant_id: vId,
|
||||
asset_id: asset.id,
|
||||
role,
|
||||
is_primary: false,
|
||||
display_order: assignedVariantAssets.filter(m => m.variantId === vId).length,
|
||||
asset,
|
||||
variant: configuredVariants.find(v => v.id === vId)
|
||||
}));
|
||||
setAssignedVariantAssets(prev => [...prev, ...newVarMappings]);
|
||||
}
|
||||
} else if (selectedVariantId !== 'global') {
|
||||
await assetsService.assignVariantAsset(selectedVariantId, {
|
||||
asset_id: asset.id,
|
||||
role,
|
||||
is_primary: !assignedVariantAssets.some(m => m.variantId === selectedVariantId && m.is_primary),
|
||||
display_order: assignedVariantAssets.filter(m => m.variantId === selectedVariantId).length
|
||||
});
|
||||
if (productId && !selectedVariantId.startsWith('temp-')) {
|
||||
await assetsService.assignVariantAsset(selectedVariantId, {
|
||||
asset_id: asset.id,
|
||||
role,
|
||||
is_primary: !assignedVariantAssets.some(m => m.variantId === selectedVariantId && m.is_primary),
|
||||
display_order: assignedVariantAssets.filter(m => m.variantId === selectedVariantId).length
|
||||
});
|
||||
} else {
|
||||
const newVarMapping = {
|
||||
id: `temp-vmap-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
variantId: selectedVariantId,
|
||||
variant_id: selectedVariantId,
|
||||
asset_id: asset.id,
|
||||
role,
|
||||
is_primary: !assignedVariantAssets.some(m => m.variantId === selectedVariantId && m.is_primary),
|
||||
display_order: assignedVariantAssets.filter(m => m.variantId === selectedVariantId).length,
|
||||
asset,
|
||||
variant: configuredVariants.find(v => v.id === selectedVariantId)
|
||||
};
|
||||
setAssignedVariantAssets(prev => [...prev, newVarMapping]);
|
||||
}
|
||||
} else {
|
||||
await assetsService.assignProductAsset(productId, {
|
||||
asset_id: asset.id,
|
||||
role,
|
||||
is_primary: assignedAssets.length === 0,
|
||||
display_order: assignedAssets.length
|
||||
});
|
||||
if (productId) {
|
||||
await assetsService.assignProductAsset(productId, {
|
||||
asset_id: asset.id,
|
||||
role,
|
||||
is_primary: assignedAssets.length === 0,
|
||||
display_order: assignedAssets.length
|
||||
});
|
||||
} else {
|
||||
const newMapping: AssetMapping = {
|
||||
id: `temp-map-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
product_id: '',
|
||||
asset_id: asset.id,
|
||||
role,
|
||||
is_primary: assignedAssets.length === 0,
|
||||
display_order: assignedAssets.length,
|
||||
asset
|
||||
};
|
||||
setAssignedAssets(prev => [...prev, newMapping]);
|
||||
}
|
||||
}
|
||||
|
||||
toast.success('Asset assigned from library');
|
||||
loadProductAssets();
|
||||
if (productId) loadProductAssets();
|
||||
refreshProductData?.();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Failed to assign asset');
|
||||
@@ -510,30 +639,40 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
if (!window.confirm('Are you sure you want to unassign this asset?')) return;
|
||||
try {
|
||||
if (variantId) {
|
||||
await assetsService.unassignVariantAsset(variantId, assetId);
|
||||
if (productId && !variantId.startsWith('temp-') && !assetId.startsWith('temp-')) {
|
||||
await assetsService.unassignVariantAsset(variantId, assetId);
|
||||
}
|
||||
setAssignedVariantAssets(prev => prev.filter(m => !((m.variantId === variantId || m.variant_id === variantId) && (m.asset_id === assetId || m.id === assetId))));
|
||||
} else {
|
||||
await assetsService.unassignProductAsset(productId, assetId);
|
||||
if (productId && !assetId.startsWith('temp-')) {
|
||||
await assetsService.unassignProductAsset(productId, assetId);
|
||||
}
|
||||
setAssignedAssets(prev => prev.filter(m => m.asset_id !== assetId && m.id !== assetId));
|
||||
}
|
||||
toast.success('Asset unassigned');
|
||||
loadProductAssets();
|
||||
if (productId) loadProductAssets();
|
||||
refreshProductData?.();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Failed to unassign asset');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Set selected asset as the primary display image
|
||||
const handleSetPrimary = async (assetId: string, variantId?: string) => {
|
||||
try {
|
||||
if (variantId) {
|
||||
await assetsService.updateVariantAsset(variantId, assetId, { is_primary: true });
|
||||
if (productId && !variantId.startsWith('temp-') && !assetId.startsWith('temp-')) {
|
||||
await assetsService.updateVariantAsset(variantId, assetId, { is_primary: true });
|
||||
}
|
||||
setAssignedVariantAssets(prev => prev.map(m => (m.variantId === variantId || m.variant_id === variantId) ? { ...m, is_primary: m.asset_id === assetId || m.id === assetId } : m));
|
||||
} else {
|
||||
await assetsService.updateProductAsset(productId, assetId, { is_primary: true });
|
||||
if (productId && !assetId.startsWith('temp-')) {
|
||||
await assetsService.updateProductAsset(productId, assetId, { is_primary: true });
|
||||
}
|
||||
setAssignedAssets(prev => prev.map(m => ({ ...m, is_primary: m.asset_id === assetId || m.id === assetId })));
|
||||
}
|
||||
toast.success('Primary image updated');
|
||||
loadProductAssets();
|
||||
if (productId) loadProductAssets();
|
||||
refreshProductData?.();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Failed to set primary image');
|
||||
@@ -551,15 +690,17 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
items[index] = items[targetIndex];
|
||||
items[targetIndex] = temp;
|
||||
|
||||
// Bulk save display order updates
|
||||
try {
|
||||
await Promise.all([
|
||||
assetsService.updateProductAsset(productId, items[index].asset_id, { display_order: index }),
|
||||
assetsService.updateProductAsset(productId, items[targetIndex].asset_id, { display_order: targetIndex })
|
||||
]);
|
||||
setAssignedAssets(items);
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Failed to reorder assets');
|
||||
setAssignedAssets(items);
|
||||
|
||||
if (productId && !items[index].asset_id?.startsWith('temp-') && !items[targetIndex].asset_id?.startsWith('temp-')) {
|
||||
try {
|
||||
await Promise.all([
|
||||
assetsService.updateProductAsset(productId, items[index].asset_id, { display_order: index }),
|
||||
assetsService.updateProductAsset(productId, items[targetIndex].asset_id, { display_order: targetIndex })
|
||||
]);
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Failed to reorder assets');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -591,8 +732,8 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
<span
|
||||
key={idx}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold border ${isSatisfied
|
||||
? 'bg-success/10 text-success border-success/20'
|
||||
: 'bg-warning/10 text-warning border-warning/20'
|
||||
? 'bg-success/10 text-success border-success/20'
|
||||
: 'bg-warning/10 text-warning border-warning/20'
|
||||
}`}
|
||||
>
|
||||
{isSatisfied ? <Check className="w-3 h-3 text-success" /> : <span className="w-1.5 h-1.5 rounded-full bg-warning" />}
|
||||
@@ -608,23 +749,119 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
{!readOnly && (
|
||||
<div className="bg-surface border border-border rounded-xl p-5 shadow-2xs space-y-4">
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{/* Asset Family Selector */}
|
||||
{/* Asset Family Multi-Selector */}
|
||||
<div className="flex-1 min-w-[240px]">
|
||||
<label className="text-xs font-bold text-foreground block mb-2">Asset Family</label>
|
||||
<div className="relative">
|
||||
<Select
|
||||
id="asset-family-select"
|
||||
value={selectedAssetFamilyId}
|
||||
onChange={(e) => setSelectedAssetFamilyId(e.target.value)}
|
||||
placeholder="Select Asset Family..."
|
||||
className="w-full text-xs font-semibold"
|
||||
<div ref={assetFamilyDropdownRef} className="relative">
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!readOnly) {
|
||||
setIsAssetFamilyDropdownOpen(!isAssetFamilyDropdownOpen);
|
||||
}
|
||||
}}
|
||||
className={`w-full border rounded-lg px-3 py-2 text-xs font-semibold flex items-center justify-between bg-surface ${
|
||||
isAssetFamilyDropdownOpen ? 'border-primary ring-2 ring-primary/20' : 'border-border'
|
||||
} ${readOnly ? 'cursor-not-allowed opacity-75' : 'cursor-pointer hover:border-primary/30'}`}
|
||||
>
|
||||
{allAssetFamilies.filter(af => af && af.status === 'active').map((af) => (
|
||||
<option key={af.id} value={af.id}>
|
||||
{af.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{(() => {
|
||||
const selectedObjs = allAssetFamilies.filter(af => selectedAssetFamilyIds.includes(String(af.id)));
|
||||
if (selectedObjs.length === 0) {
|
||||
return <span className="text-muted-foreground font-normal">Select Asset Family...</span>;
|
||||
}
|
||||
if (selectedObjs.length === 1) {
|
||||
return (
|
||||
<span className="text-foreground font-semibold truncate">
|
||||
{selectedObjs[0].name} {selectedObjs[0].code ? `(${selectedObjs[0].code})` : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="text-foreground font-semibold truncate">
|
||||
{selectedObjs.length} Asset Families selected ({selectedObjs.map(af => af.name).join(', ')})
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
<div className="flex items-center gap-1.5 shrink-0 ml-2">
|
||||
{selectedAssetFamilyIds.length > 0 && !readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedAssetFamilyIds([]);
|
||||
}}
|
||||
className="p-0.5 hover:bg-background rounded-full text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
title="Clear all selected asset families"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<ChevronDown className={`w-4 h-4 text-muted-foreground transition-transform duration-200 ${isAssetFamilyDropdownOpen ? 'rotate-180 text-primary' : ''}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAssetFamilyDropdownOpen && (
|
||||
<div className="absolute z-50 mt-1.5 w-full bg-surface border border-border rounded-xl shadow-xl overflow-hidden flex flex-col max-h-60 animate-in fade-in zoom-in-95 duration-100 font-sans">
|
||||
<div className="p-2 border-b border-border bg-surface-muted flex items-center gap-2">
|
||||
<Search className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={assetFamilySearchQuery}
|
||||
onChange={(e) => setAssetFamilySearchQuery(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="Search by name or code..."
|
||||
autoFocus
|
||||
className="w-full px-2 py-1 text-xs border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary bg-background text-foreground font-sans"
|
||||
/>
|
||||
{assetFamilySearchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAssetFamilySearchQuery('');
|
||||
}}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-1 overflow-y-auto flex-1 space-y-0.5">
|
||||
{filteredAssetFamilies.length > 0 ? (
|
||||
filteredAssetFamilies.map((af: any) => {
|
||||
const afId = String(af.id);
|
||||
const isSelected = selectedAssetFamilyIds.includes(afId);
|
||||
return (
|
||||
<div
|
||||
key={af.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isSelected) {
|
||||
setSelectedAssetFamilyIds(prev => prev.filter(id => id !== afId));
|
||||
} else {
|
||||
setSelectedAssetFamilyIds(prev => [...prev, afId]);
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-2 rounded-lg text-xs cursor-pointer transition-colors flex items-center justify-between select-none ${
|
||||
isSelected ? 'bg-primary/10 text-primary font-bold' : 'hover:bg-background text-foreground'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1 mr-2">
|
||||
<div className="font-semibold truncate">{af.name}</div>
|
||||
{af.code && <div className="text-[10px] text-muted-foreground font-mono">{af.code}</div>}
|
||||
</div>
|
||||
{isSelected && <Check className="w-4 h-4 text-primary shrink-0" />}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="p-3 text-center text-xs text-muted-foreground">
|
||||
No asset families found matching "{assetFamilySearchQuery}".
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -636,9 +873,9 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
id="asset-type-select"
|
||||
value={selectedAssetTypeId}
|
||||
onChange={(e) => setSelectedAssetTypeId(e.target.value)}
|
||||
placeholder={selectedAssetFamilyId && filteredAssetTypes.length === 0 ? "No asset types available for this family" : "Select Asset Type classification..."}
|
||||
placeholder={selectedAssetFamilyIds.length > 0 && filteredAssetTypes.length === 0 ? "No asset types available for selected families" : "Select Asset Type classification..."}
|
||||
className="w-full text-xs font-semibold"
|
||||
disabled={!!(selectedAssetFamilyId && filteredAssetTypes.length === 0)}
|
||||
disabled={!!(selectedAssetFamilyIds.length > 0 && filteredAssetTypes.length === 0)}
|
||||
>
|
||||
{filteredAssetTypes.filter(at => at && at.status === 'active').map((at) => {
|
||||
const isRequired = isAssetTypeRequiredByFamily(at.code);
|
||||
@@ -734,7 +971,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
return false;
|
||||
})
|
||||
.map(v => v.id);
|
||||
|
||||
|
||||
const isAllSelected = matchingIds.every(id => selectedVariantIds.includes(id));
|
||||
|
||||
return (
|
||||
@@ -748,11 +985,10 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
setSelectedVariantIds(prev => Array.from(new Set([...prev, ...matchingIds])));
|
||||
}
|
||||
}}
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-semibold border transition-colors cursor-pointer ${
|
||||
isAllSelected
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-semibold border transition-colors cursor-pointer ${isAllSelected
|
||||
? 'bg-primary text-white border-primary'
|
||||
: 'bg-surface hover:bg-background text-foreground border-border'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{val}
|
||||
</button>
|
||||
@@ -815,7 +1051,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
<div>
|
||||
<span className="font-bold text-foreground">Max File Size: </span>
|
||||
<span className="font-semibold text-muted-foreground">
|
||||
{maxFileSize
|
||||
{maxFileSize
|
||||
? `${Math.round(maxFileSize / (1024 * 1024))} MB`
|
||||
: '10 MB'}
|
||||
</span>
|
||||
@@ -845,9 +1081,8 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
}
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
className={`md:col-span-2 border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all flex flex-col items-center justify-center ${
|
||||
dragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/20 hover:bg-background/50 bg-surface'
|
||||
} ${isBulkMode && selectedVariantIds.length === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
className={`md:col-span-2 border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all flex flex-col items-center justify-center ${dragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/20 hover:bg-background/50 bg-surface'
|
||||
} ${isBulkMode && selectedVariantIds.length === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
@@ -957,22 +1192,20 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScopeFilter('all')}
|
||||
className={`px-2.5 py-1 rounded-full font-semibold transition-colors shrink-0 ${
|
||||
scopeFilter === 'all'
|
||||
className={`px-2.5 py-1 rounded-full font-semibold transition-colors shrink-0 ${scopeFilter === 'all'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface hover:bg-background border border-border text-muted-foreground'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
All Assets ({combinedAssets.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScopeFilter('global')}
|
||||
className={`px-2.5 py-1 rounded-full font-semibold transition-colors shrink-0 ${
|
||||
scopeFilter === 'global'
|
||||
className={`px-2.5 py-1 rounded-full font-semibold transition-colors shrink-0 ${scopeFilter === 'global'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface hover:bg-background border border-border text-muted-foreground'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
Global ({assignedAssets.length})
|
||||
</button>
|
||||
@@ -985,11 +1218,10 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
key={v.id}
|
||||
type="button"
|
||||
onClick={() => setScopeFilter(v.id)}
|
||||
className={`px-2.5 py-1 rounded-full font-semibold transition-colors shrink-0 ${
|
||||
isSelected
|
||||
className={`px-2.5 py-1 rounded-full font-semibold transition-colors shrink-0 ${isSelected
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface hover:bg-background border border-border text-muted-foreground'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
Variant: {v.name.split(' - ')[1] || v.name} ({count})
|
||||
</button>
|
||||
@@ -1081,11 +1313,10 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-primary/10 text-primary border border-primary/20 uppercase">
|
||||
{mapping.role ? mapping.role.replace('_', ' ') : 'HERO IMAGE'}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-bold border uppercase ${
|
||||
mapping.isVariant
|
||||
? 'bg-purple-50 text-purple-700 border-purple-100'
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-bold border uppercase ${mapping.isVariant
|
||||
? 'bg-purple-50 text-purple-700 border-purple-100'
|
||||
: 'bg-slate-50 text-slate-700 border-slate-100'
|
||||
}`}>
|
||||
}`}>
|
||||
{mapping.scopeLabel}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1208,8 +1439,8 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
disabled={isAssigned}
|
||||
onClick={() => handleAssignFromLibrary(asset)}
|
||||
className={`text-xs font-semibold px-3 py-1.5 rounded-lg border transition-all ${isAssigned
|
||||
? 'bg-background text-muted-foreground border-border cursor-not-allowed'
|
||||
: 'bg-primary hover:bg-primary-hover text-white border-transparent'
|
||||
? 'bg-background text-muted-foreground border-border cursor-not-allowed'
|
||||
: 'bg-primary hover:bg-primary-hover text-white border-transparent'
|
||||
}`}
|
||||
>
|
||||
{isAssigned ? 'Assigned' : 'Assign'}
|
||||
|
||||
@@ -17,6 +17,8 @@ interface VariantsTabProps {
|
||||
readOnly?: boolean;
|
||||
productAttributes?: Record<string, any>;
|
||||
availableAttributes?: any[];
|
||||
onVariantsChange?: (variants: Variant[]) => void;
|
||||
initialVariants?: Variant[];
|
||||
}
|
||||
|
||||
export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
@@ -26,10 +28,13 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
family,
|
||||
readOnly,
|
||||
productAttributes = {},
|
||||
availableAttributes = []
|
||||
availableAttributes = [],
|
||||
onVariantsChange,
|
||||
initialVariants = []
|
||||
}) => {
|
||||
const {
|
||||
variants,
|
||||
setVariants,
|
||||
loading,
|
||||
generating,
|
||||
fetchByProduct,
|
||||
@@ -38,7 +43,14 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
archiveVariant,
|
||||
generateBatch,
|
||||
bulkUpdate
|
||||
} = useVariant();
|
||||
} = useVariant(initialVariants);
|
||||
|
||||
// Sync to parent when variants change
|
||||
useEffect(() => {
|
||||
if (onVariantsChange) {
|
||||
onVariantsChange(variants);
|
||||
}
|
||||
}, [variants, onVariantsChange]);
|
||||
|
||||
const [viewLayout, setViewLayout] = useState<'list' | 'matrix'>('matrix');
|
||||
const [showGenerator, setShowGenerator] = useState(false);
|
||||
@@ -49,7 +61,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
|
||||
// Filter attributes that are marked as variant eligible OR are of eligible types (including type 'color')
|
||||
const selectableAttributes = useMemo(() => {
|
||||
return availableAttributes.filter(attr =>
|
||||
return availableAttributes.filter(attr =>
|
||||
attr.is_variant_eligible === true ||
|
||||
attr.isVariantEligible === true ||
|
||||
['select', 'enumeration', 'swatch', 'multiselect', 'color'].includes(attr.type || '')
|
||||
@@ -136,12 +148,12 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
if (!selectedAttrId) return;
|
||||
const attr = selectableAttributes.find(a => a.id === selectedAttrId);
|
||||
if (!attr) return;
|
||||
|
||||
|
||||
if (localAxes.some(la => la.code === attr.code)) {
|
||||
notify.error(`Axis with code "${attr.code}" is already added.`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const newAxis: VariantAxis = {
|
||||
...attr,
|
||||
id: attr.id,
|
||||
@@ -150,7 +162,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
type: attr.type || 'select',
|
||||
optionsList: attr.optionsList || []
|
||||
};
|
||||
|
||||
|
||||
setLocalAxes(prev => [...prev, newAxis]);
|
||||
setSelectedAttrId('');
|
||||
notify.success(`Added axis: ${attr.name}`);
|
||||
@@ -159,7 +171,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
const handleAddCustomAxis = () => {
|
||||
const name = customAxisName.trim();
|
||||
let code = customAxisCode.trim().toLowerCase().replace(/[^a-z0-9]/g, '_');
|
||||
|
||||
|
||||
if (!name) {
|
||||
notify.error('Please enter a name for the custom axis.');
|
||||
return;
|
||||
@@ -167,12 +179,12 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
if (!code) {
|
||||
code = name.toLowerCase().replace(/[^a-z0-9]/g, '_');
|
||||
}
|
||||
|
||||
|
||||
if (localAxes.some(la => la.code === code)) {
|
||||
notify.error(`Axis with code "${code}" is already added.`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const newAxis: VariantAxis = {
|
||||
id: `custom-${Date.now()}`,
|
||||
code,
|
||||
@@ -180,7 +192,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
type: 'select',
|
||||
optionsList: []
|
||||
};
|
||||
|
||||
|
||||
setLocalAxes(prev => [...prev, newAxis]);
|
||||
setCustomAxisName('');
|
||||
setCustomAxisCode('');
|
||||
@@ -195,7 +207,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
const initialSelectedValues = useMemo(() => {
|
||||
const map: Record<string, string[]> = {};
|
||||
if (!productAttributes) return map;
|
||||
|
||||
|
||||
localAxes.forEach(axis => {
|
||||
const val = productAttributes[axis.code];
|
||||
if (val !== undefined && val !== null && val !== '') {
|
||||
@@ -211,13 +223,35 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
return map;
|
||||
}, [localAxes, productAttributes]);
|
||||
|
||||
// Filter out unconfigured simple master variants (0 attributes) and ensure strict parent productId matching
|
||||
// Helper: cartesian product of axes
|
||||
const cartesian = (axes: { code: string; name: string; values: string[] }[]): Array<Array<{ code: string; value: string }>> => {
|
||||
if (!axes || axes.length === 0) return [];
|
||||
return axes.reduce<Array<Array<{ code: string; value: string }>>>((acc, axis) => {
|
||||
if (!axis.values || axis.values.length === 0) return acc;
|
||||
if (acc.length === 0) return axis.values.map(v => [{ code: axis.code, value: v }]);
|
||||
return acc.flatMap(combo => axis.values.map(v => [...combo, { code: axis.code, value: v }]));
|
||||
}, []);
|
||||
};
|
||||
|
||||
// Helper: build SKU from template
|
||||
const buildSku = (template: string | undefined, pSku: string, combo: Array<{ code: string; value: string }>): string => {
|
||||
let sku = template || '{PARENT_SKU}-{COMBO}';
|
||||
sku = sku.replace('{PARENT_SKU}', pSku || 'SKU');
|
||||
const comboStr = combo.map(c => c.value.replace(/\s+/g, '')).join('-');
|
||||
sku = sku.replace('{COMBO}', comboStr);
|
||||
for (const { code, value } of combo) {
|
||||
sku = sku.replace(new RegExp(`\\{${code}\\}`, 'gi'), value.replace(/\s+/g, ''));
|
||||
}
|
||||
return sku.toUpperCase();
|
||||
};
|
||||
|
||||
// Filter out unconfigured simple master variants (0 attributes) and ensure strict parent productId matching when productId is present
|
||||
const configuredVariants = useMemo(() => {
|
||||
if (!Array.isArray(variants) || !productId) return [];
|
||||
if (!Array.isArray(variants)) return [];
|
||||
return variants.filter(v => {
|
||||
if (!v) return false;
|
||||
const vParentId = v.parentProductId || (v as any).product_id || (v as any).productId;
|
||||
if (vParentId && vParentId !== productId) return false;
|
||||
if (productId && vParentId && vParentId !== productId) return false;
|
||||
return v.attributes && typeof v.attributes === 'object' && Object.keys(v.attributes).length > 0;
|
||||
});
|
||||
}, [variants, productId]);
|
||||
@@ -235,7 +269,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
}
|
||||
return (localAxes || []).map(a => a.code);
|
||||
}, [configuredVariants, localAxes]);
|
||||
|
||||
|
||||
const axesNames = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
(localAxes || []).forEach(a => {
|
||||
@@ -257,19 +291,6 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// If parent product has not been created/saved yet
|
||||
if (!productId) {
|
||||
return (
|
||||
<div className="p-8 text-center bg-surface rounded-xl border border-border shadow-sm">
|
||||
<Info className="w-10 h-10 text-primary mx-auto mb-3 animate-bounce" />
|
||||
<h3 className="font-semibold text-foreground mb-1">Save Product to Configure Variants</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||
You must create and save the basic product information first before you can configure and generate variants. Please fill in the required fields in the General step and click **Create Draft** on the header bar.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleGenerate = async (selected: Record<string, string[]>, skuTemplate: string) => {
|
||||
const formattedAxes = Object.entries(selected).map(([code, values]) => {
|
||||
const axisInfo = localAxes.find(a => a.code === code);
|
||||
@@ -280,15 +301,82 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await generateBatch({
|
||||
productId,
|
||||
axes: formattedAxes,
|
||||
skuTemplate
|
||||
});
|
||||
setShowGenerator(false);
|
||||
} catch (err) {
|
||||
// handled in hook
|
||||
if (productId) {
|
||||
try {
|
||||
await generateBatch({
|
||||
productId,
|
||||
axes: formattedAxes,
|
||||
skuTemplate
|
||||
});
|
||||
setShowGenerator(false);
|
||||
} catch (err) {
|
||||
// handled in hook
|
||||
}
|
||||
} else {
|
||||
// Local client-side generation before product is persisted
|
||||
const combinations = cartesian(formattedAxes);
|
||||
if (combinations.length === 0) {
|
||||
notify.error('No combinations could be generated from the selected values');
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect duplicates
|
||||
const existingSignatures = new Set(
|
||||
(variants || []).map(v => {
|
||||
return JSON.stringify(Object.fromEntries(Object.entries(v.attributes || {}).sort()));
|
||||
})
|
||||
);
|
||||
|
||||
const parentSkuVal = parentSku || 'SKU';
|
||||
const newVariants: Variant[] = [];
|
||||
let createdCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const combo of combinations) {
|
||||
const attrMap: Record<string, string> = {};
|
||||
for (const { code, value } of combo) {
|
||||
attrMap[code] = value;
|
||||
}
|
||||
const sig = JSON.stringify(Object.fromEntries(Object.entries(attrMap).sort()));
|
||||
|
||||
if (existingSignatures.has(sig)) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const generatedSku = buildSku(skuTemplate, parentSkuVal, combo);
|
||||
const variantName = combo.map(c => c.value).join(' / ');
|
||||
|
||||
const newVariant: Variant = {
|
||||
id: `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
sku: generatedSku,
|
||||
name: variantName,
|
||||
parentProductId: '',
|
||||
attributes: attrMap,
|
||||
status: 'draft',
|
||||
price: 0,
|
||||
costPrice: 0,
|
||||
currency: 'USD',
|
||||
stock: 0,
|
||||
availableStock: 0,
|
||||
reservedStock: 0,
|
||||
safetyStock: 0,
|
||||
images: []
|
||||
};
|
||||
|
||||
existingSignatures.add(sig);
|
||||
newVariants.push(newVariant);
|
||||
createdCount++;
|
||||
}
|
||||
|
||||
if (createdCount > 0) {
|
||||
setVariants(prev => [...prev, ...newVariants]);
|
||||
notify.success(`Generated ${createdCount} variant(s)${skippedCount > 0 ? `, skipped ${skippedCount} duplicates` : ''}`);
|
||||
setShowGenerator(false);
|
||||
} else {
|
||||
notify.info('All combinations already exist — no new variants created');
|
||||
setShowGenerator(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -319,9 +407,9 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
ids,
|
||||
updates
|
||||
});
|
||||
fetchByProduct(productId);
|
||||
if (productId) fetchByProduct(productId);
|
||||
setSelectedIds(new Set());
|
||||
} catch (err) {}
|
||||
} catch (err) { }
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
@@ -329,7 +417,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
try {
|
||||
await Promise.all(Array.from(selectedIds).map(id => deleteVariant(id)));
|
||||
setSelectedIds(new Set());
|
||||
} catch (err) {}
|
||||
} catch (err) { }
|
||||
};
|
||||
|
||||
const handleBulkArchive = async () => {
|
||||
@@ -337,7 +425,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
try {
|
||||
await Promise.all(Array.from(selectedIds).map(id => archiveVariant(id)));
|
||||
setSelectedIds(new Set());
|
||||
} catch (err) {}
|
||||
} catch (err) { }
|
||||
};
|
||||
|
||||
const handleSingleUpdate = async (id: string, updates: Partial<Variant>) => {
|
||||
@@ -528,7 +616,9 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchByProduct(productId)}
|
||||
onClick={() => {
|
||||
if (productId) fetchByProduct(productId);
|
||||
}}
|
||||
className="p-1.5 border border-border hover:bg-background text-muted-foreground rounded-lg"
|
||||
title="Refresh variants list"
|
||||
>
|
||||
@@ -541,11 +631,10 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewLayout('matrix')}
|
||||
className={`inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium transition-all ${
|
||||
viewLayout === 'matrix'
|
||||
? 'bg-surface text-foreground shadow-xs'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
className={`inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium transition-all ${viewLayout === 'matrix'
|
||||
? 'bg-surface text-foreground shadow-xs'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid className="w-3.5 h-3.5" />
|
||||
Matrix Grid
|
||||
@@ -553,11 +642,10 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewLayout('list')}
|
||||
className={`inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium transition-all ${
|
||||
viewLayout === 'list'
|
||||
? 'bg-surface text-foreground shadow-xs'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
className={`inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium transition-all ${viewLayout === 'list'
|
||||
? 'bg-surface text-foreground shadow-xs'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<List className="w-3.5 h-3.5" />
|
||||
List Table
|
||||
|
||||
@@ -34,13 +34,15 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
|
||||
|
||||
let setObj = blueprint.attributeSet || blueprint.attribute_set;
|
||||
const setId = blueprint.attribute_set_id || blueprint.attributeSetId || (setObj ? setObj.id : null);
|
||||
if ((!setObj || !setObj.groups) && setId) {
|
||||
if ((!setObj || !setObj.groups || setObj.groups.length === 0) && setId) {
|
||||
const fetchedSet = await attributeSetsService.getById(setId).catch(() => null);
|
||||
if (fetchedSet) setObj = fetchedSet;
|
||||
}
|
||||
setAttributeSet(setObj || null);
|
||||
|
||||
const groupsData = blueprint.groups || blueprint.attributeGroups || [];
|
||||
const groupsData = (setObj && Array.isArray(setObj.groups) && setObj.groups.length > 0)
|
||||
? setObj.groups
|
||||
: (blueprint.groups || blueprint.attributeGroups || []);
|
||||
let flatAttrs: any[] = [];
|
||||
if (Array.isArray(groupsData) && groupsData.length > 0) {
|
||||
setGroups(groupsData);
|
||||
@@ -66,34 +68,26 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
|
||||
}
|
||||
setAttributes(flatAttrs);
|
||||
|
||||
// Hydrate variant axes to ensure full attribute objects (including
|
||||
// selectable options) are used. Family blueprints commonly expose only
|
||||
// axis UUIDs, while the complete attributes live inside the hydrated
|
||||
// Attribute Set groups.
|
||||
const setAttrs: any[] = [];
|
||||
if (Array.isArray(setObj?.groups)) {
|
||||
setObj.groups.forEach((group: any) => {
|
||||
if (Array.isArray(group.attributes)) setAttrs.push(...group.attributes);
|
||||
});
|
||||
}
|
||||
const allAttrsMap = new Map<string, any>();
|
||||
[...flatAttrs, ...setAttrs].forEach((raw: any) => {
|
||||
const attr = raw?.attribute || raw;
|
||||
[raw?.id, raw?._id, raw?.attribute_id, attr?.id, attr?._id, attr?.code]
|
||||
.filter(Boolean)
|
||||
.forEach((key: any) => allAttrsMap.set(String(key), attr));
|
||||
});
|
||||
// Hydrate variant axes to ensure full attribute objects with .code and .name exist
|
||||
const allAttrsMap = new Map<string, any>(flatAttrs.map(a => [a.id, a]));
|
||||
const resolvedVariantAxes = Array.isArray(blueprint.variantAxes)
|
||||
? blueprint.variantAxes.map((va: any) => {
|
||||
const key = typeof va === 'string'
|
||||
? va
|
||||
: (va?.attribute_id || va?.id || va?._id || va?.code);
|
||||
return (key && allAttrsMap.get(String(key))) || va;
|
||||
})
|
||||
? blueprint.variantAxes.map((va: any) => typeof va === 'string' ? (allAttrsMap.get(va) || { id: va, code: va, name: va }) : va)
|
||||
: [];
|
||||
|
||||
const resolvedProductType =
|
||||
blueprint.productType ||
|
||||
blueprint.product_type ||
|
||||
(blueprint.completenessRules ? blueprint.completenessRules.productType : null) ||
|
||||
null;
|
||||
|
||||
const normalizedBlueprint = {
|
||||
...blueprint,
|
||||
attributeSet: setObj || null,
|
||||
attributeSetId: setId || null,
|
||||
attribute_set_id: setId || null,
|
||||
groups: groupsData,
|
||||
attributes: flatAttrs,
|
||||
productType: resolvedProductType,
|
||||
variantAxes: resolvedVariantAxes,
|
||||
variantEnabled: resolvedVariantAxes.length > 0 || Boolean(blueprint.variantEnabled)
|
||||
};
|
||||
@@ -101,10 +95,6 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
|
||||
setFamily(normalizedBlueprint);
|
||||
setAllowedBrands(blueprint.allowedBrands || []);
|
||||
setCategory(blueprint.category || null);
|
||||
// Preserve the hydrated attribute set when the family blueprint only
|
||||
// exposes attributeSetId/attribute_set_id instead of an embedded object.
|
||||
// Re-reading blueprint.attributeSet here used to overwrite the fetched
|
||||
// set with null, so product creation lost the family's inheritance.
|
||||
setAttributeSet(setObj || null);
|
||||
setWorkflow(blueprint.workflow || (blueprint.workflowCode ? { code: blueprint.workflowCode } : null));
|
||||
setAssetFamily(blueprint.assetRequirements || blueprint.assetFamily || null);
|
||||
|
||||
@@ -8,14 +8,13 @@ import type {
|
||||
} from '../types/variant.types';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const useVariant = () => {
|
||||
const [variants, setVariants] = useState<Variant[]>([]);
|
||||
export const useVariant = (initialVariants: Variant[] = []) => {
|
||||
const [variants, setVariants] = useState<Variant[]>(initialVariants);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const fetchByProduct = useCallback(async (productId: string) => {
|
||||
if (!productId || productId === 'new' || productId === 'null' || productId === 'undefined') {
|
||||
setVariants([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
@@ -30,6 +29,10 @@ export const useVariant = () => {
|
||||
}, []);
|
||||
|
||||
const updateVariant = useCallback(async (id: string, updates: VariantUpdateRequest) => {
|
||||
if (id.startsWith('temp-')) {
|
||||
setVariants(prev => prev.map(v => v.id === id ? { ...v, ...updates } : v));
|
||||
return { id, ...updates } as Variant;
|
||||
}
|
||||
try {
|
||||
const updated = await variantService.update(id, updates);
|
||||
setVariants(prev => prev.map(v => v.id === id ? { ...v, ...updated } : v));
|
||||
@@ -41,6 +44,11 @@ export const useVariant = () => {
|
||||
}, []);
|
||||
|
||||
const deleteVariant = useCallback(async (id: string) => {
|
||||
if (id.startsWith('temp-')) {
|
||||
setVariants(prev => prev.filter(v => v.id !== id));
|
||||
toast.success('Variant deleted');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await variantService.delete(id);
|
||||
setVariants(prev => prev.filter(v => v.id !== id));
|
||||
@@ -52,6 +60,11 @@ export const useVariant = () => {
|
||||
}, []);
|
||||
|
||||
const archiveVariant = useCallback(async (id: string) => {
|
||||
if (id.startsWith('temp-')) {
|
||||
setVariants(prev => prev.map(v => v.id === id ? { ...v, status: 'archived' } : v));
|
||||
toast.success('Variant archived');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await variantService.archive(id);
|
||||
setVariants(prev => prev.map(v => v.id === id ? { ...v, status: 'archived' } : v));
|
||||
@@ -82,18 +95,29 @@ export const useVariant = () => {
|
||||
}, []);
|
||||
|
||||
const bulkUpdate = useCallback(async (req: BulkUpdateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const results = await variantService.bulkUpdate(req);
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
// Refresh variants after bulk
|
||||
toast.success(`Updated ${successCount}/${req.ids.length} variants`);
|
||||
return results;
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Bulk update failed');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const tempIds = req.ids.filter(id => id.startsWith('temp-'));
|
||||
const realIds = req.ids.filter(id => !id.startsWith('temp-'));
|
||||
|
||||
if (tempIds.length > 0) {
|
||||
setVariants(prev => prev.map(v => tempIds.includes(v.id) ? { ...v, ...req.updates } : v));
|
||||
}
|
||||
|
||||
if (realIds.length > 0) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const results = await variantService.bulkUpdate({ ...req, ids: realIds });
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
toast.success(`Updated ${successCount + tempIds.length}/${req.ids.length} variants`);
|
||||
return results;
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Bulk update failed');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
toast.success(`Updated ${tempIds.length}/${req.ids.length} variants`);
|
||||
return tempIds.map(id => ({ id, success: true }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -12,13 +12,13 @@ import { VariantsTab } from '../components/variants/VariantsTab';
|
||||
import { DynamicAttributesSection } from '../components/DynamicAttributesSection';
|
||||
import { ProductAssetsTab } from '../components/ProductAssetsTab';
|
||||
import { useChannel } from '../../channels/hook/useChannel';
|
||||
import type { Channel } from '../../channels/types/channels.types';
|
||||
import { notify } from '../../../services/toast';
|
||||
import { useAttributeSet } from '../../attribute-sets/hook/useAttributeSet';
|
||||
import { useAttribute } from '../../attributes/hook/useAttribute';
|
||||
import { useAttributeGroup } from '../../attribute-groups/hook/useAttributeGroup';
|
||||
import { attributeSetsService } from '../../attribute-sets/services/attribute-sets.service';
|
||||
import { attributeGroupsService } from '../../attribute-groups/services/attribute-groups.service';
|
||||
import { channelsApi } from '../../channels/api/channels.api';
|
||||
import { Box, LayoutGrid, Tags, Globe, Eye, Save, Send, Image as ImageIcon, FolderTree, Check, Plus, CheckCircle2, Loader2, AlertCircle, Search, Pencil, X } from 'lucide-react';
|
||||
import { PageWrapper } from '../../../components/layouts/PageWrapper';
|
||||
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
|
||||
@@ -121,7 +121,12 @@ export default function NewProduct() {
|
||||
channels: f.channelCount ?? (Array.isArray(f.channels) ? f.channels.length : 0),
|
||||
workflow: f.workflowCode || 'Standard Approval',
|
||||
allowedBrands: f.allowedBrands || [],
|
||||
allowedUnits: f.allowedUnits || []
|
||||
allowedUnits: f.allowedUnits || [],
|
||||
category: f.category,
|
||||
categoryId: f.category_id || f.categoryId || (typeof f.category === 'object' ? f.category?.id : f.category) || null,
|
||||
productType: f.productType || f.product_type || (f.completenessRules as any)?.productType || null,
|
||||
attributeSetId: f.attributeSetId || f.attribute_set_id || (f.attributeSet ? f.attributeSet.id : null) || null,
|
||||
attributeSet: f.attributeSet || null
|
||||
}));
|
||||
}, [families]);
|
||||
|
||||
@@ -155,10 +160,10 @@ export default function NewProduct() {
|
||||
}, [attributeSetsList, attributeSetSearchQuery]);
|
||||
|
||||
const displayChannelsList = useMemo(() => {
|
||||
const defaults: Channel[] = [
|
||||
{ id: 'ch-shopify', name: 'Shopify Storefront', code: 'shopify', description: 'Direct Shopify e-commerce catalog sync', status: 'active', createdAt: '' },
|
||||
{ id: 'ch-amazon', name: 'Amazon Marketplace', code: 'amazon', description: 'Amazon seller central product listings', status: 'active', createdAt: '' },
|
||||
{ id: 'ch-custom-csv', name: 'Custom CSV Feed', code: 'custom_csv', description: 'Exportable CSV/XML syndication pipeline feed', status: 'active', createdAt: '' }
|
||||
const defaults = [
|
||||
{ id: 'ch-shopify', name: 'Shopify Storefront', code: 'shopify', description: 'Direct Shopify e-commerce catalog sync', status: 'active' as const, createdAt: '' },
|
||||
{ id: 'ch-amazon', name: 'Amazon Marketplace', code: 'amazon', description: 'Amazon seller central product listings', status: 'active' as const, createdAt: '' },
|
||||
{ id: 'ch-custom-csv', name: 'Custom CSV Feed', code: 'custom_csv', description: 'Exportable CSV/XML syndication pipeline feed', status: 'active' as const, createdAt: '' }
|
||||
];
|
||||
if (!allChannels || allChannels.length === 0) return defaults;
|
||||
const merged = [...allChannels];
|
||||
@@ -239,11 +244,16 @@ export default function NewProduct() {
|
||||
|
||||
// Sync Attribute Set ID from Product Family configuration
|
||||
useEffect(() => {
|
||||
if (attributeSet) {
|
||||
setSelectedAttributeSetId(attributeSet.id);
|
||||
setSelectedAttributeSetObj(attributeSet);
|
||||
if (!isEdit && selectedFamily && creationMethod !== 'clone') {
|
||||
if (attributeSet) {
|
||||
setSelectedAttributeSetId(attributeSet.id);
|
||||
setSelectedAttributeSetObj(attributeSet);
|
||||
} else if (family && !family.attributeSet && !family.attributeSetId && !family.attribute_set_id) {
|
||||
setSelectedAttributeSetId(null);
|
||||
setSelectedAttributeSetObj(null);
|
||||
}
|
||||
}
|
||||
}, [attributeSet]);
|
||||
}, [attributeSet, family, selectedFamily, isEdit, creationMethod]);
|
||||
|
||||
const handleAttributeSetChange = async (setId: string) => {
|
||||
setSelectedAttributeSetId(setId || null);
|
||||
@@ -638,7 +648,7 @@ export default function NewProduct() {
|
||||
await productService.update(targetId, submissionValues as any);
|
||||
await hydrateProductEditor(targetId);
|
||||
if (submissionValues.status === 'active') {
|
||||
notify.success("Product published successfully with Active status!");
|
||||
notify.success("Product updated successfully with Active status!");
|
||||
} else {
|
||||
notify.success("Changes saved successfully!");
|
||||
}
|
||||
@@ -646,7 +656,7 @@ export default function NewProduct() {
|
||||
const createdRaw: any = await productService.create({ ...submissionValues, family_id: selectedFamily } as any);
|
||||
const created = createdRaw?.data?.id ? createdRaw.data : (createdRaw?.id ? createdRaw : createdRaw?.data || createdRaw);
|
||||
if (submissionValues.status === 'active') {
|
||||
notify.success("Product published successfully with Active status!");
|
||||
notify.success("Product created successfully with Active status!");
|
||||
} else {
|
||||
notify.success("Product draft created successfully!");
|
||||
}
|
||||
@@ -654,7 +664,9 @@ export default function NewProduct() {
|
||||
setIsEditMode(true);
|
||||
navigate(`/products/${created.id}/edit`, { replace: true });
|
||||
await hydrateProductEditor(created.id);
|
||||
setActiveTab('attributes');
|
||||
if (activeTab === 'general') {
|
||||
setActiveTab('attributes');
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || err?.message || 'Unable to create product.';
|
||||
@@ -709,6 +721,20 @@ export default function NewProduct() {
|
||||
}
|
||||
}, [activeTab, currentTabs]);
|
||||
|
||||
const [maxUnlockedStep, setMaxUnlockedStep] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit || isReadOnlyView) {
|
||||
setMaxUnlockedStep(currentTabs.length);
|
||||
}
|
||||
}, [isEdit, isReadOnlyView, currentTabs.length]);
|
||||
|
||||
const isStepAccessible = useCallback((stepNum: number) => {
|
||||
if (isEdit || isReadOnlyView) return true;
|
||||
if (stepNum === 1) return true;
|
||||
return stepNum <= maxUnlockedStep;
|
||||
}, [isEdit, isReadOnlyView, maxUnlockedStep]);
|
||||
|
||||
const isNextDisabled = useMemo(() => {
|
||||
// 1. Core General fields check (always required to proceed)
|
||||
const isGeneralInvalid = !formik.values.name || !formik.values.brand || !formik.values.unit || !formik.values.category;
|
||||
@@ -793,6 +819,47 @@ export default function NewProduct() {
|
||||
formik.handleSubmit();
|
||||
};
|
||||
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
|
||||
const handlePublishToChannels = async () => {
|
||||
const isCurrentActive = product?.status === 'active' || formik.values.status === 'active';
|
||||
if (!isCurrentActive) {
|
||||
notify.error('A product must be created as ACTIVE before it can be published.');
|
||||
return;
|
||||
}
|
||||
|
||||
const inheritedCodes = (family?.channels || []).map((fc: any) => fc.channel_code);
|
||||
const optionalCodes = formik.values.metadata?.channels || [];
|
||||
const allSelectedCodes = Array.from(new Set([...inheritedCodes, ...optionalCodes]));
|
||||
|
||||
if (allSelectedCodes.length === 0) {
|
||||
notify.error('Please select at least one channel in the Channels step before publishing.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
const targetChannels = (allChannels || []).filter((ch: any) => allSelectedCodes.includes(ch.code));
|
||||
|
||||
for (const ch of targetChannels) {
|
||||
if (ch.id) {
|
||||
try {
|
||||
await channelsApi.triggerSyndication(ch.id);
|
||||
} catch (syndErr) {
|
||||
console.warn(`Syndication trigger warning for channel ${ch.name || ch.code}:`, syndErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notify.success(`Product successfully published to ${allSelectedCodes.length} selected channel(s)!`);
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || err?.message || 'Failed to publish product to channels.';
|
||||
notify.error(msg);
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setValuesRef = useRef(formik.setValues);
|
||||
useEffect(() => {
|
||||
setValuesRef.current = formik.setValues;
|
||||
@@ -1001,12 +1068,49 @@ export default function NewProduct() {
|
||||
}
|
||||
}, [id, productId, hydrateProductEditor]);
|
||||
|
||||
// Set default category when product family loads (Category Inheritance)
|
||||
// Set category, product type, and attribute set when product family is selected/changed (Inheritance in Create mode)
|
||||
const prevSelectedFamilyRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (familyCategory && !formik.values.category) {
|
||||
formik.setFieldValue('category', familyCategory.id);
|
||||
if (!isEdit && selectedFamily) {
|
||||
if (prevSelectedFamilyRef.current !== selectedFamily) {
|
||||
prevSelectedFamilyRef.current = selectedFamily;
|
||||
const resolvedCategoryId =
|
||||
(familyCategory && typeof familyCategory === 'object' ? familyCategory.id : familyCategory) ||
|
||||
(family?.category && typeof family.category === 'object' ? family.category.id : family?.category) ||
|
||||
family?.category_id ||
|
||||
family?.categoryId ||
|
||||
'';
|
||||
formik.setFieldValue('category', resolvedCategoryId || '', true);
|
||||
|
||||
const resolvedProductType =
|
||||
family?.productType ||
|
||||
selectedFamilyObj?.productType ||
|
||||
null;
|
||||
if (resolvedProductType) {
|
||||
formik.setFieldValue('type', resolvedProductType, true);
|
||||
}
|
||||
|
||||
const resolvedSet = attributeSet || family?.attributeSet || selectedFamilyObj?.attributeSet || null;
|
||||
const resolvedSetId = resolvedSet?.id || family?.attributeSetId || family?.attribute_set_id || selectedFamilyObj?.attributeSetId || null;
|
||||
if (resolvedSet) {
|
||||
setSelectedAttributeSetId(resolvedSet.id);
|
||||
setSelectedAttributeSetObj(resolvedSet);
|
||||
} else if (resolvedSetId) {
|
||||
setSelectedAttributeSetId(resolvedSetId);
|
||||
attributeSetsService.getById(resolvedSetId).then(res => {
|
||||
if (res) setSelectedAttributeSetObj(res);
|
||||
}).catch(() => { });
|
||||
} else if (family && !family.attributeSet && !family.attributeSetId && !family.attribute_set_id) {
|
||||
setSelectedAttributeSetId(null);
|
||||
setSelectedAttributeSetObj(null);
|
||||
}
|
||||
}
|
||||
} else if (!selectedFamily && !isEdit) {
|
||||
prevSelectedFamilyRef.current = null;
|
||||
setSelectedAttributeSetId(null);
|
||||
setSelectedAttributeSetObj(null);
|
||||
}
|
||||
}, [familyCategory]);
|
||||
}, [selectedFamily, familyCategory, family, attributeSet, selectedFamilyObj, isEdit]);
|
||||
|
||||
// Generate dynamic product code if empty and name is provided
|
||||
useEffect(() => {
|
||||
@@ -1090,14 +1194,47 @@ export default function NewProduct() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset previous custom attributes and excluded groups
|
||||
setExcludedGroupIds(new Set());
|
||||
setCustomAddedAttributes([]);
|
||||
|
||||
// Load configuration of the product family
|
||||
const familyId = productData.family_id || productData.familyId || (productData.family ? (typeof productData.family === 'object' ? productData.family.id : productData.family) : null);
|
||||
let blueprint: any = null;
|
||||
if (familyId) {
|
||||
setSelectedFamily(familyId);
|
||||
await loadConfiguration(familyId);
|
||||
blueprint = await loadConfiguration(familyId);
|
||||
} else {
|
||||
setSelectedFamily(null);
|
||||
}
|
||||
|
||||
// Extract saved attributes
|
||||
// Resolve Attribute Set from blueprint, productData, family, or metadata
|
||||
let resolvedSet: any = blueprint?.attributeSet || productData.family?.attributeSet || productData.attributeSet || null;
|
||||
let resolvedSetId: string | null = blueprint?.attributeSetId || blueprint?.attribute_set_id || productData.family?.attributeSetId || productData.family?.attribute_set_id || productData.metadata?.attributeSetId || productData.metadata?.attribute_set_id || productData.attributeSetId || productData.attribute_set_id || (resolvedSet ? resolvedSet.id : null);
|
||||
|
||||
if ((!resolvedSet || !resolvedSet.groups || resolvedSet.groups.length === 0) && resolvedSetId) {
|
||||
try {
|
||||
const setDetails = await attributeSetsService.getById(resolvedSetId);
|
||||
if (setDetails) {
|
||||
resolvedSet = setDetails;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load attribute set details during clone:", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedSet && resolvedSet.id) {
|
||||
setSelectedAttributeSetId(resolvedSet.id);
|
||||
setSelectedAttributeSetObj(resolvedSet);
|
||||
} else if (resolvedSetId) {
|
||||
setSelectedAttributeSetId(resolvedSetId);
|
||||
setSelectedAttributeSetObj(resolvedSet || null);
|
||||
} else {
|
||||
setSelectedAttributeSetId(null);
|
||||
setSelectedAttributeSetObj(null);
|
||||
}
|
||||
|
||||
// Extract saved attributes from productData
|
||||
const savedAttrs: Record<string, any> = {
|
||||
...(productData.metadata?.attributes || {}),
|
||||
...(productData.attributes || {})
|
||||
@@ -1111,6 +1248,73 @@ export default function NewProduct() {
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize blueprint attributes for all defined fields in the attribute set
|
||||
const blueprintAttrs: Record<string, any> = {};
|
||||
if (resolvedSet?.groups) {
|
||||
resolvedSet.groups.forEach((g: any) => {
|
||||
if (Array.isArray(g.attributes)) {
|
||||
g.attributes.forEach((attr: any) => {
|
||||
if (attr && attr.code) {
|
||||
blueprintAttrs[attr.code] = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (blueprint && Array.isArray(blueprint.attributes)) {
|
||||
blueprint.attributes.forEach((attr: any) => {
|
||||
if (attr && attr.code) {
|
||||
blueprintAttrs[attr.code] = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const mergedAttributes = {
|
||||
...blueprintAttrs,
|
||||
...savedAttrs
|
||||
};
|
||||
|
||||
// Collect codes in the resolved Attribute Set
|
||||
const setAttrCodes = new Set<string>();
|
||||
if (resolvedSet?.groups) {
|
||||
resolvedSet.groups.forEach((g: any) => {
|
||||
if (Array.isArray(g.attributes)) {
|
||||
g.attributes.forEach((a: any) => {
|
||||
if (a.code) setAttrCodes.add(a.code.toLowerCase());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Collect any custom attributes not in the resolved attribute set
|
||||
const customAdded: any[] = [];
|
||||
if (Array.isArray(productData.attributeValues)) {
|
||||
productData.attributeValues.forEach((av: any) => {
|
||||
const code = (av.attribute?.code || av.attribute_code || '').toLowerCase();
|
||||
if (code && !setAttrCodes.has(code) && !EXCLUDED_ATTRIBUTE_CODES.includes(code)) {
|
||||
const attrObj = av.attribute || allRegistryAttributes.find((a: any) => (a.code || '').toLowerCase() === code);
|
||||
if (attrObj && !customAdded.some((x: any) => x.id === attrObj.id || (x.code || '').toLowerCase() === code)) {
|
||||
customAdded.push(attrObj);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Object.keys(savedAttrs).forEach((code) => {
|
||||
const codeLower = code.toLowerCase();
|
||||
if (!setAttrCodes.has(codeLower) && !EXCLUDED_ATTRIBUTE_CODES.includes(codeLower)) {
|
||||
let attrObj = allRegistryAttributes.find((a: any) => (a.code || '').toLowerCase() === codeLower);
|
||||
if (!attrObj && Array.isArray(productData.attributeValues)) {
|
||||
const matchAv = productData.attributeValues.find((av: any) => (av.attribute?.code || av.attribute_code || '').toLowerCase() === codeLower);
|
||||
if (matchAv?.attribute) attrObj = matchAv.attribute;
|
||||
}
|
||||
if (attrObj && !customAdded.some((x: any) => x.id === attrObj.id || (x.code || '').toLowerCase() === codeLower)) {
|
||||
customAdded.push(attrObj);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setCustomAddedAttributes(customAdded);
|
||||
|
||||
const brandId = productData.brand_id || productData.brandId || (productData.brand && typeof productData.brand === 'object' ? (productData.brand as any).id : productData.brand) || '';
|
||||
const categoryId = productData.category_id || productData.categoryId || (productData.category && typeof productData.category === 'object' ? (productData.category as any).id : productData.category) || '';
|
||||
const unitId = productData.unit_id || productData.unitId || (productData.unit && typeof productData.unit === 'object' ? (productData.unit as any).id : productData.unit) || '';
|
||||
@@ -1140,7 +1344,7 @@ export default function NewProduct() {
|
||||
...(productData.metadata || {}),
|
||||
currentStage: 'draft',
|
||||
},
|
||||
attributes: savedAttrs
|
||||
attributes: mergedAttributes
|
||||
});
|
||||
notify.success(`Template preloaded from product: ${productData.name}`);
|
||||
} catch (err) {
|
||||
@@ -1175,6 +1379,70 @@ export default function NewProduct() {
|
||||
>
|
||||
<Pencil className="w-4 h-4" /> Edit Product
|
||||
</button>
|
||||
) : activeTab === 'review' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={formik.isSubmitting || isPublishing}
|
||||
onClick={() => handleSubmitWithValidation('draft')}
|
||||
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors cursor-pointer disabled:opacity-50 flex items-center gap-2 shadow-xs"
|
||||
>
|
||||
{formik.isSubmitting && formik.values.status === 'draft' ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Saving Draft...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4" />
|
||||
Create Draft
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={formik.isSubmitting || isPublishing}
|
||||
onClick={() => handleSubmitWithValidation('active')}
|
||||
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors cursor-pointer disabled:opacity-50 flex items-center gap-2 shadow-xs"
|
||||
>
|
||||
{formik.isSubmitting && formik.values.status === 'active' ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Creating Product...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Create Product
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
formik.isSubmitting ||
|
||||
isPublishing ||
|
||||
(product?.status !== 'active' && formik.values.status !== 'active')
|
||||
}
|
||||
onClick={handlePublishToChannels}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 shadow-xs ${(product?.status === 'active' || formik.values.status === 'active')
|
||||
? 'bg-primary hover:bg-primary-hover text-white cursor-pointer'
|
||||
: 'bg-surface-muted text-muted-foreground cursor-not-allowed border border-border opacity-60'
|
||||
}`}
|
||||
>
|
||||
{isPublishing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Publishing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4" />
|
||||
Publish
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
) : !isEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1307,21 +1575,22 @@ export default function NewProduct() {
|
||||
const isActive = activeTab === tab.id;
|
||||
const isDone = tab.step < activeIndex + 1;
|
||||
const isLast = idx === currentTabs.length - 1;
|
||||
const accessible = isStepAccessible(tab.step);
|
||||
return (
|
||||
<div key={tab.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center" style={{ width: 24 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isEdit || tab.id === 'general') {
|
||||
if (accessible) {
|
||||
setActiveTab(tab.id);
|
||||
}
|
||||
}}
|
||||
disabled={!isEdit && tab.id !== 'general'}
|
||||
disabled={!accessible}
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${isActive ? 'bg-primary ring-2 ring-primary/20' :
|
||||
isDone ? 'bg-success' :
|
||||
'bg-surface border-2 border-border hover:border-primary/30'
|
||||
} ${(!isEdit && tab.id !== 'general') ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||
} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
{isDone
|
||||
? <Check className="w-3 h-3 text-white" />
|
||||
@@ -1335,12 +1604,12 @@ export default function NewProduct() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isEdit || tab.id === 'general') {
|
||||
if (accessible) {
|
||||
setActiveTab(tab.id);
|
||||
}
|
||||
}}
|
||||
disabled={!isEdit && tab.id !== 'general'}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''} ${(!isEdit && tab.id !== 'general') ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||
disabled={!accessible}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span className={`text-xs font-medium leading-tight block ${isActive ? 'text-primary-dark' : isDone ? 'text-muted-foreground' : 'text-muted-foreground hover:text-muted-foreground'
|
||||
}`}>{tab.label}</span>
|
||||
@@ -1421,6 +1690,10 @@ export default function NewProduct() {
|
||||
setCreationMethod(val as any);
|
||||
setSelectedFamily(null);
|
||||
setClonedProductId(null);
|
||||
setSelectedAttributeSetId(null);
|
||||
setSelectedAttributeSetObj(null);
|
||||
setCustomAddedAttributes([]);
|
||||
setExcludedGroupIds(new Set());
|
||||
formik.resetForm();
|
||||
}}
|
||||
>
|
||||
@@ -1461,7 +1734,47 @@ export default function NewProduct() {
|
||||
key={fam.id}
|
||||
onClick={async () => {
|
||||
setSelectedFamily(fam.id);
|
||||
await loadConfiguration(fam.id);
|
||||
const cfg = await loadConfiguration(fam.id);
|
||||
const resolvedCatId =
|
||||
(cfg?.category && typeof cfg.category === 'object' ? cfg.category.id : cfg?.category) ||
|
||||
cfg?.category_id ||
|
||||
cfg?.categoryId ||
|
||||
(fam.category && typeof fam.category === 'object' ? fam.category.id : fam.category) ||
|
||||
fam.categoryId ||
|
||||
fam.category_id ||
|
||||
'';
|
||||
formik.setFieldValue('category', resolvedCatId || '', true);
|
||||
formik.setFieldTouched('category', true, false);
|
||||
|
||||
const resolvedProdType =
|
||||
cfg?.productType ||
|
||||
fam.productType ||
|
||||
(fam.completenessRules as any)?.productType ||
|
||||
null;
|
||||
if (resolvedProdType) {
|
||||
formik.setFieldValue('type', resolvedProdType, true);
|
||||
formik.setFieldTouched('type', true, false);
|
||||
}
|
||||
|
||||
const resolvedSet = cfg?.attributeSet || fam.attributeSet || null;
|
||||
const resolvedSetId = resolvedSet?.id || cfg?.attributeSetId || cfg?.attribute_set_id || fam.attributeSetId || fam.attribute_set_id || null;
|
||||
if (resolvedSet) {
|
||||
setSelectedAttributeSetId(resolvedSet.id);
|
||||
setSelectedAttributeSetObj(resolvedSet);
|
||||
} else if (resolvedSetId) {
|
||||
setSelectedAttributeSetId(resolvedSetId);
|
||||
const fetched = await attributeSetsService.getById(resolvedSetId).catch(() => null);
|
||||
if (fetched) {
|
||||
setSelectedAttributeSetObj(fetched);
|
||||
} else {
|
||||
setSelectedAttributeSetId(null);
|
||||
setSelectedAttributeSetObj(null);
|
||||
}
|
||||
} else {
|
||||
setSelectedAttributeSetId(null);
|
||||
setSelectedAttributeSetObj(null);
|
||||
}
|
||||
|
||||
setIsFamilyDropdownOpen(false);
|
||||
setFamilySearchQuery('');
|
||||
}}
|
||||
@@ -2013,40 +2326,38 @@ export default function NewProduct() {
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'variants' && (
|
||||
!isEdit ? (
|
||||
<div className="bg-surface border border-border rounded-xl p-8 text-center flex flex-col items-center justify-center min-h-[300px]">
|
||||
<Tags className="w-10 h-10 text-muted-foreground mb-3 animate-pulse" />
|
||||
<h3 className="font-semibold text-foreground mb-1">Please save the product before managing variants.</h3>
|
||||
<p className="text-xs text-muted-foreground">Variants matrix generation and overrides require a persistent product entity.</p>
|
||||
</div>
|
||||
) : (
|
||||
<VariantsTab
|
||||
productId={id || productId}
|
||||
productType={formik.values.type}
|
||||
parentSku={formik.values.sku}
|
||||
family={family}
|
||||
readOnly={isReadOnlyView}
|
||||
productAttributes={formik.values.attributes}
|
||||
availableAttributes={activeAttributesList}
|
||||
/>
|
||||
)
|
||||
<VariantsTab
|
||||
productId={id || productId}
|
||||
productType={formik.values.type}
|
||||
parentSku={formik.values.sku}
|
||||
family={family}
|
||||
readOnly={isReadOnlyView}
|
||||
productAttributes={formik.values.attributes}
|
||||
availableAttributes={activeAttributesList}
|
||||
initialVariants={product?.variants || []}
|
||||
onVariantsChange={(vars) => {
|
||||
setProduct((prev: any) => ({ ...(prev || {}), variants: vars }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'assets' && (
|
||||
!isEdit ? (
|
||||
<div className="bg-surface border border-border rounded-xl p-8 text-center flex flex-col items-center justify-center min-h-[300px]">
|
||||
<ImageIcon className="w-10 h-10 text-muted-foreground mb-3 animate-pulse" />
|
||||
<h3 className="font-semibold text-foreground mb-1">Save product before uploading assets.</h3>
|
||||
<p className="text-xs text-muted-foreground">Digital Asset Management maps files directly to database product IDs.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ProductAssetsTab
|
||||
productId={id || productId}
|
||||
family={family}
|
||||
readOnly={isReadOnlyView}
|
||||
refreshProductData={refreshProductData}
|
||||
/>
|
||||
)
|
||||
<ProductAssetsTab
|
||||
productId={id || productId}
|
||||
family={family}
|
||||
readOnly={isReadOnlyView}
|
||||
refreshProductData={refreshProductData}
|
||||
variants={product?.variants || []}
|
||||
initialAssets={product?.productAssets || []}
|
||||
initialVariantAssets={product?.variantAssets || []}
|
||||
onAssetsChange={(assets, variantAssets) => {
|
||||
setProduct((prev: any) => ({
|
||||
...(prev || {}),
|
||||
productAssets: assets,
|
||||
variantAssets: variantAssets
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'channels' && (
|
||||
@@ -2084,8 +2395,8 @@ export default function NewProduct() {
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
disabled={isInherited || isReadOnlyView}
|
||||
onChange={() => !isInherited && !isReadOnlyView && handleChannelToggle(ch.code)}
|
||||
onClick={(e) => e.stopPropagation()} // Prevent the card from toggling twice
|
||||
onChange={() => { }} // Handled by outer card click
|
||||
onClick={(e) => e.stopPropagation()} // Prevent double triggers
|
||||
className={`w-4 h-4 text-primary rounded border-border focus:ring-primary ${isInherited ? 'cursor-not-allowed opacity-75' : ''
|
||||
}`}
|
||||
/>
|
||||
@@ -2297,7 +2608,7 @@ export default function NewProduct() {
|
||||
<div className="divide-y divide-border">
|
||||
{variants.slice(0, VARIANT_PREVIEW).map((v: any, idx: number) => {
|
||||
const axes = (v.values || [])
|
||||
.map((vv: any) => `${vv.axis?.name || vv.attribute_code || 'Axis'}: ${vv.value ?? vv.value_text ?? '—'}`)
|
||||
.map((vv: any) => `${vv.axis?.name || vv.attribute_code || 'Axis'}: ${vv.value}`)
|
||||
.join(' · ');
|
||||
const sku = v.metadata?.sku || v.sku || null;
|
||||
return (
|
||||
@@ -2444,9 +2755,7 @@ export default function NewProduct() {
|
||||
|
||||
{/* ── Channels ── */}
|
||||
{(() => {
|
||||
const inheritedCodes = (family?.channels || [])
|
||||
.map((fc: any) => typeof fc === 'string' ? fc : (fc.channel_code || fc.code))
|
||||
.filter(Boolean);
|
||||
const inheritedCodes = (family?.channels || []).map((fc: any) => fc.channel_code);
|
||||
const optionalCodes = (formik.values.metadata?.channels || []).filter(
|
||||
(c: string) => !inheritedCodes.includes(c)
|
||||
);
|
||||
@@ -2539,12 +2848,21 @@ export default function NewProduct() {
|
||||
disabled={isNextDisabled}
|
||||
onClick={async () => {
|
||||
const errors: any = await formik.validateForm();
|
||||
const currentIdx = currentTabs.findIndex(t => t.id === activeTab);
|
||||
const nextTabObj = currentTabs[currentIdx + 1];
|
||||
|
||||
const advanceToNext = () => {
|
||||
if (nextTabObj) {
|
||||
setMaxUnlockedStep(prev => Math.max(prev, nextTabObj.step));
|
||||
setActiveTab(nextTabObj.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (activeTab === 'general') {
|
||||
const requiredFields = ['name', 'brand', 'unit', 'category'];
|
||||
const hasRequiredErrors = Object.keys(errors).some(k => requiredFields.includes(k));
|
||||
if (!hasRequiredErrors) {
|
||||
setActiveTab(currentTabs[currentTabs.findIndex(t => t.id === activeTab) + 1].id);
|
||||
advanceToNext();
|
||||
} else {
|
||||
const touchedObj: any = { ...formik.touched };
|
||||
requiredFields.forEach(k => {
|
||||
@@ -2570,7 +2888,7 @@ export default function NewProduct() {
|
||||
}
|
||||
|
||||
if (!hasRequiredErrors && !hasAttributeErrors && isAttributesComplete) {
|
||||
setActiveTab(currentTabs[currentTabs.findIndex(t => t.id === activeTab) + 1].id);
|
||||
advanceToNext();
|
||||
} else {
|
||||
const touchedObj: any = {
|
||||
...formik.touched,
|
||||
@@ -2604,7 +2922,7 @@ export default function NewProduct() {
|
||||
formik.setTouched(touchedObj);
|
||||
}
|
||||
} else {
|
||||
setActiveTab(currentTabs[currentTabs.findIndex(t => t.id === activeTab) + 1].id);
|
||||
advanceToNext();
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function ProductList() {
|
||||
|
||||
// Stats for KPI cards
|
||||
const stats = useMemo(() => {
|
||||
const published = products.filter(p => p.status === "published" || p.status === "active").length;
|
||||
const published = products.filter(p => p.status === "published").length;
|
||||
const pending = products.filter(p => p.status === "pending").length;
|
||||
const draft = products.filter(p => p.status === "draft").length;
|
||||
const avgCompleteness = products.length > 0
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import ProductList from '../pages/ProductList';
|
||||
import NewProduct from '../pages/NewProduct';
|
||||
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
|
||||
|
||||
export const ProductRoutes = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<ProductList />} /> {/* /products */}
|
||||
<Route path="new" element={<ProtectedRoute node="products.items" action="create"><NewProduct /></ProtectedRoute>} /> {/* /products/new */}
|
||||
<Route path=":id/edit" element={<ProtectedRoute node="products.items" action="edit"><NewProduct /></ProtectedRoute>} /> {/* /products/123/edit */}
|
||||
<Route path="new" element={<NewProduct />} /> {/* /products/new */}
|
||||
<Route path=":id/edit" element={<NewProduct />} /> {/* /products/123/edit */}
|
||||
<Route path=":id" element={<NewProduct />} /> {/* /products/123 */}
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -8,8 +8,8 @@ export const RoleRoutes = () => {
|
||||
<ProtectedRoute node="settings.roles">
|
||||
<Routes>
|
||||
<Route path="/" element={<RoleList />} />
|
||||
<Route path="/new" element={<ProtectedRoute node="settings.roles" action="create"><NewRoleForm /></ProtectedRoute>} />
|
||||
<Route path="/:id/edit" element={<ProtectedRoute node="settings.roles" action="edit"><NewRoleForm /></ProtectedRoute>} />
|
||||
<Route path="/new" element={<NewRoleForm />} />
|
||||
<Route path="/:id/edit" element={<NewRoleForm />} />
|
||||
</Routes>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
|
||||
@@ -108,10 +108,10 @@ export default function SettingList() {
|
||||
const cat = activeTab.toLowerCase();
|
||||
const data = await settingsService.getCategorySettings(cat);
|
||||
if (data) {
|
||||
if (typeof data.orgName === 'string') setOrgName(data.orgName);
|
||||
if (typeof data.subdomain === 'string') setSubdomain(data.subdomain);
|
||||
if (typeof data.requireApproval === 'boolean') setRequireApproval(data.requireApproval);
|
||||
if (typeof data.autoPublish === 'boolean') setAutoPublish(data.autoPublish);
|
||||
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
|
||||
|
||||
@@ -1,37 +1,32 @@
|
||||
import apiClient from '../../../api/axiosInstance';
|
||||
import type { Setting, SettingCreateRequest, SettingUpdateRequest } from '../types/settings.types';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
import axiosInstance from '../../../api/axiosInstance';
|
||||
|
||||
export const settingsService = {
|
||||
getAll: async (): Promise<Setting[]> => {
|
||||
const response = await apiClient.get<ApiResponse<Setting[]>>('/api/v1/settings');
|
||||
getCategorySettings: async (category: string) => {
|
||||
const response: any = await axiosInstance.get(`/settings/by-category/${category}`);
|
||||
return response.data?.data;
|
||||
},
|
||||
updateCategorySettings: async (category: string, data: Record<string, any>) => {
|
||||
const response: any = await axiosInstance.put(`/settings/by-category/${category}`, data);
|
||||
return response.data;
|
||||
},
|
||||
getById: async (id: string): Promise<Setting> => {
|
||||
const response = await apiClient.get<ApiResponse<Setting>>(`/api/v1/settings/${id}`);
|
||||
getAll: async (): Promise<any[]> => {
|
||||
const response: any = await axiosInstance.get('/settings');
|
||||
return response.data?.data || response.data || [];
|
||||
},
|
||||
getById: async (id: string): Promise<any> => {
|
||||
const response: any = await axiosInstance.get(`/settings/${id}`);
|
||||
return response.data?.data || response.data;
|
||||
},
|
||||
create: async (data: any): Promise<any> => {
|
||||
const response: any = await axiosInstance.post('/settings', data);
|
||||
return response.data?.data || response.data;
|
||||
},
|
||||
update: async (id: string, data: any): Promise<any> => {
|
||||
const response: any = await axiosInstance.put(`/settings/${id}`, data);
|
||||
return response.data?.data || response.data;
|
||||
},
|
||||
delete: async (id: string): Promise<any> => {
|
||||
const response: any = await axiosInstance.delete(`/settings/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
create: async (data: SettingCreateRequest): Promise<Setting> => {
|
||||
const response = await apiClient.post<ApiResponse<Setting>>('/api/v1/settings', data);
|
||||
return response.data;
|
||||
},
|
||||
update: async (id: string, data: SettingUpdateRequest): Promise<Setting> => {
|
||||
const response = await apiClient.put<ApiResponse<Setting>>(`/api/v1/settings/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/api/v1/settings/${id}`);
|
||||
},
|
||||
getCategorySettings: async (category: string): Promise<Record<string, unknown>> => {
|
||||
const response = await apiClient.get<ApiResponse<Record<string, unknown>>>(`/api/v1/settings/by-category/${category}`);
|
||||
return response.data;
|
||||
},
|
||||
updateCategorySettings: async (category: string, data: Record<string, unknown>) => {
|
||||
return apiClient.put<ApiResponse<Record<string, unknown>>>(`/api/v1/settings/by-category/${category}`, data);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -118,7 +118,6 @@ export function useTenant() {
|
||||
try {
|
||||
const result = await tenantService.impersonateTenant(tenantId);
|
||||
localStorage.setItem('impersonatedTenantId', tenantId);
|
||||
window.dispatchEvent(new CustomEvent('pim:impersonation-changed', { detail: tenantId }));
|
||||
notify.success(result.message || 'Support impersonation active');
|
||||
return result;
|
||||
} catch (err) {
|
||||
@@ -129,7 +128,6 @@ export function useTenant() {
|
||||
|
||||
const stopImpersonation = () => {
|
||||
localStorage.removeItem('impersonatedTenantId');
|
||||
window.dispatchEvent(new CustomEvent('pim:impersonation-changed', { detail: null }));
|
||||
notify.info('Support impersonation ended');
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import UnitList from '../pages/UnitList';
|
||||
import NewUnit from '../pages/NewUnit';
|
||||
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
|
||||
|
||||
export const UnitRoutes = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<UnitList />} />
|
||||
<Route path="new" element={<ProtectedRoute node="masters.units" action="create"><NewUnit /></ProtectedRoute>} />
|
||||
<Route path=":id/edit" element={<ProtectedRoute node="masters.units" action="edit"><NewUnit /></ProtectedRoute>} />
|
||||
<Route path="new" element={<NewUnit />} />
|
||||
<Route path=":id/edit" element={<NewUnit />} />
|
||||
<Route path=":id/view" element={<NewUnit />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,6 @@ import type { DBRole } from "../services/roles.service";
|
||||
import type { Tenant } from "../../tenants/types/tenant.types";
|
||||
import { Save, AlertCircle, CheckCircle2, Edit2, ArrowLeft } from "lucide-react";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { useAppSelector } from "../../../store";
|
||||
|
||||
function CardHeader({ title, subtitle }: { title: string; subtitle?: string }) {
|
||||
return (
|
||||
@@ -38,10 +37,6 @@ export default function NewUser() {
|
||||
|
||||
const isEdit = Boolean(id) && location.pathname.endsWith("/edit");
|
||||
const isView = Boolean(id) && !isEdit;
|
||||
const currentUser = useAppSelector((state) => state.auth.user);
|
||||
const isPlatformUser = currentUser?.user_type === "platform" || currentUser?.type === "platform";
|
||||
const currentTenantId = currentUser?.tenant_id || currentUser?.tenant?.id;
|
||||
const currentTenantName = currentUser?.tenant?.name || currentUser?.tenant_name || `Tenant #${currentTenantId}`;
|
||||
|
||||
const [roles, setRoles] = useState<DBRole[]>([]);
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
@@ -51,16 +46,14 @@ export default function NewUser() {
|
||||
const [submitSuccess, setSubmitSuccess] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlatformUser) {
|
||||
tenantService.getAll().then(setTenants).catch(console.error);
|
||||
}
|
||||
}, [isPlatformUser]);
|
||||
tenantService.getAll().then(setTenants).catch(console.error);
|
||||
}, []);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
email: "",
|
||||
roleId: "",
|
||||
tenantId: isPlatformUser ? "" : String(currentTenantId || ""),
|
||||
tenantId: "",
|
||||
name: "",
|
||||
phone: "",
|
||||
status: "active" as "active" | "inactive",
|
||||
@@ -115,16 +108,14 @@ export default function NewUser() {
|
||||
|
||||
useEffect(() => {
|
||||
setRolesLoading(true);
|
||||
const tenantId = isPlatformUser
|
||||
? (formik.values.tenantId || null)
|
||||
: (String(currentTenantId || formik.values.tenantId) || null);
|
||||
const tenantId = formik.values.tenantId || null;
|
||||
rolesService
|
||||
.getByTenant(tenantId)
|
||||
.then(setRoles)
|
||||
.catch(console.error)
|
||||
.finally(() => setRolesLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formik.values.tenantId, isPlatformUser, currentTenantId]);
|
||||
}, [formik.values.tenantId]);
|
||||
|
||||
useEffect(() => {
|
||||
if ((isEdit || isView) && id) {
|
||||
@@ -331,36 +322,22 @@ export default function NewUser() {
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-1">
|
||||
{isPlatformUser ? (
|
||||
<>
|
||||
<label className={labelClass}>Select Tenant *</label>
|
||||
<Select
|
||||
name="tenantId"
|
||||
value={formik.values.tenantId}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
placeholder="Select a Tenant..."
|
||||
>
|
||||
<option value="">Select a Tenant...</option>
|
||||
<option value="platform">Platform Core (No Tenant)</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.tenant_name}</option>
|
||||
))}
|
||||
</Select>
|
||||
{formik.touched.tenantId && formik.errors.tenantId && (
|
||||
<p className={errorClass}><AlertCircle className="w-3 h-3" />{formik.errors.tenantId}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className={labelClass}>Tenant Organization</label>
|
||||
<Input
|
||||
readOnly
|
||||
value={currentTenantName}
|
||||
className="bg-background border-border font-semibold text-foreground"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">New members are automatically added to your organization.</p>
|
||||
</>
|
||||
<label className={labelClass}>Select Tenant *</label>
|
||||
<Select
|
||||
name="tenantId"
|
||||
value={formik.values.tenantId}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
placeholder="Select a Tenant..."
|
||||
>
|
||||
<option value="">Select a Tenant...</option>
|
||||
<option value="platform">Platform Core (No Tenant)</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.tenant_name}</option>
|
||||
))}
|
||||
</Select>
|
||||
{formik.touched.tenantId && formik.errors.tenantId && (
|
||||
<p className={errorClass}><AlertCircle className="w-3 h-3" />{formik.errors.tenantId}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -370,11 +347,7 @@ export default function NewUser() {
|
||||
return (
|
||||
<label className={labelClass}>
|
||||
Assign Role *
|
||||
{!isPlatformUser ? (
|
||||
<span className="ml-2 text-xs font-normal text-primary bg-primary/5 px-2.5 py-0.5 rounded-full font-medium">
|
||||
Showing roles for {currentTenantName}
|
||||
</span>
|
||||
) : selTenant ? (
|
||||
{selTenant ? (
|
||||
<span className="ml-2 text-xs font-normal text-primary bg-primary/5 px-2.5 py-0.5 rounded-full font-medium">
|
||||
Showing roles for {selTenant.tenant_name}
|
||||
</span>
|
||||
@@ -402,7 +375,7 @@ export default function NewUser() {
|
||||
<option key={r.id} value={r.id}>{r.role_name}</option>
|
||||
))}
|
||||
</Select>
|
||||
{roles.length === 0 && !rolesLoading && (formik.values.tenantId || !isPlatformUser) && (
|
||||
{roles.length === 0 && !rolesLoading && formik.values.tenantId && (
|
||||
<p className="text-xs text-amber-600 mt-1">No roles found for this tenant. Please create a role for this tenant first.</p>
|
||||
)}
|
||||
{formik.touched.roleId && formik.errors.roleId && (
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import UserList from '../pages/UserList';
|
||||
import NewUser from '../pages/NewUser';
|
||||
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
|
||||
|
||||
export const UserRoutes = () => (
|
||||
<Routes>
|
||||
<Route index element={<UserList />} />
|
||||
<Route path="new" element={<ProtectedRoute node="settings.users" action="create"><NewUser /></ProtectedRoute>} />
|
||||
<Route path="new" element={<NewUser />} />
|
||||
<Route path=":id" element={<NewUser />} />
|
||||
<Route path=":id/view" element={<NewUser />} />
|
||||
<Route path=":id/edit" element={<ProtectedRoute node="settings.users" action="edit"><NewUser /></ProtectedRoute>} />
|
||||
<Route path=":id/edit" element={<NewUser />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -181,6 +181,39 @@ export default function NewVariant() {
|
||||
const activeProducts = products.filter(p => p.status === 'active');
|
||||
const isLoading = productsLoading || variantLoading || parentLoading;
|
||||
|
||||
const [highestVisitedStep, setHighestVisitedStep] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit || isView) {
|
||||
setHighestVisitedStep(STEPS.length);
|
||||
}
|
||||
}, [isEdit, isView]);
|
||||
|
||||
const isBasicValid = Boolean(formik.values.sku?.trim() && formik.values.name?.trim() && formik.values.productId && !formik.errors.sku && !formik.errors.name && !formik.errors.productId);
|
||||
|
||||
const isStepAccessible = useCallback((stepNum: number) => {
|
||||
if (isEdit || isView) return true;
|
||||
if (stepNum === 1) return true;
|
||||
if (!isBasicValid) return false;
|
||||
return stepNum <= highestVisitedStep + 1;
|
||||
}, [isEdit, isView, isBasicValid, highestVisitedStep]);
|
||||
|
||||
const handleNextStep = useCallback(() => {
|
||||
if (activeStep === 'basic') {
|
||||
if (!isBasicValid) {
|
||||
formik.setFieldTouched('sku', true);
|
||||
formik.setFieldTouched('name', true);
|
||||
formik.setFieldTouched('productId', true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (activeIndex < STEPS.length - 1) {
|
||||
const nextStepObj = STEPS[activeIndex + 1];
|
||||
setHighestVisitedStep(prev => Math.max(prev, nextStepObj.step));
|
||||
setActiveStep(nextStepObj.id);
|
||||
}
|
||||
}, [activeStep, isBasicValid, activeIndex, formik]);
|
||||
|
||||
return (
|
||||
<ProtectedRoute node="products.variants">
|
||||
<PageWrapper>
|
||||
@@ -235,17 +268,23 @@ export default function NewVariant() {
|
||||
const isActive = activeStep === s.id;
|
||||
const isDone = idx < activeIndex;
|
||||
const isLast = idx === STEPS.length - 1;
|
||||
const accessible = isStepAccessible(s.step);
|
||||
return (
|
||||
<div key={s.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center" style={{ width: 24 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${
|
||||
isActive ? 'bg-primary ring-2 ring-primary/20' :
|
||||
isDone ? 'bg-success' :
|
||||
'bg-surface border-2 border-border hover:border-primary/30'
|
||||
}`}
|
||||
} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
{isDone
|
||||
? <Check className="w-3 h-3 text-white" />
|
||||
@@ -258,8 +297,13 @@ export default function NewVariant() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveStep(s.id)}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''}`}
|
||||
disabled={!accessible}
|
||||
onClick={() => {
|
||||
if (accessible) {
|
||||
setActiveStep(s.id);
|
||||
}
|
||||
}}
|
||||
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? 'pb-0' : ''} ${!accessible ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span className={`text-xs font-medium leading-tight block ${
|
||||
isActive ? 'text-primary' : isDone ? 'text-muted-foreground' : 'text-muted-foreground hover:text-muted-foreground'
|
||||
@@ -586,7 +630,7 @@ export default function NewVariant() {
|
||||
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm font-medium text-muted-foreground hover:bg-background transition-colors">Back</button>
|
||||
)}
|
||||
{activeIndex < STEPS.length - 1 && (
|
||||
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
|
||||
<button type="button" onClick={handleNextStep} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,12 +50,6 @@ export default function VariantList() {
|
||||
},
|
||||
{ key: "name", label: "Variant Name", sortable: true },
|
||||
{ key: "parentProductName", label: "Product", sortable: true },
|
||||
{
|
||||
key: "tenantId",
|
||||
label: "Tenant",
|
||||
sortable: true,
|
||||
render: (val: number | string | null) => val ? `Tenant #${val}` : "Platform Global"
|
||||
},
|
||||
{
|
||||
key: "price",
|
||||
label: "Price",
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import VariantList from '../pages/VariantList';
|
||||
import VariantDetail from '../pages/VariantDetail';
|
||||
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
|
||||
|
||||
export const VariantRoutes = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<VariantList />} />
|
||||
<Route path=":id" element={<VariantDetail />} />
|
||||
<Route path=":id/edit" element={<ProtectedRoute node="products.variants" action="edit"><VariantDetail /></ProtectedRoute>} />
|
||||
<Route path=":id/edit" element={<VariantDetail />} />
|
||||
<Route path=":id/view" element={<VariantDetail />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@ export interface Variant {
|
||||
sku: string;
|
||||
parentProductId: string;
|
||||
parentProductName: string;
|
||||
tenantId?: number | string | null;
|
||||
name: string;
|
||||
attributes: Record<string, string>;
|
||||
status: VariantStatus;
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import App from './App';
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<Provider store={store}>
|
||||
<BrowserRouter basename={import.meta.env.BASE_URL}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
|
||||
+21
-26
@@ -1,10 +1,9 @@
|
||||
// src/routes/AppRoutes.tsx
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthGuard, PlatformGuard, TenantWorkspaceGuard } 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';
|
||||
import SSOCallback from '../authentication/pages/SSOCallback';
|
||||
|
||||
// Feature Routes
|
||||
import { DashboardRoutes } from '../features/dashboard/routes/dashboard.routes';
|
||||
@@ -45,7 +44,6 @@ const AppRoutes = () => {
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/accept-invite" element={<AcceptInvite />} />
|
||||
<Route path="/sso/callback" element={<SSOCallback />} />
|
||||
|
||||
{/* Default Redirect */}
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
@@ -57,46 +55,43 @@ const AppRoutes = () => {
|
||||
<Route path="/platform/overview" element={<PlatformGuard><PlatformOverview /></PlatformGuard>} />
|
||||
<Route path="/platform/tenants" element={<PlatformGuard><PlatformTenantsPage /></PlatformGuard>} />
|
||||
|
||||
<Route element={<TenantWorkspaceGuard />}>
|
||||
<Route path="/dashboard/*" element={<DashboardRoutes />} />
|
||||
|
||||
{/* Catalog */}
|
||||
<Route path="/products/*" element={<ProtectedRoute node="products.items"><ProductRoutes /></ProtectedRoute>} />
|
||||
<Route path="/families/*" element={<ProtectedRoute node="products.families"><FamilyRoutes /></ProtectedRoute>} />
|
||||
<Route path="/categories/*" element={<ProtectedRoute node="products.categories"><CategoryRoutes /></ProtectedRoute>} />
|
||||
<Route path="/variants/*" element={<ProtectedRoute node="products.variants"><VariantRoutes /></ProtectedRoute>} />
|
||||
<Route path="/attributes/*" element={<ProtectedRoute node="products.attributes"><AttributeRoutes /></ProtectedRoute>} />
|
||||
<Route path="/attribute-groups/*" element={<ProtectedRoute node="products.attributes"><AttributeGroupRoutes /></ProtectedRoute>} />
|
||||
<Route path="/attribute-sets/*" element={<ProtectedRoute node="products.attributes"><AttributeSetRoutes /></ProtectedRoute>} />
|
||||
<Route path="/products/*" element={<ProductRoutes />} />
|
||||
<Route path="/families/*" element={<FamilyRoutes />} />
|
||||
<Route path="/categories/*" element={<CategoryRoutes />} />
|
||||
<Route path="/variants/*" element={<VariantRoutes />} />
|
||||
<Route path="/attributes/*" element={<AttributeRoutes />} />
|
||||
<Route path="/attribute-groups/*" element={<AttributeGroupRoutes />} />
|
||||
<Route path="/attribute-sets/*" element={<AttributeSetRoutes />} />
|
||||
|
||||
{/* Masters */}
|
||||
<Route path="/brands/*" element={<ProtectedRoute node="masters.brands"><BrandRoutes /></ProtectedRoute>} />
|
||||
<Route path="/units/*" element={<ProtectedRoute node="masters.units"><UnitRoutes /></ProtectedRoute>} />
|
||||
<Route path="/brands/*" element={<BrandRoutes />} />
|
||||
<Route path="/units/*" element={<UnitRoutes />} />
|
||||
|
||||
{/* Assets */}
|
||||
<Route path="/assets/*" element={<ProtectedRoute node="products.items"><AssetRoutes /></ProtectedRoute>} />
|
||||
<Route path="/asset-types/*" element={<ProtectedRoute node="products.items"><AssetTypeRoutes /></ProtectedRoute>} />
|
||||
<Route path="/asset-families/*" element={<ProtectedRoute node="products.items"><AssetFamilyRoutes /></ProtectedRoute>} />
|
||||
<Route path="/assets/*" element={<AssetRoutes />} />
|
||||
<Route path="/asset-types/*" element={<AssetTypeRoutes />} />
|
||||
<Route path="/asset-families/*" element={<AssetFamilyRoutes />} />
|
||||
|
||||
{/* Operations */}
|
||||
<Route path="/workflow/*" element={<ProtectedRoute node="products.items"><WorkflowRoutes /></ProtectedRoute>} />
|
||||
<Route path="/channels/*" element={<ProtectedRoute node="settings.integrations"><ChannelRoutes /></ProtectedRoute>} />
|
||||
<Route path="/channel-types/*" element={<ProtectedRoute node="settings.integrations"><ChannelTypeRoutes /></ProtectedRoute>} />
|
||||
<Route path="/integrations/*" element={<ProtectedRoute node="settings.integrations"><IntegrationRoutes /></ProtectedRoute>} />
|
||||
<Route path="/workflow/*" element={<WorkflowRoutes />} />
|
||||
<Route path="/channels/*" element={<ChannelRoutes />} />
|
||||
<Route path="/channel-types/*" element={<ChannelTypeRoutes />} />
|
||||
<Route path="/integrations/*" element={<IntegrationRoutes />} />
|
||||
|
||||
{/* Users */}
|
||||
<Route path="/users/tenants/*" element={<TenantRoutes />} />
|
||||
<Route path="/users/roles/*" element={<ProtectedRoute node="settings.roles"><RoleRoutes /></ProtectedRoute>} />
|
||||
<Route path="/users/*" element={<ProtectedRoute node="settings.users"><UserRoutes /></ProtectedRoute>} />
|
||||
|
||||
{/* Tenant reports are available to tenant users and audited Support Mode only. */}
|
||||
<Route path="/reports/*" element={<ProtectedRoute node="reports"><ReportRoutes /></ProtectedRoute>} />
|
||||
<Route path="/settings/*" element={<ProtectedRoute node="settings.users"><SettingRoutes /></ProtectedRoute>} />
|
||||
</Route>
|
||||
|
||||
{/* Notifications */}
|
||||
<Route path="/notifications/*" element={<ProtectedRoute node="notifications"><NotificationRoutes /></ProtectedRoute>} />
|
||||
<Route path="/notifications/*" element={<NotificationRoutes />} />
|
||||
|
||||
{/* Admin */}
|
||||
<Route path="/reports/*" element={<ReportRoutes />} />
|
||||
<Route path="/settings/*" element={<SettingRoutes />} />
|
||||
</Route>
|
||||
|
||||
{/* Catch-all */}
|
||||
|
||||
@@ -7,10 +7,6 @@ export interface RouteConfig {
|
||||
}
|
||||
|
||||
export const protectedRoutes: RouteConfig[] = [
|
||||
// SaaS control plane
|
||||
{ path: '/platform/overview', title: 'SaaS Dashboard', description: 'Platform health, tenant growth, and aggregate usage' },
|
||||
{ path: '/platform/tenants', title: 'Tenants', description: 'Provision and manage tenant organizations' },
|
||||
|
||||
// Dashboard
|
||||
{ path: '/dashboard', title: 'Dashboard', description: 'Overview of your PIM platform' },
|
||||
|
||||
|
||||
@@ -125,10 +125,10 @@ export const sidebarConfig: SidebarItem[] = [
|
||||
label: 'Channels & Integration',
|
||||
href: '/channels',
|
||||
icon: Radio,
|
||||
permission: 'settings.integrations',
|
||||
permission: 'channels.syndication',
|
||||
children: [
|
||||
{ label: 'Channel Registry', href: '/channels', icon: Radio, permission: 'settings.integrations' },
|
||||
{ label: 'Channel Types', href: '/channel-types', icon: Layers2, permission: 'settings.integrations' },
|
||||
{ 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' },
|
||||
]
|
||||
},
|
||||
@@ -150,36 +150,11 @@ export const sidebarConfig: SidebarItem[] = [
|
||||
{
|
||||
label: 'Settings',
|
||||
href: '/settings',
|
||||
icon: Settings,
|
||||
permission: 'settings.users'
|
||||
icon: Settings
|
||||
},
|
||||
{
|
||||
label: 'Notifications',
|
||||
href: '/notifications',
|
||||
icon: Bell,
|
||||
permission: 'notifications'
|
||||
},
|
||||
];
|
||||
|
||||
// Phase-1 SaaS control-plane navigation. Tenant catalog modules are exposed to
|
||||
// platform operators only after they explicitly enter audited Support Mode.
|
||||
export const platformSidebarConfig: SidebarItem[] = [
|
||||
{
|
||||
label: 'SaaS Dashboard',
|
||||
href: '/platform/overview',
|
||||
icon: LayoutDashboard,
|
||||
platformOnly: true,
|
||||
},
|
||||
{
|
||||
label: 'Tenants',
|
||||
href: '/platform/tenants',
|
||||
icon: Building2,
|
||||
platformOnly: true,
|
||||
},
|
||||
{
|
||||
label: 'Notifications',
|
||||
href: '/notifications',
|
||||
icon: Bell,
|
||||
platformOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
+1
-4
@@ -3,9 +3,6 @@ import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
// Supports either a dedicated hostname or a reverse-proxy prefix such as
|
||||
// `/pim_frontend/`. Vite requires a trailing slash for sub-path deployments.
|
||||
base: process.env.VITE_BASE_PATH || '/',
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
@@ -15,4 +12,4 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user