29/6/2026 v1.1 correction in ui

This commit is contained in:
MohamedHasan07
2026-07-02 16:00:07 +05:30
parent 26808d63c4
commit d0ebc9b805
79 changed files with 2415 additions and 2770 deletions
BIN
View File
Binary file not shown.
+38
View File
@@ -46,6 +46,7 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"ts-morph": "^28.0.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
@@ -1931,6 +1932,18 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@ts-morph/common": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.29.0.tgz",
"integrity": "sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"minimatch": "^10.0.1",
"path-browserify": "^1.0.1",
"tinyglobby": "^0.2.14"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -2591,6 +2604,13 @@
"node": ">=6"
}
},
"node_modules/code-block-writer": {
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -4106,6 +4126,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"dev": true,
"license": "MIT"
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -4647,6 +4674,17 @@
"typescript": ">=4.8.4"
}
},
"node_modules/ts-morph": {
"version": "28.0.0",
"resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-28.0.0.tgz",
"integrity": "sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ts-morph/common": "~0.29.0",
"code-block-writer": "^13.0.3"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+1
View File
@@ -48,6 +48,7 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"ts-morph": "^28.0.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
-1
View File
@@ -2,7 +2,6 @@ const fs = require('fs');
const path = require('path');
const features = [
{ name: 'attribute-groups', Name: 'AttributeGroup' },
{ name: 'scopes', Name: 'Scope' },
{ name: 'assets', Name: 'Asset' },
{ name: 'asset-types', Name: 'AssetType' },
{ name: 'asset-families', Name: 'AssetFamily' },
+19 -12
View File
@@ -1,37 +1,44 @@
// src/App.tsx
import { useEffect, useRef, useState } from 'react';
import AppRoutes from './routes/AppRoutes';
import { ToastContainer } from 'react-toastify';
import { LanguageProvider } from './contexts/LanguageContext';
import { useAppDispatch } from './store';
import { HeaderProvider } from './contexts/HeaderContext';
import { Loader } from "./components/customs/Loader";
function App() {
const dispatch = useAppDispatch();
const bootstrappedRef = useRef(false);
const [bootstrapped, setBootstrapped] = useState(false);
const [progress, setProgress] = useState(0);
useEffect(() => {
if (bootstrappedRef.current) return;
bootstrappedRef.current = true;
const bootstrap = async () => {
// Set default permissions for development
// dispatch(setPermissions({})); // Already set in initialState, but can override here
const steps = [15, 35, 55, 75, 92, 100];
for (const step of steps) {
await new Promise(resolve => setTimeout(resolve, 160));
setProgress(step);
}
await new Promise(resolve => setTimeout(resolve, 300));
setBootstrapped(true);
};
bootstrap();
}, [dispatch]);
}, []);
if (!bootstrapped) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="flex flex-col items-center">
<div className="w-10 h-10 border-4 border-primary-600 border-t-transparent rounded-full animate-spin" />
<p className="mt-4 text-sm text-gray-600">Initializing PIM Platform...</p>
</div>
</div>
<Loader
size="xl"
message="Initializing PIM Platform"
subMessage="Preparing the Data..."
progress={progress}
fullScreen
/>
);
}
+58
View File
@@ -0,0 +1,58 @@
// src/components/customs/ActionMenu.tsx
import { MoreHorizontal, Eye, Edit2, Trash2 } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "./Dropdown";
export interface ActionMenuProps {
onView?: () => void;
onEdit?: () => void;
onDelete?: () => void;
}
export function ActionMenu({ onView, onEdit, onDelete }: ActionMenuProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="p-1.5 hover:bg-purple-100 text-purple-400 hover:text-purple-700 rounded-lg transition-colors focus:outline-none"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="w-5 h-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-36 bg-purple-50 border border-purple-200 shadow-lg rounded-xl overflow-hidden p-1">
{onView && (
<DropdownMenuItem
onClick={(e) => { e.stopPropagation(); onView(); }}
className="cursor-pointer text-purple-900 hover:bg-purple-100 focus:bg-purple-100 focus:text-purple-900 rounded-lg px-3 py-2 flex items-center outline-none"
>
<Eye className="w-4 h-4 mr-2 text-purple-600" />
<span className="font-medium text-sm">View</span>
</DropdownMenuItem>
)}
{onEdit && (
<DropdownMenuItem
onClick={(e) => { e.stopPropagation(); onEdit(); }}
className="cursor-pointer text-purple-900 hover:bg-purple-100 focus:bg-purple-100 focus:text-purple-900 rounded-lg px-3 py-2 flex items-center outline-none mt-1"
>
<Edit2 className="w-4 h-4 mr-2 text-purple-600" />
<span className="font-medium text-sm">Edit</span>
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem
onClick={(e) => { e.stopPropagation(); onDelete(); }}
className="cursor-pointer text-red-600 hover:bg-red-50 focus:bg-red-50 focus:text-red-700 rounded-lg px-3 py-2 flex items-center outline-none mt-1 border-t border-purple-100"
>
<Trash2 className="w-4 h-4 mr-2" />
<span className="font-medium text-sm">Delete</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
+202 -66
View File
@@ -1,7 +1,9 @@
import { useState, useMemo, type ReactNode } from "react";
import { Search, X, ChevronsUpDown, ChevronUp, ChevronDown, ChevronLeft, ChevronRight, Settings2 } from "lucide-react";
// ── Types ─────────────────────────────────────────────────────────────────────
import { useState, useMemo, useRef, useEffect, type ReactNode } from "react";
import {
Search, X, ChevronsUpDown, ChevronUp, ChevronDown,
ChevronLeft, ChevronRight, Settings2, Check, Filter
} from "lucide-react";
import { ActionMenu } from "./ActionMenu";
export interface DataTableColumn<T = any> {
key: string;
@@ -21,7 +23,6 @@ export interface DataTableProps<T = any> {
onSelectionChange?: (ids: Set<string>) => void;
onRowClick?: (row: T) => void;
rowIdKey?: keyof T;
actions?: (row: T) => ReactNode;
pageSizeOptions?: number[];
resultLabel?: string;
maxHeight?: string;
@@ -30,47 +31,96 @@ export interface DataTableProps<T = any> {
draggable?: boolean;
onReorder?: (newData: T[]) => void;
searchPlaceholder?: string;
}
statusKey?: string;
// ── Component ─────────────────────────────────────────────────────────────────
actionConfig?: {
onView?: (row: T) => void;
onEdit?: (row: T) => void;
onDelete?: (row: T) => void;
};
}
export function DataTable<T extends Record<string, any> = any>({
columns: initialColumns,
data,
selectable = false,
selectedIds: _selectedIds = new Set(),
onSelectionChange: _onSelectionChange,
selectable: _selectable = true,
selectedIds: externalSelectedIds,
onSelectionChange,
onRowClick,
rowIdKey = "id" as keyof T,
actions,
pageSizeOptions = [5, 10, 25, 50],
resultLabel = "results",
maxHeight = "520px",
toolbarRight,
toolbarLeft,
toolbarRight,
draggable = false,
onReorder: _onReorder,
searchPlaceholder = "Search...",
statusKey = "status",
actionConfig,
}: DataTableProps<T>) {
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set());
const selectedIds = externalSelectedIds ?? internalSelectedIds;
const handleSelectionChange = (ids: Set<string>) => {
setInternalSelectedIds(ids);
onSelectionChange?.(ids);
};
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(pageSizeOptions[0] ?? 10);
const [sortCol, setSortCol] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [visibleColumns, _setVisibleColumns] = useState<Record<string, boolean>>(
initialColumns.reduce((acc, col) => ({ ...acc, [col.key]: col.visible !== false }), {})
const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>(
initialColumns.reduce((acc, col) => ({ ...acc, [col.key]: col.visible !== false }), {} as Record<string, boolean>)
);
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
const [showStatusMenu, setShowStatusMenu] = useState(false);
const [showColumnMenu, setShowColumnMenu] = useState(false);
const statusRef = useRef<HTMLDivElement>(null);
const columnRef = useRef<HTMLDivElement>(null);
// Click Outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (statusRef.current && !statusRef.current.contains(event.target as Node)) setShowStatusMenu(false);
if (columnRef.current && !columnRef.current.contains(event.target as Node)) setShowColumnMenu(false);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
useEffect(() => { if (showStatusMenu) setShowColumnMenu(false); }, [showStatusMenu]);
useEffect(() => { if (showColumnMenu) setShowStatusMenu(false); }, [showColumnMenu]);
const columns = useMemo(() =>
initialColumns.filter(col => visibleColumns[col.key] !== false),
initialColumns.filter(col => visibleColumns[col.key] !== false),
[initialColumns, visibleColumns]
);
const statusOptions = useMemo(() => {
const counts: Record<string, number> = {};
data.forEach(row => {
const status = String(row[statusKey] || "Unknown").trim();
counts[status] = (counts[status] || 0) + 1;
});
return Object.entries(counts).map(([status, count]) => ({ status, count }))
.sort((a, b) => a.status.localeCompare(b.status));
}, [data, statusKey]);
const toggleColumn = (key: string) => setVisibleColumns(prev => ({ ...prev, [key]: !prev[key] }));
const filteredData = useMemo(() => {
let result = [...data];
const q = search.trim().toLowerCase();
if (!q) return data;
return data.filter(row => columns.some(col => String(row[col.key] ?? "").toLowerCase().includes(q)));
}, [data, search, columns]);
if (q) result = result.filter(row => columns.some(col => String(row[col.key] ?? "").toLowerCase().includes(q)));
if (selectedStatuses.length > 0) result = result.filter(row => selectedStatuses.includes(String(row[statusKey])));
return result;
}, [data, search, columns, selectedStatuses, statusKey]);
const sorted = useMemo(() => {
if (!sortCol) return filteredData;
@@ -78,8 +128,7 @@ export function DataTable<T extends Record<string, any> = any>({
const av = a[sortCol], bv = b[sortCol];
if (av == null) return 1;
if (bv == null) return -1;
if (typeof av === "number" && typeof bv === "number")
return sortDir === "asc" ? av - bv : bv - av;
if (typeof av === "number" && typeof bv === "number") return sortDir === "asc" ? av - bv : bv - av;
return sortDir === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
}, [filteredData, sortCol, sortDir]);
@@ -91,21 +140,42 @@ export function DataTable<T extends Record<string, any> = any>({
const paged = useMemo(() => sorted.slice((page - 1) * pageSize, page * pageSize), [sorted, page, pageSize]);
const resetPage = () => setPage(1);
const handleSearch = (v: string) => { setSearch(v); resetPage(); };
const handlePageSize = (s: number) => { setPageSize(s); resetPage(); };
const handleSort = (key: string) => {
if (sortCol === key) setSortDir(d => d === "asc" ? "desc" : "asc");
else { setSortCol(key); setSortDir("asc"); }
resetPage();
};
const colSpan = columns.length + (selectable ? 1 : 0) + (actions ? 1 : 0) + (draggable ? 1 : 0);
const colSpan = columns.length + 1 + (actionConfig ? 1 : 0) + (draggable ? 1 : 0);
const pagedIds = paged.map(row => String(row[rowIdKey]));
const allPageSelected = pagedIds.length > 0 && pagedIds.every(id => selectedIds.has(id));
const somePageSelected = pagedIds.some(id => selectedIds.has(id));
const toggleSelectAll = () => {
const next = new Set(selectedIds);
if (allPageSelected) {
pagedIds.forEach(id => next.delete(id));
} else {
pagedIds.forEach(id => next.add(id));
}
handleSelectionChange(next);
};
const toggleRow = (id: string, e: React.MouseEvent) => {
e.stopPropagation();
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id); else next.add(id);
handleSelectionChange(next);
};
return (
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
{/* Toolbar - Tight spacing */}
{/* Toolbar */}
<div className="flex items-center justify-between px-6 py-3 border-b border-gray-100">
<div className="flex items-center gap-4">
{toolbarLeft}
@@ -116,7 +186,7 @@ export function DataTable<T extends Record<string, any> = any>({
value={search}
onChange={e => handleSearch(e.target.value)}
placeholder={searchPlaceholder}
className="w-full pl-10 pr-4 h-9 text-sm bg-white border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-500"
className="w-full pl-10 pr-4 h-9 text-sm bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
{search && (
<button onClick={() => handleSearch("")} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600">
@@ -128,23 +198,99 @@ export function DataTable<T extends Record<string, any> = any>({
<div className="flex items-center gap-3">
{toolbarRight}
<button className="flex items-center gap-2 px-4 py-2 text-sm border border-gray-200 rounded-xl hover:bg-gray-50">
<Settings2 className="w-4 h-4" /> Columns
</button>
{/* Status Filter - Checkboxes */}
<div className="relative" ref={statusRef}>
<button
onClick={() => setShowStatusMenu(!showStatusMenu)}
className="flex items-center gap-2 px-4 py-2 text-sm border border-gray-200 rounded-lg hover:bg-gray-50 hover:border-purple-200 transition-colors"
>
<Filter className="w-4 h-4" />
Status {selectedStatuses.length > 0 && `(${selectedStatuses.length})`}
</button>
{showStatusMenu && (
<div className="absolute right-0 mt-2 w-40 bg-white rounded-lg border border-gray-200 shadow-xl z-50 py-2 max-h-[320px] overflow-auto">
<div className="px-4 py-2 text-xs font-semibold text-gray-500 border-b">STATUS FILTER</div>
<label className="flex items-center gap-3 px-4 py-2.5 hover:bg-purple-50 cursor-pointer text-sm ">
<input
type="checkbox"
checked={selectedStatuses.length === 0}
onChange={() => {
setSelectedStatuses([]);
resetPage();
}}
className="w-4 h-4 accent-purple-600"
/>
<span>All Status</span>
<span className="ml-auto text-gray-500 text-xs">({data.length})</span>
</label>
{statusOptions.map(({ status, count }) => (
<label key={status} className="flex items-center gap-3 px-4 py-2.5 hover:bg-purple-50 cursor-pointer text-sm">
<input
type="checkbox"
checked={selectedStatuses.includes(status)}
onChange={() => {
const newSelected = selectedStatuses.includes(status)
? selectedStatuses.filter(s => s !== status)
: [...selectedStatuses, status];
setSelectedStatuses(newSelected);
resetPage();
}}
className="w-4 h-4 accent-purple-600"
/>
<span className="capitalize">{status}</span>
<span className="ml-auto text-gray-500 text-xs">({count})</span>
</label>
))}
</div>
)}
</div>
{/* Columns Menu */}
<div className="relative" ref={columnRef}>
<button onClick={() => setShowColumnMenu(!showColumnMenu)} className="flex items-center gap-2 px-4 py-2 text-sm border border-gray-200 rounded-lg hover:bg-gray-50 hover:border-purple-200 transition-colors">
<Settings2 className="w-4 h-4" /> Columns
</button>
{showColumnMenu && (
<div className="absolute right-0 mt-2 w-45 bg-white rounded-xl border border-gray-200 shadow-xl z-50 py-1 max-h-[280px] overflow-auto">
<div className="px-4 py-2 text-xs font-semibold text-gray-500 border-b">SHOW COLUMNS</div>
{initialColumns.map((col) => (
<label key={col.key} className="flex items-center gap-3 px-4 py-2 hover:bg-purple-50 cursor-pointer text-sm">
<input type="checkbox" checked={visibleColumns[col.key] !== false} onChange={() => toggleColumn(col.key)} className="accent-purple-600" />
<span>{col.label}</span>
{visibleColumns[col.key] !== false && <Check className="w-4 h-4 text-purple-600 ml-auto" />}
</label>
))}
</div>
)}
</div>
</div>
</div>
{/* Table - Compact & Clean */}
{/* Table */}
<div className="overflow-x-auto">
<div className="overflow-auto" style={{ maxHeight }}>
<table className="w-full border-collapse">
<thead className="sticky top-0 z-20 bg-purple-50">
<tr className="border-b-2 border-purple-200">
{draggable && <th className="w-10 px-4 py-3 border-r border-purple-100" />}
{selectable && <th className="w-12 px-4 py-3 border-r border-purple-100" />}
<th className="w-12 px-4 py-3 border-r border-purple-100 text-center">
<input
type="checkbox"
checked={allPageSelected}
ref={el => { if (el) el.indeterminate = somePageSelected && !allPageSelected; }}
onChange={toggleSelectAll}
className="w-4 h-4 accent-purple-600 cursor-pointer"
title="Select all on this page"
/>
</th>
{columns.map((col) => {
const align = col.align || "left";
const align = col.align || "center";
return (
<th
key={col.key}
@@ -165,48 +311,46 @@ export function DataTable<T extends Record<string, any> = any>({
);
})}
{actions && (
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-purple-700 border-r border-purple-100 last:border-r-0 w-28">
ACTION
</th>
{actionConfig && (
<th className="px-4 py-3 text-center text-xs font-semibold uppercase tracking-wider text-purple-700 w-40">ACTION</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-purple-100">
{paged.length === 0 ? (
<tr>
<td colSpan={colSpan} className="px-6 py-14 text-center text-gray-400">No records found</td>
</tr>
<tr><td colSpan={colSpan} className="px-6 py-14 text-center text-gray-400">No records found</td></tr>
) : (
paged.map((row) => {
const id = String(row[rowIdKey]);
return (
<tr
key={id}
onClick={() => onRowClick?.(row)}
className={`hover:bg-purple-50/70 transition-colors ${onRowClick ? "cursor-pointer" : ""}`}
>
<tr key={id} onClick={() => onRowClick?.(row)} className={`hover:bg-purple-50/70 transition-colors ${onRowClick ? "cursor-pointer" : ""}`}>
{draggable && <td className="px-4 py-3 border-r border-purple-100 text-center text-gray-400"></td>}
{selectable && <td className="px-4 py-3 border-r border-purple-100"><input type="checkbox" className="accent-purple-600" /></td>}
<td className="px-4 py-3 border-r border-purple-100 text-center" onClick={e => toggleRow(id, e)}>
<input
type="checkbox"
checked={selectedIds.has(id)}
onChange={() => {}}
className="w-4 h-4 accent-purple-600 cursor-pointer"
/>
</td>
{columns.map(col => {
const align = col.align || "left";
const align = col.align || "center";
return (
<td
key={col.key}
className={`px-4 py-3 text-sm text-gray-700 border-r border-purple-100 last:border-r-0 ${
align === "center" ? "text-center" : align === "right" ? "text-right" : "text-left"
}`}
>
<td key={col.key} className={`px-4 py-3 text-sm text-gray-700 border-r border-purple-100 last:border-r-0 ${align === "center" ? "text-center" : align === "right" ? "text-right" : "text-left"}`}>
{col.render ? col.render(row[col.key], row) : (row[col.key] ?? "—")}
</td>
);
})}
{actions && (
<td className="px-4 py-3 text-right border-r border-purple-100 last:border-r-0" onClick={e => e.stopPropagation()}>
{actions(row)}
{actionConfig && (
<td className="px-4 py-3 text-center w-40" onClick={e => e.stopPropagation()}>
<ActionMenu
onView={actionConfig.onView ? () => actionConfig.onView!(row) : undefined}
onEdit={actionConfig.onEdit ? () => actionConfig.onEdit!(row) : undefined}
onDelete={actionConfig.onDelete ? () => actionConfig.onDelete!(row) : undefined}
/>
</td>
)}
</tr>
@@ -223,11 +367,7 @@ export function DataTable<T extends Record<string, any> = any>({
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
Rows per page
<select
value={pageSize}
onChange={e => handlePageSize(Number(e.target.value))}
className="border border-gray-200 rounded-lg px-3 py-1 focus:ring-purple-500"
>
<select value={pageSize} onChange={e => handlePageSize(Number(e.target.value))} className="border border-gray-200 rounded-lg px-3 py-1 focus:ring-purple-500">
{pageSizeOptions.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
@@ -239,11 +379,7 @@ export function DataTable<T extends Record<string, any> = any>({
<ChevronLeft className="w-4 h-4" />
</button>
{Array.from({ length: Math.min(7, totalPages) }, (_, i) => i + 1).map(p => (
<button
key={p}
onClick={() => setPage(p)}
className={`w-9 h-9 rounded-lg border font-medium ${p === page ? "bg-purple-600 text-white border-purple-600" : "hover:bg-purple-50"}`}
>
<button key={p} onClick={() => setPage(p)} className={`w-9 h-9 rounded-lg border font-medium ${p === page ? "bg-purple-600 text-white border-purple-600" : "hover:bg-purple-50"}`}>
{p}
</button>
))}
@@ -254,4 +390,4 @@ export function DataTable<T extends Record<string, any> = any>({
</div>
</div>
);
}
}
+55 -56
View File
@@ -1,69 +1,68 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
// src/components/customs/Loader.tsx
import { Package } from "lucide-react";
import { cn } from "../../lib/utils";
interface LoaderProps {
size?: 'sm' | 'md' | 'lg' | 'xl';
text?: string;
export interface LoaderProps {
size?: "sm" | "md" | "lg" | "xl";
message?: string;
subMessage?: string;
progress?: number;
fullScreen?: boolean;
className?: string;
fullScreen?: boolean; // New prop for full page loader
}
/**
* Size mapping for consistent loader dimensions
*/
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-8 h-8',
lg: 'w-12 h-12',
xl: 'w-16 h-16',
};
/**
* Inline Loader - Used inside components, cards, buttons, etc.
*/
export const CustomLoader: React.FC<LoaderProps> = ({
size = 'md',
text,
className = '',
}) => {
return (
<div className={`flex flex-col items-center justify-center p-4 ${className}`}>
<Loader2 className={`${sizeClasses[size]} animate-spin text-primary`} />
{text && (
<p className="mt-2 text-sm text-gray-500 font-medium">{text}</p>
)}
</div>
);
};
/**
* Page Loader - Full screen overlay for initial loading or route transitions
*/
export const PageLoader: React.FC<LoaderProps> = ({
size = 'lg',
text = 'Loading...',
className = '',
}) => {
export function Loader({
size = "lg",
message = "Loading...",
subMessage,
progress,
fullScreen = false,
className,
}: LoaderProps) {
return (
<div
className={`fixed inset-0 z-[9999] flex flex-col items-center justify-center
bg-white/90 backdrop-blur-sm ${className}`}
className={cn(
"flex flex-col items-center justify-center gap-6 z-50",
fullScreen
? "fixed inset-0 bg-gradient-to-br from-purple-50/95 via-white/95 to-indigo-50/95 backdrop-blur-md"
: "min-h-[300px]",
className
)}
>
<div className="relative flex flex-col items-center">
<Loader2 className={`${sizeClasses[size]} animate-spin text-primary`} />
<div className="relative flex items-center justify-center">
<div className={cn(
"absolute border-4 border-purple-200 rounded-full animate-spin",
size === "xl" ? "w-20 h-20" : size === "lg" ? "w-16 h-16" : "w-10 h-10"
)} />
{/* Optional subtle ring effect */}
<div className={`absolute inset-0 ${sizeClasses[size]} border-4 border-gray-200 rounded-full`} />
<div
className={cn(
"absolute border-4 border-transparent border-t-purple-600 border-r-purple-600 rounded-full animate-spin",
size === "xl" ? "w-20 h-20" : size === "lg" ? "w-16 h-16" : "w-10 h-10"
)}
style={{ animationDuration: "1.2s", animationDirection: "reverse" }}
/>
<div className="absolute bg-white rounded-2xl p-3 shadow-xl shadow-purple-500/10">
<div className="bg-gradient-to-br from-purple-600 to-violet-600 text-white rounded-xl flex items-center justify-center">
<Package size={size === "xl" ? 52 : size === "lg" ? 42 : 28} strokeWidth={2.25} />
</div>
</div>
</div>
{text && (
<p className="mt-6 text-sm font-medium text-gray-600 tracking-wide animate-pulse">
{text}
</p>
<div className="text-center">
<p className="text-lg font-semibold text-gray-900">{message}</p>
{subMessage && <p className="text-sm text-gray-500 mt-1">{subMessage}</p>}
</div>
{typeof progress === "number" && (
<div className="w-64 bg-gray-100 h-1 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-purple-600 to-violet-600 transition-all duration-300"
style={{ width: `${Math.max(5, Math.min(100, progress))}%` }}
/>
</div>
)}
</div>
);
};
// Default export (most commonly used)
export default CustomLoader;
}
-211
View File
@@ -1,211 +0,0 @@
import type { ReactNode } from "react";
import { ChevronsUpDown, ChevronUp, ChevronDown } from "lucide-react";
import { useState, useMemo } from "react";
export interface TableColumn<T = any> {
key: string;
label: string;
sortable?: boolean;
width?: string;
render?: (value: any, row: T) => ReactNode;
}
export interface TableProps<T = any> {
columns: TableColumn<T>[];
data: T[];
onRowClick?: (row: T) => void;
selectable?: boolean;
selectedIds?: Set<string>;
onSelectionChange?: (selected: Set<string>) => void;
actions?: (row: T) => ReactNode;
rowIdKey?: keyof T;
/** Rendered inside the toolbar row above the table (e.g. SearchBar) */
header?: ReactNode;
/** Max height for the scrollable body. Defaults to 480px */
maxHeight?: string;
variant?: "card" | "flat";
}
export function Table<T extends Record<string, any> = any>({
columns,
data,
onRowClick,
selectable = false,
selectedIds = new Set(),
onSelectionChange,
actions,
rowIdKey = "id" as keyof T,
header,
maxHeight = "480px",
variant = "flat",
}: TableProps<T>) {
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const sortedData = useMemo(() => {
if (!sortColumn) return data;
return [...data].sort((a, b) => {
const aVal = a[sortColumn];
const bVal = b[sortColumn];
if (aVal == null) return 1;
if (bVal == null) return -1;
if (typeof aVal === "number" && typeof bVal === "number")
return sortDirection === "asc" ? aVal - bVal : bVal - aVal;
return sortDirection === "asc"
? String(aVal).localeCompare(String(bVal))
: String(bVal).localeCompare(String(aVal));
});
}, [data, sortColumn, sortDirection]);
const handleSort = (key: string) => {
if (sortColumn === key) {
setSortDirection(prev => (prev === "asc" ? "desc" : "asc"));
} else {
setSortColumn(key);
setSortDirection("asc");
}
};
const handleSelectRow = (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!onSelectionChange) return;
const next = new Set(selectedIds);
next.has(id) ? next.delete(id) : next.add(id);
onSelectionChange(next);
};
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!onSelectionChange) return;
e.target.checked
? onSelectionChange(new Set(sortedData.map(r => String(r[rowIdKey]))))
: onSelectionChange(new Set());
};
const isAllSelected =
sortedData.length > 0 &&
sortedData.every(r => selectedIds.has(String(r[rowIdKey])));
const isSomeSelected =
sortedData.some(r => selectedIds.has(String(r[rowIdKey]))) && !isAllSelected;
const colCount = columns.length + (selectable ? 1 : 0) + (actions ? 1 : 0);
return (
<div className={variant === "card" ? "bg-white rounded-xl overflow-hidden" : ""}>
{/* Toolbar */}
{header && (
<div className="px-4 py-3 border-b border-gray-100 bg-white">
{header}
</div>
)}
{/* Table wrapper: header is sticky, body scrolls */}
<div className="overflow-x-auto">
<div style={{ maxHeight, overflowY: "auto" }} className="relative">
<table className="w-full min-w-full border-collapse">
{/* ── Sticky thead ── */}
<thead className="sticky top-0 z-10" style={{ backgroundColor: "#F5F0FF" }}>
<tr className="border-b border-purple-100">
{selectable && (
<th className="w-10 px-4 py-3.5 text-left">
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-300 accent-purple-600 cursor-pointer"
checked={isAllSelected}
ref={el => { if (el) el.indeterminate = isSomeSelected; }}
onChange={handleSelectAll}
/>
</th>
)}
{columns.map(col => (
<th
key={col.key}
onClick={() => col.sortable && handleSort(col.key)}
className={`px-4 py-3.5 text-left text-xs font-bold uppercase tracking-wider select-none ${col.sortable ? "cursor-pointer hover:text-purple-800" : ""} ${col.width ?? ""}`}
style={{ color: "#7C3AED" }}
>
<div className="flex items-center gap-1">
{col.label}
{col.sortable && (
sortColumn === col.key ? (
sortDirection === "asc"
? <ChevronUp className="w-3.5 h-3.5 text-purple-600" />
: <ChevronDown className="w-3.5 h-3.5 text-purple-600" />
) : (
<ChevronsUpDown className="w-3.5 h-3.5 text-purple-300" />
)
)}
</div>
</th>
))}
{actions && (
<th
className="px-4 py-3.5 text-right text-xs font-bold uppercase tracking-wider"
style={{ color: "#7C3AED" }}
>
Action
</th>
)}
</tr>
</thead>
{/* ── Scrollable tbody ── */}
<tbody className="divide-y divide-gray-100 bg-white">
{sortedData.length === 0 ? (
<tr>
<td
colSpan={colCount}
className="px-4 py-14 text-center text-sm text-gray-400"
>
No records found
</td>
</tr>
) : (
sortedData.map(row => {
const id = String(row[rowIdKey]);
const isSelected = selectedIds.has(id);
return (
<tr
key={id}
onClick={() => onRowClick?.(row)}
className={`transition-colors ${onRowClick ? "cursor-pointer" : ""} ${isSelected ? "bg-purple-50/60" : "hover:bg-gray-50/70"}`}
>
{selectable && (
<td className="px-4 py-3.5">
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-300 accent-purple-600 cursor-pointer"
checked={isSelected}
onClick={e => handleSelectRow(id, e)}
onChange={() => {}}
/>
</td>
)}
{columns.map(col => (
<td
key={col.key}
className="px-4 py-3.5 text-sm text-gray-800 whitespace-nowrap"
>
{col.render
? col.render(row[col.key], row)
: (row[col.key] ?? "—")}
</td>
))}
{actions && (
<td
className="px-4 py-3.5 text-right"
onClick={e => e.stopPropagation()}
>
{actions(row)}
</td>
)}
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -106,7 +106,7 @@ export function Sidebar() {
}
}}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all group text-sm font-medium ${isActive || hasActiveChild
? "bg-primary text-white shadow-sm"
? "bg-primary/10 text-primary shadow-sm"
: "text-gray-700 hover:bg-gray-50"
}`}
>
@@ -1,11 +1,12 @@
import { useState } from "react";
import { Plus, Edit2, Trash2, Eye, Search, Filter, Image as ImageIcon, Video, FileText, CheckCircle2, Layers, RefreshCw, LayoutGrid, BarChart2 } from "lucide-react";
import { Plus, Image as ImageIcon, Video, FileText, CheckCircle2, Layers, RefreshCw, LayoutGrid, BarChart2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { DataTable } from "../../../components/customs/DataTable";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { AssetTypeStatsCard } from "../components/AssetTypeStatsCard";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
// Mock Data
const MOCK_ASSET_TYPES = [
@@ -109,7 +110,7 @@ const MOCK_ASSET_TYPES = [
export default function AssetTypeList() {
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState("");
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: "", name: "" });
return (
<PageWrapper>
@@ -160,35 +161,7 @@ export default function AssetTypeList() {
icon={<BarChart2 className="w-5 h-5" />}
/>
</div>
{/* Table & Controls Container */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden min-h-[500px] flex flex-col">
{/* Toolbar */}
<div className="p-4 border-b border-gray-200 bg-white flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="relative w-64">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search asset types..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-4 h-9 bg-white border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
<div className="flex items-center border border-gray-200 rounded-lg bg-white px-3 h-9 text-sm text-gray-600 gap-2 cursor-pointer hover:bg-gray-50">
<Filter className="w-4 h-4 text-gray-400" />
<span>All Categories</span>
</div>
<div className="flex items-center border border-gray-200 rounded-lg bg-white px-3 h-9 text-sm text-gray-600 gap-2 cursor-pointer hover:bg-gray-50">
<span>All Statuses</span>
</div>
</div>
<div className="text-sm text-gray-500">
12 of 12 types
</div>
</div>
{/* Table */}
<DataTable
columns={[
@@ -264,15 +237,21 @@ export default function AssetTypeList() {
}
]}
data={MOCK_ASSET_TYPES}
actions={() => (
<div className="flex items-center justify-end gap-2 text-gray-400 opacity-70 hover:opacity-100 transition-opacity">
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded border border-transparent hover:border-gray-200 shadow-sm transition-all"><Eye className="w-4 h-4" /></button>
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded border border-transparent hover:border-gray-200 shadow-sm transition-all"><Edit2 className="w-4 h-4" /></button>
<button className="p-1.5 hover:bg-white hover:text-red-600 rounded border border-transparent hover:border-gray-200 shadow-sm transition-all"><Trash2 className="w-4 h-4" /></button>
</div>
)}
actionConfig={{
onView: (row) => navigate(`/settings/asset-types/${row.id}`),
onEdit: (row) => navigate(`/settings/asset-types/${row.id}`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
}}
/>
</div>
<ConfirmationModal
isOpen={deleteModal.isOpen}
title="Delete Asset Type"
description="Are you sure you want to delete this asset type? This action cannot be undone."
itemName={deleteModal.name}
onConfirm={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</PageWrapper>
);
}
@@ -0,0 +1,60 @@
import type { ReactNode } from "react";
interface AssetCardProps {
title: string;
value: string | number;
subtitle: string;
icon?: ReactNode;
color: "purple" | "green" | "blue" | "slate" | "indigo" | "red" | "orange";
}
const COLOR_MAP = {
purple: { border: "border-purple-200", iconText: "text-purple-600", bgGradient: "bg-purple-50/40" },
green: { border: "border-green-200", iconText: "text-green-600", bgGradient: "bg-green-50/40" },
blue: { border: "border-blue-200", iconText: "text-blue-600", bgGradient: "bg-blue-50/40" },
slate: { border: "border-slate-200", iconText: "text-slate-500", bgGradient: "bg-slate-50/40" },
indigo: { border: "border-indigo-200", iconText: "text-indigo-600", bgGradient: "bg-indigo-50/40" },
red: { border: "border-red-200", iconText: "text-red-600", bgGradient: "bg-red-50/40" },
orange: { border: "border-orange-200", iconText: "text-orange-600", bgGradient: "bg-orange-50/40" },
};
export function AssetCard({ title, value, subtitle, icon, color }: AssetCardProps) {
const c = COLOR_MAP[color];
return (
<div
className={`
${c.bgGradient}
${c.border}
border
rounded-lg
h-[110px]
px-4
py-3
flex
flex-col
transition-all
duration-200
hover:shadow-sm
`}
>
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-[15px] font-medium text-slate-600 leading-none">
{title}
</span>
{icon && <span className={c.iconText}>{icon}</span>}
</div>
{/* Value */}
<div className="mt-1 text-[24px] font-bold leading-none text-slate-900">
{value}
</div>
{/* Subtitle */}
<div className="mt-2 text-[12px] leading-none text-slate-500">
{subtitle}
</div>
</div>
);
}
+183 -225
View File
@@ -1,26 +1,16 @@
import { useState } from "react";
import {
Search, ChevronDown, Download, Archive, Upload,
Image as ImageIcon, Video, FileText, Eye, Edit2, List, Grid,
FileBadge, BookOpen, Presentation, PenTool, Package, Layers,
ChevronRight
import { useNavigate } from "react-router-dom";
import {
Download, Archive, Upload,
Image as ImageIcon, Video, FileText, Grid,
FileBadge, Presentation, Package, Layers,
ChevronRight, TrendingUp, AlertTriangle, Clock
} from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { DataTable } from "../../../components/customs/DataTable";
// Mock Data
const ASSET_CATEGORIES = [
{ id: "all", name: "All Assets", count: 125, icon: Layers, active: true },
{ id: "product-images", name: "Product Images", count: 45, icon: ImageIcon },
{ id: "variant-images", name: "Variant Images", count: 32, icon: Package },
{ id: "videos", name: "Videos", count: 8, icon: Video },
{ id: "documents", name: "Documents", count: 15, icon: FileText },
{ id: "certificates", name: "Certificates", count: 12, icon: FileBadge },
{ id: "manuals", name: "User Manuals", count: 6, icon: BookOpen },
{ id: "marketing", name: "Marketing Assets", count: 18, icon: Presentation },
{ id: "drawings", name: "Technical Drawings", count: 4, icon: PenTool },
{ id: "packaging", name: "Packaging Assets", count: 10, icon: Package },
];
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { AssetCard } from "../components/AssetCard"; // ← Imported AssetCard
const MOCK_ASSETS = [
{
@@ -118,8 +108,9 @@ const MOCK_VARIANT_GROUPS = [
];
export default function AssetList() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState("Product Assets");
const getAssetIcon = (type: string, color: string) => {
if (type === "Image") return <ImageIcon className={`w-5 h-5 ${color}`} />;
if (type === "Video") return <Video className={`w-5 h-5 ${color}`} />;
@@ -127,96 +118,11 @@ export default function AssetList() {
};
return (
<div className="flex h-[calc(100vh-64px)] bg-gray-50/50">
{/* Left Sidebar */}
<div className="w-64 bg-white border-r border-gray-200 flex flex-col h-full overflow-y-auto">
<div className="p-4 border-b border-gray-100">
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search assets..."
className="w-full pl-9 pr-4 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
</div>
<div className="p-4 border-b border-gray-100">
<h3 className="text-xs font-bold text-gray-500 tracking-wider mb-3">FILTERS</h3>
<div className="space-y-4">
<div>
<label className="text-xs text-gray-500 mb-1.5 block">Asset Type</label>
<div className="relative">
<select className="w-full appearance-none bg-white border border-gray-200 text-gray-700 py-2 px-3 pr-8 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-500">
<option>All Types</option>
</select>
<ChevronDown className="w-4 h-4 text-gray-400 absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none" />
</div>
</div>
<div>
<label className="text-xs text-gray-500 mb-1.5 block">Status</label>
<div className="relative">
<select className="w-full appearance-none bg-white border border-gray-200 text-gray-700 py-2 px-3 pr-8 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-500">
<option>All Status</option>
</select>
<ChevronDown className="w-4 h-4 text-gray-400 absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none" />
</div>
</div>
</div>
</div>
<div className="p-4 flex-1">
<h3 className="text-xs font-bold text-gray-500 tracking-wider mb-2">ASSET CATEGORIES</h3>
<ul className="space-y-1">
{ASSET_CATEGORIES.map((cat) => (
<li key={cat.id}>
<button className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors ${cat.active ? 'bg-purple-50 text-purple-700 font-medium' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}`}>
<div className="flex items-center gap-2.5">
<cat.icon className="w-4 h-4" />
{cat.name}
</div>
<span className={cat.active ? 'text-purple-600' : 'text-gray-400'}>{cat.count}</span>
</button>
</li>
))}
</ul>
</div>
<div className="p-4">
<div className="bg-purple-50/50 rounded-xl p-4 border border-purple-100/50">
<h3 className="text-xs font-bold text-gray-700 tracking-wider mb-3">STORAGE SUMMARY</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between items-center">
<span className="text-gray-600">Total Assets</span>
<span className="font-medium text-gray-900">10</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Storage Used</span>
<span className="font-medium text-purple-600">2.8 GB</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Active</span>
<span className="font-medium text-emerald-600">8</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Pending Review</span>
<span className="font-medium text-amber-600">1</span>
</div>
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="bg-white border-b border-gray-200 px-6 py-5 flex justify-between items-start">
<div>
<h1 className="text-2xl font-bold text-gray-900">Product Asset Management</h1>
<p className="text-sm text-gray-500 mt-1">Manage images, videos, documents, certificates, manuals, marketing content and variant-specific assets</p>
</div>
<div className="flex items-center gap-3">
<PageWrapper>
<Breadcrumb
items={[{ label: "Home" }, { label: "Asset Manager" }]}
actions={
<>
<Button variant="outline" className="text-gray-700">
<Download className="w-4 h-4 mr-2" />
Download
@@ -229,78 +135,124 @@ export default function AssetList() {
<Upload className="w-4 h-4 mr-2" />
Upload Asset
</Button>
</>
}
/>
{/* Stats Cards using AssetCard */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<AssetCard
title="Total Assets"
value={2847}
subtitle="All assets"
icon={<Package className="w-5 h-5" />}
color="purple"
/>
<AssetCard
title="Active Assets"
value={2712}
subtitle="Currently in use"
icon={<TrendingUp className="w-5 h-5" />}
color="green"
/>
<AssetCard
title="Orphan Assets"
value={47}
subtitle="Needs cleanup"
icon={<AlertTriangle className="w-5 h-5" />}
color="red"
/>
<AssetCard
title="Last Upload"
value="2h ago"
subtitle="Today at 10:45"
icon={<Clock className="w-5 h-5" />}
color="slate"
/>
</div>
{/* Main Content Container */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
{/* Tabs */}
<div className="border-b border-gray-200 px-6">
<div className="flex gap-8 overflow-x-auto">
{[
"Product Assets",
"Variant Assets",
"Asset Library",
"Asset Relations",
"Usage Analytics"
].map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`py-4 px-1 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${
activeTab === tab
? "border-purple-600 text-purple-700"
: "border-transparent text-gray-500 hover:text-gray-900 hover:border-gray-300"
}`}
>
{tab === "Product Assets" && <Package className="w-4 h-4" />}
{tab === "Variant Assets" && <Grid className="w-4 h-4" />}
{tab === "Asset Library" && <Layers className="w-4 h-4" />}
{tab === "Asset Relations" && <FileBadge className="w-4 h-4" />}
{tab}
</button>
))}
</div>
</div>
{/* Tabs */}
<div className="bg-white px-6 border-b border-gray-200 flex gap-6">
{["Product Assets", "Variant Assets", "Asset Library", "Asset Relations", "Usage Analytics"].map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`py-3.5 px-1 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 ${
activeTab === tab
? 'border-purple-600 text-purple-700'
: 'border-transparent text-gray-500 hover:text-gray-900 hover:border-gray-300'
}`}
>
{tab === "Product Assets" && <Package className="w-4 h-4" />}
{tab === "Variant Assets" && <Grid className="w-4 h-4" />}
{tab === "Asset Library" && <Layers className="w-4 h-4" />}
{tab}
</button>
))}
</div>
{/* Content Area */}
<div className="flex-1 overflow-auto px-6 pb-6 pt-4">
<div className="p-6">
{activeTab === "Product Assets" && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden flex flex-col">
{/* Toolbar */}
<div className="p-4 border-b border-gray-200 bg-white flex justify-between items-center">
<Button variant="outline" className="text-gray-700 bg-white h-9 px-3">
<Upload className="w-4 h-4 mr-2" />
Bulk Upload
</Button>
<div className="flex items-center gap-1 bg-white border border-gray-200 rounded-lg p-1">
<button className="p-1.5 bg-purple-50 text-purple-600 rounded shadow-sm cursor-pointer">
<List className="w-4 h-4" />
</button>
<button className="p-1.5 text-gray-400 hover:text-gray-600 cursor-pointer">
<Grid className="w-4 h-4" />
</button>
</div>
</div>
{/* Table */}
<DataTable
selectable
columns={[
{ key: "preview", label: "PREVIEW", render: (_: any, row: any) => (
<DataTable
selectable
columns={[
{
key: "preview",
label: "PREVIEW",
render: (_: any, row: any) => (
<div className="w-12 h-12 rounded-lg bg-gray-50 border border-gray-200 flex items-center justify-center">
{getAssetIcon(row.type, row.iconColor)}
</div>
)},
{ key: "name", label: "ASSET NAME", render: (val: string, row: any) => (
)
},
{
key: "name",
label: "ASSET NAME",
render: (val: string, row: any) => (
<div className="min-w-[180px]">
<div className="font-medium text-gray-900">{val}</div>
<div className="text-xs text-gray-500 mt-0.5">{row.ext}</div>
</div>
)},
{ key: "type", label: "ASSET TYPE", render: (val: string, row: any) => (
)
},
{
key: "type",
label: "ASSET TYPE",
render: (val: string, row: any) => (
<span className={`inline-flex items-center px-2.5 py-1 rounded border text-xs font-medium ${row.typeColor}`}>
{val}
</span>
)},
{ key: "product", label: "PRODUCT", render: (val: string, row: any) => (
)
},
{
key: "product",
label: "PRODUCT",
render: (val: string, row: any) => (
<div>
<div className="text-sm text-gray-900">{val}</div>
<div className="text-xs text-gray-500 mt-0.5">{row.productYear}</div>
</div>
)},
{ key: "size", label: "FILE SIZE", render: (val: string) => <span className="text-sm text-gray-600 whitespace-nowrap">{val}</span> },
{ key: "resolution", label: "RESOLUTION", render: (val: string) => <span className="text-sm text-gray-600 whitespace-nowrap">{val}</span> },
{ key: "tags", label: "TAGS", render: (val: string[]) => (
)
},
{ key: "size", label: "FILE SIZE", render: (val: string) => <span className="text-sm text-gray-600 whitespace-nowrap">{val}</span> },
{ key: "resolution", label: "RESOLUTION", render: (val: string) => <span className="text-sm text-gray-600 whitespace-nowrap">{val}</span> },
{
key: "tags",
label: "TAGS",
render: (val: string[]) => (
<div className="flex flex-wrap gap-1.5 min-w-[150px]">
{val.map((tag, i) => (
<span key={i} className="inline-flex px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 border border-gray-200 text-[11px] font-medium">
@@ -308,33 +260,36 @@ export default function AssetList() {
</span>
))}
</div>
)},
{ key: "status", label: "STATUS", render: (val: string) => (
)
},
{
key: "status",
label: "STATUS",
render: (val: string) => (
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-emerald-50 text-emerald-700 border border-emerald-100">
{val}
</span>
)}
]}
data={MOCK_ASSETS}
actions={() => (
<div className="flex items-center justify-end gap-2 text-gray-400 opacity-70 hover:opacity-100 transition-opacity">
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded border border-transparent hover:border-gray-200 shadow-sm transition-all"><Eye className="w-4 h-4" /></button>
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded border border-transparent hover:border-gray-200 shadow-sm transition-all"><Edit2 className="w-4 h-4" /></button>
</div>
)}
/>
</div>
)
}
]}
data={MOCK_ASSETS}
actionConfig={{
onView: (row) => navigate(`${row.id}/view`),
onEdit: (row) => navigate(`${row.id}/edit`),
onDelete: (row) => console.log("Delete asset", row.id)
}}
/>
)}
{activeTab === "Variant Assets" && (
<div>
<div className="mb-4">
<div className="mb-6">
<h2 className="text-lg font-semibold text-gray-900">Variant Asset Management</h2>
<p className="text-sm text-gray-500">Manage assets for specific product variants</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{MOCK_VARIANT_GROUPS.map((group) => (
<div key={group.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div key={group.id} className="bg-white rounded-lg shadow-sm border border-gray-200 p-5">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-lg bg-purple-50 flex items-center justify-center border border-purple-100">
<Package className="w-5 h-5 text-purple-600" />
@@ -366,72 +321,75 @@ export default function AssetList() {
</div>
)}
{activeTab === "Asset Relations" && (
<div className="flex flex-col items-center justify-center py-20 text-center min-h-[400px]">
<div className="w-16 h-16 rounded-2xl bg-purple-50 flex items-center justify-center mb-6">
<FileBadge className="w-8 h-8 text-purple-600" />
</div>
<h3 className="text-xl font-semibold text-gray-900">Asset Relationship Viewer</h3>
<p className="text-sm text-gray-500 mt-2 max-w-md">
Visualize asset usage across products, variants, and channels
</p>
<div className="mt-8 text-xs text-gray-400">This feature is coming soon</div>
</div>
)}
{activeTab === "Usage Analytics" && (
<div className="space-y-6">
{/* KPIs */}
<div className="space-y-8">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-5">
<h3 className="text-sm font-medium text-gray-500 mb-2">Total Assets</h3>
<div className="text-3xl font-bold text-gray-900">10</div>
<div className="text-3xl font-bold text-gray-900">2,847</div>
<p className="text-xs text-emerald-600 mt-1"> 12% from last month</p>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-5">
<h3 className="text-sm font-medium text-gray-500 mb-2">Most Used Asset</h3>
<div className="text-lg font-bold text-gray-900">Nike Front View</div>
<p className="text-xs text-gray-500 mt-1">Used in 42 products</p>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-5">
<h3 className="text-sm font-medium text-gray-500 mb-2">Orphan Assets</h3>
<div className="text-3xl font-bold text-amber-500">3</div>
<div className="text-3xl font-bold text-amber-500">47</div>
<p className="text-xs text-amber-600 mt-1">Need cleanup</p>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-5">
<h3 className="text-sm font-medium text-gray-500 mb-2">Missing Alt Text</h3>
<div className="text-3xl font-bold text-red-500">8</div>
<div className="text-3xl font-bold text-red-500">183</div>
<p className="text-xs text-red-600 mt-1">Needs attention</p>
</div>
</div>
{/* Supported Types */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-sm font-bold text-gray-900 mb-4">Asset Types Supported</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-4 border border-gray-100 rounded-xl">
<div className="flex items-center gap-2 mb-2">
<ImageIcon className="w-5 h-5 text-purple-600" />
<h4 className="font-semibold text-gray-900">Images</h4>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="font-semibold text-lg mb-5">Supported Asset Types</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div className="p-4 border border-gray-100 rounded-lg flex flex-col items-center text-center">
<ImageIcon className="w-8 h-8 text-blue-600 mb-3" />
<h4 className="font-medium">Images</h4>
<p className="text-xs text-gray-500">JPG, PNG, WEBP</p>
</div>
<div className="p-4 border border-gray-100 rounded-xl">
<div className="flex items-center gap-2 mb-2">
<Video className="w-5 h-5 text-purple-600" />
<h4 className="font-semibold text-gray-900">Videos</h4>
</div>
<div className="p-4 border border-gray-100 rounded-lg flex flex-col items-center text-center">
<Video className="w-8 h-8 text-purple-600 mb-3" />
<h4 className="font-medium">Videos</h4>
<p className="text-xs text-gray-500">MP4, MOV</p>
</div>
<div className="p-4 border border-gray-100 rounded-xl">
<div className="flex items-center gap-2 mb-2">
<FileText className="w-5 h-5 text-purple-600" />
<h4 className="font-semibold text-gray-900">Documents</h4>
</div>
<div className="p-4 border border-gray-100 rounded-lg flex flex-col items-center text-center">
<FileText className="w-8 h-8 text-emerald-600 mb-3" />
<h4 className="font-medium">Documents</h4>
<p className="text-xs text-gray-500">PDF, DOCX</p>
</div>
<div className="p-4 border border-gray-100 rounded-xl">
<div className="flex items-center gap-2 mb-2">
<FileBadge className="w-5 h-5 text-purple-600" />
<h4 className="font-semibold text-gray-900">Certificates</h4>
</div>
<div className="p-4 border border-gray-100 rounded-lg flex flex-col items-center text-center">
<FileBadge className="w-8 h-8 text-amber-600 mb-3" />
<h4 className="font-medium">Certificates</h4>
<p className="text-xs text-gray-500">ISO, CE, RoHS</p>
</div>
<div className="p-4 border border-gray-100 rounded-xl">
<div className="flex items-center gap-2 mb-2">
<Presentation className="w-5 h-5 text-purple-600" />
<h4 className="font-semibold text-gray-900">Marketing</h4>
</div>
<p className="text-xs text-gray-500">Banners, Brochures</p>
<div className="p-4 border border-gray-100 rounded-lg flex flex-col items-center text-center">
<Presentation className="w-8 h-8 text-rose-600 mb-3" />
<h4 className="font-medium">Marketing</h4>
<p className="text-xs text-gray-500">Banners, Posters</p>
</div>
<div className="p-4 border border-gray-100 rounded-xl">
<div className="flex items-center gap-2 mb-2">
<Package className="w-5 h-5 text-purple-600" />
<h4 className="font-semibold text-gray-900">Packaging</h4>
</div>
<div className="p-4 border border-gray-100 rounded-lg flex flex-col items-center text-center">
<Package className="w-8 h-8 text-indigo-600 mb-3" />
<h4 className="font-medium">Packaging</h4>
<p className="text-xs text-gray-500">Labels, Artwork</p>
</div>
</div>
@@ -440,7 +398,7 @@ export default function AssetList() {
)}
{(activeTab === "Asset Library" || activeTab === "Asset Relations") && (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="flex flex-col items-center justify-center py-20 text-center min-h-[400px]">
<Layers className="w-12 h-12 text-gray-300 mb-4" />
<h3 className="text-lg font-medium text-gray-900">Coming Soon</h3>
<p className="text-sm text-gray-500 mt-1">This section is currently under development.</p>
@@ -448,6 +406,6 @@ export default function AssetList() {
)}
</div>
</div>
</div>
</PageWrapper>
);
}
}
@@ -7,6 +7,7 @@ import { Button } from "../../../components/customs/Button";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { DataTable } from "../../../components/customs/DataTable";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
interface AttributeGroup {
id: string;
@@ -29,6 +30,7 @@ const mockGroups: AttributeGroup[] = [
export default function AttributeGroupList() {
const navigate = useNavigate();
const [statusFilter, setStatusFilter] = useState("All Status");
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: "", name: "" });
const filteredGroups = useMemo(() => {
return mockGroups.filter((group) =>
@@ -127,8 +129,22 @@ export default function AttributeGroupList() {
<option>Disabled</option>
</select>
}
actionConfig={{
onView: (row) => navigate(`/attribute-groups/${row.id}/view`),
onEdit: (row) => navigate(`/attribute-groups/${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
}}
/>
</div>
<ConfirmationModal
isOpen={deleteModal.isOpen}
title="Delete Attribute Group"
description="Are you sure you want to delete this attribute group? This action cannot be undone."
itemName={deleteModal.name}
onConfirm={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</PageWrapper>
</ProtectedRoute>
);
@@ -1,5 +1,5 @@
import { useEffect, useMemo } from "react";
import { Plus, Edit2, Trash2, Eye, Copy, Grid3x3, CheckCircle2, LayoutGrid, Star, Filter } from "lucide-react";
import { Plus, Grid3x3, CheckCircle2, LayoutGrid, Star, Filter } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
@@ -225,14 +225,11 @@ export default function AttributeList() {
Filters
</Button>
}
actions={(row) => (
<div className="flex items-center justify-end gap-2 text-gray-400">
<Eye className="w-4 h-4 cursor-pointer hover:text-gray-700 transition-colors" />
<Edit2 className="w-4 h-4 cursor-pointer hover:text-blue-600 transition-colors" onClick={(e) => { e.stopPropagation(); navigate(`/attributes/${row.id}/edit`); }} />
<Copy className="w-4 h-4 cursor-pointer hover:text-gray-700 transition-colors" />
<Trash2 className="w-4 h-4 cursor-pointer hover:text-red-500 transition-colors" onClick={(e) => { e.stopPropagation(); setDeleteModal({ isOpen: true, id: row.id, name: row.name }); }} />
</div>
)}
actionConfig={{
onView: (row) => navigate(`/attributes/${row.id}/view`),
onEdit: (row) => navigate(`/attributes/${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
}}
/>
</div>
+6 -23
View File
@@ -1,5 +1,5 @@
import { useEffect } from "react";
import { Plus, Edit2, Trash2 } from "lucide-react";
import { Plus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
@@ -76,28 +76,11 @@ export default function BrandList() {
data={brands}
onRowClick={(row) => navigate(`/brands/${row.id}/edit`)}
searchPlaceholder="Search brands by code, name, or description..."
actions={(row) => (
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
icon={<Edit2 className="w-4 h-4" />}
onClick={(e) => {
e?.stopPropagation();
navigate(`/brands/${row.id}/edit`);
}}
/>
<Button
variant="ghost"
size="sm"
icon={<Trash2 className="w-4 h-4 text-red-500 hover:bg-red-50" />}
onClick={(e) => {
e?.stopPropagation();
setDeleteModal({ isOpen: true, id: row.id, name: row.name });
}}
/>
</div>
)}
actionConfig={{
onView: (row) => navigate(`/brands/${row.id}/view`),
onEdit: (row) => navigate(`/brands/${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
}}
/>
</div>
@@ -1,5 +1,5 @@
import { useEffect } from "react";
import { Plus, Edit2, Trash2, Eye, Grid3x3, Layers, Package, LayoutGrid } from "lucide-react";
import { Plus, Grid3x3, Layers, Package, LayoutGrid } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
@@ -127,13 +127,11 @@ export default function CategoryList() {
data={categories}
onRowClick={(row) => navigate(`/categories/${row.id}/edit`)}
searchPlaceholder="Search categories by code, name, parent category, or description..."
actions={(row) => (
<div className="flex items-center justify-end gap-3 text-gray-400 px-2">
<Eye className="w-4 h-4 cursor-pointer hover:text-gray-700 transition-colors" />
<Edit2 className="w-4 h-4 cursor-pointer hover:text-blue-600 transition-colors" onClick={(e) => { e.stopPropagation(); navigate(`/categories/${row.id}/edit`); }} />
<Trash2 className="w-4 h-4 cursor-pointer hover:text-red-500 transition-colors" onClick={(e) => { e.stopPropagation(); setDeleteModal({ isOpen: true, id: row.id, name: row.name }); }} />
</div>
)}
actionConfig={{
onView: (row) => navigate(`/categories/${row.id}/view`),
onEdit: (row) => navigate(`/categories/${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
}}
/>
</div>
@@ -0,0 +1,2 @@
import { channelTypesService } from '../services/channel-types.service';
export const channelTypesApi = channelTypesService;
@@ -1,20 +1,21 @@
import type { ReactNode } from "react";
/**
* ImportStatsCard a premium styled card component mirroring the visual design of ProductCard.
* It displays a title, a prominent value, a subtitle, and an optional icon.
* ProductCard a premium styled card component mirroring the visual design of VariantStatsCards.
* It displays a title, a prominent value (e.g., price), a subtitle, and an optional icon.
* The appearance can be themed via the `color` prop which selects a soft background gradient and border.
*/
interface ImportStatsCardProps {
interface ChannelTypeCardProps {
title: string;
value: string | number;
subtitle: string;
/** Optional icon or image displayed on the right side */
icon?: ReactNode;
/** Colour theme for the card */
/** Colour theme for the card matches VariantStatsCards themes */
color: "purple" | "green" | "blue" | "slate" | "indigo" | "red" | "orange";
}
// Reuse the same colour map as VariantStatsCards for visual consistency
const COLOR_MAP = {
purple: {
border: "border-purple-200",
@@ -53,7 +54,7 @@ const COLOR_MAP = {
},
};
export function ImportStatsCard({ title, value, subtitle, icon, color }: ImportStatsCardProps) {
export function ChannelTypeCard({ title, value, subtitle, icon, color }: ChannelTypeCardProps) {
const c = COLOR_MAP[color];
return (
<div
@@ -0,0 +1,67 @@
import { useState, useCallback } from 'react';
import { channelTypesService } from '../services/channel-types.service';
import type { ChannelType, ChannelTypeCreateRequest, ChannelTypeUpdateRequest } from '../types/channel-types.types';
import { toast } from 'react-toastify';
export const useChannelType = () => {
const [items, setItems] = useState<ChannelType[]>([]);
const [loading, setLoading] = useState(false);
const fetchItems = useCallback(async () => {
setLoading(true);
try {
const data = await channelTypesService.getAll();
setItems(data);
} catch (err: any) {
toast.error(err.message || 'Failed to fetch channel types');
} finally {
setLoading(false);
}
}, []);
const createItem = useCallback(async (req: ChannelTypeCreateRequest) => {
setLoading(true);
try {
const created = await channelTypesService.create(req);
setItems((prev) => [...prev, created]);
toast.success('Channel type created successfully!');
return created;
} catch (err: any) {
toast.error(err.message || 'Failed to create channel type');
throw err;
} finally {
setLoading(false);
}
}, []);
const updateItem = useCallback(async (id: string, req: ChannelTypeUpdateRequest) => {
setLoading(true);
try {
const updated = await channelTypesService.update(id, req);
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
toast.success('Channel type updated successfully!');
return updated;
} catch (err: any) {
toast.error(err.message || 'Failed to update channel type');
throw err;
} finally {
setLoading(false);
}
}, []);
const deleteItem = useCallback(async (id: string) => {
setLoading(true);
try {
await channelTypesService.delete(id);
setItems((prev) => prev.filter((p) => p.id !== id));
toast.success('Channel type deleted successfully!');
} catch (err: any) {
toast.error(err.message || 'Failed to delete channel type');
throw err;
} finally {
setLoading(false);
}
}, []);
return { items, loading, fetchItems, createItem, updateItem, deleteItem };
};
+4
View File
@@ -0,0 +1,4 @@
export * from './types/channel-types.types';
export * from './services/channel-types.service';
export * from './hook/useChannelType';
export * from './routes/channel-types.routes';
@@ -0,0 +1,205 @@
import { useEffect, useState } from "react";
import {
Plus,
RefreshCw,
Layers2,
CheckCircle,
XCircle,
LayoutGrid,
// Import all icons used in ICON_MAP
ShoppingCart,
ShoppingBag,
Server,
Warehouse,
Store,
Globe,
Smartphone,
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";
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 { ChannelTypeCard } from "../components/ChannelTypeCard";
const ICON_MAP: Record<string, any> = {
ShoppingCart,
ShoppingBag,
Server,
Warehouse,
Store,
Globe,
Smartphone,
Monitor,
LayoutGrid,
};
export default function ChannelTypeList() {
const navigate = useNavigate();
const { items, fetchItems, loading, deleteItem } = useChannelType();
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({
isOpen: false,
id: "",
name: "",
});
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
fetchItems();
}, [fetchItems]);
const totalActive = items.filter(i => i.status === "active").length;
const totalInactive = items.filter(i => i.status === "inactive").length;
const totalChannels = items.reduce((sum, i) => sum + (i.channelCount ?? 0), 0);
const columns = [
{
key: "name",
label: "Type Name",
sortable: true,
render: (_: any, row: ChannelType) => {
const Icon = ICON_MAP[row.icon || ""] ?? LayoutGrid;
return (
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-purple-50 flex items-center justify-center shrink-0">
<Icon className="w-4 h-4 text-purple-600" />
</div>
<div>
<div className="font-medium text-gray-900">{row.name}</div>
<div className="text-xs text-gray-400 font-mono mt-0.5">{row.code}</div>
</div>
</div>
);
},
},
{
key: "description",
label: "Description",
render: (val: string) => <span className="text-sm text-gray-600 line-clamp-1">{val || "—"}</span>,
},
{
key: "channelCount",
label: "Channels",
render: (val: number) => (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-purple-50 text-purple-700">
{val ?? 0} channels
</span>
),
},
{
key: "status",
label: "Status",
render: (val: any) => <StatusBadge status={val} />,
},
{
key: "createdAt",
label: "Created",
render: (val: any, row: ChannelType) => (
<div>
<div className="text-gray-900 font-medium text-sm">
{new Date(val).toLocaleDateString()}
</div>
<div className="text-gray-500 text-xs mt-0.5">{row.author || "Admin"}</div>
</div>
),
},
];
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
items={[{ label: "Home" }, { label: "Channel Types" }]}
actions={
<>
<Button
variant="outline"
className="bg-white border-gray-200 text-gray-700 hover:bg-gray-50"
onClick={fetchItems}
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
<Button onClick={() => navigate("new")} className="bg-primary hover:bg-primary-hover text-white">
<Plus className="w-4 h-4 mr-2" />
Add Channel Type
</Button>
</>
}
/>
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<ChannelTypeCard
title="Total Types"
value={items.length}
subtitle="All channel types"
color="purple"
icon={<Layers2 className="w-5 h-5" />}
/>
<ChannelTypeCard
title="Active Types"
value={totalActive}
subtitle="Currently active"
color="green"
icon={<CheckCircle className="w-5 h-5" />}
/>
<ChannelTypeCard
title="Inactive Types"
value={totalInactive}
subtitle="Not in use"
color="red"
icon={<XCircle className="w-5 h-5" />}
/>
<ChannelTypeCard
title="Total Channels"
value={totalChannels}
subtitle="Linked channels"
color="blue"
icon={<LayoutGrid className="w-5 h-5" />}
/>
</div>
<DataTable
columns={columns}
data={items}
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>
);
}
@@ -0,0 +1,224 @@
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import {
ShoppingCart, ShoppingBag, Server, Warehouse, Store,
Globe, Smartphone, Monitor, LayoutGrid,
} from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { useChannelType } from "../hook/useChannelType";
import { channelTypeSchema } from "../validation/channel-types.schema";
const ICON_OPTIONS = [
{ id: "ShoppingCart", label: "E-Commerce", icon: ShoppingCart, bg: "bg-blue-50", color: "text-blue-600" },
{ id: "ShoppingBag", label: "Marketplace", icon: ShoppingBag, bg: "bg-orange-50", color: "text-orange-600" },
{ id: "Server", label: "ERP", icon: Server, bg: "bg-red-50", color: "text-red-600" },
{ id: "Warehouse", label: "WMS", icon: Warehouse, bg: "bg-yellow-50", color: "text-yellow-600" },
{ id: "Store", label: "POS", icon: Store, bg: "bg-green-50", color: "text-green-600" },
{ id: "Globe", label: "B2B Portal", icon: Globe, bg: "bg-cyan-50", color: "text-cyan-600" },
{ id: "Smartphone", label: "Mobile App", icon: Smartphone, bg: "bg-purple-50", color: "text-purple-600" },
{ id: "Monitor", label: "Website", icon: Monitor, bg: "bg-indigo-50", color: "text-indigo-600" },
{ id: "LayoutGrid", label: "Other", icon: LayoutGrid, bg: "bg-gray-100", color: "text-gray-600" },
];
export default function NewChannelType() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const isEdit = Boolean(id);
const { createItem, updateItem, fetchItems, items, loading } = useChannelType();
useEffect(() => {
if (isEdit) fetchItems();
}, [isEdit, fetchItems]);
const existing = isEdit ? items.find(i => i.id === id) : undefined;
const formik = useFormik({
enableReinitialize: true,
initialValues: {
name: existing?.name ?? "",
code: existing?.code ?? "",
description: existing?.description ?? "",
icon: existing?.icon ?? "LayoutGrid",
status: existing?.status ?? "active" as "active" | "inactive",
},
validationSchema: channelTypeSchema,
onSubmit: async (values) => {
try {
if (isEdit && id) {
await updateItem(id, values);
} else {
await createItem({ ...values, channelCount: 0 });
}
navigate("/channel-types");
} catch {
// handled by hook
}
},
});
// Auto-generate code from name
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
formik.setFieldValue("name", val);
if (!isEdit) {
formik.setFieldValue(
"code",
val.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/g, "")
);
}
};
return (
<PageWrapper>
<Breadcrumb
items={[
{ label: "Home" },
{ label: "Channel Types", href: "/channel-types" },
{ label: isEdit ? "Edit Channel Type" : "New Channel Type" },
]}
backTo="/channel-types"
actions={
<>
<Button variant="outline" type="button" onClick={() => navigate("/channel-types")}>
Cancel
</Button>
<Button
type="button"
onClick={() => formik.submitForm()}
loading={formik.isSubmitting || loading}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
{isEdit ? "Update Channel Type" : "Save Channel Type"}
</Button>
</>
}
/>
<form onSubmit={formik.handleSubmit} className="max-w-3xl mx-auto space-y-6 pb-10">
{/* 1. Basic Information */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-6 h-6 bg-purple-600 text-white rounded-full flex items-center justify-center text-xs font-bold">1</div>
<h2 className="text-lg font-semibold text-gray-900">Basic Information</h2>
</div>
<div className="space-y-5 pl-9">
<div className="grid grid-cols-2 gap-5">
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Type Name <span className="text-red-500">*</span>
</label>
<input
name="name"
value={formik.values.name}
onChange={handleNameChange}
onBlur={formik.handleBlur}
placeholder="e.g. E-Commerce"
className={`w-full border rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-white ${
formik.touched.name && formik.errors.name
? "border-red-400 focus:ring-red-400"
: "border-gray-200 focus:ring-purple-500"
}`}
/>
{formik.touched.name && formik.errors.name && (
<p className="text-xs text-red-500 mt-1">{formik.errors.name}</p>
)}
</div>
{/* Code */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Code <span className="text-red-500">*</span>
</label>
<input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. ecommerce"
className={`w-full border rounded-lg px-3 py-2.5 text-sm font-mono focus:outline-none focus:ring-2 focus:border-transparent bg-white ${
formik.touched.code && formik.errors.code
? "border-red-400 focus:ring-red-400"
: "border-gray-200 focus:ring-purple-500"
}`}
/>
{formik.touched.code && formik.errors.code && (
<p className="text-xs text-red-500 mt-1">{formik.errors.code}</p>
)}
<p className="text-xs text-gray-400 mt-1">Lowercase letters, numbers and underscores only</p>
</div>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Description</label>
<textarea
name="description"
value={formik.values.description}
onChange={formik.handleChange}
rows={3}
placeholder="Describe what kind of channels belong to this type..."
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent bg-white resize-none"
/>
</div>
</div>
</div>
{/* 2. Icon */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-6 h-6 bg-purple-600 text-white rounded-full flex items-center justify-center text-xs font-bold">2</div>
<h2 className="text-lg font-semibold text-gray-900">Icon</h2>
</div>
<div className="pl-9">
<p className="text-sm text-gray-500 mb-4">Choose an icon that best represents this channel type.</p>
<div className="grid grid-cols-5 gap-3">
{ICON_OPTIONS.map((opt) => {
const Icon = opt.icon;
const isSelected = formik.values.icon === opt.id;
return (
<button
key={opt.id}
type="button"
onClick={() => formik.setFieldValue("icon", opt.id)}
className={`flex flex-col items-center justify-center gap-2 p-3 rounded-xl border-2 transition-all ${
isSelected
? "border-purple-500 bg-purple-50 shadow-sm"
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
}`}
>
<div className={`w-9 h-9 rounded-lg flex items-center justify-center ${opt.bg}`}>
<Icon className={`w-4 h-4 ${opt.color}`} />
</div>
<span className={`text-xs font-medium text-center leading-tight ${isSelected ? "text-purple-700" : "text-gray-600"}`}>
{opt.label}
</span>
</button>
);
})}
</div>
</div>
</div>
{/* 3. Summary */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-6 h-6 bg-purple-600 text-white rounded-full flex items-center justify-center text-xs font-bold">3</div>
<h2 className="text-lg font-semibold text-gray-900">Summary</h2>
</div>
</div>
</form>
</PageWrapper>
);
}
@@ -0,0 +1,11 @@
import { Routes, Route } from 'react-router-dom';
import ChannelTypeList from '../pages/ChannelTypeList';
import NewChannelType from '../pages/NewChannelType';
export const ChannelTypeRoutes = () => (
<Routes>
<Route index element={<ChannelTypeList />} />
<Route path="new" element={<NewChannelType />} />
<Route path=":id/edit" element={<NewChannelType />} />
</Routes>
);
@@ -0,0 +1,68 @@
import type { ChannelType, ChannelTypeCreateRequest, ChannelTypeUpdateRequest } from '../types/channel-types.types';
const STORAGE_KEY = 'pim_channel_types';
const INITIAL_DATA: ChannelType[] = [
{ id: '1', name: 'E-Commerce', code: 'ecommerce', description: 'Online retail storefronts and shopping platforms', icon: 'ShoppingCart', status: 'active', channelCount: 4, createdAt: '2025-01-10', author: 'Admin User' },
{ id: '2', name: 'Marketplace', code: 'marketplace', description: 'Third-party marketplace listings like Amazon and eBay', icon: 'ShoppingBag', status: 'active', channelCount: 3, createdAt: '2025-01-12', author: 'Sarah Chen' },
{ id: '3', name: 'ERP System', code: 'erp', description: 'Enterprise resource planning system integrations', icon: 'Server', status: 'active', channelCount: 2, createdAt: '2025-01-15', author: 'Michael Torres' },
{ id: '4', name: 'Warehouse (WMS)', code: 'wms', description: 'Warehouse management system for stock and logistics', icon: 'Warehouse', status: 'active', channelCount: 1, createdAt: '2025-01-18', author: 'Emma Wilson' },
{ id: '5', name: 'Point of Sale', code: 'pos', description: 'In-store point of sale and retail terminal systems', icon: 'Store', status: 'active', channelCount: 2, createdAt: '2025-02-01', author: 'Admin User' },
{ id: '6', name: 'B2B Portal', code: 'b2b_portal', description: 'Business-to-business buyer portals and wholesale platforms', icon: 'Globe', status: 'active', channelCount: 1, createdAt: '2025-02-10', author: 'Sarah Chen' },
{ id: '7', name: 'Mobile App', code: 'mobile_app', description: 'Native mobile applications for iOS and Android', icon: 'Smartphone', status: 'inactive', channelCount: 0, createdAt: '2025-02-20', author: 'Michael Torres' },
{ id: '8', name: 'Corporate Website', code: 'website', description: 'Company marketing websites and product catalogues', icon: 'Monitor', status: 'active', channelCount: 2, createdAt: '2025-03-01', author: 'Emma Wilson' },
];
const getStored = (): ChannelType[] => {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(INITIAL_DATA));
return INITIAL_DATA;
}
return JSON.parse(stored);
};
const setStored = (items: ChannelType[]) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
};
export const channelTypesService = {
getAll: async (): Promise<ChannelType[]> =>
new Promise((resolve) => setTimeout(() => resolve(getStored()), 300)),
getById: async (id: string): Promise<ChannelType | undefined> =>
new Promise((resolve) => setTimeout(() => resolve(getStored().find(p => p.id === id)), 200)),
create: async (req: ChannelTypeCreateRequest): Promise<ChannelType> =>
new Promise((resolve) => {
setTimeout(() => {
const list = getStored();
const newItem: ChannelType = { ...req, id: String(Date.now()), createdAt: new Date().toISOString() };
list.push(newItem);
setStored(list);
resolve(newItem);
}, 300);
}),
update: async (id: string, req: ChannelTypeUpdateRequest): Promise<ChannelType> =>
new Promise((resolve, reject) => {
setTimeout(() => {
const list = getStored();
const index = list.findIndex(p => p.id === id);
if (index === -1) { reject(new Error('Not found')); return; }
const updated = { ...list[index], ...req };
list[index] = updated;
setStored(list);
resolve(updated);
}, 300);
}),
delete: async (id: string): Promise<boolean> =>
new Promise((resolve) => {
setTimeout(() => {
const list = getStored().filter(p => p.id !== id);
setStored(list);
resolve(true);
}, 300);
}),
};
@@ -0,0 +1,15 @@
export interface ChannelType {
id: string;
name: string;
code: string;
description?: string;
icon?: string;
status: 'active' | 'inactive';
createdAt: string;
updatedAt?: string;
author?: string;
channelCount?: number;
}
export type ChannelTypeCreateRequest = Omit<ChannelType, 'id' | 'createdAt'>;
export type ChannelTypeUpdateRequest = Partial<ChannelTypeCreateRequest>;
@@ -0,0 +1,8 @@
import * as Yup from 'yup';
export const channelTypeSchema = Yup.object().shape({
name: Yup.string().required('Channel type name is required'),
code: Yup.string()
.required('Code is required')
.matches(/^[a-z0-9_]+$/, 'Code must be lowercase letters, numbers, or underscores'),
});
+38 -50
View File
@@ -1,16 +1,15 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Plus, Edit2, Trash2, RefreshCw, Radio, CheckCircle, Layers, TrendingUp, ShoppingCart, ShoppingBag, Server, Warehouse, Store, Smartphone, Globe, Monitor } from "lucide-react";
import { Plus, RefreshCw, Radio, CheckCircle, Layers, TrendingUp, ShoppingCart, ShoppingBag, Server, Warehouse, Store, Smartphone, Globe, Monitor } from "lucide-react";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { Button } from "../../../components/customs/Button";
import { Table } from "../../../components/customs/Table";
import { SearchBar } from "../../../components/customs/SearchBar";
import { KPIGrid } from "../../../components/customs/KPI";
import { DataTable } from "../../../components/customs/DataTable";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { ChannelStatsCard } from "../components/ChannelStatsCard";
import { useChannel } from "../hook/useChannel";
import type { Channel } from "../types/channels.types";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
const CHANNEL_TYPES_META: Record<string, { label: string, icon: any, typeColor: string, typeBg: string }> = {
ecommerce: { label: "Ecommerce", icon: ShoppingCart, typeColor: "text-blue-600", typeBg: "bg-blue-50" },
@@ -24,9 +23,10 @@ const CHANNEL_TYPES_META: Record<string, { label: string, icon: any, typeColor:
};
export default function ChannelList() {
const [searchQuery, setSearchQuery] = useState("");
const navigate = useNavigate();
const { items, fetchItems, loading, deleteItem } = useChannel();
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: "", name: "" });
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
fetchItems();
@@ -91,29 +91,26 @@ export default function ChannelList() {
},
];
const handleDelete = async (id: string) => {
if (confirm("Are you sure you want to delete this channel?")) {
try {
await deleteItem(id);
} catch {
// handled
}
const handleDeleteConfirm = async () => {
if (!deleteModal.id) return;
setIsDeleting(true);
try {
await deleteItem(deleteModal.id);
setDeleteModal({ isOpen: false, id: "", name: "" });
} catch {
// handled
} finally {
setIsDeleting(false);
}
};
const actions = (row: Channel) => (
<div className="flex items-center justify-end gap-1 text-gray-400">
<button onClick={() => navigate(`${row.id}/edit`)} className="p-1.5 hover:bg-white hover:text-gray-900 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><Edit2 className="w-4 h-4" /></button>
<button onClick={() => handleDelete(row.id)} className="p-1.5 hover:bg-white hover:text-red-600 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><Trash2 className="w-4 h-4" /></button>
</div>
);
const filteredItems = items.filter(item => item.name.toLowerCase().includes(searchQuery.toLowerCase()) || (item.code && item.code.toLowerCase().includes(searchQuery.toLowerCase())));
return (
<PageWrapper>
<Breadcrumb
items={[{ label: "Home" }, { label: "Channel Master" }]}
items={[{ label: "Home" }, { label: "Channel Registry" }]}
actions={
<>
<Button variant="outline" className="bg-white border-gray-200 text-gray-700 hover:bg-gray-50" onClick={fetchItems}>
@@ -127,9 +124,8 @@ export default function ChannelList() {
</>
}
/>
{/* Stats Cards */}
<KPIGrid className="mb-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{/* Stats Cards */}
<ChannelStatsCard
title="Total Channels"
value="9"
@@ -158,40 +154,32 @@ export default function ChannelList() {
color="orange"
icon={<TrendingUp className="w-5 h-5" />}
/>
</KPIGrid>
</div>
{/* Main Content Area */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{/* Toolbar */}
<div className="p-4 border-b border-gray-200 flex justify-between items-center bg-white">
<div className="flex gap-4 items-center">
<div className="w-64">
<SearchBar value={searchQuery} onChange={setSearchQuery} placeholder="Search channels..." />
</div>
<select className="h-9 px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary text-gray-700 bg-white">
<option>All Statuses</option>
<option>Active</option>
<option>Inactive</option>
</select>
<select className="h-9 px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary text-gray-700 bg-white">
<option>All Types</option>
<option>Ecommerce</option>
<option>Marketplace</option>
</select>
</div>
<div className="text-sm text-gray-500">
{filteredItems.length} of {items.length} channels
</div>
</div>
{/* Table */}
<Table
columns={columns}
data={filteredItems}
actions={actions}
variant="flat"
<DataTable
columns={columns}
data={items}
actionConfig={{
onView: (row) => navigate(`${row.id}/view`),
onEdit: (row) => navigate(`${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
}}
/>
</div>
<ConfirmationModal
isOpen={deleteModal.isOpen}
title="Delete Channel"
description="Are you sure you want to delete this channel? This action cannot be undone."
itemName={deleteModal.name}
loading={isDeleting}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</PageWrapper>
);
}
+4 -74
View File
@@ -4,7 +4,7 @@ import { useFormik } from "formik";
import * as Yup from "yup";
import {
ShoppingCart, ShoppingBag, Server, Warehouse, Store,
Globe, Smartphone, Monitor, Upload, Zap
Globe, Smartphone, Monitor, Zap
} from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { useChannel } from "../hook/useChannel";
@@ -177,80 +177,10 @@ export default function NewChannel() {
</div>
</div>
</div>
{/* 2. Channel Type */}
{/* 2. Availability */}
<div>
<div className="flex items-center gap-3 mb-6">
<div className="w-6 h-6 bg-purple-600 text-white rounded-full flex items-center justify-center text-xs font-bold">2</div>
<h2 className="text-lg font-semibold text-gray-900">Channel Type</h2>
</div>
<div className="pl-9">
<p className="text-sm text-gray-500 mb-4">Select the type of destination this channel represents.</p>
<div className="grid grid-cols-4 gap-4">
{CHANNEL_TYPES.map((type) => {
const Icon = type.icon;
const isSelected = selectedType === type.id;
return (
<button
key={type.id}
type="button"
onClick={() => {
setSelectedType(type.id);
formik.setFieldValue("channelType", type.id);
}}
className={`flex flex-col items-center justify-center p-4 rounded-xl border-2 transition-all gap-3 ${
isSelected
? "border-purple-500 bg-purple-50 shadow-sm"
: "border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50"
}`}
>
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${type.bg}`}>
<Icon className={`w-5 h-5 ${type.color}`} />
</div>
<span className={`text-sm font-medium ${isSelected ? "text-purple-700" : "text-gray-700"}`}>
{type.label}
</span>
</button>
);
})}
</div>
{formik.touched.channelType && formik.errors.channelType && (
<p className="text-xs text-red-500 mt-3">{formik.errors.channelType}</p>
)}
</div>
</div>
{/* 3. Channel Icon */}
<div>
<div className="flex items-center gap-3 mb-6">
<div className="w-6 h-6 bg-gray-400 text-white rounded-full flex items-center justify-center text-xs font-bold">3</div>
<h2 className="text-lg font-semibold text-gray-900">Channel Icon <span className="text-sm text-gray-400 font-normal ml-2">Optional</span></h2>
</div>
<div className="pl-9">
<div className="flex items-start gap-8">
<div>
<p className="text-xs text-gray-500 font-medium mb-2">Upload Icon</p>
<div className="w-28 h-28 border-2 border-dashed border-gray-200 rounded-xl flex flex-col items-center justify-center gap-2 cursor-pointer hover:border-purple-400 hover:bg-purple-50/30 transition-colors">
<Upload className="w-5 h-5 text-gray-400" />
<span className="text-[10px] text-gray-400 text-center leading-tight">Click to upload<br/>PNG, SVG, WebP</span>
</div>
</div>
<div>
<p className="text-xs text-gray-500 font-medium mb-2">Preview</p>
<div className="w-40 h-28 border border-gray-200 rounded-xl flex flex-col items-center justify-center gap-1 bg-gray-50">
<div className="w-8 h-8 rounded bg-gray-200 mb-1" />
<span className="text-xs font-medium text-gray-700">{formik.values.name || "Channel Name"}</span>
<span className="text-[10px] text-gray-400">{selectedType ? CHANNEL_TYPES.find(t => t.id === selectedType)?.label : "Type"}</span>
</div>
</div>
</div>
</div>
</div>
{/* 4. Availability */}
<div>
<div className="flex items-center gap-3 mb-6">
<div className="w-6 h-6 bg-purple-600 text-white rounded-full flex items-center justify-center text-xs font-bold">4</div>
<h2 className="text-lg font-semibold text-gray-900">Availability</h2>
</div>
<div className="pl-9">
@@ -279,10 +209,10 @@ export default function NewChannel() {
</div>
</div>
{/* 5. Summary */}
{/* 3. Summary */}
<div>
<div className="flex items-center gap-3 mb-6">
<div className="w-6 h-6 bg-purple-600 text-white rounded-full flex items-center justify-center text-xs font-bold">5</div>
<div className="w-6 h-6 bg-purple-600 text-white rounded-full flex items-center justify-center text-xs font-bold">3</div>
<h2 className="text-lg font-semibold text-gray-900">Summary</h2>
</div>
<div className="pl-9 grid grid-cols-2 gap-4">
@@ -60,7 +60,7 @@ export function InfoCard({
return (
<div
className={`
relative rounded-xl p-5 flex flex-col gap-3
relative rounded-lg p-5 flex flex-col gap-3
${CARD_STYLES[variant]} ${className}
`}
>
+1 -1
View File
@@ -77,7 +77,7 @@ export function ChartTooltipContent({
return (
<div
className={cn(
"border border-border/50 bg-white rounded-lg px-3 py-2 text-xs shadow-xl",
"border border-border/50 bg-white rounded-lg px-3 py-2 text-xs shadow-xl",
className
)}
>
+7 -5
View File
@@ -19,7 +19,6 @@ import {
import { InfoCard, InfoCardGrid } from "../components/StatsCard";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
// ── Publication Trends data ───────────────────────────────────────────────────
@@ -65,7 +64,10 @@ const recentActivity = [
export default function Dashboard() {
return (
<PageWrapper>
<Breadcrumb items={[{ label: "Home" }, { label: "Dashboard" }]} />
<div className="mb-6">
<h2 className="text-xl font-bold text-gray-900 mb-1">Welcome back, John</h2>
<p className="text-sm text-gray-600">Here's what's happening with your product catalog today.</p>
</div>
{/* KPI InfoCards */}
@@ -106,7 +108,7 @@ export default function Dashboard() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Publication Trends - Line Chart */}
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="bg-white border border-gray-200 rounded-lg p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-gray-900">Publication Trends</h3>
<span className="text-xs text-gray-500 bg-gray-100 px-3 py-1.5 rounded-lg cursor-pointer hover:bg-gray-200 transition-colors">
@@ -141,7 +143,7 @@ export default function Dashboard() {
</div>
{/* Channel Distribution - Pie Chart */}
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="bg-white border border-gray-200 rounded-lg p-5">
<h3 className="text-sm font-semibold text-gray-900 mb-4">Channel Distribution</h3>
<div className="flex items-center gap-6">
{/* Donut Chart */}
@@ -187,7 +189,7 @@ export default function Dashboard() {
</div>
</div>
{/* Recent Activity */}
<div className="mt-6 bg-white border border-gray-200 rounded-xl p-5">
<div className="mt-6 bg-white border border-gray-200 rounded-lg p-5">
<div className="flex items-center justify-between mb-6">
<h3 className="text-sm font-semibold text-gray-900">Recent Activity</h3>
</div>
+60 -36
View File
@@ -1,54 +1,71 @@
// src/features/family/components/FamilyCard.tsx ← Recommended new name
import type { ReactNode } from "react";
interface FamilyCardProps {
/**
* ProductCard a premium styled card component mirroring the visual design of VariantStatsCards.
* It displays a title, a prominent value (e.g., price), a subtitle, and an optional icon.
* The appearance can be themed via the `color` prop which selects a soft background gradient and border.
*/
interface ProductCardProps {
title: string;
value: string | number;
subtitle: string;
/** Optional icon or image displayed on the right side */
icon?: ReactNode;
color: "purple" | "green" | "blue" | "slate" | "indigo" | "orange";
/** Colour theme for the card matches VariantStatsCards themes */
color: "purple" | "green" | "blue" | "slate" | "indigo" | "red" | "orange";
}
// Reuse the same colour map as VariantStatsCards for visual consistency
const COLOR_MAP = {
purple: { border: "border-purple-200",
iconText: "text-purple-600",
bgGradient: "bg-purple-50/40" },
green: { border: "border-green-200",
iconText: "text-green-600",
bgGradient: "bg-green-50/40" },
blue: { border: "border-blue-200",
iconText: "text-blue-600",
bgGradient: "bg-blue-50/40" },
slate: { border: "border-slate-200",
iconText: "text-slate-500",
bgGradient: "bg-slate-50/40" },
indigo: { border: "border-indigo-200",
iconText: "text-indigo-600",
bgGradient: "bg-indigo-50/40" },
orange: { border: "border-orange-200",
iconText: "text-orange-600",
bgGradient: "bg-orange-50/40" },
purple: {
border: "border-purple-200",
iconText: "text-purple-600",
bgGradient: "bg-purple-50/40",
},
green: {
border: "border-green-200",
iconText: "text-green-600",
bgGradient: "bg-green-50/40",
},
blue: {
border: "border-blue-200",
iconText: "text-blue-600",
bgGradient: "bg-blue-50/40",
},
slate: {
border: "border-slate-200",
iconText: "text-slate-500",
bgGradient: "bg-slate-50/40",
},
indigo: {
border: "border-indigo-200",
iconText: "text-indigo-600",
bgGradient: "bg-indigo-50/40",
},
red: {
border: "border-red-200",
iconText: "text-red-600",
bgGradient: "bg-red-50/40",
},
orange: {
border: "border-orange-200",
iconText: "text-orange-600",
bgGradient: "bg-orange-50/40",
},
};
export function FamilyCard({ title, value, subtitle, icon, color }: FamilyCardProps) {
export function FamilyCard({ title, value, subtitle, icon, color }: ProductCardProps) {
const c = COLOR_MAP[color];
return (
<div
className={`
${c.bgGradient}
${c.border}
border
rounded-xl
rounded-lg
h-[110px]
px-5
py-4
px-4
py-3
flex
flex-col
transition-all
@@ -56,20 +73,27 @@ export function FamilyCard({ title, value, subtitle, icon, color }: FamilyCardPr
hover:shadow-sm
`}
>
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-[15px] font-medium text-slate-600">
<span className="text-[15px] font-medium text-slate-600 leading-none">
{title}
</span>
{icon && <span className={c.iconText}>{icon}</span>}
{icon && (
<span className={c.iconText}>
{icon}
</span>
)}
</div>
{/* Value */}
<div className="mt-1 text-[24px] font-bold leading-none text-slate-900">
{value}
</div>
<div className="mt-2 text-[13px] text-slate-500">
{/* Subtitle */}
<div className="mt-2 text-[12px] leading-none text-slate-500">
{subtitle}
</div>
</div>
);
}
}
+6 -17
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo } from "react";
import { Plus, Edit2, Trash2, LayoutGrid, BookCheck, Box, TrendingUp } from "lucide-react";
import { Plus, LayoutGrid, BookCheck, Box, TrendingUp } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
@@ -181,22 +181,11 @@ export default function FamilyList() {
columns={columns}
data={families}
onRowClick={(row) => navigate(`/families/${row.id}/edit`)}
actions={(row) => (
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
icon={<Edit2 className="w-4 h-4" />}
onClick={() => navigate(`/families/${row.id}/edit`)} // ← Fixed
/>
<Button
variant="ghost"
size="sm"
icon={<Trash2 className="w-4 h-4 text-red-500" />}
onClick={(e) => { e?.stopPropagation(); setDeleteModal({ isOpen: true, id: row.id, name: row.name }); }}
/>
</div>
)}
actionConfig={{
onView: (row) => navigate(`/families/${row.id}/view`),
onEdit: (row) => navigate(`/families/${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
}}
/>
<ConfirmationModal
isOpen={deleteModal.isOpen}
+5 -11
View File
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
FileText, LayoutGrid, Tags, Globe, Eye, Settings2, Save,
CheckCircle2, AlertCircle, Edit2, Trash2, Plus,
CheckCircle2, AlertCircle, Plus,
CheckSquare, Image as ImageIcon,
Check
} from 'lucide-react';
@@ -212,16 +212,10 @@ export default function NewFamily() {
{ id: '2', name: "Storage", code: "storage", desc: "Storage capacity variations", type: "dropdown", required: true, status: "Active" },
{ id: '3', name: "RAM", code: "ram", desc: "Memory capacity variations", type: "dropdown", required: false, status: "Active" },
]}
actions={() => (
<div className="flex items-center justify-end gap-1 opacity-70 hover:opacity-100 transition-opacity">
<button className="p-2 hover:bg-gray-100 rounded-lg text-gray-500 hover:text-gray-700">
<Edit2 className="w-4 h-4" />
</button>
<button className="p-2 hover:bg-red-50 rounded-lg text-gray-500 hover:text-red-600">
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
actionConfig={{
onEdit: () => console.log('Edit axis'),
onDelete: () => console.log('Delete axis')
}}
/>
</div>
</div>
-2
View File
@@ -1,2 +0,0 @@
import { importsService } from '../services/imports.service';
export const importsApi = importsService;
@@ -1,201 +0,0 @@
import type { ImportRecord, ImportStatus } from "../types/imports.types";
interface ImportCardProps {
record: ImportRecord;
}
const STATUS_CONFIG: Record<ImportStatus, {
label: string;
dot: string;
bg: string;
text: string;
border: string;
progress: string;
}> = {
processing: {
label: "processing",
dot: "bg-amber-400",
bg: "bg-amber-50",
text: "text-amber-700",
border: "border-amber-200",
progress: "bg-amber-500",
},
completed: {
label: "completed",
dot: "bg-green-500",
bg: "bg-green-50",
text: "text-green-700",
border: "border-green-200",
progress: "bg-green-500",
},
failed: {
label: "failed",
dot: "bg-red-500",
bg: "bg-red-50",
text: "text-red-700",
border: "border-red-200",
progress: "bg-red-500",
},
pending: {
label: "pending",
dot: "bg-gray-400",
bg: "bg-gray-50",
text: "text-gray-500",
border: "border-gray-200",
progress: "bg-gray-400",
},
};
const FILE_TYPE_ICON = () => (
<svg className="w-3.5 h-3.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
);
const PauseIcon = () => (
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
);
const DownloadIcon = () => (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
);
const RetryIcon = () => (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
);
function formatDate(dateStr: string) {
const d = new Date(dateStr);
return d.toLocaleDateString("en-US", { year: "numeric", month: "2-digit", day: "2-digit" })
+ " " + d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
}
export function ImportCard({ record }: ImportCardProps) {
const cfg = STATUS_CONFIG[record.status];
const pct = record.totalRecords > 0
? Math.round((record.processedRecords / record.totalRecords) * 100)
: 0;
return (
<div className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
{/* Top Row: supplier info + actions */}
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-3">
{/* Status icon circle */}
<div className={`w-10 h-10 rounded-full flex items-center justify-center border-2 ${
record.status === "completed" ? "border-green-400 bg-green-50"
: record.status === "processing" ? "border-amber-400 bg-amber-50"
: record.status === "failed" ? "border-red-400 bg-red-50"
: "border-gray-300 bg-gray-50"
}`}>
{record.status === "completed" && (
<svg className="w-5 h-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7"/>
</svg>
)}
{record.status === "processing" && (
<svg className="w-5 h-5 text-amber-500 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" strokeDasharray="30 60" strokeLinecap="round"/>
</svg>
)}
{record.status === "failed" && (
<svg className="w-5 h-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2"/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 9l-6 6M9 9l6 6"/>
</svg>
)}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-900 text-base">{record.supplierName}</span>
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${cfg.bg} ${cfg.text} border ${cfg.border}`}>
{cfg.label}
</span>
<span className="text-sm text-gray-500 font-mono">{record.importCode}</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5 text-xs text-gray-500">
<FILE_TYPE_ICON />
<span>{record.fileName}</span>
<span className="text-gray-300"></span>
<span>{formatDate(record.importedAt)}</span>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-3 text-sm text-gray-500">
{record.status === "processing" && (
<button className="flex items-center gap-1.5 hover:text-gray-700 transition-colors">
<PauseIcon /> Pause
</button>
)}
{record.status === "failed" && (
<button className="flex items-center gap-1.5 hover:text-gray-700 transition-colors">
<RetryIcon /> Retry
</button>
)}
<button className="flex items-center gap-1.5 hover:text-gray-700 transition-colors">
<DownloadIcon /> Download
</button>
</div>
</div>
{/* Progress */}
<div className="mt-4">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium text-gray-500">Progress</span>
<span className="text-xs font-medium text-gray-700">
{record.processedRecords.toLocaleString()} / {record.totalRecords.toLocaleString()} records ({pct}%)
</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-500 ${cfg.progress}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
{/* Stats: Processed / Success / Errors */}
<div className="grid grid-cols-3 gap-3 mt-4">
<div className="bg-gray-50 rounded-lg p-3">
<div className="text-xs text-gray-500 mb-1">Processed</div>
<div className="text-xl font-bold text-gray-900">{record.processedRecords.toLocaleString()}</div>
</div>
<div className="bg-green-50 rounded-lg p-3">
<div className="text-xs text-gray-500 mb-1">Success</div>
<div className="text-xl font-bold text-green-600">{record.successRecords.toLocaleString()}</div>
</div>
<div className="bg-red-50 rounded-lg p-3">
<div className="text-xs text-gray-500 mb-1">Errors</div>
<div className="text-xl font-bold text-red-500">{record.errorRecords.toLocaleString()}</div>
</div>
</div>
{/* Error alert */}
{record.errorRecords > 0 && (
<div className="mt-3 flex items-center justify-between bg-red-50 border border-red-200 rounded-lg px-4 py-3">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-red-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
<div>
<span className="text-sm font-semibold text-red-700">{record.errorRecords} errors detected</span>
<p className="text-xs text-red-500">Review the error log to resolve issues with failed records</p>
</div>
</div>
<button className="px-3 py-1.5 bg-red-600 text-white text-xs font-semibold rounded-lg hover:bg-red-700 transition-colors">
View Errors
</button>
</div>
)}
</div>
);
}
@@ -1,26 +0,0 @@
import { supplierPerformance } from "../data/mockImports";
export function SupplierPerformancePanel() {
return (
<div className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm h-full">
<h3 className="font-semibold text-gray-900 mb-4">Supplier Performance</h3>
<div className="space-y-5">
{supplierPerformance.map((s) => (
<div key={s.name}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-gray-800">{s.name}</span>
<span className="text-sm font-semibold text-gray-900">{s.rate}%</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-2 mb-1">
<div
className="h-2 rounded-full bg-violet-600 transition-all duration-700"
style={{ width: `${s.rate}%` }}
/>
</div>
<span className="text-xs text-gray-400">{s.imports} imports</span>
</div>
))}
</div>
</div>
);
}
@@ -1,64 +0,0 @@
import { useRef, useState } from "react";
const UploadIcon = () => (
<svg className="w-10 h-10 text-violet-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
</svg>
);
export function UploadNewImportPanel() {
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const handleFile = (file: File) => setSelectedFile(file);
return (
<div className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm h-full">
<h3 className="font-semibold text-gray-900 mb-4">Upload New Import</h3>
<div
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
const f = e.dataTransfer.files[0];
if (f) handleFile(f);
}}
className={`border-2 border-dashed rounded-xl flex flex-col items-center justify-center py-10 px-6 transition-colors cursor-pointer ${
dragging ? "border-violet-400 bg-violet-50" : "border-gray-200 bg-gray-50 hover:border-violet-300 hover:bg-violet-50/30"
}`}
onClick={() => inputRef.current?.click()}
>
<input
ref={inputRef}
type="file"
accept=".csv,.xlsx,.xml"
className="hidden"
onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])}
/>
<div className="w-16 h-16 bg-violet-100 rounded-full flex items-center justify-center mb-3">
<UploadIcon />
</div>
{selectedFile ? (
<>
<p className="text-sm font-semibold text-gray-800">{selectedFile.name}</p>
<p className="text-xs text-gray-500 mt-0.5">{(selectedFile.size / 1024).toFixed(1)} KB</p>
</>
) : (
<>
<p className="text-sm font-medium text-gray-700">Upload supplier data file</p>
<p className="text-xs text-gray-400 mt-0.5">Supports CSV, XLSX, and XML formats</p>
</>
)}
<button
type="button"
onClick={(e) => { e.stopPropagation(); inputRef.current?.click(); }}
className="mt-4 px-5 py-2 bg-violet-600 text-white text-sm font-semibold rounded-lg hover:bg-violet-700 transition-colors"
>
{selectedFile ? "Change File" : "Select File"}
</button>
</div>
</div>
);
}
-52
View File
@@ -1,52 +0,0 @@
import type { ImportRecord } from "../types/imports.types";
// ── Mock data matching the screenshots ────────────────────────────────────────
export const mockImports: ImportRecord[] = [
{
id: "IMP-001",
supplierName: "Supplier ABC",
importCode: "IMP-001",
fileName: "products_batch_234.csv",
fileType: "csv",
importedAt: "2024-05-14T10:30:00",
status: "processing",
totalRecords: 1500,
processedRecords: 1005,
successRecords: 982,
errorRecords: 23,
},
{
id: "IMP-002",
supplierName: "Supplier XYZ",
importCode: "IMP-002",
fileName: "inventory_update.xlsx",
fileType: "xlsx",
importedAt: "2024-05-14T09:15:00",
status: "completed",
totalRecords: 850,
processedRecords: 850,
successRecords: 850,
errorRecords: 0,
},
{
id: "IMP-003",
supplierName: "Supplier DEF",
importCode: "IMP-003",
fileName: "new_products.xml",
fileType: "xml",
importedAt: "2024-05-14T08:45:00",
status: "failed",
totalRecords: 2200,
processedRecords: 990,
successRecords: 856,
errorRecords: 134,
},
];
export const supplierPerformance = [
{ name: "Supplier ABC", rate: 98.5, imports: 8 },
{ name: "Supplier XYZ", rate: 100, imports: 12 },
{ name: "Supplier DEF", rate: 87.2, imports: 4 },
];
-67
View File
@@ -1,67 +0,0 @@
import { useState, useCallback } from 'react';
import { importsService } from '../services/imports.service';
import type { Import, ImportCreateRequest, ImportUpdateRequest } from '../types/imports.types';
import { toast } from 'react-toastify';
export const useImport = () => {
const [items, setItems] = useState<Import[]>([]);
const [loading, setLoading] = useState(false);
const fetchItems = useCallback(async () => {
setLoading(true);
try {
const data = await importsService.getAll();
setItems(data);
} catch (err: any) {
toast.error(err.message || 'Failed to fetch items');
} finally {
setLoading(false);
}
}, []);
const createItem = useCallback(async (req: ImportCreateRequest) => {
setLoading(true);
try {
const created = await importsService.create(req);
setItems((prev) => [...prev, created]);
toast.success('Import created successfully!');
return created;
} catch (err: any) {
toast.error(err.message || 'Failed to create item');
throw err;
} finally {
setLoading(false);
}
}, []);
const updateItem = useCallback(async (id: string, req: ImportUpdateRequest) => {
setLoading(true);
try {
const updated = await importsService.update(id, req);
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
toast.success('Import updated successfully!');
return updated;
} catch (err: any) {
toast.error(err.message || 'Failed to update item');
throw err;
} finally {
setLoading(false);
}
}, []);
const deleteItem = useCallback(async (id: string) => {
setLoading(true);
try {
await importsService.delete(id);
setItems((prev) => prev.filter((p) => p.id !== id));
toast.success('Import deleted successfully!');
} catch (err: any) {
toast.error(err.message || 'Failed to delete item');
throw err;
} finally {
setLoading(false);
}
}, []);
return { items, loading, fetchItems, createItem, updateItem, deleteItem };
};
-4
View File
@@ -1,4 +0,0 @@
export * from './types/imports.types';
export * from './services/imports.service';
export * from './hook/useImport';
export * from './routes/imports.routes';
-98
View File
@@ -1,98 +0,0 @@
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { ImportCard } from "../components/ImportCard";
import { SupplierPerformancePanel } from "../components/SupplierPerformancePanel";
import { UploadNewImportPanel } from "../components/UploadNewImportPanel";
import { mockImports } from "../data/mockImports";
import { ImportStatsCard } from "../components/ImportStatsCard";
// ── Stat Icons ─────────────────────────────────────────────────────────────────
const UploadStat = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
</svg>
);
const CheckStat = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
);
const ClockStat = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2"/>
<path strokeLinecap="round" d="M12 6v6l4 2"/>
</svg>
);
const XStat = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2"/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 9l-6 6M9 9l6 6"/>
</svg>
);
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
export default function ImportList() {
const stats = {
total: 24,
completed: 18,
processing: 3,
failed: 3,
};
return (
<PageWrapper>
<Breadcrumb items={[{ label: "Home" }, { label: "Supplier Imports" }]} />
{/* KPI Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<ImportStatsCard
title="Total Imports"
value={stats.total}
subtitle="uploaded files"
color="indigo"
icon={<UploadStat />}
/>
<ImportStatsCard
title="Completed"
value={stats.completed}
subtitle="successfully imported"
color="green"
icon={<CheckStat />}
/>
<ImportStatsCard
title="Processing"
value={stats.processing}
subtitle="running tasks"
color="orange"
icon={<ClockStat />}
/>
<ImportStatsCard
title="Failed"
value={stats.failed}
subtitle="errors found"
color="red"
icon={<XStat />}
/>
</div>
{/* Recent Imports Section */}
<div className="mb-6">
<div className="bg-white border border-gray-200 rounded-xl shadow-sm">
<div className="px-5 py-4 border-b border-gray-100">
<h2 className="font-semibold text-gray-900">Recent Imports</h2>
</div>
<div className="p-5 space-y-4">
{mockImports.map((record) => (
<ImportCard key={record.id} record={record} />
))}
</div>
</div>
</div>
{/* Bottom: Upload + Supplier Performance */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<UploadNewImportPanel />
<SupplierPerformancePanel />
</div>
</PageWrapper>
);
}
-117
View File
@@ -1,117 +0,0 @@
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { useImport } from "../hook/useImport";
import { importSchema } from "../validation/imports.schema";
import { importsService } from "../services/imports.service";
import type { ImportCreateRequest } from "../types/imports.types";
import { Save } from 'lucide-react';
import { Breadcrumb } from '../../../components/layouts/Breadcrumb';
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-purple-500'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
const errorClass = 'text-xs text-red-500 mt-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
export default function NewImport() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const isEdit = Boolean(id);
const { createItem, updateItem } = useImport();
const formik = useFormik({
initialValues: {
name: "",
status: "active",
},
validationSchema: importSchema,
onSubmit: async (values, { setSubmitting }) => {
try {
if (isEdit && id) {
await updateItem(id, values as any);
} else {
await createItem(values as ImportCreateRequest);
}
navigate("..");
} catch {
// toast handled
} finally {
setSubmitting(false);
}
},
});
useEffect(() => {
if (isEdit && id) {
importsService.getById(id).then(item => {
if (item) formik.setValues({ name: item.name, status: item.status });
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id]);
return (
<PageWrapper>
<Breadcrumb
items={[{ label: 'Home' }, { label: 'Imports', href: '/imports' }, { label: isEdit ? 'Edit Import' : 'Create Import' }]}
backTo="/imports"
actions={
<>
<Button variant="outline" size="md" type="button" onClick={() => navigate('..')} disabled={formik.isSubmitting}>Cancel</Button>
<Button variant="primary" size="md" type="submit" form="import-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>{isEdit ? 'Update Import' : 'Create Import'}</Button>
</>
}
/>
<form id="import-form" onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Basic Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className={labelClass}>Import Name *</label>
<input
name="name"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Daily Product Sync"
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div className="md:col-span-2">
<label className={labelClass}>Status</label>
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="active"
checked={formik.values.status === "active"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Active</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="inactive"
checked={formik.values.status === "inactive"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Inactive</span>
</label>
</div>
</div>
</div>
</div>
</form>
</PageWrapper>
);
}
@@ -1,11 +0,0 @@
import { Routes, Route } from 'react-router-dom';
import ImportList from '../pages/ImportList';
import NewImport from '../pages/NewImport';
export const ImportRoutes = () => (
<Routes>
<Route index element={<ImportList />} />
<Route path="new" element={<NewImport />} />
<Route path=":id/edit" element={<NewImport />} />
</Routes>
);
@@ -1,55 +0,0 @@
import type { Import, ImportCreateRequest, ImportUpdateRequest } from '../types/imports.types';
const STORAGE_KEY = 'pim_imports';
const getStored = (): Import[] => {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return [];
return JSON.parse(stored);
};
const setStored = (items: Import[]) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
};
export const importsService = {
getAll: async (): Promise<Import[]> => {
return new Promise((resolve) => setTimeout(() => resolve(getStored()), 300));
},
getById: async (id: string): Promise<Import | undefined> => {
return new Promise((resolve) => setTimeout(() => resolve(getStored().find(p => p.id === id)), 200));
},
create: async (req: ImportCreateRequest): Promise<Import> => {
return new Promise((resolve) => {
setTimeout(() => {
const list = getStored();
const newItem: Import = { ...req, id: String(Date.now()), createdAt: new Date().toISOString() };
list.push(newItem);
setStored(list);
resolve(newItem);
}, 300);
});
},
update: async (id: string, req: ImportUpdateRequest): Promise<Import> => {
return new Promise((resolve, reject) => {
setTimeout(() => {
const list = getStored();
const index = list.findIndex(p => p.id === id);
if (index === -1) { reject(new Error('Not found')); return; }
const updated = { ...list[index], ...req };
list[index] = updated;
setStored(list);
resolve(updated);
}, 300);
});
},
delete: async (id: string): Promise<boolean> => {
return new Promise((resolve) => {
setTimeout(() => {
const list = getStored().filter(p => p.id !== id);
setStored(list);
resolve(true);
}, 300);
});
},
};
@@ -1,25 +0,0 @@
export type ImportStatus = 'processing' | 'completed' | 'failed' | 'pending';
export interface ImportRecord {
id: string;
supplierName: string;
importCode: string;
fileName: string;
fileType: 'csv' | 'xlsx' | 'xml';
importedAt: string;
status: ImportStatus;
totalRecords: number;
processedRecords: number;
successRecords: number;
errorRecords: number;
}
// Legacy compatibility
export interface Import {
id: string;
name: string;
status: 'active' | 'inactive';
createdAt: string;
}
export type ImportCreateRequest = Omit<Import, 'id' | 'createdAt'>;
export type ImportUpdateRequest = Partial<ImportCreateRequest>;
@@ -1,5 +0,0 @@
import * as Yup from 'yup';
export const importSchema = Yup.object().shape({
name: Yup.string().required('Import name is required'),
});
@@ -0,0 +1,115 @@
import { useState } from "react";
import { Download, Filter, Clock } from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { SearchBar } from "../../../components/customs/SearchBar";
const MOCK_LOGS = [
{
time: "2025-06-09 14:35",
event: "Started Sync",
integration: "Amazon India",
description: "Manual full catalogue sync initiated — 4,240 products by Sarah Chen",
user: "Sarah Chen"
},
{
time: "2025-06-09 12:00",
event: "Scheduled Sync",
integration: "Retail POS Network",
description: "Daily catalogue sync triggered by schedule",
user: "System"
},
{
time: "2025-06-08 16:22",
event: "Updated Credentials",
integration: "Shopify Main Store",
description: "Access token rotated. Previous token expires 2025-07-01",
user: "Michael Torres"
},
{
time: "2025-06-08 09:00",
event: "Created Integration",
integration: "WooCommerce EU",
description: "New integration created in staging environment",
user: "Admin User"
},
];
export default function AuditLogsList() {
const [searchQuery, setSearchQuery] = useState("");
// Optional: Filter logs based on search
const filteredLogs = MOCK_LOGS.filter(log =>
log.event.toLowerCase().includes(searchQuery.toLowerCase()) ||
log.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
log.integration.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div className="space-y-6 pb-6">
{/* SearchBar + Action Buttons */}
<div className="flex items-center gap-3">
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search logs..."
className="flex-1"
/>
<Button variant="outline" className="border-gray-200 bg-white px-4">
<Filter className="w-4 h-4" />
</Button>
<Button variant="outline" className="border-gray-200 bg-white px-4">
<Download className="w-4 h-4" /> Export
</Button>
</div>
{/* Vertical Timeline */}
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
<div className="divide-y divide-gray-100">
{filteredLogs.length > 0 ? (
filteredLogs.map((log, index) => (
<div key={index} className="p-6 flex gap-5 hover:bg-gray-50 transition-colors group">
{/* Purple Dot */}
<div className="mt-1.5 w-3 h-3 rounded-full bg-purple-600 flex-shrink-0 ring-4 ring-purple-100" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<div className="font-semibold text-gray-900 text-[15px]">{log.event}</div>
<span className="px-3 py-0.5 text-xs font-medium bg-purple-100 text-purple-700 rounded-full">
{log.integration}
</span>
</div>
<div className="mt-2 text-sm text-gray-600 leading-relaxed">
{log.description}
</div>
<div className="mt-4 flex items-center gap-3 text-xs text-gray-500">
<div className="flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{log.time}
</div>
<span className="text-gray-400"></span>
<span>by {log.user}</span>
</div>
</div>
</div>
))
) : (
<div className="p-12 text-center text-gray-500">
No matching logs found.
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-5 border-t border-gray-100 bg-gray-50 text-center">
<button className="text-sm text-purple-600 font-medium hover:text-purple-700 flex items-center gap-1.5 mx-auto">
Load More Activity
<span className="text-base leading-none"></span>
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,171 @@
import { useNavigate } from "react-router-dom";
import { AlertTriangle } from "lucide-react";
import { DataTable } from "../../../components/customs/DataTable";
import { StatusBadge, type BadgeVariant } from "../../../components/customs/StatusBadge";
const MOCK_ERRORS = [
{
id: "1",
product: "Samsung Galaxy S25 Ultra",
variant: "256GB Black",
sku: "P-8841",
integration: "Amazon India",
errorType: "Validation Error",
message: "Required attribute 'bullet_points' missing for Amazon listing compliance",
date: "2025-06-09 14:32",
severity: "High",
status: "Open"
},
{
id: "2",
product: "Sony WH-1000XM5",
variant: "Black",
sku: "P-6621",
integration: "Amazon India",
errorType: "Image Rejected",
message: "Primary image does not meet Amazon image guidelines (minimum 1000px)",
date: "2025-06-09 14:28",
severity: "High",
status: "Open"
},
{
id: "3",
product: "Nike Air Max 270",
variant: "UK 8 White",
sku: "P-4412",
integration: "Shopify Main Store",
errorType: "SKU Conflict",
message: "SKU 'NAM270-W8' already exists in Shopify with different product ID",
date: "2025-06-09 14:01",
severity: "Critical",
status: "Retrying"
},
{
id: "4",
product: "Adidas Ultraboost 24",
variant: "UK 9 Blue",
sku: "P-4520",
integration: "Shopify Main Store",
errorType: "Rate Limit",
message: "Shopify API rate limit exceeded (40/s). Request queued for retry.",
date: "2025-06-09 13:58",
severity: "Medium",
status: "Resolved"
},
{
id: "5",
product: "WMS Inventory Batch",
variant: "-",
sku: "BATCH-284",
integration: "Warehouse WMS",
errorType: "Connection Timeout",
message: "Connection to warehouse endpoint timed out after 30s. Host: wms.internal:8080",
date: "2025-06-08 08:03",
severity: "Critical",
status: "Open"
},
];
export default function ErrorCenterList() {
const navigate = useNavigate();
const columns = [
{
key: "product",
label: "PRODUCT",
render: (_: any, row: any) => (
<div>
<div className="font-medium text-gray-900">{row.product}</div>
<div className="text-xs text-gray-500 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-gray-700">{val}</span>
</div>
),
},
{
key: "message",
label: "MESSAGE",
render: (val: string) => <div className="text-sm text-gray-600 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 (
<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-white 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-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500">
<option>All Integrations</option>
<option>Amazon India</option>
<option>Shopify Main Store</option>
</select>
<select className="h-9 px-3 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500">
<option>All Severities</option>
<option>Critical</option>
<option>High</option>
<option>Medium</option>
</select>
<select className="h-9 px-3 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500">
<option>All Statuses</option>
<option>Open</option>
<option>Retrying</option>
<option>Resolved</option>
</select>
</div>
}
/>
</div>
);
}
@@ -1,64 +1,29 @@
import type { ReactNode } from "react";
import { Plug, CheckCircle, RefreshCw, AlertCircle, Package, Clock } from "lucide-react";
/* ── Single Stat Card ── */
interface StatCardProps {
label: string;
interface IntegrationCardProps {
title: string;
value: string | number;
subtitle: string;
icon: ReactNode;
/** Colour theme for the card */
/** Optional icon or image displayed on the right side */
icon?: ReactNode;
/** Colour theme matches VariantStatsCards themes */
color: "purple" | "green" | "blue" | "slate" | "indigo" | "red" | "orange";
}
// Re-use the same colour map as VariantStatsCards for visual consistency
// Reuse the same colour map as VariantStatsCards for visual consistency
const COLOR_MAP = {
purple: {
border: "border-purple-200",
iconText: "text-purple-600",
bgGradient: "bg-purple-50/40",
},
green: {
border: "border-green-200",
iconText: "text-green-600",
bgGradient: "bg-green-50/40",
},
blue: {
border: "border-blue-200",
iconText: "text-blue-600",
bgGradient: "bg-blue-50/40",
},
slate: {
border: "border-slate-200",
iconText: "text-slate-500",
bgGradient: "bg-slate-50/40",
},
indigo: {
border: "border-indigo-200",
iconText: "text-indigo-600",
bgGradient: "bg-indigo-50/40",
},
red: {
border: "border-red-200",
iconText: "text-red-600",
bgGradient: "bg-red-50/40",
},
orange: {
border: "border-orange-200",
iconText: "text-orange-600",
bgGradient: "bg-orange-50/40",
},
purple: { border: "border-purple-200", iconText: "text-purple-600", bgGradient: "bg-purple-50/40" },
green: { border: "border-green-200", iconText: "text-green-600", bgGradient: "bg-green-50/40" },
blue: { border: "border-blue-200", iconText: "text-blue-600", bgGradient: "bg-blue-50/40" },
slate: { border: "border-slate-200", iconText: "text-slate-500", bgGradient: "bg-slate-50/40" },
indigo: { border: "border-indigo-200", iconText: "text-indigo-600", bgGradient: "bg-indigo-50/40" },
red: { border: "border-red-200", iconText: "text-red-600", bgGradient: "bg-red-50/40" },
orange: { border: "border-orange-200", iconText: "text-orange-600", bgGradient: "bg-orange-50/40" },
};
function StatCard({
label,
value,
subtitle,
icon,
color,
}: StatCardProps) {
export function IntegrationCard({ title, value, subtitle, icon, color }: IntegrationCardProps) {
const c = COLOR_MAP[color];
return (
<div
className={`
@@ -66,7 +31,7 @@ function StatCard({
${c.border}
border
rounded-lg
h-[100px]
h-[110px]
px-4
py-3
flex
@@ -78,90 +43,17 @@ function StatCard({
>
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-[15px] font-medium text-slate-600 leading-none">
{label}
</span>
{icon && (
<span className={c.iconText}>
{icon}
</span>
)}
<span className="text-[15px] font-medium text-slate-600 leading-none">{title}</span>
{icon && <span className={c.iconText}>{icon}</span>}
</div>
{/* Value */}
<div className="mt-1 text-[24px] font-bold leading-none text-slate-900">
{value}
</div>
<div className="mt-1 text-[24px] font-bold leading-none text-slate-900">{value}</div>
{/* Subtitle */}
<div className="mt-2 text-[12px] leading-none text-slate-500">
{subtitle}
</div>
<div className="mt-2 text-[12px] leading-none text-slate-500">{subtitle}</div>
</div>
);
}
interface IntegrationStatsCardsProps {
total?: number | string;
connected?: number | string;
activeJobs?: number | string;
failedJobs?: number | string;
publishedProducts?: number | string;
lastSync?: string;
}
export function IntegrationStatsCards({
total = 6,
connected = 4,
activeJobs = 1,
failedJobs = 1,
publishedProducts = "18,262",
lastSync = "14:32",
}: IntegrationStatsCardsProps) {
return (
<div className="grid grid-cols-1 sm:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
<StatCard
label="Total Integrations"
value={total}
subtitle="4 active"
icon={<Plug className="w-5 h-5" />}
color="purple"
/>
<StatCard
label="Connected Systems"
value={connected}
subtitle="Healthy"
icon={<CheckCircle className="w-5 h-5" />}
color="green"
/>
<StatCard
label="Active Sync Jobs"
value={activeJobs}
subtitle="In progress"
icon={<RefreshCw className="w-5 h-5" />}
color="blue"
/>
<StatCard
label="Failed Sync Jobs"
value={failedJobs}
subtitle="Need attention"
icon={<AlertCircle className="w-5 h-5" />}
color="red"
/>
<StatCard
label="Published Products"
value={publishedProducts}
subtitle="Across channels"
icon={<Package className="w-5 h-5" />}
color="orange"
/>
<StatCard
label="Last Synchronised"
value={lastSync}
subtitle="Today"
icon={<Clock className="w-5 h-5" />}
color="slate"
/>
</div>
);
}
@@ -0,0 +1,169 @@
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 { 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 navigate = useNavigate();
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: "", name: "" });
const columns = [
{
key: "name",
label: "RULE NAME",
sortable: true,
render: (val: string, row: any) => (
<div>
<div className="font-semibold text-gray-900">{val}</div>
<div className="text-xs text-gray-500 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-gray-900">{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" },
];
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-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 bg-white">
<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-purple-600 hover:bg-purple-700 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>
);
}
@@ -0,0 +1,159 @@
import { useNavigate } from "react-router-dom";
import { RefreshCw, Download } from "lucide-react";
import { DataTable } from "../../../components/customs/DataTable";
import { StatusBadge, type BadgeVariant } from "../../../components/customs/StatusBadge";
import { Button } from "../../../components/customs/Button";
const MOCK_JOBS = [
{
id: "JOB-2891",
integration: "Amazon India",
type: "Full Sync",
records: "4,240",
success: "4,237",
failed: "3",
status: "Completed",
started: "2025-06-09 14:00",
completed: "2025-06-09 14:32",
duration: "32m 14s",
triggered: "Scheduled"
},
{
id: "JOB-2892",
integration: "Shopify Main Store",
type: "Delta Sync",
records: "128",
success: "128",
failed: "0",
status: "Running",
started: "2025-06-09 14:30",
completed: "",
duration: "In progress",
triggered: "Realtime trigger"
},
{
id: "JOB-2890",
integration: "Amazon UAE",
type: "Full Sync",
records: "1,840",
success: "1,840",
failed: "0",
status: "Completed",
started: "2025-06-09 13:00",
completed: "2025-06-09 13:15",
duration: "15m 02s",
triggered: "Scheduled"
},
{
id: "JOB-2889",
integration: "Warehouse WMS",
type: "Inventory Pull",
records: "284",
success: "0",
failed: "284",
status: "Failed",
started: "2025-06-08 08:00",
completed: "2025-06-08 08:03",
duration: "3m 12s",
triggered: "Scheduled"
},
{
id: "JOB-2888",
integration: "Retail POS Network",
type: "Catalogue Sync",
records: "3,240",
success: "3,240",
failed: "0",
status: "Completed",
started: "2025-06-09 12:00",
completed: "2025-06-09 12:18",
duration: "18m 40s",
triggered: "Scheduled"
},
{
id: "JOB-2887",
integration: "Amazon India",
type: "Price Update",
records: "521",
success: "521",
failed: "0",
status: "Cancelled",
started: "2025-06-08 18:00",
completed: "2025-06-08 18:01",
duration: "1m 04s",
triggered: "Manual"
},
];
export default function SyncJobsList() {
const navigate = useNavigate();
const columns = [
{ key: "id", label: "JOB ID", render: (val: string) => <span className="font-mono text-purple-600 font-medium">{val}</span> },
{ key: "integration", label: "INTEGRATION" },
{ key: "type", label: "JOB TYPE" },
{
key: "records",
label: "RECORDS",
render: (_: any, row: any) => (
<div className="text-sm">
<span className="font-semibold text-gray-900">{row.records}</span>
{row.failed && parseInt(row.failed) > 0 && (
<span className="text-red-600 text-xs ml-1">({row.failed} failed)</span>
)}
</div>
),
},
{
key: "status",
label: "STATUS",
render: (val: string) => {
let variant: BadgeVariant = "neutral";
if (val === "Completed") variant = "success";
if (val === "Running") variant = "warning";
if (val === "Failed") variant = "error";
if (val === "Cancelled") variant = "neutral";
return <StatusBadge status={variant} label={val} />;
},
},
{ key: "started", label: "STARTED AT" },
{ key: "completed", label: "COMPLETED AT" },
{ key: "duration", label: "DURATION" },
{ key: "triggered", label: "TRIGGERED BY" },
];
return (
<div className="space-y-6">
<DataTable
columns={columns}
data={MOCK_JOBS}
actionConfig={{
onView: (row) => navigate(`${row.id}/view`),
}}
searchPlaceholder="Search jobs..."
toolbarLeft={
<div className="flex gap-2">
<select className="h-9 px-3 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 bg-white">
<option>All Statuses</option>
<option>Completed</option>
<option>Running</option>
<option>Failed</option>
</select>
<Button variant="outline" className="flex items-center gap-2">
<RefreshCw className="w-4 h-4" />
Refresh Jobs
</Button>
</div>
}
toolbarRight={
<Button variant="outline">
<Download className="w-4 h-4 mr-2" />
Export Log
</Button>
}
/>
</div>
);
}
@@ -1,17 +1,38 @@
import { useState, useEffect } from "react";
import { Plus, Edit2, Trash2, RefreshCw, ShoppingCart, ShoppingBag, Server, Warehouse, Store, Globe, Smartphone, Monitor, Code2 } from "lucide-react";
import {
Plug, CheckCircle, AlertCircle, Clock, RefreshCw, Plus,
ShoppingCart,
Server,
ShoppingBag,
Store,
Smartphone,
Code2,
Monitor,
Warehouse,
Globe
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { DataTable } from "../../../components/customs/DataTable";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { IntegrationStatsCards } from "../components/IntegrationStatsCards";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { useIntegration } from "../hook/useIntegration";
import { useChannel } from "../../channels/hook/useChannel";
import type { Integration } from "../types/integrations.types";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { IntegrationCard } from "../components/IntegrationStatsCards";
const INTEGRATION_META: Record<string, { label: string, icon: any, color: string, bg: string }> = {
// Import Tab Components
import PublishingRulesList from "../components/PublishingRulesTab";
import SyncJobsList from "../components/SyncJobsTab";
import ErrorCenterList from "../components/ErrorCenterTab";
import AuditLogsList from "../components/AuditLogsTab";
// ──────────────────────────────────────────────
const INTEGRATION_META: Record<string, { label: string; icon: any; color: string; bg: string }> = {
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" },
@@ -24,10 +45,13 @@ const INTEGRATION_META: Record<string, { label: string, icon: any, color: string
};
export default function IntegrationList() {
const [activeTab, setActiveTab] = useState("Connections");
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<"Connections" | "Publishing Rules" | "Sync Jobs" | "Error Center" | "Audit & Logs">("Connections");
const [statusFilter, setStatusFilter] = useState("All Status");
const [typeFilter, setTypeFilter] = useState("All Types");
const navigate = useNavigate();
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: "", name: "" });
const [isDeleting, setIsDeleting] = useState(false);
const { items, fetchItems, loading, deleteItem } = useIntegration();
const { items: channels, fetchItems: fetchChannels } = useChannel();
@@ -37,13 +61,16 @@ export default function IntegrationList() {
fetchChannels();
}, [fetchItems, fetchChannels]);
const handleDelete = async (id: string) => {
if (confirm("Are you sure you want to delete this integration?")) {
try {
await deleteItem(id);
} catch {
// handled
}
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);
}
};
@@ -81,25 +108,19 @@ export default function IntegrationList() {
label: "Type",
render: (val: string) => {
const meta = INTEGRATION_META[val] || INTEGRATION_META.custom_api;
return (
<span className="text-xs font-medium text-gray-600 capitalize">
{meta.label}
</span>
);
return <span className="text-xs font-medium text-gray-600 capitalize">{meta.label}</span>;
}
},
{
key: "environment",
label: "Environment",
render: (val: string) => (
<span className="text-blue-600 text-sm font-medium capitalize">{val}</span>
),
render: (val: string) => <span className="text-blue-600 text-sm font-medium capitalize">{val}</span>,
},
{
key: "status",
label: "Connection Status",
render: (val: string) => {
const isSuccess = val === "Connected" || val === "active" || val === "Connected";
const isSuccess = val === "Connected" || val === "active";
const isWarning = val === "Pending" || val === "pending";
return (
<StatusBadge
@@ -115,9 +136,9 @@ export default function IntegrationList() {
render: (_: any, row: Integration) => (
<div>
<div className="text-gray-900 font-medium text-sm">{row.lastSync || "—"}</div>
{row.syncErrors && row.syncErrors > 0 ? (
{row.syncErrors && row.syncErrors > 0 && (
<div className="text-red-600 text-xs mt-0.5">{row.syncErrors} failed</div>
) : null}
)}
</div>
),
},
@@ -128,33 +149,26 @@ export default function IntegrationList() {
render: (_: any, row: Integration) => (
<div>
<div className="text-gray-900 text-sm font-medium">{row.author || "Admin"}</div>
<div className="text-gray-500 text-xs mt-0.5">{row.createdAt ? new Date(row.createdAt).toLocaleDateString() : "—"}</div>
<div className="text-gray-500 text-xs mt-0.5">
{row.createdAt ? new Date(row.createdAt).toLocaleDateString() : "—"}
</div>
</div>
),
},
];
const actions = (row: Integration) => (
<div className="flex items-center justify-end gap-1 text-gray-400">
<button onClick={() => navigate(`${row.id}/edit`)} title="Edit" className="p-1.5 hover:bg-white hover:text-purple-600 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><Edit2 className="w-4 h-4" /></button>
<button onClick={() => handleDelete(row.id)} title="Delete" className="p-1.5 hover:bg-white hover:text-red-600 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><Trash2 className="w-4 h-4" /></button>
</div>
);
const filteredItems = items.filter(item => {
const matchesStatus = statusFilter === "All Status" ||
(statusFilter === "Connected" && (item.status === "Connected" || item.status === "active")) ||
(statusFilter === "Pending" && (item.status === "Pending" || item.status === "pending")) ||
(statusFilter === "Disconnected" && (item.status === "Disconnected" || item.status === "inactive"));
const matchesType = typeFilter === "All Types" ||
(typeFilter.toLowerCase() === item.integrationType.toLowerCase());
typeFilter.toLowerCase() === item.integrationType.toLowerCase();
return matchesStatus && matchesType;
});
const failedCount = items.filter(i => i.syncErrors && i.syncErrors > 0).length;
return (
<PageWrapper>
<Breadcrumb
@@ -165,30 +179,59 @@ export default function IntegrationList() {
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button onClick={() => navigate("new")} className="bg-purple-600 hover:bg-purple-700 text-white"><Plus className="w-4 h-4 mr-2" />New Integration</Button>
<Button onClick={() => navigate("new")} className="bg-purple-600 hover:bg-purple-700 text-white">
<Plus className="w-4 h-4 mr-2" />New Integration
</Button>
</>
}
/>
{/* Stats Cards */}
<IntegrationStatsCards />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<IntegrationCard
title="Total Integrations"
value={items.length}
subtitle="All integrations"
icon={<Plug className="w-5 h-5" />}
color="purple"
/>
<IntegrationCard
title="Connected Systems"
value={items.filter(i => i.status === "Connected" || i.status === "active").length}
subtitle="Healthy"
icon={<CheckCircle className="w-5 h-5" />}
color="green"
/>
<IntegrationCard
title="Failed Sync Jobs"
value={items.filter(i => i.syncErrors && i.syncErrors > 0).length}
subtitle="Need attention"
icon={<AlertCircle className="w-5 h-5" />}
color="red"
/>
<IntegrationCard
title="Last Synchronised"
value="14:32"
subtitle="Today"
icon={<Clock className="w-5 h-5" />}
color="slate"
/>
</div>
{/* Main Content Area */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 flex flex-col">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 mt-6">
{/* Tabs */}
<div className="flex border-b border-gray-100 px-2 pt-2">
<div className="flex border-b border-gray-100 px-6 pt-2">
{[
{ id: 'Connections', count: items.length },
{ id: 'Publishing Rules', count: 5 },
{ id: 'Sync Jobs', count: 6 },
{ id: 'Error Center', count: failedCount },
{ id: 'Audit & Logs', count: null }
{ id: "Connections", count: items.length },
{ id: "Publishing Rules", count: 5 },
{ id: "Sync Jobs", count: 6 },
{ id: "Error Center", count: items.filter(i => i.syncErrors && i.syncErrors > 0).length },
{ id: "Audit & Logs", count: null }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
onClick={() => setActiveTab(tab.id as any)}
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'
@@ -206,38 +249,63 @@ export default function IntegrationList() {
))}
</div>
{/* Table container */}
<div className="w-full">
<DataTable
columns={columns}
data={filteredItems}
actions={actions}
searchPlaceholder="Search integrations..."
toolbarLeft={
<div className="flex gap-2">
<select
className="h-9 px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-gray-700 bg-white"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option>All Status</option>
<option>Connected</option>
<option>Disconnected</option>
</select>
<select
className="h-9 px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-gray-700 bg-white"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
<option>All Types</option>
<option>Marketplace</option>
<option>E-Commerce</option>
</select>
</div>
}
/>
{/* Tab Content */}
<div className="p-6">
{activeTab === "Connections" && (
<DataTable
columns={columns}
data={filteredItems}
rowIdKey="id"
resultLabel="integrations"
statusKey="status"
actionConfig={{
onView: (row) => navigate(`${row.id}/view`),
onEdit: (row) => navigate(`${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
}}
searchPlaceholder="Search integrations..."
toolbarLeft={
<div className="flex gap-2">
<select
className="h-9 px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-gray-700 bg-white"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option>All Status</option>
<option>Connected</option>
<option>Pending</option>
<option>Disconnected</option>
</select>
<select
className="h-9 px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-gray-700 bg-white"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
<option>All Types</option>
<option>Marketplace</option>
<option>E-Commerce</option>
</select>
</div>
}
/>
)}
{activeTab === "Publishing Rules" && <PublishingRulesList />}
{activeTab === "Sync Jobs" && <SyncJobsList />}
{activeTab === "Error Center" && <ErrorCenterList />}
{activeTab === "Audit & Logs" && <AuditLogsList />}
</div>
</div>
<ConfirmationModal
isOpen={deleteModal.isOpen}
title="Delete Integration"
description="Are you sure you want to delete this integration? This action cannot be undone."
itemName={deleteModal.name}
loading={isDeleting}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</PageWrapper>
);
}
}
@@ -175,7 +175,7 @@ export default function NewIntegration() {
const val = e.target.value;
formik.setFieldValue("channel", val);
// Auto-match integrationType to channelType from Channel Master
// 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);
@@ -326,7 +326,7 @@ export default function NewIntegration() {
onBlur={formik.handleBlur}
className={inputClass(formik.touched.channel && Boolean(formik.errors.channel))}
>
<option value="">Select a channel from Channel Master...</option>
<option value="">Select a channel from Channel Registry...</option>
{channels.map((chan) => (
<option key={chan.id} value={chan.code || chan.id}>
{chan.name} ({chan.code})
@@ -334,39 +334,9 @@ export default function NewIntegration() {
))}
</select>
</div>
<p className="text-xs text-gray-400 mt-1">Channels are defined in Channel Master. Integration Hub stores connection credentials separately.</p>
<p className="text-xs text-gray-400 mt-1">Channels are defined in Channel Registry. Integration Hub stores connection credentials separately.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Integration Type *</label>
<div className="grid grid-cols-3 gap-3">
{INTEGRATION_TYPES.map((type) => {
const Icon = type.icon;
const isSelected = formik.values.integrationType === type.id;
return (
<button
key={type.id}
type="button"
onClick={() => formik.setFieldValue("integrationType", type.id)}
className={`flex items-start gap-3 p-3 rounded-xl border-2 transition-all text-left ${
isSelected
? "border-purple-500 bg-purple-50"
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
}`}
>
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${isSelected ? "bg-purple-100" : "bg-gray-100"}`}>
<Icon className={`w-4 h-4 ${isSelected ? "text-purple-600" : "text-gray-500"}`} />
</div>
<div>
<div className={`text-sm font-medium ${isSelected ? "text-purple-700" : "text-gray-900"}`}>{type.label}</div>
<div className="text-[11px] text-gray-500 mt-0.5 leading-tight">{type.sub}</div>
</div>
</button>
);
})}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Environment</label>
+25 -33
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { CheckCircle2, Clock, Package, AlertCircle, Plus, Eye, Edit, Trash2 } from "lucide-react";
import { CheckCircle2, Clock, Package, AlertCircle, Plus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
@@ -13,6 +13,7 @@ import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { DataTable, type DataTableColumn } from "../../../components/customs/DataTable";
import type { Product } from "../types/product.types";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import {Loader}from "../../../components/customs/Loader"
// ── Completeness bar ───────────────────────────────────────────────────────────
@@ -43,7 +44,7 @@ function ProductThumb({ name: _name }: { name: string }) {
export default function ProductList() {
const { canImport, canExport } = usePermissions("products.items");
const navigate = useNavigate();
const { products, fetchProducts, deleteProduct } = useProduct();
const { products, fetchProducts, deleteProduct, loading } = useProduct();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" });
const [isDeleting, setIsDeleting] = useState(false);
@@ -66,8 +67,8 @@ export default function ProductList() {
// Stats for KPI cards
const stats = useMemo(() => {
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 pending = products.filter(p => p.status === "pending").length;
const draft = products.filter(p => p.status === "draft").length;
const avgCompleteness = products.length > 0
? Math.round(products.reduce((s, p) => s + (p.completeness || 0), 0) / products.length)
: 0;
@@ -153,15 +154,26 @@ export default function ProductList() {
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<ProductCard title="Published" value={stats.published} subtitle="Ready to sell" color="green" icon={<CheckCircle2 className="w-6 h-6" />} />
<ProductCard title="Pending Review" value={stats.pending} subtitle="Needs attention" color="orange" icon={<Clock className="w-6 h-6" />} />
<ProductCard title="Draft" value={stats.draft} subtitle="In your catalog" color="slate" icon={<Package className="w-6 h-6" />} />
<ProductCard title="Avg Completeness" value={`${stats.avgCompleteness}%`} subtitle="Overall" color="red" icon={<AlertCircle className="w-6 h-6" />} />
<ProductCard title="Published" value={stats.published} subtitle="Ready to sell" color="green" icon={<CheckCircle2 className="w-6 h-6" />} />
<ProductCard title="Pending Review" value={stats.pending} subtitle="Needs attention" color="orange" icon={<Clock className="w-6 h-6" />} />
<ProductCard title="Draft" value={stats.draft} subtitle="In your catalog" color="slate" icon={<Package className="w-6 h-6" />} />
<ProductCard title="Avg Completeness" value={`${stats.avgCompleteness}%`} subtitle="Overall" color="red" icon={<AlertCircle className="w-6 h-6" />} />
</div>
{/* Loading State */}
{loading && (
<div className="flex justify-center py-12">
<Loader
size="lg"
message="Fetching products..."
/>
</div>
)}
{/* DataTable */}
<DataTable<Product>
columns={columns}
statusKey="status"
data={products}
selectable
selectedIds={selected}
@@ -170,31 +182,11 @@ export default function ProductList() {
rowIdKey="id"
resultLabel="products"
pageSizeOptions={[5, 10, 25, 50]}
actions={row => (
<div className="flex items-center justify-end gap-1 text-gray-400">
<button
onClick={e => { e.stopPropagation(); navigate(`${row.id}/edit`); }}
className="p-1.5 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
title="View"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={e => { e.stopPropagation(); navigate(`${row.id}/edit`); }}
className="p-1.5 hover:text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
title="Edit"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={e => { e.stopPropagation(); setDeleteModal({ isOpen: true, id: row.id, name: row.name }); }}
className="p-1.5 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
actionConfig={{
onView: (row) => navigate(`${row.id}/edit`),
onEdit: (row) => navigate(`${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
}}
/>
<ConfirmationModal
@@ -44,7 +44,7 @@ export function KPI({
const styles = VARIANT_STYLES[variant];
return (
<div className={`flex items-center gap-4 p-5 rounded-2xl border ${styles.card} ${className}`}>
<div className={`flex items-center gap-4 p-5 rounded-lg border ${styles.card} ${className}`}>
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${styles.iconWrap}`}>
<Icon className={`w-6 h-6 ${styles.icon}`} />
</div>
+12 -12
View File
@@ -13,7 +13,7 @@ import {
Line,
} from "recharts";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { KPIGrid } from "../../../components/customs/KPI";
import { KPIGrid } from "../components/KPI";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
const publicationData = [
@@ -58,7 +58,7 @@ export default function ReportList() {
{/* KPI Cards */}
<KPIGrid className="mb-6 grid-cols-1 md:grid-cols-4">
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm flex flex-col justify-between">
<div className="bg-white p-5 rounded-lg border border-gray-100 shadow-sm flex flex-col justify-between">
<div className="flex justify-between items-start mb-4">
<span className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">CATALOG GROWTH</span>
<div className="w-8 h-8 rounded-lg bg-purple-50 flex items-center justify-center">
@@ -71,7 +71,7 @@ export default function ReportList() {
</div>
</div>
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm flex flex-col justify-between">
<div className="bg-white p-5 rounded-lg border border-gray-100 shadow-sm flex flex-col justify-between">
<div className="flex justify-between items-start mb-4">
<span className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">APPROVAL RATE</span>
<div className="w-8 h-8 rounded-lg bg-purple-50 flex items-center justify-center">
@@ -87,7 +87,7 @@ export default function ReportList() {
</div>
</div>
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm flex flex-col justify-between">
<div className="bg-white p-5 rounded-lg border border-gray-100 shadow-sm flex flex-col justify-between">
<div className="flex justify-between items-start mb-4">
<span className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">AVG. TIME TO PUBLISH</span>
<div className="w-8 h-8 rounded-lg bg-purple-50 flex items-center justify-center">
@@ -100,7 +100,7 @@ export default function ReportList() {
</div>
</div>
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm flex flex-col justify-between">
<div className="bg-white p-5 rounded-lg border border-gray-100 shadow-sm flex flex-col justify-between">
<div className="flex justify-between items-start mb-4">
<span className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">ACTIVE CONTRIBUTORS</span>
<div className="w-8 h-8 rounded-lg bg-purple-50 flex items-center justify-center">
@@ -120,7 +120,7 @@ export default function ReportList() {
{/* Main Charts: Row 1 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
{/* Publication Metrics (AreaChart) - Spans 2 columns */}
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm lg:col-span-2 flex flex-col">
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm lg:col-span-2 flex flex-col">
<div className="flex justify-between items-center mb-6">
<div>
<h3 className="font-bold text-gray-900 text-lg">Publication Metrics</h3>
@@ -164,7 +164,7 @@ export default function ReportList() {
</div>
{/* Products by Category (BarChart) - Spans 1 column */}
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm flex flex-col">
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm flex flex-col">
<div className="mb-6">
<h3 className="font-bold text-gray-900 text-lg">Products by Category</h3>
<p className="text-sm text-gray-500">Top 5 categories</p>
@@ -189,7 +189,7 @@ export default function ReportList() {
{/* Main Charts: Row 2 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
{/* Weekly Activity (LineChart) - Spans 2 columns */}
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm lg:col-span-2 flex flex-col">
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm lg:col-span-2 flex flex-col">
<div className="mb-6">
<h3 className="font-bold text-gray-900 text-lg">Weekly Activity</h3>
<p className="text-sm text-gray-500">Product creation and publication timeline</p>
@@ -216,7 +216,7 @@ export default function ReportList() {
</div>
{/* Top Contributors - Spans 1 column */}
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm flex flex-col">
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm flex flex-col">
<div className="mb-6">
<h3 className="font-bold text-gray-900 text-lg">Top Contributors</h3>
</div>
@@ -242,7 +242,7 @@ export default function ReportList() {
{/* Bottom Row Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Completeness Distribution */}
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm">
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
<h3 className="font-bold text-gray-900 mb-6">Completeness Distribution</h3>
<div className="flex flex-col gap-4">
<div>
@@ -276,7 +276,7 @@ export default function ReportList() {
</div>
{/* Workflow Efficiency */}
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm flex flex-col justify-center">
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm flex flex-col justify-center">
<h3 className="font-bold text-gray-900 mb-6">Workflow Efficiency</h3>
<div className="flex flex-col gap-6">
<div>
@@ -298,7 +298,7 @@ export default function ReportList() {
</div>
{/* Channel Performance */}
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm">
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
<h3 className="font-bold text-gray-900 mb-6">Channel Performance</h3>
<div className="flex flex-col gap-4">
<div>
-2
View File
@@ -1,2 +0,0 @@
import { scopesService } from '../services/scopes.service';
export const scopesApi = scopesService;
-67
View File
@@ -1,67 +0,0 @@
import { useState, useCallback } from 'react';
import { scopesService } from '../services/scopes.service';
import type { Scope, ScopeCreateRequest, ScopeUpdateRequest } from '../types/scopes.types';
import { toast } from 'react-toastify';
export const useScope = () => {
const [items, setItems] = useState<Scope[]>([]);
const [loading, setLoading] = useState(false);
const fetchItems = useCallback(async () => {
setLoading(true);
try {
const data = await scopesService.getAll();
setItems(data);
} catch (err: any) {
toast.error(err.message || 'Failed to fetch items');
} finally {
setLoading(false);
}
}, []);
const createItem = useCallback(async (req: ScopeCreateRequest) => {
setLoading(true);
try {
const created = await scopesService.create(req);
setItems((prev) => [...prev, created]);
toast.success('Scope created successfully!');
return created;
} catch (err: any) {
toast.error(err.message || 'Failed to create item');
throw err;
} finally {
setLoading(false);
}
}, []);
const updateItem = useCallback(async (id: string, req: ScopeUpdateRequest) => {
setLoading(true);
try {
const updated = await scopesService.update(id, req);
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
toast.success('Scope updated successfully!');
return updated;
} catch (err: any) {
toast.error(err.message || 'Failed to update item');
throw err;
} finally {
setLoading(false);
}
}, []);
const deleteItem = useCallback(async (id: string) => {
setLoading(true);
try {
await scopesService.delete(id);
setItems((prev) => prev.filter((p) => p.id !== id));
toast.success('Scope deleted successfully!');
} catch (err: any) {
toast.error(err.message || 'Failed to delete item');
throw err;
} finally {
setLoading(false);
}
}, []);
return { items, loading, fetchItems, createItem, updateItem, deleteItem };
};
-4
View File
@@ -1,4 +0,0 @@
export * from './types/scopes.types';
export * from './services/scopes.service';
export * from './hook/useScope';
export * from './routes/scopes.routes';
-211
View File
@@ -1,211 +0,0 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { Button } from "../../../components/customs/Button";
import { useScope } from "../hook/useScope";
import { scopeSchema } from "../validation/scopes.schema";
import type { ScopeCreateRequest } from "../types/scopes.types";
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-purple-500'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
const textareaClass = `w-full border border-gray-200 focus:ring-purple-500 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 min-h-[100px] resize-y`;
const labelClass = "block text-sm font-medium text-gray-700 mb-1.5";
const errorClass = "text-xs text-red-500 mt-1";
const PRESET_COLORS = [
{ name: "Purple", value: "#7C3AED", bg: "bg-purple-600" },
{ name: "Green", value: "#10B981", bg: "bg-emerald-500" },
{ name: "Orange", value: "#F59E0B", bg: "bg-orange-500" },
{ name: "Blue", value: "#3B82F6", bg: "bg-blue-500" },
{ name: "Violet", value: "#8B5CF6", bg: "bg-violet-500" },
{ name: "Red", value: "#F97316", bg: "bg-red-500" },
{ name: "Cyan", value: "#06B6D4", bg: "bg-cyan-500" },
{ name: "Pink", value: "#EC4899", bg: "bg-pink-500" },
];
export default function NewScope() {
const navigate = useNavigate();
const { id } = useParams<{ id?: string }>();
const isEdit = Boolean(id);
const { createItem, updateItem } = useScope();
const [selectedColor, setSelectedColor] = useState("#7C3AED");
const [isActive, setIsActive] = useState(true);
const formik = useFormik({
initialValues: {
name: "",
code: "",
description: "",
},
validationSchema: scopeSchema,
onSubmit: async (values, { setSubmitting }) => {
const payload: ScopeCreateRequest = {
...values,
status: isActive ? "active" : "inactive",
color: selectedColor,
};
try {
if (isEdit && id) {
await updateItem(id, payload as any);
} else {
await createItem(payload);
}
navigate("..");
} catch {
// Handled by hook
} finally {
setSubmitting(false);
}
},
});
useEffect(() => {
if (isEdit && id) {
// Fetch existing scope if editing
// scopesService.getById(id).then(...);
}
}, [isEdit, id]);
return (
<PageWrapper>
<Breadcrumb
items={[{ label: 'Home' }, { label: 'Scopes', href: '/scopes' }, { label: isEdit ? 'Edit Scope' : 'Create Scope' }]}
backTo="/scopes"
actions={
<>
<Button variant="outline" size="md" type="button" onClick={() => navigate('/scopes')} disabled={formik.isSubmitting}>Cancel</Button>
<Button variant="outline" size="md" type="button">Save as Draft</Button>
<Button variant="primary" size="md" type="submit" form="scope-form" loading={formik.isSubmitting}>{isEdit ? 'Save Changes' : 'Create Scope'}</Button>
</>
}
/>
<div className="max-w-4xl mx-auto">
<form id="scope-form" onSubmit={formik.handleSubmit} className="space-y-10">
{/* Basic Information */}
<div className="bg-white border border-gray-200 rounded-xl p-8">
<h2 className="text-lg font-semibold mb-2">Basic Information</h2>
<p className="text-sm text-gray-500 mb-8">Enter the basic details for this scope</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className={labelClass}>Scope Name <span className="text-red-500">*</span></label>
<input
name="name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g., Ecommerce"
className={inputClass(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}>Internal Code <span className="text-red-500">*</span></label>
<input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g., ecommerce"
className={inputClass(formik.touched.code && Boolean(formik.errors.code))}
/>
</div>
<div className="md:col-span-2">
<label className={labelClass}>Description</label>
<textarea
name="description"
value={formik.values.description}
onChange={formik.handleChange}
placeholder="Describe the purpose of this scope..."
className={textareaClass}
/>
</div>
<div className="md:col-span-2 pt-2">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="w-5 h-5 rounded border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600"
/>
<span className="text-sm font-medium text-gray-700">
Active <span className="text-gray-500 font-normal">(Unchecked scopes will be saved as inactive)</span>
</span>
</label>
</div>
</div>
</div>
{/* Visual Configuration */}
<div className="bg-white border border-gray-200 rounded-xl p-8">
<h2 className="text-lg font-semibold mb-2">Visual Configuration</h2>
<p className="text-sm text-gray-500 mb-6">Choose a color to identify this scope</p>
<div>
<p className="text-sm font-medium text-gray-700 mb-3">Preset Colors</p>
<div className="grid grid-cols-4 gap-3">
{PRESET_COLORS.map((color) => (
<button
key={color.value}
type="button"
onClick={() => setSelectedColor(color.value)}
className={`flex items-center gap-3 p-3 border rounded-lg hover:border-gray-400 transition-all ${selectedColor === color.value ? 'border-purple-600 bg-purple-50' : 'border-gray-200'}`}
>
<div className={`w-8 h-8 rounded-md ${color.bg}`} />
<div className="text-left">
<div className="font-medium text-sm">{color.name}</div>
<div className="text-xs text-gray-500 font-mono">{color.value}</div>
</div>
</button>
))}
</div>
</div>
{/* Custom Color */}
<div className="mt-8">
<p className="text-sm font-medium text-gray-700 mb-3">Custom Color</p>
<div className="flex items-center gap-4">
<input
type="color"
value={selectedColor}
onChange={(e) => setSelectedColor(e.target.value)}
className="w-12 h-12 p-1 border border-gray-200 rounded-lg cursor-pointer"
/>
<input
type="text"
value={selectedColor}
onChange={(e) => setSelectedColor(e.target.value)}
className="font-mono text-sm border border-gray-200 rounded-lg px-4 py-2.5 w-40"
/>
</div>
</div>
{/* Preview */}
<div className="mt-8 bg-gray-50 border border-gray-200 rounded-xl p-6">
<p className="text-sm text-gray-500 mb-3">Preview</p>
<div className="flex items-center gap-4 bg-white p-4 rounded-lg border">
<div className="w-10 h-10 rounded-lg" style={{ backgroundColor: selectedColor }} />
<div>
<p className="font-medium">Scope Preview</p>
<p className="text-sm text-gray-500">This is how the scope color will appear in the system</p>
</div>
</div>
</div>
</div>
</form>
</div>
</PageWrapper>
);
}
-74
View File
@@ -1,74 +0,0 @@
import { useEffect, useState } from "react";
import { Plus, Edit2, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { DataTable } from "../../../components/customs/DataTable";
import { useScope } from "../hook/useScope";
import type { Scope } from "../types/scopes.types";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
export default function ScopeList() {
const navigate = useNavigate();
const { items, fetchItems, deleteItem } = useScope();
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" });
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
fetchItems();
}, [fetchItems]);
const handleDeleteConfirm = async () => {
if (!deleteModal.id) return;
setIsDeleting(true);
try {
await deleteItem(deleteModal.id);
setDeleteModal({ isOpen: false, id: "", name: "" });
} catch (error) {
// Error is handled in the hook
} finally {
setIsDeleting(false);
}
};
const columns = [
{ key: "name", label: "Name", sortable: true },
{ key: "status", label: "Status", sortable: true },
{ key: "createdAt", label: "Created At", sortable: true, render: (val: string) => new Date(val).toLocaleDateString() },
];
const actions = (item: Scope) => (
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" icon={<Edit2 className="w-4 h-4" />} onClick={() => navigate(item.id + "/edit")} />
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={() => setDeleteModal({ isOpen: true, id: item.id, name: item.name })} />
</div>
);
return (
<PageWrapper>
<Breadcrumb
items={[{ label: "Home" }, { label: "Scopes" }]}
actions={
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
Create Scope
</Button>
}
/>
<div className="mb-8">
<DataTable data={items} columns={columns} selectable actions={actions} searchPlaceholder="Search scopes..." />
</div>
<ConfirmationModal
isOpen={deleteModal.isOpen}
title="Delete Scope"
description="Are you sure you want to delete this scope? This action cannot be undone."
itemName={deleteModal.name}
loading={isDeleting}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</PageWrapper>
);
}
@@ -1,11 +0,0 @@
import { Routes, Route } from 'react-router-dom';
import ScopeList from '../pages/ScopeList';
import NewScope from '../pages/NewScope';
export const ScopeRoutes = () => (
<Routes>
<Route index element={<ScopeList />} />
<Route path="new" element={<NewScope />} />
<Route path=":id/edit" element={<NewScope />} />
</Routes>
);
@@ -1,55 +0,0 @@
import type { Scope, ScopeCreateRequest, ScopeUpdateRequest } from '../types/scopes.types';
const STORAGE_KEY = 'pim_scopes';
const getStored = (): Scope[] => {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return [];
return JSON.parse(stored);
};
const setStored = (items: Scope[]) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
};
export const scopesService = {
getAll: async (): Promise<Scope[]> => {
return new Promise((resolve) => setTimeout(() => resolve(getStored()), 300));
},
getById: async (id: string): Promise<Scope | undefined> => {
return new Promise((resolve) => setTimeout(() => resolve(getStored().find(p => p.id === id)), 200));
},
create: async (req: ScopeCreateRequest): Promise<Scope> => {
return new Promise((resolve) => {
setTimeout(() => {
const list = getStored();
const newItem: Scope = { ...req, id: String(Date.now()), createdAt: new Date().toISOString() };
list.push(newItem);
setStored(list);
resolve(newItem);
}, 300);
});
},
update: async (id: string, req: ScopeUpdateRequest): Promise<Scope> => {
return new Promise((resolve, reject) => {
setTimeout(() => {
const list = getStored();
const index = list.findIndex(p => p.id === id);
if (index === -1) { reject(new Error('Not found')); return; }
const updated = { ...list[index], ...req };
list[index] = updated;
setStored(list);
resolve(updated);
}, 300);
});
},
delete: async (id: string): Promise<boolean> => {
return new Promise((resolve) => {
setTimeout(() => {
const list = getStored().filter(p => p.id !== id);
setStored(list);
resolve(true);
}, 300);
});
},
};
-11
View File
@@ -1,11 +0,0 @@
export interface Scope {
id: string;
name: string;
code?: string;
description?: string;
color?: string;
status: 'active' | 'inactive';
createdAt: string;
}
export type ScopeCreateRequest = Omit<Scope, 'id' | 'createdAt'>;
export type ScopeUpdateRequest = Partial<ScopeCreateRequest>;
@@ -1,5 +0,0 @@
import * as Yup from 'yup';
export const scopeSchema = Yup.object().shape({
name: Yup.string().required('Scope name is required'),
});
-174
View File
@@ -1,174 +0,0 @@
import type { ReactNode } from "react";
import { ChevronDown, ChevronUp, GripVertical } from "lucide-react";
import { useState } from "react";
interface Column {
key: string;
label: string;
sortable?: boolean;
width?: string;
render?: (value: any, row: any) => ReactNode;
}
interface DataTableProps {
columns: Column[];
data: any[];
onRowClick?: (row: any) => void;
selectable?: boolean;
actions?: (row: any) => ReactNode;
header?: ReactNode;
variant?: "card" | "flat";
draggable?: boolean;
onReorder?: (newData: any[]) => void;
}
export function DataTable({
columns,
data,
onRowClick,
selectable = false,
actions,
header,
variant = "flat", // Changed default to flat
draggable = false,
onReorder,
}: DataTableProps) {
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const handleDragStart = (e: React.DragEvent<HTMLTableRowElement>, index: number) => {
setDraggedIndex(index);
e.dataTransfer.effectAllowed = "move";
};
const handleDragOver = (e: React.DragEvent<HTMLTableRowElement>, index: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) return;
setDragOverIndex(index);
};
const handleDrop = (e: React.DragEvent<HTMLTableRowElement>, dropIndex: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === dropIndex) return;
const newData = [...data];
const [movedItem] = newData.splice(draggedIndex, 1);
newData.splice(dropIndex, 0, movedItem);
onReorder?.(newData);
setDraggedIndex(null);
setDragOverIndex(null);
};
const handleDragEnd = () => {
setDraggedIndex(null);
setDragOverIndex(null);
};
return (
<div className={variant === "card" ? "bg-white rounded-t-xl overflow-hidden" : ""}>
{header && (
<div className="p-4 border-b border-gray-200 bg-white">
{header}
</div>
)}
<div className="overflow-x-auto">
<table className="w-full min-w-full">
<thead style={{ backgroundColor: '#FAF5FF' }} className="border-b border-gray-200">
<tr>
{draggable && <th className="w-10 px-4 py-3"></th>}
{selectable && (
<th className="w-12 px-4 py-3">
<input type="checkbox" className="w-4 h-4 rounded border-gray-300" />
</th>
)}
{columns.map((column) => (
<th
key={column.key}
className={`px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide ${column.width || ''}`}
style={{ color: '#9810FA' }}
>
<div className="flex items-center gap-1.5">
{column.label}
{column.sortable && (
<div className="flex flex-col -space-y-1">
<ChevronUp className="w-3 h-3 text-gray-400" />
<ChevronDown className="w-3 h-3 text-gray-400" />
</div>
)}
</div>
</th>
))}
{actions && <th className="w-16 px-4 py-3 text-right">Actions</th>}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{data.length === 0 ? (
<tr>
<td
colSpan={
columns.length +
(draggable ? 1 : 0) +
(selectable ? 1 : 0) +
(actions ? 1 : 0)
}
className="px-4 py-12 text-center text-gray-400"
>
No data available
</td>
</tr>
) : (
data.map((row, index) => (
<tr
key={row.id || index}
draggable={draggable}
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={(e) => handleDrop(e, index)}
onDragEnd={handleDragEnd}
onClick={() => onRowClick?.(row)}
className={`
group hover:bg-gray-50 transition-colors
${onRowClick ? "cursor-pointer" : ""}
${draggedIndex === index ? "opacity-60" : ""}
${dragOverIndex === index ? "bg-purple-50 border-t-2 border-purple-500" : ""}
`}
>
{draggable && (
<td className="px-3 py-3 text-gray-400 hover:text-gray-600 cursor-grab active:cursor-grabbing w-10">
<GripVertical className="w-5 h-5" />
</td>
)}
{selectable && (
<td className="px-4 py-3">
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-300"
onClick={(e) => e.stopPropagation()}
/>
</td>
)}
{columns.map((column) => (
<td key={column.key} className="px-4 py-3.5 text-sm text-gray-900 whitespace-nowrap">
{column.render ? column.render(row[column.key], row) : (row[column.key] ?? "—")}
</td>
))}
{actions && (
<td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
{actions(row)}
</td>
)}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
+30 -80
View File
@@ -1,30 +1,28 @@
import { useEffect, useState, useMemo } from "react";
import { Plus, Edit, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Plus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { usePermissions } from "../../../hooks/usePermission";
import { Can } from "../../../components/customs/Can";
import { Button, ExportButton, ImportButton } from "../../../components/customs/Button";
import { SearchBar, useSearch } from "../../../components/customs/SearchBar";
import { DataTable } from "../../../components/customs/DataTable";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { DataTable } from "../components/DataTable";
import { Pagination } from "../../../components/customs/Pagination";
import { useUnit } from "../hook/useUnit";
import type { Unit, UnitStatus } from "../types/unit.types";
import type { Unit } from "../types/unit.types";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
export default function UnitList() {
const { canImport, canExport } = usePermissions("masters.units");
const { query, setQuery } = useSearch();
const navigate = useNavigate();
const { units, fetchUnits, deleteUnit } = useUnit();
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" });
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({
isOpen: false,
id: "",
name: "",
});
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
@@ -38,37 +36,17 @@ export default function UnitList() {
await deleteUnit(deleteModal.id);
setDeleteModal({ isOpen: false, id: "", name: "" });
} catch (error) {
// Keep modal open on error
console.error(error);
} finally {
setIsDeleting(false);
}
};
useEffect(() => {
setCurrentPage(1);
}, [query]);
const filteredUnits = units.filter((unit) =>
unit.name.toLowerCase().includes(query.toLowerCase()) ||
unit.code.toLowerCase().includes(query.toLowerCase()) ||
unit.symbol.toLowerCase().includes(query.toLowerCase()) ||
unit.unitType.toLowerCase().includes(query.toLowerCase())
);
const paginatedUnits = useMemo(() => {
const start = (currentPage - 1) * pageSize;
return filteredUnits.slice(start, start + pageSize);
}, [filteredUnits, currentPage, pageSize]);
const handleRowClick = (unit: Unit) => {
navigate(`${unit.id}/edit`);
};
const columns = [
{ key: "name", label: "UNIT NAME" },
{ key: "code", label: "CODE" },
{ key: "symbol", label: "SYMBOL" },
{ key: "unitType", label: "TYPE" },
{ key: "name", label: "UNIT NAME", sortable: true },
{ key: "code", label: "CODE", sortable: true },
{ key: "symbol", label: "SYMBOL", sortable: true },
{ key: "unitType", label: "TYPE", sortable: true },
{
key: "conversionFactor",
label: "CONVERSION",
@@ -78,7 +56,8 @@ export default function UnitList() {
{
key: "status",
label: "STATUS",
render: (value: UnitStatus) => (
sortable: true,
render: (value: string) => (
<StatusBadge
status={value === "active" ? "active" : "disabled"}
label={value === "active" ? "Active" : "Inactive"}
@@ -110,49 +89,20 @@ export default function UnitList() {
}
/>
{/* Main Card Container - Table + Pagination Together */}
<div className="bg-white border border-gray-200 rounded-2xl overflow-hidden shadow-sm">
<DataTable
columns={columns}
data={paginatedUnits}
onRowClick={handleRowClick}
header={
<SearchBar
value={query}
onChange={setQuery}
placeholder="Search by name, code, symbol or type..."
/>
}
actions={(row: Unit) => (
<div className="flex items-center gap-1 justify-end">
<Button
variant="ghost"
size="sm"
icon={<Edit className="w-4 h-4" />}
onClick={() => navigate(`${row.id}/edit`)}
/>
<Button
variant="ghost"
size="sm"
icon={<Trash2 className="w-4 h-4 text-red-500" />}
onClick={(e) => {
e?.stopPropagation();
setDeleteModal({ isOpen: true, id: row.id, name: row.name });
}}
/>
</div>
)}
/>
{/* Pagination - Inside the same card */}
<Pagination
page={currentPage}
pageSize={pageSize}
total={filteredUnits.length}
onPageChange={setCurrentPage}
onPageSizeChange={setPageSize}
/>
</div>
<DataTable
columns={columns}
data={units}
onRowClick={(unit) => navigate(`${unit.id}/edit`)}
rowIdKey="id" // ← This was causing the error
resultLabel="units"
pageSizeOptions={[5, 10, 25, 50]}
statusKey="status"
actionConfig={{
onView: (row) => navigate(`${row.id}/view`),
onEdit: (row) => navigate(`${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
}}
/>
<ConfirmationModal
isOpen={deleteModal.isOpen}
+7 -9
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Plus, Edit2, Key, Trash2, Shield, Users, Lock, Box, LayoutGrid, Tags, Globe, Settings, Eye } from "lucide-react";
import { Plus, Edit2, Key, Shield, Users, Lock, Box, LayoutGrid, Tags, Globe, Settings, Eye } from "lucide-react";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { DataTable } from "../../../components/customs/DataTable";
@@ -149,13 +149,7 @@ export default function UserList() {
}
];
const actions = () => (
<div className="flex items-center justify-end gap-1 text-gray-400">
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded transition-colors"><Edit2 className="w-4 h-4" /></button>
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded transition-colors"><Key className="w-4 h-4" /></button>
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded transition-colors"><Trash2 className="w-4 h-4" /></button>
</div>
);
return (
<PageWrapper>
@@ -229,7 +223,11 @@ export default function UserList() {
<DataTable
columns={columns}
data={MOCK_USERS}
actions={actions}
actionConfig={{
onView: () => {},
onEdit: () => {},
onDelete: () => {}
}}
searchPlaceholder="Search members..."
toolbarLeft={
<select className="h-9 px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-gray-700 bg-white w-40">
@@ -1,13 +1,18 @@
import type { ReactNode } from "react";
/* ── Single Stat Card ── */
interface StatCardProps {
label: string;
/**
* VariantCard a premium styled card component mirroring the visual design of VariantStatsCards.
* It displays a title, a prominent value (e.g., price), a subtitle, and an optional icon.
* The appearance can be themed via the `color` prop which selects a soft background gradient and border.
*/
interface VariantCardProps {
title: string;
value: string | number;
subtitle: string;
icon: ReactNode;
/** Colour theme for the card */
color: "purple" | "green" | "blue" | "slate" | "indigo" | "red" | "orange";
/** Optional icon or image displayed on the right side */
icon?: ReactNode;
/** Colour theme for the card matches VariantStatsCards themes */
color: "purple" | "green" | "blue" | "slate" | "indigo" | "red" | "orange";
}
// Reuse the same colour map as VariantStatsCards for visual consistency
@@ -48,15 +53,9 @@ const COLOR_MAP = {
bgGradient: "bg-orange-50/40",
},
};
function StatCard({
label,
value,
subtitle,
icon,
color,
}: StatCardProps) {
const c = COLOR_MAP[color];
export function VariantCard({ title, value, subtitle, icon, color }: VariantCardProps) {
const c = COLOR_MAP[color];
return (
<div
className={`
@@ -64,7 +63,7 @@ function StatCard({
${c.border}
border
rounded-lg
h-[100px]
h-[110px]
px-4
py-3
flex
@@ -77,7 +76,7 @@ function StatCard({
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-[15px] font-medium text-slate-600 leading-none">
{label}
{title}
</span>
{icon && (
<span className={c.iconText}>
@@ -98,105 +97,4 @@ function StatCard({
</div>
);
}
/* ── Stats Row ── */
interface VariantStatsCardsProps {
total: number;
active: number;
draft: number;
disabled: number;
published: number;
}
export function VariantStatsCards({ total, active, draft, disabled, published }: VariantStatsCardsProps) {
const activePercent = total > 0 ? Math.round((active / total) * 100) : 0;
return (
<div className="grid grid-cols-5 gap-4 mb-6">
<StatCard
label="Total Variants"
value={total}
subtitle="All SKUs in catalog"
icon={<GridIcon />}
color="purple"
/>
<StatCard
label="Active"
value={active}
subtitle={`${activePercent}% of total`}
icon={<CheckIcon />}
color="green"
/>
<StatCard
label="Draft"
value={draft}
subtitle="Pending completion"
icon={<ClockIcon />}
color="slate"
/>
<StatCard
label="Disabled"
value={disabled}
subtitle="Not available"
icon={<XIcon />}
color="red"
/>
<StatCard
label="Published"
value={published}
subtitle="Live on channels"
icon={<GlobeIcon />}
color="indigo"
/>
</div>
);
}
/* ── Inline SVG icons (keeps the component selfcontained) ── */
function GridIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" /><rect x="3" y="14" width="7" height="7" />
</svg>
);
}
function CheckIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
);
}
function ClockIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
</svg>
);
}
function XIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
);
}
function GlobeIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /><line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
);
}
+65 -28
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Upload, Download, RefreshCw, Plus, Edit, Trash2 } from "lucide-react";
import { Upload, Download, RefreshCw, Plus, Package, CheckCircle, Edit3, XCircle } from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { useVariant } from "../hook/useVariant";
@@ -9,7 +9,7 @@ import { useNavigate } from "react-router-dom";
import { Can } from "../../../components/customs/Can";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { VariantStatsCards } from "../components/VariantStatsCards";
import { VariantCard } from "../components/VariantStatsCards";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
export default function VariantList() {
@@ -30,13 +30,14 @@ export default function VariantList() {
await deleteVariant(deleteModal.id);
setDeleteModal({ isOpen: false, id: "", name: "" });
} catch (error) {
// Keep modal open on error
console.error(error);
} finally {
setIsDeleting(false);
}
};
const activeCount = variants.filter(v => v.status === 'active').length;
// Stats Calculations
const total = variants.length;
const draftCount = variants.filter(v => v.status === 'draft').length;
const disabledCount = variants.filter(v => v.status === 'disabled').length;
const publishedCount = variants.filter(v => v.status === 'published').length;
@@ -45,7 +46,7 @@ export default function VariantList() {
{ key: "sku", label: "SKU", sortable: true },
{ key: "name", label: "Variant Name", sortable: true },
{ key: "parentProductName", label: "Product", sortable: true },
{ key: "price", label: "Price", sortable: true, render: (val: any) => `$${val.toFixed(2)}` },
{ key: "price", label: "Price", sortable: true, render: (val: any) => `$${val?.toFixed(2) || '0.00'}` },
{ key: "stock", label: "Stock", sortable: true },
{
key: "status",
@@ -53,18 +54,23 @@ export default function VariantList() {
sortable: true,
render: (val: any) => (
<StatusBadge
status={val === "active" || val === "published" ? "active" : val === "disabled" ? "disabled" : "pending"}
label={val.charAt(0).toUpperCase() + val.slice(1)}
status={val === "active" || val === "published" ? "active" : val === "disabled" ? "disabled" : "draft"}
label={val ? val.charAt(0).toUpperCase() + val.slice(1) : "Unknown"}
/>
),
},
{ key: "lastUpdated", label: "Last Updated", sortable: true, render: (val: string) => new Date(val).toLocaleDateString() },
{
key: "lastUpdated",
label: "Last Updated",
sortable: true,
render: (val: string) => val ? new Date(val).toLocaleDateString() : "-"
},
];
return (
<ProtectedRoute node="products.variants">
<PageWrapper>
{/* Subheader */}
{/* Breadcrumb & Actions */}
<Breadcrumb
items={[{ label: "Home" }, { label: "Variant Management" }]}
actions={
@@ -73,22 +79,56 @@ export default function VariantList() {
<Button variant="outline" size="md" icon={<Download className="w-4 h-4" />}>Export</Button>
<Button variant="outline" size="md" icon={<RefreshCw className="w-4 h-4" />}>Bulk Update</Button>
<Can node="products.variants" action="create">
<Button variant="primary" size="md" icon={<Plus className="w-4 h-4" />} onClick={() => navigate('new')}>Create Variant</Button>
<Button
variant="primary"
size="md"
icon={<Plus className="w-4 h-4" />}
onClick={() => navigate('new')}
>
Create Variant
</Button>
</Can>
</>
}
/>
{/* Stats Cards */}
<VariantStatsCards
total={variants.length}
active={activeCount}
draft={draftCount}
disabled={disabledCount}
published={publishedCount}
/>
{/* Table Container */}
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<VariantCard
title="Total Variants"
value={total}
subtitle="All variants"
color="purple"
icon={<Package className="w-5 h-5" />}
/>
<VariantCard
title="Published"
value={publishedCount}
subtitle="Live & active"
color="green"
icon={<CheckCircle className="w-5 h-5" />}
/>
<VariantCard
title="Drafts"
value={draftCount}
subtitle="In progress"
color="blue"
icon={<Edit3 className="w-5 h-5" />}
/>
<VariantCard
title="Disabled"
value={disabledCount}
subtitle="Not available"
color="red"
icon={<XCircle className="w-5 h-5" />}
/>
</div>
{/* Data Table */}
<div className="mb-8">
<DataTable
columns={columns}
@@ -98,18 +138,15 @@ export default function VariantList() {
onSelectionChange={setSelectedIds}
onRowClick={(row) => navigate(`${row.id}/edit`)}
searchPlaceholder="Search variants by SKU, name, or product..."
actions={(row) => (
<div className="flex items-center gap-2 justify-end">
<Button variant="ghost" size="sm" icon={<Edit className="w-4 h-4" />} onClick={(e) => { e?.stopPropagation(); navigate(`${row.id}/edit`); }} />
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={(e) => {
e?.stopPropagation();
setDeleteModal({ isOpen: true, id: row.id, name: row.name });
}} />
</div>
)}
actionConfig={{
onView: (row) => navigate(`${row.id}/view`),
onEdit: (row) => navigate(`${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
}}
/>
</div>
{/* Delete Confirmation */}
<ConfirmationModal
isOpen={deleteModal.isOpen}
title="Delete Variant"
@@ -1,5 +1,5 @@
import { Plus, Check, Trash2 } from "lucide-react";
import { Plus, Check } from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { DataTable } from "../../../components/customs/DataTable";
import { Badge, StatusBadge } from "../../../components/customs/StatusBadge";
@@ -56,13 +56,9 @@ export const StageTransitionsTab = () => {
<DataTable
columns={columns}
data={MOCK_TRANSITIONS}
actions={() => (
<div className="flex items-center justify-end">
<button className="p-1.5 hover:bg-white text-gray-400 hover:text-red-500 rounded border border-transparent hover:border-gray-200 shadow-sm transition-all">
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
actionConfig={{
onDelete: (row) => console.log("Delete transition", row.id)
}}
/>
</div>
</div>
+27 -23
View File
@@ -1,12 +1,13 @@
import { Plus, Edit2, Eye, MoreHorizontal, Upload, Download, Activity, CheckCircle, Clock, Layers } from "lucide-react";
import { useState } from "react";
import { Plus, Upload, Download, Activity, CheckCircle, Clock, Layers } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { DataTable } from "../../../components/customs/DataTable";
import { KPIGrid } from "../../../components/customs/KPI";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { WorkflowStatsCard } from "../components/WorkflowStatsCard";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
// Mock Data
const MOCK_WORKFLOWS = [
@@ -19,6 +20,7 @@ const MOCK_WORKFLOWS = [
export default function WorkflowList() {
const navigate = useNavigate();
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: "", name: "" });
const columns = [
{
@@ -86,14 +88,6 @@ export default function WorkflowList() {
},
];
const actions = (row: any) => (
<div className="flex items-center justify-end gap-1 text-gray-400">
<button title="View" onClick={() => navigate(`${row.id}/edit`)} className="p-1.5 hover:bg-white hover:text-gray-900 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><Eye className="w-4 h-4" /></button>
<button title="Edit" onClick={() => navigate(`${row.id}/edit`)} className="p-1.5 hover:bg-white hover:text-purple-600 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><Edit2 className="w-4 h-4" /></button>
<button title="More" className="p-1.5 hover:bg-white hover:text-gray-900 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><MoreHorizontal className="w-4 h-4" /></button>
</div>
);
return (
<PageWrapper>
<Breadcrumb
@@ -116,8 +110,8 @@ export default function WorkflowList() {
}
/>
{/* Stats Cards */}
<KPIGrid className="mb-6">
{/* Stats Cards - Using your own WorkflowStatsCard */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<WorkflowStatsCard
title="Total Workflows"
value="5"
@@ -146,17 +140,27 @@ export default function WorkflowList() {
color="orange"
icon={<Clock className="w-5 h-5" />}
/>
</KPIGrid>
{/* Main Content Area */}
<div className="mb-8">
<DataTable
columns={columns}
data={MOCK_WORKFLOWS}
actions={actions}
searchPlaceholder="Search workflows..."
/>
</div>
<DataTable
columns={columns}
data={MOCK_WORKFLOWS}
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 workflows..."
/>
<ConfirmationModal
isOpen={deleteModal.isOpen}
title="Delete Workflow"
description="Are you sure you want to delete this workflow? This action cannot be undone."
itemName={deleteModal.name}
onConfirm={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</PageWrapper>
);
}
}
+2 -4
View File
@@ -17,13 +17,12 @@ import { UnitRoutes } from '../features/units/routes/unit.routes';
// New Scaffolded Features
import { AttributeGroupRoutes } from '../features/attribute-groups/routes/attribute-groups.routes';
import { ScopeRoutes } from '../features/scopes/routes/scopes.routes';
import { AssetRoutes } from '../features/assets/routes/assets.routes';
import { AssetTypeRoutes } from '../features/asset-types/routes/asset-types.routes';
import { AssetFamilyRoutes } from '../features/asset-families/routes/asset-families.routes';
import { ImportRoutes } from '../features/imports/routes/imports.routes';
import { WorkflowRoutes } from '../features/workflow/routes/workflow.routes';
import { ChannelRoutes } from '../features/channels/routes/channels.routes';
import { ChannelTypeRoutes } from '../features/channel-types/routes/channel-types.routes';
import { IntegrationRoutes } from '../features/integrations/routes/integrations.routes';
import { UserRoutes } from '../features/users/routes/users.routes';
import { ReportRoutes } from '../features/reports/routes/reports.routes';
@@ -56,7 +55,6 @@ const AppRoutes = () => {
{/* Masters */}
<Route path="/brands/*" element={<BrandRoutes />} />
<Route path="/units/*" element={<UnitRoutes />} />
<Route path="/scopes/*" element={<ScopeRoutes />} />
{/* Assets */}
<Route path="/assets/*" element={<AssetRoutes />} />
@@ -64,9 +62,9 @@ const AppRoutes = () => {
<Route path="/asset-families/*" element={<AssetFamilyRoutes />} />
{/* Operations */}
<Route path="/imports/*" element={<ImportRoutes />} />
<Route path="/workflow/*" element={<WorkflowRoutes />} />
<Route path="/channels/*" element={<ChannelRoutes />} />
<Route path="/channel-types/*" element={<ChannelTypeRoutes />} />
<Route path="/integrations/*" element={<IntegrationRoutes />} />
{/* Admin */}
+7 -10
View File
@@ -50,13 +50,9 @@ export const protectedRoutes: RouteConfig[] = [
{ path: '/units/new', title: 'Create Unit', description: 'Add a new unit of measure', sidebarRoot: '/units' },
{ path: '/units/:id/edit', title: 'Edit Unit', description: 'Update unit details', sidebarRoot: '/units' },
// Scopes
{ path: '/scopes', title: 'Scopes', description: 'Manage data scopes and locales' },
{ path: '/scopes/new', title: 'Create Scope', description: 'Add a new scope', sidebarRoot: '/scopes' },
{ path: '/scopes/:id/edit', title: 'Edit Scope', description: 'Update scope details', sidebarRoot: '/scopes' },
// Assets
{ path: '/assets', title: 'Asset Manager', description: 'Manage digital assets for your products' },
{ path: '/assets', title: 'Asset Manager', description: 'Manage images, videos, documents, certificates, manuals, marketing content and variant-specific assets' },
{ path: '/assets/new', title: 'Upload Asset', description: 'Add a new digital asset', sidebarRoot: '/assets' },
{ path: '/assets/:id/edit', title: 'Edit Asset', description: 'Update asset details', sidebarRoot: '/assets' },
@@ -70,20 +66,21 @@ export const protectedRoutes: RouteConfig[] = [
{ path: '/asset-families/new', title: 'Create Asset Family', description: 'Add a new asset family', sidebarRoot: '/asset-families' },
{ path: '/asset-families/:id/edit', title: 'Edit Asset Family', description: 'Update asset family details', sidebarRoot: '/asset-families' },
// Imports
{ path: '/imports', title: 'Supplier Imports', description: 'Manage supplier data imports' },
{ path: '/imports/new', title: 'New Import', description: 'Start a new supplier data import', sidebarRoot: '/imports' },
// Workflow
{ path: '/workflow', title: 'Workflow & Approvals', description: 'Manage product approval workflows' },
{ path: '/workflow/new', title: 'Create Workflow', description: 'Define a new approval workflow', sidebarRoot: '/workflow' },
{ path: '/workflow/:id/edit', title: 'Edit Workflow', description: 'Update workflow details', sidebarRoot: '/workflow' },
// Channels
{ path: '/channels', title: 'Channel Master', description: 'Manage publishing channels' },
{ path: '/channels', title: 'Channel Registry', description: 'Manage publishing channels' },
{ path: '/channels/new', title: 'Create Channel', description: 'Add a new publishing channel', sidebarRoot: '/channels' },
{ path: '/channels/:id/edit', title: 'Edit Channel', description: 'Update channel details', sidebarRoot: '/channels' },
// Channel Types
{ path: '/channel-types', title: 'Channel Types', description: 'Manage channel type definitions' },
{ path: '/channel-types/new', title: 'Create Channel Type', description: 'Add a new channel type', sidebarRoot: '/channel-types' },
{ path: '/channel-types/:id/edit', title: 'Edit Channel Type', description: 'Update channel type details', sidebarRoot: '/channel-types' },
// Integrations
{ path: '/integrations', title: 'Integration Hub', description: 'Manage external system integrations' },
{ path: '/integrations/new', title: 'Create Integration', description: 'Connect a new external system', sidebarRoot: '/integrations' },
+4 -11
View File
@@ -8,18 +8,17 @@ import {
Database,
Ruler,
Award,
Target,
Image,
Folder,
LayoutGrid,
Upload,
Workflow,
Radio,
Plug,
Users,
BarChart,
Settings,
List
List,
Layers2
} from 'lucide-react';
import React from 'react';
@@ -86,7 +85,6 @@ export const sidebarConfig: SidebarItem[] = [
children: [
{ label: 'Units', href: '/units', icon: Ruler, permission: 'masters.units' },
{ label: 'Brands', href: '/brands', icon: Award, permission: 'masters.brands' },
{ label: 'Scopes', href: '/scopes', icon: Target, permission: 'masters.scopes' },
]
},
{
@@ -100,12 +98,6 @@ export const sidebarConfig: SidebarItem[] = [
{ label: 'Asset Families', href: '/asset-families', icon: Layers, permission: 'assets.families' },
]
},
{
label: 'Supplier Imports',
href: '/imports',
icon: Upload,
permission: 'imports'
},
{
label: 'Workflow & Approvals',
href: '/workflow',
@@ -118,7 +110,8 @@ export const sidebarConfig: SidebarItem[] = [
icon: Radio,
permission: 'channels',
children: [
{ label: 'Channel Master', href: '/channels', icon: Radio, permission: 'channels.master' },
{ label: 'Channel Registry', href: '/channels', icon: Radio, permission: 'channels.master' },
{ label: 'Channel Types', href: '/channel-types', icon: Layers2, permission: 'channels.types' },
{ label: 'Integration Hub', href: '/integrations', icon: Plug, permission: 'channels.integrations' },
]
},
+1 -1
View File
@@ -18,7 +18,6 @@ export const defaultPermissions = {
"masters": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"masters.brands": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"masters.units": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"masters.scopes": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"assets": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"assets.manager": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"assets.types": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
@@ -27,6 +26,7 @@ export const defaultPermissions = {
"workflow": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"channels": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"channels.master": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"channels.types": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"channels.integrations": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"users": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"reports": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
+29
View File
@@ -0,0 +1,29 @@
// src/utils/showLoader.ts
import { createRoot } from 'react-dom/client';
import { Loader } from "../components/customs/Loader";
export const showFullScreenLoader = (
message: string = "Processing...",
duration: number = 2000,
subMessage?: string
) => {
const container = document.createElement('div');
container.id = 'global-loader';
document.body.appendChild(container);
const root = createRoot(container);
root.render(
<Loader
fullScreen
size="xl"
message={message}
subMessage={subMessage}
/>
);
setTimeout(() => {
root.unmount();
container.remove();
}, duration);
};