29/6/2026 v1.1 correction in ui
This commit is contained in:
Binary file not shown.
Generated
+38
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
+6
-5
@@ -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";
|
||||
}
|
||||
|
||||
// Re‑use 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 };
|
||||
};
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -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,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}
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
// Re‑use 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './types/imports.types';
|
||||
export * from './services/imports.service';
|
||||
export * from './hook/useImport';
|
||||
export * from './routes/imports.routes';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
// Re‑use 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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
import { scopesService } from '../services/scopes.service';
|
||||
export const scopesApi = scopesService;
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './types/scopes.types';
|
||||
export * from './services/scopes.service';
|
||||
export * from './hook/useScope';
|
||||
export * from './routes/scopes.routes';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
// Re‑use 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 self‑contained) ── */
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
{/* Sub‑header */}
|
||||
{/* 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user