Merge pull request #10 from MohamedHasan07/hasan_frontend
27/6/2026 correction in ui
This commit is contained in:
@@ -1,45 +0,0 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
interface BadgeProps {
|
||||
children: ReactNode;
|
||||
variant?: "default" | "success" | "warning" | "error" | "info" | "secondary";
|
||||
size?: "sm" | "md";
|
||||
dot?: boolean;
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
variant = "default",
|
||||
size = "md",
|
||||
dot = false
|
||||
}: BadgeProps) {
|
||||
const variants = {
|
||||
default: "bg-gray-100 text-gray-700",
|
||||
success: "bg-green-50 text-green-700 border border-green-200",
|
||||
warning: "bg-yellow-50 text-yellow-700 border border-yellow-200",
|
||||
error: "bg-red-50 text-red-700 border border-red-200",
|
||||
info: "bg-blue-50 text-blue-700 border border-blue-200",
|
||||
secondary: "bg-purple-50 text-purple-700 border border-purple-200",
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: "px-2 py-0.5 text-xs",
|
||||
md: "px-2.5 py-1 text-xs",
|
||||
};
|
||||
|
||||
const dotColors = {
|
||||
default: "bg-gray-400",
|
||||
success: "bg-green-500",
|
||||
warning: "bg-yellow-500",
|
||||
error: "bg-red-500",
|
||||
info: "bg-blue-500",
|
||||
secondary: "bg-purple-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full font-medium ${variants[variant]} ${sizes[size]}`}>
|
||||
{dot && <span className={`w-1.5 h-1.5 rounded-full ${dotColors[variant]}`}></span>}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -41,9 +41,10 @@ interface ButtonProps {
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
icon?: ReactNode;
|
||||
onClick?: () => void;
|
||||
onClick?: (e?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
className?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
form?: string;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
@@ -56,6 +57,7 @@ export function Button({
|
||||
onClick,
|
||||
className = "",
|
||||
type = "button",
|
||||
form,
|
||||
}: ButtonProps) {
|
||||
const baseStyles =
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-all focus:outline-none focus:ring-2 focus:ring-primary/20";
|
||||
@@ -81,6 +83,7 @@ export function Button({
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
form={form}
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${disabledStyles} ${className}`}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useState, useMemo, type ReactNode } from "react";
|
||||
import { Search, X, ChevronsUpDown, ChevronUp, ChevronDown, ChevronLeft, ChevronRight, Settings2 } from "lucide-react";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface DataTableColumn<T = any> {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
width?: string;
|
||||
render?: (value: any, row: T) => ReactNode;
|
||||
align?: "left" | "center" | "right";
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
export interface DataTableProps<T = any> {
|
||||
columns: DataTableColumn<T>[];
|
||||
data: T[];
|
||||
selectable?: boolean;
|
||||
selectedIds?: Set<string>;
|
||||
onSelectionChange?: (ids: Set<string>) => void;
|
||||
onRowClick?: (row: T) => void;
|
||||
rowIdKey?: keyof T;
|
||||
actions?: (row: T) => ReactNode;
|
||||
pageSizeOptions?: number[];
|
||||
resultLabel?: string;
|
||||
maxHeight?: string;
|
||||
toolbarRight?: ReactNode;
|
||||
toolbarLeft?: ReactNode;
|
||||
draggable?: boolean;
|
||||
onReorder?: (newData: T[]) => void;
|
||||
searchPlaceholder?: string;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function DataTable<T extends Record<string, any> = any>({
|
||||
columns: initialColumns,
|
||||
data,
|
||||
selectable = false,
|
||||
selectedIds: _selectedIds = new Set(),
|
||||
onSelectionChange: _onSelectionChange,
|
||||
onRowClick,
|
||||
rowIdKey = "id" as keyof T,
|
||||
actions,
|
||||
pageSizeOptions = [5, 10, 25, 50],
|
||||
resultLabel = "results",
|
||||
maxHeight = "520px",
|
||||
toolbarRight,
|
||||
toolbarLeft,
|
||||
draggable = false,
|
||||
onReorder: _onReorder,
|
||||
searchPlaceholder = "Search...",
|
||||
}: DataTableProps<T>) {
|
||||
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 columns = useMemo(() =>
|
||||
initialColumns.filter(col => visibleColumns[col.key] !== false),
|
||||
[initialColumns, visibleColumns]
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
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]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!sortCol) return filteredData;
|
||||
return [...filteredData].sort((a, b) => {
|
||||
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;
|
||||
return sortDir === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
|
||||
});
|
||||
}, [filteredData, sortCol, sortDir]);
|
||||
|
||||
const total = sorted.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const start = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||
const end = Math.min(page * pageSize, total);
|
||||
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);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
|
||||
{/* Toolbar - Tight spacing */}
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b border-gray-100">
|
||||
<div className="flex items-center gap-4">
|
||||
{toolbarLeft}
|
||||
<div className="relative w-80">
|
||||
<Search className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => handleSearch("")} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table - Compact & Clean */}
|
||||
<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" />}
|
||||
|
||||
{columns.map((col) => {
|
||||
const align = col.align || "left";
|
||||
return (
|
||||
<th
|
||||
key={col.key}
|
||||
onClick={() => col.sortable && handleSort(col.key)}
|
||||
className={`px-4 py-3 text-xs font-semibold uppercase tracking-wider text-purple-700 border-r border-purple-100 last:border-r-0 ${col.sortable ? "cursor-pointer hover:text-purple-900" : ""}`}
|
||||
>
|
||||
<div className={`flex items-center gap-1 ${align === "center" ? "justify-center" : align === "right" ? "justify-end" : ""}`}>
|
||||
{col.label}
|
||||
{col.sortable && (
|
||||
sortCol === col.key ? (
|
||||
sortDir === "asc" ? <ChevronUp className="w-4 h-4 text-purple-600" /> : <ChevronDown className="w-4 h-4 text-purple-600" />
|
||||
) : (
|
||||
<ChevronsUpDown className="w-4 h-4 text-purple-300" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</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>
|
||||
) : (
|
||||
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" : ""}`}
|
||||
>
|
||||
{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>}
|
||||
|
||||
{columns.map(col => {
|
||||
const align = col.align || "left";
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
{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)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between px-6 py-3.5 border-t border-gray-100 bg-white text-sm text-gray-500">
|
||||
<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"
|
||||
>
|
||||
{pageSizeOptions.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<span>Showing {start} - {end} of {total} {resultLabel}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1} className="w-9 h-9 flex items-center justify-center rounded-lg border hover:bg-gray-50 disabled:opacity-40">
|
||||
<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"}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
<button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages} className="w-9 h-9 flex items-center justify-center rounded-lg border hover:bg-gray-50 disabled:opacity-40">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ interface PaginationProps {
|
||||
onPageChange: (page: number) => void;
|
||||
pageSizeOptions?: number[];
|
||||
onPageSizeChange?: (size: number) => void;
|
||||
/** Optional label for result text (e.g., "attribute groups", "products", "users", etc.) */
|
||||
resultLabel?: string;
|
||||
}
|
||||
|
||||
@@ -18,65 +17,53 @@ export function Pagination({
|
||||
onPageChange,
|
||||
pageSizeOptions = [5, 10, 25, 50, 100],
|
||||
onPageSizeChange,
|
||||
resultLabel = "results", // Default fallback
|
||||
resultLabel = "results",
|
||||
}: PaginationProps) {
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const start = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||
const end = Math.min(page * pageSize, total);
|
||||
|
||||
const getPages = () => {
|
||||
const pages: (number | "…")[] = [];
|
||||
|
||||
if (totalPages <= 5) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (page > 3) pages.push("…");
|
||||
|
||||
const startPage = Math.max(2, page - 1);
|
||||
const endPage = Math.min(totalPages - 1, page + 1);
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (page < totalPages - 2) pages.push("…");
|
||||
pages.push(totalPages);
|
||||
}
|
||||
const getPages = (): (number | "…")[] => {
|
||||
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
const pages: (number | "…")[] = [1];
|
||||
if (page > 3) pages.push("…");
|
||||
for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++)
|
||||
pages.push(i);
|
||||
if (page < totalPages - 2) pages.push("…");
|
||||
pages.push(totalPages);
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 bg-white rounded-b-xl">
|
||||
{/* Result count */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500">
|
||||
{total === 0
|
||||
? "No results found"
|
||||
: `Showing ${start}–${end} of ${total} ${resultLabel}`}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 bg-white">
|
||||
{/* Left: rows per page + result info */}
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
{onPageSizeChange && (
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||
className="text-xs border border-gray-200 rounded-md px-2 py-1 text-gray-600 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
{pageSizeOptions.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s} / page
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Rows per page</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={e => onPageSizeChange(Number(e.target.value))}
|
||||
className="border border-gray-200 rounded-md px-2 py-1 text-xs text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-purple-400"
|
||||
>
|
||||
{pageSizeOptions.map(s => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<span>
|
||||
{total === 0
|
||||
? "No results"
|
||||
: `Showing ${start} - ${end} of ${total} ${resultLabel}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Page buttons */}
|
||||
{/* Right: page buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
aria-label="Previous page"
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg border border-gray-200 text-gray-500 hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
@@ -84,21 +71,15 @@ export function Pagination({
|
||||
|
||||
{getPages().map((p, i) =>
|
||||
p === "…" ? (
|
||||
<span
|
||||
key={`ellipsis-${i}`}
|
||||
className="w-8 text-center text-xs text-gray-400"
|
||||
>
|
||||
…
|
||||
</span>
|
||||
<span key={`e-${i}`} className="w-8 text-center text-xs text-gray-400">…</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onPageChange(p as number)}
|
||||
aria-label={`Page ${p}`}
|
||||
className={`w-8 h-8 text-xs font-medium rounded-lg border transition-colors ${
|
||||
className={`w-8 h-8 text-xs font-semibold rounded-lg border transition-colors ${
|
||||
p === page
|
||||
? "bg-purple-600 text-white border-purple-600"
|
||||
: "border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||
? "bg-purple-600 text-white border-purple-600 shadow-sm"
|
||||
: "border-gray-200 text-gray-600 hover:bg-purple-50 hover:border-purple-200 hover:text-purple-700"
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
@@ -109,7 +90,6 @@ export function Pagination({
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
aria-label="Next page"
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg border border-gray-200 text-gray-500 hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
@@ -117,4 +97,4 @@ export function Pagination({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { IconButton } from "./Button";
|
||||
|
||||
// ── Icons (inline SVG to avoid extra deps) ────────────────────────────────────
|
||||
|
||||
const SearchIcon = () => (
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ClearIcon = () => (
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FilterIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2a1 1 0 01-.293.707L13 13.414V19a1 1 0 01-.553.894l-4 2A1 1 0 017 21v-7.586L3.293 6.707A1 1 0 013 6V4z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ListIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const GridIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
import { Search, X, Filter, LayoutList, LayoutGrid } from "lucide-react";
|
||||
|
||||
export type ViewMode = "list" | "grid";
|
||||
|
||||
@@ -41,19 +7,15 @@ interface SearchBarProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
/** Show the Filters button */
|
||||
showFilters?: boolean;
|
||||
onFilterClick?: () => void;
|
||||
filterCount?: number; // badge count on filter button
|
||||
/** Show list/grid view toggle */
|
||||
filterCount?: number;
|
||||
showViewToggle?: boolean;
|
||||
viewMode?: ViewMode;
|
||||
onViewChange?: (mode: ViewMode) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function SearchBar({
|
||||
value,
|
||||
onChange,
|
||||
@@ -68,52 +30,17 @@ export function SearchBar({
|
||||
}: SearchBarProps) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
{/* Search input */}
|
||||
<div className="relative flex-1">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<SearchIcon />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="
|
||||
w-full pl-9 pr-8 h-9 text-sm bg-white
|
||||
border border-gray-200 rounded-lg
|
||||
text-gray-800 placeholder:text-gray-400
|
||||
focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary
|
||||
transition-colors
|
||||
"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("")}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<ClearIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters button */}
|
||||
{showFilters && (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onFilterClick}
|
||||
className="
|
||||
inline-flex items-center gap-2 h-9 px-3 text-sm font-medium
|
||||
bg-white border border-gray-200 rounded-lg
|
||||
text-gray-600 hover:bg-gray-50 hover:text-gray-800
|
||||
transition-colors focus:outline-none focus:ring-2 focus:ring-primary/20
|
||||
"
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 text-sm font-medium bg-white border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<FilterIcon />
|
||||
<Filter className="w-3.5 h-3.5" />
|
||||
Filters
|
||||
{filterCount > 0 && (
|
||||
<span className="min-w-[18px] h-[18px] px-1 bg-primary text-white text-[10px] font-bold rounded-full flex items-center justify-center">
|
||||
<span className="min-w-[18px] h-[18px] px-1 bg-purple-600 text-white text-[10px] font-bold rounded-full flex items-center justify-center">
|
||||
{filterCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -121,34 +48,47 @@ export function SearchBar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List / Grid view toggle */}
|
||||
{showViewToggle && onViewChange && (
|
||||
<div className="flex items-center border border-gray-200 rounded-lg overflow-hidden">
|
||||
<IconButton
|
||||
icon={<ListIcon />}
|
||||
<button
|
||||
onClick={() => onViewChange("list")}
|
||||
active={viewMode === "list"}
|
||||
variant="outline"
|
||||
title="List view"
|
||||
className="rounded-none border-0 border-r border-gray-200"
|
||||
/>
|
||||
<IconButton
|
||||
icon={<GridIcon />}
|
||||
className={`p-2 transition-colors ${viewMode === "list" ? "bg-purple-50 text-purple-600" : "text-gray-400 hover:bg-gray-50"}`}
|
||||
>
|
||||
<LayoutList className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onViewChange("grid")}
|
||||
active={viewMode === "grid"}
|
||||
variant="outline"
|
||||
title="Grid view"
|
||||
className="rounded-none border-0"
|
||||
/>
|
||||
className={`p-2 transition-colors ${viewMode === "grid" ? "bg-purple-50 text-purple-600" : "text-gray-400 hover:bg-gray-50"}`}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-56 pl-9 pr-8 h-9 text-sm bg-white border border-gray-200 rounded-lg text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-400 focus:border-purple-400 transition-colors"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("")}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── useSearch hook ─────────────────────────────────────────────────────────────
|
||||
// Handles the search state so pages don't need to manage it manually.
|
||||
|
||||
export function useSearch(initialValue = "") {
|
||||
const [query, setQuery] = useState(initialValue);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||
@@ -159,8 +99,8 @@ export function useSearch(initialValue = "") {
|
||||
): T[] => {
|
||||
if (!query.trim()) return items;
|
||||
const q = query.toLowerCase();
|
||||
return items.filter((item) =>
|
||||
keys.some((k) => String(item[k] ?? "").toLowerCase().includes(q))
|
||||
return items.filter(item =>
|
||||
keys.some(k => String(item[k] ?? "").toLowerCase().includes(q))
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
// ── StatusBadge ───────────────────────────────────────────────────────────────
|
||||
// Reusable colored pill badges for statuses across all feature tables.
|
||||
|
||||
export type BadgeVariant =
|
||||
| "active"
|
||||
@@ -16,18 +17,18 @@ export type BadgeVariant =
|
||||
| "neutral";
|
||||
|
||||
const BADGE_STYLES: Record<BadgeVariant, string> = {
|
||||
active: "bg-green-100 text-green-700 border-green-200",
|
||||
draft: "bg-blue-100 text-blue-700 border-blue-200",
|
||||
disabled: "bg-gray-100 text-gray-500 border-gray-200",
|
||||
published: "bg-purple-100 text-purple-700 border-purple-200",
|
||||
pending: "bg-amber-100 text-amber-700 border-amber-200",
|
||||
archived: "bg-slate-100 text-slate-500 border-slate-200",
|
||||
error: "bg-red-100 text-red-700 border-red-200",
|
||||
incomplete: "bg-red-100 text-red-700 border-red-200",
|
||||
info: "bg-sky-100 text-sky-700 border-sky-200",
|
||||
success: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||
warning: "bg-orange-100 text-orange-700 border-orange-200",
|
||||
neutral: "bg-gray-50 text-gray-600 border-gray-200",
|
||||
active: "bg-green-100 text-green-700 border border-green-200",
|
||||
draft: "bg-blue-100 text-blue-700 border border-blue-200",
|
||||
disabled: "bg-gray-100 text-gray-500 border border-gray-200",
|
||||
published: "bg-purple-100 text-purple-700 border border-purple-200",
|
||||
pending: "bg-amber-100 text-amber-700 border border-amber-200",
|
||||
archived: "bg-slate-100 text-slate-500 border border-slate-200",
|
||||
error: "bg-red-100 text-red-700 border border-red-200",
|
||||
incomplete:"bg-red-100 text-red-700 border border-red-200",
|
||||
info: "bg-sky-100 text-sky-700 border border-sky-200",
|
||||
success: "bg-emerald-100 text-emerald-700 border border-emerald-200",
|
||||
warning: "bg-orange-100 text-orange-700 border border-orange-200",
|
||||
neutral: "bg-gray-50 text-gray-600 border border-gray-200",
|
||||
};
|
||||
|
||||
const DOT_COLORS: Record<BadgeVariant, string> = {
|
||||
@@ -47,8 +48,8 @@ const DOT_COLORS: Record<BadgeVariant, string> = {
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: BadgeVariant;
|
||||
label?: string; // Override the displayed text (defaults to capitalized status)
|
||||
showDot?: boolean; // Show the colored dot (default: true)
|
||||
label?: string;
|
||||
showDot?: boolean;
|
||||
size?: "sm" | "md";
|
||||
className?: string;
|
||||
}
|
||||
@@ -61,18 +62,20 @@ export function StatusBadge({
|
||||
className = "",
|
||||
}: StatusBadgeProps) {
|
||||
const safeStatus = status ?? "neutral";
|
||||
const displayLabel = label ?? (safeStatus.charAt(0).toUpperCase() + safeStatus.slice(1));
|
||||
const sizeClass = size === "sm" ? "text-xs px-2 py-0.5" : "text-xs px-2.5 py-1";
|
||||
const displayLabel = label ?? safeStatus.charAt(0).toUpperCase() + safeStatus.slice(1);
|
||||
const sizeClass = size === "sm" ? "text-xs px-3 py-1" : "text-xs px-4 py-1.5";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 font-medium rounded-full border
|
||||
${sizeClass} ${BADGE_STYLES[safeStatus] ?? BADGE_STYLES.neutral} ${className}
|
||||
inline-flex items-center gap-1.5 font-medium
|
||||
rounded-lg border ${sizeClass}
|
||||
${BADGE_STYLES[safeStatus] ?? BADGE_STYLES.neutral}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{showDot && (
|
||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${DOT_COLORS[safeStatus] ?? DOT_COLORS.neutral}`} />
|
||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${DOT_COLORS[safeStatus]}`} />
|
||||
)}
|
||||
{displayLabel}
|
||||
</span>
|
||||
@@ -80,7 +83,6 @@ export function StatusBadge({
|
||||
}
|
||||
|
||||
// ── Generic Badge ─────────────────────────────────────────────────────────────
|
||||
// For non-status use-cases like data type pills, counts, tags, etc.
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
@@ -88,17 +90,21 @@ interface BadgeProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
import React from "react";
|
||||
|
||||
export function Badge({ children, variant = "neutral", className = "" }: BadgeProps) {
|
||||
export function Badge({
|
||||
children,
|
||||
variant = "neutral",
|
||||
className = ""
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center text-xs font-medium px-2 py-0.5
|
||||
rounded border ${BADGE_STYLES[variant]} ${className}
|
||||
inline-flex items-center text-xs font-medium
|
||||
px-3 py-1 rounded-lg border
|
||||
${BADGE_STYLES[variant] ?? BADGE_STYLES.neutral}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
+131
-115
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { ChevronsUpDown, ChevronUp, ChevronDown } from "lucide-react";
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
export interface TableColumn<T = any> {
|
||||
@@ -19,7 +19,10 @@ export interface TableProps<T = any> {
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -33,163 +36,176 @@ export function Table<T extends Record<string, any> = any>({
|
||||
actions,
|
||||
rowIdKey = "id" as keyof T,
|
||||
header,
|
||||
variant = "flat", // Default to flat for better container control
|
||||
maxHeight = "480px",
|
||||
variant = "flat",
|
||||
}: TableProps<T>) {
|
||||
const [currentPage] = useState(1);
|
||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
const pageSize = 10;
|
||||
|
||||
// Sorting
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sortColumn) return data;
|
||||
const sorted = [...data];
|
||||
sorted.sort((a, b) => {
|
||||
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") {
|
||||
if (typeof aVal === "number" && typeof bVal === "number")
|
||||
return sortDirection === "asc" ? aVal - bVal : bVal - aVal;
|
||||
}
|
||||
|
||||
const aStr = String(aVal).toLowerCase();
|
||||
const bStr = String(bVal).toLowerCase();
|
||||
return sortDirection === "asc"
|
||||
? aStr.localeCompare(bStr)
|
||||
: bStr.localeCompare(aStr);
|
||||
? String(aVal).localeCompare(String(bVal))
|
||||
: String(bVal).localeCompare(String(aVal));
|
||||
});
|
||||
return sorted;
|
||||
}, [data, sortColumn, sortDirection]);
|
||||
|
||||
// Pagination
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return sortedData.slice(start, start + pageSize);
|
||||
}, [sortedData, currentPage]);
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
if (sortColumn === key) {
|
||||
setSortDirection(prev => prev === "asc" ? "desc" : "asc");
|
||||
setSortDirection(prev => (prev === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortColumn(key);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
};
|
||||
|
||||
// Selection
|
||||
const handleSelectRow = (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!onSelectionChange) return;
|
||||
const newSelected = new Set(selectedIds);
|
||||
newSelected.has(id) ? newSelected.delete(id) : newSelected.add(id);
|
||||
onSelectionChange(newSelected);
|
||||
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(paginatedData.map(row => String(row[rowIdKey]))))
|
||||
? onSelectionChange(new Set(sortedData.map(r => String(r[rowIdKey]))))
|
||||
: onSelectionChange(new Set());
|
||||
};
|
||||
|
||||
const isAllSelected = paginatedData.length > 0 && paginatedData.every(row => selectedIds.has(String(row[rowIdKey])));
|
||||
const isSomeSelected = paginatedData.some(row => selectedIds.has(String(row[rowIdKey]))) && !isAllSelected;
|
||||
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-t-xl overflow-hidden" : ""}>
|
||||
<div className={variant === "card" ? "bg-white rounded-xl overflow-hidden" : ""}>
|
||||
{/* Toolbar */}
|
||||
{header && (
|
||||
<div className="p-4 border-b border-gray-200 bg-white">
|
||||
<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">
|
||||
<table className="w-full min-w-full border-collapse">
|
||||
<thead style={{ backgroundColor: '#FAF5FF' }} className="border-b border-gray-200">
|
||||
<tr>
|
||||
{selectable && (
|
||||
<th className="w-12 px-5 py-3 text-left" style={{ color: '#9810FA' }}>
|
||||
<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((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
onClick={() => column.sortable && handleSort(column.key)}
|
||||
className={`px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider ${column.sortable ? "cursor-pointer" : ""} ${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.5 h-3.5 ${sortColumn === column.key && sortDirection === "asc" ? "text-purple-600" : "text-gray-400"}`} />
|
||||
<ChevronDown className={`w-3.5 h-3.5 ${sortColumn === column.key && sortDirection === "desc" ? "text-purple-600" : "text-gray-400"}`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
{actions && <th className="w-16 px-5 py-3 text-right" style={{ color: '#9810FA' }}>Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="divide-y divide-gray-100 bg-white">
|
||||
{paginatedData.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length + (selectable ? 1 : 0) + (actions ? 1 : 0)}
|
||||
className="px-5 py-12 text-center text-sm text-gray-400"
|
||||
>
|
||||
No records found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedData.map((row) => {
|
||||
const id = String(row[rowIdKey]);
|
||||
return (
|
||||
<tr
|
||||
key={id}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
className={`hover:bg-gray-50 transition-colors ${onRowClick ? "cursor-pointer" : ""}`}
|
||||
<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" }}
|
||||
>
|
||||
{selectable && (
|
||||
<td className="px-5 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 accent-purple-600 cursor-pointer"
|
||||
checked={selectedIds.has(id)}
|
||||
onClick={(e) => handleSelectRow(id, e)}
|
||||
onChange={() => { }}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} className="px-5 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-5 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
{actions(row)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex items-center gap-1">
|
||||
{col.label}
|
||||
{col.sortable && (
|
||||
sortColumn === col.key ? (
|
||||
sortDirection === "asc"
|
||||
? <ChevronUp className="w-3.5 h-3.5 text-purple-600" />
|
||||
: <ChevronDown className="w-3.5 h-3.5 text-purple-600" />
|
||||
) : (
|
||||
<ChevronsUpDown className="w-3.5 h-3.5 text-purple-300" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
{actions && (
|
||||
<th
|
||||
className="px-4 py-3.5 text-right text-xs font-bold uppercase tracking-wider"
|
||||
style={{ color: "#7C3AED" }}
|
||||
>
|
||||
Action
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{/* ── Scrollable tbody ── */}
|
||||
<tbody className="divide-y divide-gray-100 bg-white">
|
||||
{sortedData.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={colCount}
|
||||
className="px-4 py-14 text-center text-sm text-gray-400"
|
||||
>
|
||||
No records found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedData.map(row => {
|
||||
const id = String(row[rowIdKey]);
|
||||
const isSelected = selectedIds.has(id);
|
||||
return (
|
||||
<tr
|
||||
key={id}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
className={`transition-colors ${onRowClick ? "cursor-pointer" : ""} ${isSelected ? "bg-purple-50/60" : "hover:bg-gray-50/70"}`}
|
||||
>
|
||||
{selectable && (
|
||||
<td className="px-4 py-3.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 accent-purple-600 cursor-pointer"
|
||||
checked={isSelected}
|
||||
onClick={e => handleSelectRow(id, e)}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{columns.map(col => (
|
||||
<td
|
||||
key={col.key}
|
||||
className="px-4 py-3.5 text-sm text-gray-800 whitespace-nowrap"
|
||||
>
|
||||
{col.render
|
||||
? col.render(row[col.key], row)
|
||||
: (row[col.key] ?? "—")}
|
||||
</td>
|
||||
))}
|
||||
{actions && (
|
||||
<td
|
||||
className="px-4 py-3.5 text-right"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{actions(row)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { ChevronRight, Home } from "lucide-react";
|
||||
import { ChevronRight, Home, ArrowLeft } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface BreadcrumbItem {
|
||||
@@ -11,9 +11,11 @@ interface BreadcrumbItem {
|
||||
interface BreadcrumbProps {
|
||||
items: BreadcrumbItem[];
|
||||
actions?: React.ReactNode;
|
||||
backTo?: string;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function Breadcrumb({ items, actions }: BreadcrumbProps) {
|
||||
export function Breadcrumb({ items, actions, backTo, onBack }: BreadcrumbProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = (item: BreadcrumbItem, isLast: boolean) => {
|
||||
@@ -27,35 +29,51 @@ export function Breadcrumb({ items, actions }: BreadcrumbProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (onBack) onBack();
|
||||
else if (backTo) navigate(backTo);
|
||||
else navigate(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-4 pb-2.5 border-b border-gray-100">
|
||||
{/* Left — breadcrumb trail */}
|
||||
<nav className="flex items-center gap-1" aria-label="Breadcrumb">
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1;
|
||||
return (
|
||||
<span key={index} className="flex items-center gap-1">
|
||||
{index === 0 && (
|
||||
<Home className="w-3.5 h-3.5 text-gray-400 mr-0.5 flex-shrink-0" />
|
||||
)}
|
||||
{index > 0 && (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-gray-300 flex-shrink-0" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleClick(item, isLast)}
|
||||
disabled={isLast}
|
||||
className={
|
||||
isLast
|
||||
? "text-sm font-semibold text-gray-800 cursor-default select-none"
|
||||
: "text-sm text-gray-400 hover:text-primary transition-colors duration-150 cursor-pointer"
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{/* Left — back button (optional) + breadcrumb trail */}
|
||||
<div className="flex items-center gap-3">
|
||||
{(backTo !== undefined || onBack !== undefined) && (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<nav className="flex items-center gap-1" aria-label="Breadcrumb">
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1;
|
||||
return (
|
||||
<span key={index} className="flex items-center gap-1">
|
||||
{index === 0 && (
|
||||
<Home className="w-3.5 h-3.5 text-gray-400 mr-0.5 flex-shrink-0" />
|
||||
)}
|
||||
{index > 0 && (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-gray-300 flex-shrink-0" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleClick(item, isLast)}
|
||||
disabled={isLast}
|
||||
className={
|
||||
isLast
|
||||
? "text-sm font-semibold text-gray-800 cursor-default select-none"
|
||||
: "text-sm text-gray-400 hover:text-primary transition-colors duration-150 cursor-pointer"
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Right — page actions */}
|
||||
{actions && (
|
||||
|
||||
@@ -1,35 +1,20 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Bell, ChevronDown, Building2, Globe } from "lucide-react";
|
||||
import { useLanguage, type Language } from "../../contexts/LanguageContext";
|
||||
import { useHeader } from "../../contexts/HeaderContext";
|
||||
|
||||
const PAGE_TITLES: Record<string, { title: string; subtitle?: string }> = {
|
||||
"/dashboard": { title: "Dashboard", subtitle: "Overview of your PIM platform" },
|
||||
"/products": { title: "Products", subtitle: "Manage your product catalog" },
|
||||
"/families": { title: "Product Families", subtitle: "Manage product families" },
|
||||
"/reports": { title: "Reports & Analytics", subtitle: "Comprehensive insights into your product catalog performance" },
|
||||
"/users": { title: "Users & Roles", subtitle: "Manage team members, permissions, and access controls" },
|
||||
"/settings": { title: "Settings", subtitle: "System configurations and preferences" },
|
||||
};
|
||||
|
||||
export function Header() {
|
||||
const location = useLocation();
|
||||
const { title, subtitle } = useHeader();
|
||||
const { language, setLanguage } = useLanguage();
|
||||
|
||||
const defaultPage = PAGE_TITLES[location.pathname] || { title: "PIM Platform" };
|
||||
const displayTitle = title || defaultPage.title;
|
||||
const displaySubtitle = title ? subtitle : defaultPage.subtitle;
|
||||
|
||||
return (
|
||||
<header className="h-16 w-full flex items-center justify-between px-6 bg-surface border-b border-border sticky top-0 z-30">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
{displayTitle}
|
||||
{title}
|
||||
</h1>
|
||||
{displaySubtitle && (
|
||||
{subtitle && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{displaySubtitle}
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { matchRoute } from "../../routes/routeConfig";
|
||||
|
||||
interface NavItem {
|
||||
icon: React.ElementType;
|
||||
@@ -36,9 +37,16 @@ export function Sidebar() {
|
||||
);
|
||||
};
|
||||
|
||||
// The sidebar root to highlight for the current URL (handles child/edit/new pages)
|
||||
const activeRoot = matchRoute(location.pathname)?.sidebarRoot ?? location.pathname;
|
||||
|
||||
const isItemActive = (href: string): boolean => {
|
||||
return location.pathname === href || activeRoot === href;
|
||||
};
|
||||
|
||||
const isChildActive = (item: NavItem): boolean => {
|
||||
if (!item.children) return false;
|
||||
return item.children.some(child => location.pathname.startsWith(child.href));
|
||||
return item.children.some(child => isItemActive(child.href) || location.pathname.startsWith(child.href));
|
||||
};
|
||||
|
||||
const filterNavItems = (items: NavItem[]): NavItem[] => {
|
||||
@@ -81,7 +89,7 @@ export function Sidebar() {
|
||||
<nav className="space-y-0.5">
|
||||
{filteredNavItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location.pathname === item.href ||
|
||||
const isActive = isItemActive(item.href) ||
|
||||
(item.children && isChildActive(item));
|
||||
const hasChildren = !!item.children?.length;
|
||||
const isExpanded = expandedSections.includes(item.href);
|
||||
@@ -98,8 +106,8 @@ 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"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
? "bg-primary text-white shadow-sm"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
@@ -122,15 +130,15 @@ export function Sidebar() {
|
||||
<div className="ml-4 mt-1 space-y-0.5">
|
||||
{item.children!.map((child) => {
|
||||
const ChildIcon = child.icon;
|
||||
const isChildActiveItem = location.pathname === child.href;
|
||||
const isChildActiveItem = isItemActive(child.href) || location.pathname.startsWith(child.href);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={child.href}
|
||||
onClick={() => navigate(child.href)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-all group text-sm ${isChildActiveItem
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-gray-600 hover:bg-gray-50"
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<ChildIcon className="w-4 h-4 flex-shrink-0" />
|
||||
|
||||
@@ -1,44 +1,30 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from "react";
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { matchRoute } from '../routes/routeConfig';
|
||||
|
||||
interface HeaderContextType {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
setHeaderInfo: (title: string, subtitle: string) => void;
|
||||
}
|
||||
|
||||
const HeaderContext = createContext<HeaderContextType | undefined>(undefined);
|
||||
const HeaderContext = createContext<HeaderContextType>({ title: '', subtitle: '' });
|
||||
|
||||
export function HeaderProvider({ children }: { children: React.ReactNode }) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [subtitle, setSubtitle] = useState("");
|
||||
const location = useLocation();
|
||||
const route = matchRoute(location.pathname);
|
||||
|
||||
const setHeaderInfo = useCallback((newTitle: string, newSubtitle: string) => {
|
||||
setTitle(newTitle);
|
||||
setSubtitle(newSubtitle);
|
||||
}, []);
|
||||
const value: HeaderContextType = {
|
||||
title: route?.title ?? 'PIM Platform',
|
||||
subtitle: route?.description ?? '',
|
||||
};
|
||||
|
||||
return (
|
||||
<HeaderContext.Provider value={{ title, subtitle, setHeaderInfo }}>
|
||||
<HeaderContext.Provider value={value}>
|
||||
{children}
|
||||
</HeaderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useHeader() {
|
||||
const context = useContext(HeaderContext);
|
||||
if (!context) {
|
||||
throw new Error("useHeader must be used within a HeaderProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function usePageHeader(title: string, subtitle: string = "") {
|
||||
const { setHeaderInfo } = useHeader();
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderInfo(title, subtitle);
|
||||
return () => {
|
||||
setHeaderInfo("", "");
|
||||
};
|
||||
}, [title, subtitle, setHeaderInfo]);
|
||||
return useContext(HeaderContext);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Plus, Edit2, Search, Tag, ChevronUp, ChevronDown, Image as ImageIcon, F
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { usePageHeader } from "../../../contexts/HeaderContext";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
|
||||
// Mock Data
|
||||
@@ -88,7 +87,6 @@ const TYPE_COLORS: Record<string, string> = {
|
||||
|
||||
export default function AssetFamilyList() {
|
||||
const navigate = useNavigate();
|
||||
usePageHeader("Asset Families", "Configure asset types and attributes for your digital asset library");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedFamily, setSelectedFamily] = useState(MOCK_FAMILIES[0]);
|
||||
const [attributesOpen, setAttributesOpen] = useState(true);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useFormik } from "formik";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Save } from "lucide-react";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useAssetFamily } from "../hook/useAssetFamily";
|
||||
import { assetFamilySchema } from "../validation/asset-families.schema";
|
||||
import type { AssetFamilyCreateRequest } from "../types/asset-families.types";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'active', label: 'Active' },
|
||||
@@ -67,22 +68,17 @@ export default function NewAssetFamily() {
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("..")}
|
||||
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{isEdit ? 'Edit Asset Family' : 'Create Asset Family'}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">{isEdit ? 'Update asset family details' : 'Add a new asset family'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
|
||||
<Breadcrumb
|
||||
items={[{ label: 'Home' }, { label: 'Asset Families', href: '/asset-families' }, { label: isEdit ? 'Edit Asset Family' : 'Create Asset Family' }]}
|
||||
backTo="/asset-families"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate('..')} disabled={formik.isSubmitting}>Cancel</Button>
|
||||
<Button variant="primary" size="md" type="submit" form="asset-family-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>{isEdit ? 'Update Asset Family' : 'Create Asset Family'}</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<form id="asset-family-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">Information</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@@ -116,15 +112,6 @@ export default function NewAssetFamily() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate("..")} disabled={formik.isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
|
||||
{formik.isSubmitting ? 'Saving…' : isEdit ? 'Update Asset Family' : 'Create Asset Family'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</PageWrapper>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* AssetTypeStatsCard – a premium styled card component mirroring the visual design of ProductCard.
|
||||
* It displays a title, a prominent value, a subtitle, and an optional icon.
|
||||
* The appearance can be themed via the `color` prop which selects a soft background gradient and border.
|
||||
*/
|
||||
interface AssetTypeStatsCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle: string;
|
||||
/** Optional icon or image displayed on the right side */
|
||||
icon?: ReactNode;
|
||||
/** Colour theme for the card */
|
||||
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 AssetTypeStatsCard({ title, value, subtitle, icon, color }: AssetTypeStatsCardProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,9 @@ import { Plus, Edit2, Trash2, Eye, Search, Filter, Image as ImageIcon, Video, Fi
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { usePageHeader } from "../../../contexts/HeaderContext";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { AssetTypeStatsCard } from "../components/AssetTypeStatsCard";
|
||||
|
||||
// Mock Data
|
||||
const MOCK_ASSET_TYPES = [
|
||||
@@ -107,7 +108,6 @@ const MOCK_ASSET_TYPES = [
|
||||
];
|
||||
|
||||
export default function AssetTypeList() {
|
||||
usePageHeader("Asset Types", "Define reusable asset classifications for product families and products");
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
@@ -131,38 +131,34 @@ export default function AssetTypeList() {
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<div className="flex items-center gap-2 mb-2 text-gray-500">
|
||||
<LayoutGrid className="w-4 h-4 text-purple-500" />
|
||||
<h3 className="text-xs font-bold tracking-wider uppercase">Total Asset Types</h3>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900">12</div>
|
||||
<div className="text-xs text-gray-500 mt-1">across all categories</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<div className="flex items-center gap-2 mb-2 text-gray-500">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
|
||||
<h3 className="text-xs font-bold tracking-wider uppercase">Active Types</h3>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900">11</div>
|
||||
<div className="text-xs text-gray-500 mt-1">1 inactive</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<div className="flex items-center gap-2 mb-2 text-gray-500">
|
||||
<Layers className="w-4 h-4 text-blue-500" />
|
||||
<h3 className="text-xs font-bold tracking-wider uppercase">Families Using</h3>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900">286</div>
|
||||
<div className="text-xs text-gray-500 mt-1">family assignments</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
|
||||
<div className="flex items-center gap-2 mb-2 text-gray-500">
|
||||
<BarChart2 className="w-4 h-4 text-amber-500" />
|
||||
<h3 className="text-xs font-bold tracking-wider uppercase">Products Using</h3>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900">68,420</div>
|
||||
<div className="text-xs text-gray-500 mt-1">product associations</div>
|
||||
</div>
|
||||
<AssetTypeStatsCard
|
||||
title="Total Asset Types"
|
||||
value={12}
|
||||
subtitle="across all categories"
|
||||
color="purple"
|
||||
icon={<LayoutGrid className="w-5 h-5" />}
|
||||
/>
|
||||
<AssetTypeStatsCard
|
||||
title="Active Types"
|
||||
value={11}
|
||||
subtitle="1 inactive"
|
||||
color="green"
|
||||
icon={<CheckCircle2 className="w-5 h-5" />}
|
||||
/>
|
||||
<AssetTypeStatsCard
|
||||
title="Families Using"
|
||||
value={286}
|
||||
subtitle="family assignments"
|
||||
color="blue"
|
||||
icon={<Layers className="w-5 h-5" />}
|
||||
/>
|
||||
<AssetTypeStatsCard
|
||||
title="Products Using"
|
||||
value="68,420"
|
||||
subtitle="product associations"
|
||||
color="orange"
|
||||
icon={<BarChart2 className="w-5 h-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table & Controls Container */}
|
||||
@@ -194,91 +190,88 @@ export default function AssetTypeList() {
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse min-w-max">
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: '#FAF5FF' }} className="border-b border-gray-200">
|
||||
<th className="py-4 px-6 text-xs font-bold tracking-wider uppercase" style={{ color: '#9810FA' }}>Asset Type Name</th>
|
||||
<th className="py-4 px-4 text-xs font-bold tracking-wider uppercase" style={{ color: '#9810FA' }}>Code</th>
|
||||
<th className="py-4 px-4 text-xs font-bold tracking-wider uppercase" style={{ color: '#9810FA' }}>Category</th>
|
||||
<th className="py-4 px-4 text-xs font-bold tracking-wider uppercase" style={{ color: '#9810FA' }}>File Types</th>
|
||||
<th className="py-4 px-4 text-xs font-bold tracking-wider uppercase" style={{ color: '#9810FA' }}>Status</th>
|
||||
<th className="py-4 px-4 text-xs font-bold tracking-wider uppercase" style={{ color: '#9810FA' }}>Families</th>
|
||||
<th className="py-4 px-4 text-xs font-bold tracking-wider uppercase" style={{ color: '#9810FA' }}>Products</th>
|
||||
<th className="py-4 px-4 text-xs font-bold tracking-wider uppercase" style={{ color: '#9810FA' }}>Updated</th>
|
||||
<th className="py-4 px-6 text-xs font-bold tracking-wider uppercase text-right" style={{ color: '#9810FA' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{MOCK_ASSET_TYPES.map((type) => {
|
||||
const Icon = type.icon;
|
||||
return (
|
||||
<tr key={type.id} className="hover:bg-gray-50/80 transition-colors group">
|
||||
<td className="py-4 px-6 max-w-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded bg-blue-50 text-blue-600 flex items-center justify-center mt-1 shrink-0">
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-gray-900">{type.name}</span>
|
||||
{type.required && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold uppercase rounded bg-red-50 text-red-600">Required</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1 truncate max-w-xs">{type.description}</p>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
key: "name", label: "ASSET TYPE NAME", render: (_: any, type: any) => {
|
||||
const Icon = type.icon;
|
||||
return (
|
||||
<div className="flex items-start gap-3 max-w-sm">
|
||||
<div className="w-8 h-8 rounded bg-blue-50 text-blue-600 flex items-center justify-center mt-1 shrink-0">
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
<code className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded border border-gray-200">
|
||||
{type.code}
|
||||
</code>
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border ${type.categoryColor}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{type.category}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-gray-900">{type.name}</span>
|
||||
{type.required && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold uppercase rounded bg-red-50 text-red-600">Required</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1 truncate max-w-xs">{type.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "code", label: "CODE", render: (val: string) => (
|
||||
<code className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded border border-gray-200">{val}</code>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "category", label: "CATEGORY", render: (val: string, type: any) => {
|
||||
const Icon = type.icon;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border ${type.categoryColor}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{val}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "fileTypes", label: "FILE TYPES", render: (val: string[]) => (
|
||||
<div className="flex flex-wrap gap-1 w-32">
|
||||
{val.map((ft, i) => (
|
||||
<span key={i} className="text-[10px] font-bold text-gray-600 uppercase">
|
||||
{ft}{i < val.length - 1 && ' '}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
<div className="flex flex-wrap gap-1 w-32">
|
||||
{type.fileTypes.map((ft, i) => (
|
||||
<span key={i} className="text-[10px] font-bold text-gray-600 uppercase">
|
||||
{ft}{i < type.fileTypes.length - 1 && ' '}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500"></div>
|
||||
<span className="text-xs font-medium text-emerald-700">{type.status}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
<span className="font-medium text-gray-900">{type.families}</span>
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
<span className="font-medium text-gray-900">{type.products}</span>
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
<div className="text-xs text-gray-900">{type.updatedDate}</div>
|
||||
<div className="text-[11px] text-gray-500">{type.updatedBy}</div>
|
||||
</td>
|
||||
<td className="py-4 px-6 text-right">
|
||||
<div className="flex items-center justify-end gap-2 text-gray-400 opacity-0 group-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>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "status", label: "STATUS", render: (val: string) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500"></div>
|
||||
<span className="text-xs font-medium text-emerald-700">{val}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "families", label: "FAMILIES", render: (val: string) => <span className="font-medium text-gray-900">{val}</span>
|
||||
},
|
||||
{
|
||||
key: "products", label: "PRODUCTS", render: (val: string) => <span className="font-medium text-gray-900">{val}</span>
|
||||
},
|
||||
{
|
||||
key: "updatedDate", label: "UPDATED", render: (val: string, type: any) => (
|
||||
<div>
|
||||
<div className="text-xs text-gray-900">{val}</div>
|
||||
<div className="text-[11px] text-gray-500">{type.updatedBy}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]}
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useFormik } from "formik";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Save } from "lucide-react";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useAssetType } from "../hook/useAssetType";
|
||||
import { assetTypeSchema } from "../validation/asset-types.schema";
|
||||
import type { AssetTypeCreateRequest } from "../types/asset-types.types";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'active', label: 'Active' },
|
||||
@@ -67,22 +68,17 @@ export default function NewAssetType() {
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("..")}
|
||||
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{isEdit ? 'Edit Asset Type' : 'Create Asset Type'}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">{isEdit ? 'Update asset type details' : 'Add a new asset type'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
|
||||
<Breadcrumb
|
||||
items={[{ label: 'Home' }, { label: 'Asset Types', href: '/asset-types' }, { label: isEdit ? 'Edit Asset Type' : 'Create Asset Type' }]}
|
||||
backTo="/asset-types"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate('..')} disabled={formik.isSubmitting}>Cancel</Button>
|
||||
<Button variant="primary" size="md" type="submit" form="asset-type-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>{isEdit ? 'Update Asset Type' : 'Create Asset Type'}</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<form id="asset-type-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">Information</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@@ -116,15 +112,6 @@ export default function NewAssetType() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate("..")} disabled={formik.isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
|
||||
{formik.isSubmitting ? 'Saving…' : isEdit ? 'Update Asset Type' : 'Create Asset Type'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</PageWrapper>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ChevronRight
|
||||
} from "lucide-react";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
|
||||
// Mock Data
|
||||
const ASSET_CATEGORIES = [
|
||||
@@ -272,79 +273,56 @@ export default function AssetList() {
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: '#FAF5FF' }} className="border-b border-gray-200">
|
||||
<th className="py-4 px-4 pl-6">
|
||||
<input type="checkbox" className="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
</th>
|
||||
<th className="py-4 px-4 text-xs font-bold tracking-wider uppercase" style={{ color: '#9810FA' }}>Preview</th>
|
||||
<th className="py-4 px-4 text-xs font-bold tracking-wider uppercase" style={{ color: '#9810FA' }}>Asset Name</th>
|
||||
<th className="py-4 px-4 text-xs font-bold tracking-wider uppercase" style={{ color: '#9810FA' }}>Asset Type</th>
|
||||
<th className="py-4 px-4 text-xs font-bold tracking-wider uppercase" style={{ color: '#9810FA' }}>Product</th>
|
||||
<th className="py-4 px-4 text-xs font-bold tracking-wider uppercase" style={{ color: '#9810FA' }}>File Size</th>
|
||||
<th className="py-4 px-4 text-xs font-bold tracking-wider uppercase" style={{ color: '#9810FA' }}>Resolution</th>
|
||||
<th className="py-4 px-4 text-xs font-bold tracking-wider uppercase" style={{ color: '#9810FA' }}>Tags</th>
|
||||
<th className="py-4 px-4 text-xs font-bold tracking-wider uppercase" style={{ color: '#9810FA' }}>Status</th>
|
||||
<th className="py-4 px-4 text-xs font-bold tracking-wider uppercase text-right pr-6" style={{ color: '#9810FA' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{MOCK_ASSETS.map((asset) => (
|
||||
<tr key={asset.id} className="hover:bg-gray-50 transition-colors group">
|
||||
<td className="py-4 px-4 pl-6 w-12">
|
||||
<input type="checkbox" className="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
</td>
|
||||
<td className="py-4 px-4 w-24">
|
||||
<div className="w-12 h-12 rounded-lg bg-gray-50 border border-gray-200 flex items-center justify-center">
|
||||
{getAssetIcon(asset.type, asset.iconColor)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-4 min-w-[180px]">
|
||||
<div className="font-medium text-gray-900">{asset.name}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{asset.ext}</div>
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-1 rounded border text-xs font-medium ${asset.typeColor}`}>
|
||||
{asset.type}
|
||||
<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) => (
|
||||
<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) => (
|
||||
<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) => (
|
||||
<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[]) => (
|
||||
<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">
|
||||
{tag}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
<div className="text-sm text-gray-900">{asset.product}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{asset.productYear}</div>
|
||||
</td>
|
||||
<td className="py-4 px-4 text-sm text-gray-600 whitespace-nowrap">
|
||||
{asset.size}
|
||||
</td>
|
||||
<td className="py-4 px-4 text-sm text-gray-600 whitespace-nowrap">
|
||||
{asset.resolution}
|
||||
</td>
|
||||
<td className="py-4 px-4 min-w-[150px]">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{asset.tags.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">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
<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">
|
||||
{asset.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-4 pr-6 text-right">
|
||||
<div className="flex items-center justify-end gap-2 text-gray-400 opacity-0 group-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>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)},
|
||||
{ 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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import { useAsset } from "../hook/useAsset";
|
||||
import { assetSchema } from "../validation/assets.schema";
|
||||
import { assetsService } from "../services/assets.service";
|
||||
import type { AssetCreateRequest } from "../types/assets.types";
|
||||
import { ArrowLeft, Save } from 'lucide-react';
|
||||
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`;
|
||||
@@ -53,21 +54,17 @@ export default function NewAsset() {
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<button
|
||||
onClick={() => navigate('..')}
|
||||
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{isEdit ? 'Edit Asset' : 'Create Asset'}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">{isEdit ? 'Update asset details' : 'Add a new asset to your system'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
|
||||
<Breadcrumb
|
||||
items={[{ label: 'Home' }, { label: 'Assets', href: '/assets' }, { label: isEdit ? 'Edit Asset' : 'Create Asset' }]}
|
||||
backTo="/assets"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate('..')} disabled={formik.isSubmitting}>Cancel</Button>
|
||||
<Button variant="primary" size="md" type="submit" form="asset-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>{isEdit ? 'Update Asset' : 'Create Asset'}</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<form id="asset-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">
|
||||
@@ -114,15 +111,6 @@ export default function NewAsset() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate('..')} disabled={formik.isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
|
||||
{isEdit ? 'Update Asset' : 'Create Asset'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</PageWrapper>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { Plus, Upload, Download } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom"; // ← Added
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { SearchBar, useSearch } from "../../../components/customs/SearchBar";
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { Table } from "../../../components/customs/Table";
|
||||
import { usePageHeader } from "../../../contexts/HeaderContext";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { Pagination } from "../../../components/customs/Pagination";
|
||||
|
||||
interface AttributeGroup {
|
||||
id: string;
|
||||
@@ -30,31 +27,14 @@ const mockGroups: AttributeGroup[] = [
|
||||
];
|
||||
|
||||
export default function AttributeGroupList() {
|
||||
const navigate = useNavigate(); // ← Added
|
||||
usePageHeader("Attribute Groups", "Organize attributes into logical groups for better management");
|
||||
|
||||
const { query, setQuery } = useSearch();
|
||||
const navigate = useNavigate();
|
||||
const [statusFilter, setStatusFilter] = useState("All Status");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [query, statusFilter]);
|
||||
|
||||
const filteredGroups = useMemo(() => {
|
||||
return mockGroups.filter((group) =>
|
||||
(group.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
group.code.toLowerCase().includes(query.toLowerCase()) ||
|
||||
group.description.toLowerCase().includes(query.toLowerCase())) &&
|
||||
(statusFilter === "All Status" || group.status.toLowerCase() === statusFilter.toLowerCase())
|
||||
statusFilter === "All Status" || group.status.toLowerCase() === statusFilter.toLowerCase()
|
||||
);
|
||||
}, [query, statusFilter]);
|
||||
|
||||
const paginatedGroups = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filteredGroups.slice(start, start + pageSize);
|
||||
}, [filteredGroups, currentPage, pageSize]);
|
||||
}, [statusFilter]);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
@@ -119,11 +99,10 @@ export default function AttributeGroupList() {
|
||||
<Button variant="outline" icon={<Upload className="w-4 h-4" />}>Import</Button>
|
||||
<Button variant="outline" icon={<Download className="w-4 h-4" />}>Export</Button>
|
||||
|
||||
{/* Connected Button */}
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
onClick={() => navigate("/attribute-groups/new")} // ← Connected
|
||||
onClick={() => navigate("/attribute-groups/new")}
|
||||
>
|
||||
Create Group
|
||||
</Button>
|
||||
@@ -131,40 +110,24 @@ export default function AttributeGroupList() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-2xl overflow-hidden shadow-sm">
|
||||
<Table<AttributeGroup>
|
||||
<div className="mb-8">
|
||||
<DataTable<AttributeGroup>
|
||||
columns={columns}
|
||||
data={paginatedGroups}
|
||||
header={
|
||||
<div className="flex items-center gap-3 p-4 border-b border-gray-200">
|
||||
<div className="flex-1">
|
||||
<SearchBar
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Search groups by name, code, or description..."
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="h-9 px-3 py-1.5 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option>All Status</option>
|
||||
<option>Active</option>
|
||||
<option>Draft</option>
|
||||
<option>Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
data={filteredGroups}
|
||||
searchPlaceholder="Search groups by name, code, or description..."
|
||||
toolbarRight={
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="h-9 px-3 py-1.5 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option>All Status</option>
|
||||
<option>Active</option>
|
||||
<option>Draft</option>
|
||||
<option>Disabled</option>
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
page={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={filteredGroups.length}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
/>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useFormik } from "formik";
|
||||
import { ArrowLeft, Plus, Save, Info } from "lucide-react";
|
||||
import { Plus, Save, Info } from "lucide-react";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useAttributeGroup } from "../hook/useAttributeGroup";
|
||||
import { attributeGroupSchema } from "../validation/attribute-groups.schema";
|
||||
import type { AttributeGroupCreateRequest } from "../types/attribute-groups.types";
|
||||
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`;
|
||||
@@ -107,39 +108,18 @@ export default function NewAttributeGroup() {
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<Breadcrumb
|
||||
items={[{ label: 'Home' }, { label: 'Attribute Groups', href: '/attribute-groups' }, { label: isEdit ? 'Edit Attribute Group' : 'Create Attribute Group' }]}
|
||||
backTo="/attribute-groups"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="md" onClick={() => navigate('..')}>Cancel</Button>
|
||||
<Button variant="primary" size="md" type="submit" form="attribute-group-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>Save Group</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Top Navigation */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate("..")}
|
||||
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Create Attribute Group</h1>
|
||||
<p className="text-sm text-gray-500">Attribute Groups / Create New Group</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" size="md" onClick={() => navigate("..")}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
icon={<Save className="w-4 h-4" />}
|
||||
loading={formik.isSubmitting}
|
||||
onClick={() => formik.handleSubmit()}
|
||||
>
|
||||
Save Group
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={formik.handleSubmit} className="space-y-8">
|
||||
<form id="attribute-group-form" onSubmit={formik.handleSubmit} className="space-y-8">
|
||||
{/* Basic Information */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-8">
|
||||
<div className="mb-6">
|
||||
|
||||
@@ -1,42 +1,24 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Plus, Edit2, Trash2, Eye, Copy, 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";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { SearchBar, useSearch } from "../../../components/customs/SearchBar";
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { Table } from "../../../components/customs/Table";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { useAttribute } from "../hook/useAttribute";
|
||||
import type { Attribute } from "../types/attribute.types";
|
||||
import { usePageHeader } from "../../../contexts/HeaderContext";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { AttributeCard } from "../components/AttributeCard";
|
||||
import { Pagination } from "../../../components/customs/Pagination"; // ← Added
|
||||
|
||||
export default function AttributeList() {
|
||||
const navigate = useNavigate();
|
||||
const { query, setQuery } = useSearch();
|
||||
const { attributes, fetchAttributes, deleteAttribute } = useAttribute();
|
||||
|
||||
// Pagination State
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
usePageHeader(
|
||||
"Attributes",
|
||||
`${attributes.length} total attributes configured`
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes();
|
||||
}, [fetchAttributes]);
|
||||
|
||||
// Reset to first page when search changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [query]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = attributes.length;
|
||||
const active = attributes.filter(a => a.status === 'active').length;
|
||||
@@ -52,19 +34,6 @@ export default function AttributeList() {
|
||||
return { total, active, variantEligible, mostUsed };
|
||||
}, [attributes]);
|
||||
|
||||
const filteredAttributes = attributes.filter((attr) =>
|
||||
attr.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
attr.code.toLowerCase().includes(query.toLowerCase()) ||
|
||||
attr.group.toLowerCase().includes(query.toLowerCase()) ||
|
||||
attr.type.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
// Paginated Data
|
||||
const paginatedAttributes = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filteredAttributes.slice(start, start + pageSize);
|
||||
}, [filteredAttributes, currentPage, pageSize]);
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (confirm(`Are you sure you want to delete attribute "${name}"?`)) {
|
||||
await deleteAttribute(id);
|
||||
@@ -233,25 +202,17 @@ export default function AttributeList() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table + Pagination Container */}
|
||||
<div className="bg-white border border-gray-200 rounded-2xl overflow-hidden shadow-sm">
|
||||
<Table<Attribute>
|
||||
{/* Table Container */}
|
||||
<div className="mb-8">
|
||||
<DataTable<Attribute>
|
||||
columns={columns}
|
||||
data={paginatedAttributes}
|
||||
data={attributes}
|
||||
onRowClick={(row) => navigate(`/attributes/${row.id}/edit`)}
|
||||
header={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<SearchBar
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Search by attribute name, code, or description..."
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" icon={<Filter className="w-4 h-4" />}>
|
||||
Filters
|
||||
</Button>
|
||||
</div>
|
||||
searchPlaceholder="Search by attribute name, code, or description..."
|
||||
toolbarRight={
|
||||
<Button variant="outline" icon={<Filter className="w-4 h-4" />}>
|
||||
Filters
|
||||
</Button>
|
||||
}
|
||||
actions={(row) => (
|
||||
<div className="flex items-center justify-end gap-2 text-gray-400">
|
||||
@@ -262,15 +223,6 @@ export default function AttributeList() {
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
page={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={filteredAttributes.length}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
/>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useFormik } from "formik";
|
||||
import { ArrowLeft, Tag, Info } from "lucide-react";
|
||||
import { Info } from "lucide-react";
|
||||
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useAttribute } from "../hook/useAttribute";
|
||||
import { attributeSchema } from "../validation/attribute.schema";
|
||||
import type { AttributeCreateRequest } from "../types/attribute.types";
|
||||
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`;
|
||||
@@ -117,30 +118,17 @@ export default function NewAttribute() {
|
||||
return (
|
||||
<ProtectedRoute node="products.attributes">
|
||||
<PageWrapper>
|
||||
{/* Back and Header */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<button
|
||||
onClick={() => navigate("/attributes")}
|
||||
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-50 text-purple-600 rounded-lg">
|
||||
<Tag className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{isEdit ? "Edit Attribute" : "Create New Attribute"}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{isEdit ? "Update attribute details" : "Add a new attribute to describe your products"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={formik.handleSubmit} className="max-w-4xl mx-auto space-y-10">
|
||||
<Breadcrumb
|
||||
items={[{ label: 'Home' }, { label: 'Attributes', href: '/attributes' }, { label: isEdit ? 'Edit Attribute' : 'Create Attribute' }]}
|
||||
backTo="/attributes"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate('/attributes')}>Cancel</Button>
|
||||
<Button variant="primary" size="md" type="submit" form="attribute-form" loading={formik.isSubmitting}>{isEdit ? 'Save Changes' : 'Save Attribute'}</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<form id="attribute-form" onSubmit={formik.handleSubmit} className="max-w-4xl mx-auto space-y-10">
|
||||
{/* General Information - Same as before */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-6">General Information</h2>
|
||||
@@ -328,12 +316,8 @@ export default function NewAttribute() {
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate("/attributes")}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="md" type="submit" loading={formik.isSubmitting}>
|
||||
{isEdit ? "Save Changes" : "Save Attribute"}
|
||||
</Button>
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate("/attributes")}>Cancel</Button>
|
||||
<Button variant="primary" size="md" type="submit" loading={formik.isSubmitting}>{isEdit ? "Save Changes" : "Save Attribute"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</PageWrapper>
|
||||
|
||||
@@ -1,52 +1,24 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Plus, Edit2, Trash2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { SearchBar, useSearch } from "../../../components/customs/SearchBar";
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { Table } from "../../../components/customs/Table";
|
||||
import { Pagination } from "../../../components/customs/Pagination";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { useBrand } from "../hook/useBrand";
|
||||
import type { Brand } from "../types/brand.types";
|
||||
import { formatDate } from "../../../utils/formatters";
|
||||
import { usePageHeader } from "../../../contexts/HeaderContext";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
|
||||
export default function BrandList() {
|
||||
const navigate = useNavigate();
|
||||
const { query, setQuery } = useSearch();
|
||||
const { brands, fetchBrands, deleteBrand } = useBrand();
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
usePageHeader(
|
||||
"Brands",
|
||||
`${brands.length} total brands configured`
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBrands();
|
||||
}, [fetchBrands]);
|
||||
|
||||
// Reset page when search changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [query]);
|
||||
|
||||
const filteredBrands = brands.filter((brand) =>
|
||||
brand.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
brand.code.toLowerCase().includes(query.toLowerCase()) ||
|
||||
(brand.description && brand.description.toLowerCase().includes(query.toLowerCase()))
|
||||
);
|
||||
|
||||
const paginatedBrands = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filteredBrands.slice(start, start + pageSize);
|
||||
}, [filteredBrands, currentPage, pageSize]);
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (confirm(`Are you sure you want to delete brand "${name}"?`)) {
|
||||
await deleteBrand(id);
|
||||
@@ -87,45 +59,35 @@ export default function BrandList() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Single Container: Table + Pagination */}
|
||||
<div className="bg-white border border-gray-200 rounded-2xl overflow-hidden shadow-sm">
|
||||
<Table<Brand>
|
||||
<div className="mb-8">
|
||||
<DataTable<Brand>
|
||||
columns={columns}
|
||||
data={paginatedBrands}
|
||||
data={brands}
|
||||
onRowClick={(row) => navigate(`/brands/${row.id}/edit`)}
|
||||
header={
|
||||
<SearchBar
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Search brands by code, name, or description..."
|
||||
/>
|
||||
}
|
||||
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={() => navigate(`/brands/${row.id}/edit`)}
|
||||
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={() => handleDelete(row.id, row.name)}
|
||||
onClick={(e) => {
|
||||
e?.stopPropagation();
|
||||
handleDelete(row.id, row.name);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Pagination at bottom inside same container */}
|
||||
<Pagination
|
||||
page={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={filteredBrands.length}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
/>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
|
||||
@@ -2,12 +2,12 @@ import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { ArrowLeft, Award } from "lucide-react";
|
||||
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useBrand } from "../hook/useBrand";
|
||||
import type { BrandCreateRequest } from "../types/brand.types";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
|
||||
const brandSchema = Yup.object().shape({
|
||||
code: Yup.string()
|
||||
@@ -76,31 +76,17 @@ export default function NewBrand() {
|
||||
return (
|
||||
<ProtectedRoute node="masters.brands">
|
||||
<PageWrapper>
|
||||
{/* Back and Header */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<button
|
||||
onClick={() => navigate("/brands")}
|
||||
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-50 text-purple-600 rounded-lg">
|
||||
<Award className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{isEdit ? "Edit Brand" : "Create Brand"}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{isEdit ? "Update brand details" : "Add a new brand to the system"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Container */}
|
||||
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
|
||||
<Breadcrumb
|
||||
items={[{ label: 'Home' }, { label: 'Brands', href: '/brands' }, { label: isEdit ? 'Edit Brand' : 'Create Brand' }]}
|
||||
backTo="/brands"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate('/brands')} disabled={formik.isSubmitting}>Cancel</Button>
|
||||
<Button variant="primary" size="md" type="submit" form="brand-form" loading={formik.isSubmitting}>{isEdit ? 'Save Changes' : 'Create Brand'}</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<form id="brand-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">Brand Information</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
@@ -1,42 +1,24 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Plus, Edit2, Trash2, Eye, 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";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { SearchBar, useSearch } from "../../../components/customs/SearchBar";
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { Table } from "../../../components/customs/Table";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { useCategory } from "../hook/useCategory";
|
||||
import type { Category } from "../types/category.types";
|
||||
import { usePageHeader } from "../../../contexts/HeaderContext";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { CategoryCard } from "../components/CategoryCard";
|
||||
import { Pagination } from "../../../components/customs/Pagination"; // ← Added
|
||||
|
||||
export default function CategoryList() {
|
||||
const navigate = useNavigate();
|
||||
const { query, setQuery } = useSearch();
|
||||
const { categories, fetchCategories, deleteCategory } = useCategory();
|
||||
|
||||
// Pagination State
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
usePageHeader(
|
||||
"Categories",
|
||||
`${categories.length} total categories configured`
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
}, [fetchCategories]);
|
||||
|
||||
// Reset to first page when search changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [query]);
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
total: categories.length || 9,
|
||||
@@ -45,19 +27,6 @@ export default function CategoryList() {
|
||||
families: 406
|
||||
};
|
||||
|
||||
const filteredCategories = categories.filter((cat) =>
|
||||
cat.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
cat.code.toLowerCase().includes(query.toLowerCase()) ||
|
||||
(cat.parentName && cat.parentName.toLowerCase().includes(query.toLowerCase())) ||
|
||||
(cat.description && cat.description.toLowerCase().includes(query.toLowerCase()))
|
||||
);
|
||||
|
||||
// Paginated Data
|
||||
const paginatedCategories = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filteredCategories.slice(start, start + pageSize);
|
||||
}, [filteredCategories, currentPage, pageSize]);
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (confirm(`Are you sure you want to delete category "${name}"?`)) {
|
||||
await deleteCategory(id);
|
||||
@@ -140,19 +109,13 @@ export default function CategoryList() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table + Pagination Container */}
|
||||
<div className="bg-white border border-gray-200 rounded-2xl overflow-hidden shadow-sm">
|
||||
<Table<Category>
|
||||
{/* Table Container */}
|
||||
<div className="mb-8">
|
||||
<DataTable<Category>
|
||||
columns={columns}
|
||||
data={paginatedCategories}
|
||||
data={categories}
|
||||
onRowClick={(row) => navigate(`/categories/${row.id}/edit`)}
|
||||
header={
|
||||
<SearchBar
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Search categories by code, name, parent category, or description..."
|
||||
/>
|
||||
}
|
||||
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" />
|
||||
@@ -161,15 +124,6 @@ export default function CategoryList() {
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
page={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={filteredCategories.length}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
/>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { ArrowLeft, Image as ImageIcon, Info } from "lucide-react";
|
||||
import { Image as ImageIcon, Info } from "lucide-react";
|
||||
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useCategory } from "../hook/useCategory";
|
||||
import type { CategoryCreateRequest, CategoryStatus } from "../types/category.types";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
|
||||
const categorySchema = Yup.object().shape({
|
||||
code: Yup.string()
|
||||
@@ -65,34 +66,18 @@ export default function NewCategory() {
|
||||
return (
|
||||
<ProtectedRoute node="products.categories">
|
||||
<PageWrapper>
|
||||
<Breadcrumb
|
||||
items={[{ label: 'Home' }, { label: 'Categories', href: '/categories' }, { label: 'Create Category' }]}
|
||||
backTo="/categories"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => navigate('/categories')}>Cancel</Button>
|
||||
<Button variant="primary" type="submit" form="category-form" loading={formik.isSubmitting}>Save Category</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate("/categories")}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Create Category</h1>
|
||||
<p className="text-sm text-gray-500">Category Trees / Create New Category</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={() => navigate("/categories")}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => formik.handleSubmit()}
|
||||
loading={formik.isSubmitting}
|
||||
>
|
||||
Save Category
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="category-form" onSubmit={formik.handleSubmit}>
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-7 space-y-8">
|
||||
{/* Basic Information */}
|
||||
@@ -239,10 +224,8 @@ export default function NewCategory() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* ChannelStatsCard – a premium styled card component mirroring the visual design of ProductCard.
|
||||
* It displays a title, a prominent value, a subtitle, and an optional icon.
|
||||
* The appearance can be themed via the `color` prop which selects a soft background gradient and border.
|
||||
*/
|
||||
interface ChannelStatsCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle: string;
|
||||
/** Optional icon or image displayed on the right side */
|
||||
icon?: ReactNode;
|
||||
/** Colour theme for the card */
|
||||
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 ChannelStatsCard({ title, value, subtitle, icon, color }: ChannelStatsCardProps) {
|
||||
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,11 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Edit2, Trash2, Eye, RefreshCw, Radio, CheckCircle, Layers, TrendingUp, ShoppingCart, ShoppingBag, Globe, Smartphone, Monitor } from "lucide-react";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { Table } from "../../../components/customs/Table";
|
||||
import { KPI, KPIGrid } from "../../../components/customs/KPI";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { KPIGrid } from "../../../components/customs/KPI";
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { SearchBar } from "../../../components/customs/SearchBar";
|
||||
import { ChannelStatsCard } from "../components/ChannelStatsCard";
|
||||
|
||||
// Mock Data
|
||||
const MOCK_CHANNELS = [
|
||||
@@ -16,12 +15,9 @@ const MOCK_CHANNELS = [
|
||||
{ id: "5", name: "Retail POS", desc: "In-store point-of-sale terminals", code: "pos_retail", type: "POS", status: "active", families: 20, products: "9,840", updated: "2025-03-10", author: "James Park", icon: Smartphone, typeColor: "text-emerald-600", typeBg: "bg-emerald-50" },
|
||||
{ id: "6", name: "SAP ERP System", desc: "SAP S/4HANA enterprise resource planning integration", code: "sap_erp", type: "ERP", status: "active", families: 28, products: "11,200", updated: "2025-03-05", author: "Tech Team", icon: Monitor, typeColor: "text-red-600", typeBg: "bg-red-50" },
|
||||
];
|
||||
import { usePageHeader } from "../../../contexts/HeaderContext";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
|
||||
export default function ChannelList() {
|
||||
usePageHeader("Channel Master", "Manage publication destinations for product information");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const columns = [
|
||||
{
|
||||
@@ -108,42 +104,57 @@ export default function ChannelList() {
|
||||
|
||||
{/* Stats Cards */}
|
||||
<KPIGrid className="mb-6">
|
||||
<KPI value="9" label="TOTAL CHANNELS" icon={Radio} variant="purple" />
|
||||
<KPI value="8" label="ACTIVE CHANNELS" icon={CheckCircle} variant="green" />
|
||||
<KPI value="183" label="FAMILIES ASSIGNED" icon={Layers} variant="blue" />
|
||||
<KPI value="69,905" label="PRODUCTS PUBLISHED" icon={TrendingUp} variant="amber" />
|
||||
<ChannelStatsCard
|
||||
title="Total Channels"
|
||||
value="9"
|
||||
subtitle="Active and inactive"
|
||||
color="purple"
|
||||
icon={<Radio className="w-5 h-5" />}
|
||||
/>
|
||||
<ChannelStatsCard
|
||||
title="Active Channels"
|
||||
value="8"
|
||||
subtitle="Serving traffic"
|
||||
color="green"
|
||||
icon={<CheckCircle className="w-5 h-5" />}
|
||||
/>
|
||||
<ChannelStatsCard
|
||||
title="Families Assigned"
|
||||
value="183"
|
||||
subtitle="Across all storefronts"
|
||||
color="blue"
|
||||
icon={<Layers className="w-5 h-5" />}
|
||||
/>
|
||||
<ChannelStatsCard
|
||||
title="Products Published"
|
||||
value="69,905"
|
||||
subtitle="Synced items"
|
||||
color="orange"
|
||||
icon={<TrendingUp className="w-5 h-5" />}
|
||||
/>
|
||||
</KPIGrid>
|
||||
|
||||
{/* 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-purple-500 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-purple-500 text-gray-700 bg-white">
|
||||
<option>All Types</option>
|
||||
<option>Ecommerce</option>
|
||||
<option>Marketplace</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
9 of 9 channels
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Table
|
||||
<div className="mb-8">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={MOCK_CHANNELS}
|
||||
actions={actions}
|
||||
variant="flat"
|
||||
searchPlaceholder="Search channels..."
|
||||
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">
|
||||
<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-purple-500 text-gray-700 bg-white">
|
||||
<option>All Types</option>
|
||||
<option>Ecommerce</option>
|
||||
<option>Marketplace</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useChannel } from "../hook/useChannel";
|
||||
import type { ChannelCreateRequest } from "../types/channels.types";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
|
||||
const CHANNEL_TYPES = [
|
||||
{ id: "ecommerce", label: "Ecommerce", icon: ShoppingCart, color: "text-blue-500", bg: "bg-blue-50" },
|
||||
@@ -76,24 +78,21 @@ export default function NewChannel() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||
{/* Top Action Bar */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-3 flex justify-end items-center gap-3 sticky top-0 z-10">
|
||||
<Button variant="outline" type="button" onClick={() => navigate("..")} className="text-gray-600">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => formik.submitForm()}
|
||||
loading={formik.isSubmitting}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white"
|
||||
>
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
{isEdit ? "Update Channel" : "Create Channel"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={formik.handleSubmit} className="flex-1 max-w-2xl mx-auto w-full py-8 px-4 space-y-6">
|
||||
<PageWrapper>
|
||||
<Breadcrumb
|
||||
items={[{ label: 'Home' }, { label: 'Channels', href: '/channels' }, { label: isEdit ? 'Edit Channel' : 'Create Channel' }]}
|
||||
backTo="/channels"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => navigate('..')}>Cancel</Button>
|
||||
<Button type="button" onClick={() => formik.submitForm()} loading={formik.isSubmitting} className="bg-purple-600 hover:bg-purple-700 text-white">
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
{isEdit ? 'Update Channel' : 'Create Channel'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<form onSubmit={formik.handleSubmit} className="max-w-2xl mx-auto w-full space-y-6">
|
||||
{/* 1. Basic Information */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100 flex items-center gap-3">
|
||||
@@ -309,13 +308,13 @@ export default function NewChannel() {
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<div className="flex justify-end gap-3 py-4">
|
||||
<Button variant="outline" type="button" onClick={() => navigate("..")}>Cancel</Button>
|
||||
<Button variant="outline" type="button" onClick={() => navigate('..')}>Cancel</Button>
|
||||
<Button type="submit" loading={formik.isSubmitting} className="bg-purple-600 hover:bg-purple-700 text-white">
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
{isEdit ? "Update Channel" : "Create Channel"}
|
||||
{isEdit ? 'Update Channel' : 'Create Channel'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
|
||||
import { InfoCard, InfoCardGrid } from "../components/StatsCard";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { usePageHeader } from "../../../contexts/HeaderContext";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
|
||||
|
||||
@@ -64,8 +63,6 @@ const recentActivity = [
|
||||
|
||||
// ── Dashboard Component ───────────────────────────────────────────────────────
|
||||
export default function Dashboard() {
|
||||
usePageHeader("Dashboard", "Overview of your PIM platform");
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<Breadcrumb items={[{ label: "Home" }, { label: "Dashboard" }]} />
|
||||
|
||||
@@ -1,41 +1,24 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Plus, Edit2, Trash2, 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";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { SearchBar, useSearch } from "../../../components/customs/SearchBar";
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { Table } from "../../../components/customs/Table";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { useFamily } from "../hook/useFamily";
|
||||
import type { Family } from "../types/family.types";
|
||||
import { usePageHeader } from "../../../contexts/HeaderContext";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { FamilyCard } from "../components/FamilyCard";
|
||||
import { Pagination } from "../../../components/customs/Pagination"; // ← Added
|
||||
|
||||
export default function FamilyList() {
|
||||
const navigate = useNavigate();
|
||||
const { query, setQuery } = useSearch();
|
||||
const { families, fetchFamilies, deleteFamily } = useFamily();
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
usePageHeader(
|
||||
"Product Families",
|
||||
`${families.length} total product families in your catalog`
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFamilies();
|
||||
}, [fetchFamilies]);
|
||||
|
||||
// Reset to first page when search changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [query]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const published = families.filter((f) => f.status === 'active').length;
|
||||
const totalProducts = families.reduce((sum, f) => sum + (f.productCount ?? 0), 0);
|
||||
@@ -46,17 +29,6 @@ export default function FamilyList() {
|
||||
return { total: families.length, published, totalProducts, avgCompleteness };
|
||||
}, [families]);
|
||||
|
||||
const filteredFamilies = families.filter((fam) =>
|
||||
fam.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
fam.code.toLowerCase().includes(query.toLowerCase()) ||
|
||||
(fam.description && fam.description.toLowerCase().includes(query.toLowerCase()))
|
||||
);
|
||||
|
||||
const paginatedFamilies = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filteredFamilies.slice(start, start + pageSize);
|
||||
}, [filteredFamilies, currentPage, pageSize]);
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (confirm(`Are you sure you want to delete product family "${name}"?`)) {
|
||||
await deleteFamily(id);
|
||||
@@ -78,7 +50,9 @@ export default function FamilyList() {
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "code", label: "Code", sortable: true,
|
||||
key: "code",
|
||||
label: "Code",
|
||||
sortable: true,
|
||||
render: (val: string) => <span className="font-mono text-sm text-gray-600">{val}</span>,
|
||||
},
|
||||
{
|
||||
@@ -102,7 +76,9 @@ export default function FamilyList() {
|
||||
render: (val: string[]) => (
|
||||
<div className="text-center">
|
||||
<div className="font-semibold text-gray-900">{val.length}</div>
|
||||
<div className="text-xs text-gray-400">{val.length} {val.length === 1 ? 'axis' : 'axes'}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{val.length} {val.length === 1 ? 'axis' : 'axes'}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -189,46 +165,28 @@ export default function FamilyList() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table + Pagination Container */}
|
||||
<div className="bg-white border border-gray-200 rounded-2xl overflow-hidden shadow-sm">
|
||||
<Table<Family>
|
||||
columns={columns}
|
||||
data={paginatedFamilies}
|
||||
onRowClick={(row) => navigate(`/families/${row.id}/edit`)}
|
||||
header={
|
||||
<SearchBar
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Search product families by code, name, or description..."
|
||||
{/* DataTable */}
|
||||
<DataTable<Family>
|
||||
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
|
||||
/>
|
||||
}
|
||||
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`)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<Trash2 className="w-4 h-4 text-red-500 hover:bg-red-50" />}
|
||||
onClick={() => handleDelete(row.id, row.name)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
page={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={filteredFamilies.length}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<Trash2 className="w-4 h-4 text-red-500" />}
|
||||
onClick={() => handleDelete(row.id, row.name)} // ← Fixed
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft, FileText, LayoutGrid, Tags, Globe, Eye, Settings2,
|
||||
FileText, LayoutGrid, Tags, Globe, Eye, Settings2, Save,
|
||||
CheckCircle2, AlertCircle, Edit2, Trash2, Plus,
|
||||
CheckSquare, Image as ImageIcon,
|
||||
Check
|
||||
} from 'lucide-react';
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
|
||||
const TABS = [
|
||||
{ id: 'basic', label: 'Basic Information', icon: FileText },
|
||||
@@ -35,34 +38,20 @@ export default function NewFamily() {
|
||||
const labelClass = "block text-sm font-medium text-gray-700 mb-1.5";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4 sticky top-0 z-10 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/families')}
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors text-gray-500"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">Create Product Family</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Define a reusable product template</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => navigate('/families')} className="px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 flex items-center gap-2">
|
||||
Cancel
|
||||
</button>
|
||||
<button className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<Save className="w-4 h-4" /> Create Family
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 max-w-[1400px] w-full mx-auto px-6 py-6">
|
||||
<PageWrapper>
|
||||
<Breadcrumb
|
||||
items={[{ label: 'Home' }, { label: 'Families', href: '/families' }, { label: 'Create Product Family' }]}
|
||||
backTo="/families"
|
||||
actions={
|
||||
<>
|
||||
<button onClick={() => navigate('/families')} className="px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50">Cancel</button>
|
||||
<button className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<Save className="w-4 h-4" /> Create Family
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 max-w-[1400px] w-full mx-auto">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-gray-200 mb-6 overflow-x-auto hide-scrollbar">
|
||||
{TABS.map(tab => {
|
||||
@@ -176,91 +165,46 @@ export default function NewFamily() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-full">
|
||||
<thead>
|
||||
<tr className="bg-[#FAF5FF] border-b border-gray-200">
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-purple-700 w-52">AXIS NAME</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-purple-700 w-32">CODE</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-purple-700">DESCRIPTION</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-purple-700 w-40">DATA TYPE</th>
|
||||
<th className="px-6 py-4 text-center text-xs font-semibold text-purple-700 w-24">REQUIRED</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-purple-700 w-28">STATUS</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-purple-700 w-20">ACTIONS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{[
|
||||
{
|
||||
name: "Color",
|
||||
code: "color",
|
||||
desc: "Product color variations",
|
||||
type: "color swatch",
|
||||
required: true,
|
||||
status: "Active"
|
||||
},
|
||||
{
|
||||
name: "Storage",
|
||||
code: "storage",
|
||||
desc: "Storage capacity variations",
|
||||
type: "dropdown",
|
||||
required: true,
|
||||
status: "Active"
|
||||
},
|
||||
{
|
||||
name: "RAM",
|
||||
code: "ram",
|
||||
desc: "Memory capacity variations",
|
||||
type: "dropdown",
|
||||
required: false,
|
||||
status: "Active"
|
||||
},
|
||||
].map((axis, index) => (
|
||||
<tr key={index} className="hover:bg-gray-50 transition-colors group">
|
||||
<td className="px-6 py-4 font-medium text-gray-900">{axis.name}</td>
|
||||
<td className="px-6 py-4 font-mono text-gray-500">{axis.code}</td>
|
||||
<td className="px-6 py-4 text-gray-600 text-sm">{axis.desc}</td>
|
||||
|
||||
{/* Data Type - Clean & Professional */}
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-3.5 py-1 text-xs font-medium rounded-lg border ${axis.type === 'color swatch'
|
||||
? 'bg-purple-50 text-purple-700 border-purple-100'
|
||||
: 'bg-amber-50 text-amber-700 border-amber-100'
|
||||
}`}>
|
||||
{axis.type}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 text-center">
|
||||
<span className={`inline-block px-4 py-1 text-xs font-medium rounded-full ${axis.required
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}>
|
||||
{axis.required ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4">
|
||||
<StatusBadge
|
||||
status="active"
|
||||
label="Active"
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-1 opacity-70 group-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>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mb-4">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: "name", label: "AXIS NAME" },
|
||||
{ key: "code", label: "CODE", render: (val: string) => <span className="font-mono text-gray-500">{val}</span> },
|
||||
{ key: "desc", label: "DESCRIPTION", render: (val: string) => <span className="text-gray-600 text-sm">{val}</span> },
|
||||
{ key: "type", label: "DATA TYPE", render: (val: string) => (
|
||||
<span className={`inline-flex items-center px-3.5 py-1 text-xs font-medium rounded-lg border ${val === 'color swatch'
|
||||
? 'bg-purple-50 text-purple-700 border-purple-100'
|
||||
: 'bg-amber-50 text-amber-700 border-amber-100'
|
||||
}`}>
|
||||
{val}
|
||||
</span>
|
||||
)},
|
||||
{ key: "required", label: "REQUIRED", align: "center", render: (val: boolean) => (
|
||||
<span className={`inline-block px-4 py-1 text-xs font-medium rounded-full ${val
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}>
|
||||
{val ? 'Yes' : 'No'}
|
||||
</span>
|
||||
)},
|
||||
{ key: "status", label: "STATUS", render: (val: string) => <StatusBadge status={val.toLowerCase() as any} label={val} /> },
|
||||
]}
|
||||
data={[
|
||||
{ id: '1', name: "Color", code: "color", desc: "Product color variations", type: "color swatch", required: true, status: "Active" },
|
||||
{ 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>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -473,15 +417,6 @@ export default function NewFamily() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure Save icon is imported
|
||||
const Save = ({ className }: { className?: string }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
|
||||
<polyline points="17 21 17 13 7 13 7 21"></polyline>
|
||||
<polyline points="7 3 7 8 15 8"></polyline>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,98 @@
|
||||
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.
|
||||
* The appearance can be themed via the `color` prop which selects a soft background gradient and border.
|
||||
*/
|
||||
interface ImportStatsCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle: string;
|
||||
/** Optional icon or image displayed on the right side */
|
||||
icon?: ReactNode;
|
||||
/** Colour theme for the card */
|
||||
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 ImportStatsCard({ title, value, subtitle, icon, color }: ImportStatsCardProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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 = () => (
|
||||
@@ -27,33 +28,9 @@ const XStat = () => (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 9l-6 6M9 9l6 6"/>
|
||||
</svg>
|
||||
);
|
||||
// ── KPI Card ───────────────────────────────────────────────────────────────────
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number;
|
||||
icon: React.ReactNode;
|
||||
iconBg: string;
|
||||
iconColor: string;
|
||||
}
|
||||
function StatCard({ label, value, icon, iconBg, iconColor }: StatCardProps) {
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 flex items-center gap-4 shadow-sm">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 ${iconBg}`}>
|
||||
<span className={iconColor}>{icon}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">{value}</div>
|
||||
<div className="text-sm text-gray-500">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
import { usePageHeader } from "../../../contexts/HeaderContext";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
|
||||
export default function ImportList() {
|
||||
usePageHeader("Supplier Imports", "Import and sync product data from suppliers");
|
||||
const stats = {
|
||||
total: 24,
|
||||
completed: 18,
|
||||
@@ -67,10 +44,34 @@ export default function ImportList() {
|
||||
|
||||
{/* KPI Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard label="Total Imports" value={stats.total} icon={<UploadStat />} iconBg="bg-violet-100" iconColor="text-violet-600" />
|
||||
<StatCard label="Completed" value={stats.completed} icon={<CheckStat />} iconBg="bg-green-100" iconColor="text-green-600" />
|
||||
<StatCard label="Processing" value={stats.processing} icon={<ClockStat />} iconBg="bg-amber-100" iconColor="text-amber-500" />
|
||||
<StatCard label="Failed" value={stats.failed} icon={<XStat />} iconBg="bg-red-100" iconColor="text-red-500" />
|
||||
<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 */}
|
||||
|
||||
@@ -7,7 +7,8 @@ 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 { ArrowLeft, Save } from 'lucide-react';
|
||||
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`;
|
||||
@@ -53,21 +54,17 @@ export default function NewImport() {
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<button
|
||||
onClick={() => navigate('..')}
|
||||
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{isEdit ? 'Edit Import' : 'Create Import'}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">{isEdit ? 'Update import details' : 'Configure a new data import job'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
|
||||
<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">
|
||||
@@ -114,15 +111,6 @@ export default function NewImport() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate('..')} disabled={formik.isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
|
||||
{isEdit ? 'Update Import' : 'Create Import'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</PageWrapper>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Plug, CheckCircle, RefreshCw, AlertCircle, Package, Clock } from "lucide-react";
|
||||
|
||||
/* ── Single Stat Card ── */
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
subtitle: string;
|
||||
icon: ReactNode;
|
||||
/** Colour theme for the card */
|
||||
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",
|
||||
},
|
||||
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) {
|
||||
const c = COLOR_MAP[color];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
${c.bgGradient}
|
||||
${c.border}
|
||||
border
|
||||
rounded-lg
|
||||
h-[100px]
|
||||
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">
|
||||
{label}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Edit2, Eye, MoreHorizontal, Activity, FileText, Download, Plug, CheckCircle, RefreshCw, AlertCircle, Package, Clock } from "lucide-react";
|
||||
import { Plus, Edit2, Eye, MoreHorizontal, Activity, FileText, Download } from "lucide-react";
|
||||
import { ShoppingBag, ShoppingCart, Smartphone } from "lucide-react";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { Table } from "../../../components/customs/Table";
|
||||
import { KPI, KPIGrid } from "../../../components/customs/KPI";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { SearchBar } from "../../../components/customs/SearchBar";
|
||||
import { IntegrationStatsCards } from "../components/IntegrationStatsCards";
|
||||
|
||||
// Mock Data
|
||||
const MOCK_INTEGRATIONS = [
|
||||
@@ -15,13 +14,10 @@ const MOCK_INTEGRATIONS = [
|
||||
{ id: "3", name: "Shopify Main Store", desc: "Main Shopify storefront with...", channel: "Shopify Storefront", type: "E-Commerce", env: "Production", status: "Connected", lastSync: "2025-06-09 14:00", syncErrors: 12, published: "8,942", author: "Emma Wilson", createdAt: "2024-03-01", icon: ShoppingCart, iconColor: "text-purple-600", iconBg: "bg-purple-50" },
|
||||
{ id: "4", name: "Retail POS Network", desc: "Point-of-sale retail network product...", channel: "Retail POS", type: "POS", env: "Production", status: "Connected", lastSync: "2025-06-09 12:00", syncErrors: 0, published: "3,240", author: "David Park", createdAt: "2024-04-15", icon: Smartphone, iconColor: "text-purple-600", iconBg: "bg-purple-50" },
|
||||
];
|
||||
import { usePageHeader } from "../../../contexts/HeaderContext";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
|
||||
export default function IntegrationList() {
|
||||
usePageHeader("Integration Hub", "Manage external system connections and synchronisation jobs");
|
||||
const [activeTab, setActiveTab] = useState("Connections");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const columns = [
|
||||
{
|
||||
@@ -103,14 +99,7 @@ export default function IntegrationList() {
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<KPIGrid className="mb-6 grid-cols-1 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<KPI value="6" label="Total Integrations" icon={Plug} variant="purple" dots={[{ color: "amber", label: "4 active" }]} />
|
||||
<KPI value="4" label="Connected Systems" icon={CheckCircle} variant="green" dots={[{ color: "green", label: "Healthy" }]} />
|
||||
<KPI value="1" label="Active Sync Jobs" icon={RefreshCw} variant="blue" dots={[{ color: "blue", label: "In progress" }]} />
|
||||
<KPI value="1" label="Failed Sync Jobs" icon={AlertCircle} variant="red" dots={[{ color: "red", label: "Need attention" }]} />
|
||||
<KPI value="18,262" label="Published Products" icon={Package} variant="amber" dots={[{ color: "blue", label: "Across channels" }]} />
|
||||
<KPI value="14:32" label="Last Synchronised" icon={Clock} variant="slate" dots={[{ color: "green", label: "Today" }]} />
|
||||
</KPIGrid>
|
||||
<IntegrationStatsCards />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 flex flex-col">
|
||||
@@ -145,35 +134,29 @@ export default function IntegrationList() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="p-4 flex justify-between items-center border-b border-gray-200 bg-white">
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="w-64">
|
||||
<SearchBar value={searchQuery} onChange={setSearchQuery} placeholder="Search integrations..." />
|
||||
</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-purple-500 text-gray-700 bg-white">
|
||||
<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">
|
||||
<option>All Types</option>
|
||||
<option>Marketplace</option>
|
||||
<option>E-Commerce</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
6 integrations
|
||||
</div>
|
||||
{/* Table container */}
|
||||
<div className="w-full">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={MOCK_INTEGRATIONS}
|
||||
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">
|
||||
<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">
|
||||
<option>All Types</option>
|
||||
<option>Marketplace</option>
|
||||
<option>E-Commerce</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Table
|
||||
columns={columns}
|
||||
data={MOCK_INTEGRATIONS}
|
||||
actions={actions}
|
||||
variant="flat"
|
||||
/>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
|
||||
@@ -3,11 +3,13 @@ import { useNavigate } from "react-router-dom";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import {
|
||||
ArrowLeft, Zap, ShoppingCart, Server, Warehouse,
|
||||
Zap, ShoppingCart, Server, Warehouse,
|
||||
CreditCard, Code2, Store, Eye, EyeOff, Settings2,
|
||||
Calendar, Clock, Wifi, ChevronDown, Info
|
||||
} from "lucide-react";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
|
||||
const TABS = ["General Information", "Connection Configuration", "Synchronisation Settings", "Advanced Configuration"];
|
||||
|
||||
@@ -85,7 +87,7 @@ export default function NewIntegration() {
|
||||
|
||||
const IntegrationSummary = () => (
|
||||
<div className="w-60 shrink-0">
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-5 sticky top-24">
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-5 sticky top-4">
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<Zap className="w-4 h-4 text-amber-500" />
|
||||
<h3 className="font-semibold text-gray-900 text-sm">Integration Summary</h3>
|
||||
@@ -130,26 +132,12 @@ export default function NewIntegration() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||
{/* Top Header */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4 sticky top-0 z-10">
|
||||
<nav className="text-xs text-gray-500 mb-2 flex items-center gap-1.5">
|
||||
<span className="cursor-pointer hover:text-gray-700" onClick={() => navigate("..")}>Integration Hub</span>
|
||||
<span>›</span>
|
||||
<span className="text-gray-900">Create Integration</span>
|
||||
</nav>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("..")}
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors text-gray-500"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<h1 className="text-xl font-bold text-gray-900">New Integration</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<PageWrapper>
|
||||
<Breadcrumb
|
||||
items={[{ label: 'Home' }, { label: 'Integrations', href: '/integrations' }, { label: 'Create Integration' }]}
|
||||
backTo="/integrations"
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 py-2 px-4 border border-amber-300 bg-amber-50 text-amber-700 rounded-lg text-sm font-medium hover:bg-amber-100 transition-colors"
|
||||
@@ -157,25 +145,16 @@ export default function NewIntegration() {
|
||||
<Zap className="w-4 h-4" />
|
||||
Test Connection
|
||||
</button>
|
||||
<Button variant="outline" type="button" onClick={() => navigate("..")} className="text-gray-600">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="outline" type="button" onClick={() => navigate('..')}>Cancel</Button>
|
||||
{activeTab >= 2 && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => formik.submitForm()}
|
||||
loading={formik.isSubmitting}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white"
|
||||
>
|
||||
Save Integration
|
||||
</Button>
|
||||
<Button type="button" onClick={() => formik.submitForm()} loading={formik.isSubmitting} className="bg-purple-600 hover:bg-purple-700 text-white">Save Integration</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white border-b border-gray-200 px-6">
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<div className="flex gap-8">
|
||||
{TABS.map((tab, i) => (
|
||||
<button
|
||||
@@ -195,7 +174,7 @@ export default function NewIntegration() {
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<form onSubmit={formik.handleSubmit} className="flex-1 px-6 py-6 flex gap-6 max-w-5xl mx-auto w-full">
|
||||
<form onSubmit={formik.handleSubmit} className="flex gap-6 max-w-5xl mx-auto w-full pb-6">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Tab 0: General Information */}
|
||||
{activeTab === 0 && (
|
||||
@@ -576,6 +555,6 @@ export default function NewIntegration() {
|
||||
{/* Right: Integration Summary */}
|
||||
<IntegrationSummary />
|
||||
</form>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Box, LayoutGrid, Tags, Globe, Eye, Settings2, Save, Send, CheckCircle2, Copy, Upload, Image as ImageIcon, Video, FileText, Link as LinkIcon, Info, FolderTree, AlertCircle, RefreshCw, Briefcase } from 'lucide-react';
|
||||
import { usePageHeader } from '../../../contexts/HeaderContext';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Box, LayoutGrid, Tags, Globe, Eye, Settings2, Save, Send, CheckCircle2, Copy, Upload, Image as ImageIcon, Video, FileText, Link as LinkIcon, Info, FolderTree, AlertCircle, RefreshCw, Briefcase } from 'lucide-react';
|
||||
import { PageWrapper } from '../../../components/layouts/PageWrapper';
|
||||
import { Breadcrumb } from '../../../components/layouts/Breadcrumb';
|
||||
|
||||
|
||||
const MOCK_FAMILIES = [
|
||||
@@ -39,7 +40,6 @@ const TABS = [
|
||||
];
|
||||
|
||||
export default function NewProduct() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const isEdit = Boolean(id);
|
||||
|
||||
@@ -48,11 +48,6 @@ export default function NewProduct() {
|
||||
const [selectedFamily, setSelectedFamily] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('general');
|
||||
|
||||
usePageHeader(
|
||||
isEdit ? 'Edit Product' : 'Create Product',
|
||||
step === 1 ? 'Select a product family to get started' : 'Fill in product details across all tabs'
|
||||
);
|
||||
|
||||
// Form State
|
||||
const [formData, setFormData] = useState({
|
||||
name: '', code: 'PROF-LAPTOP', sku: '', barcode: '',
|
||||
@@ -72,36 +67,28 @@ export default function NewProduct() {
|
||||
const labelClass = "block text-sm font-medium text-gray-700 mb-1.5";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||
{/* Back + Actions Bar */}
|
||||
<div className="px-6 py-3 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step === 2 ? setStep(1) : navigate('/products')}
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors text-gray-500"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{step === 2 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="px-2 py-0.5 text-xs font-medium text-purple-700 bg-purple-50 border border-purple-200 rounded-md">
|
||||
{MOCK_FAMILIES.find(f => f.id === selectedFamily)?.name}
|
||||
</span>
|
||||
<button className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
<Save className="w-4 h-4" /> Save Draft
|
||||
</button>
|
||||
<button className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
<Send className="w-4 h-4" /> Submit for Review
|
||||
</button>
|
||||
<button className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
Publish
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 max-w-[1400px] w-full mx-auto px-6 pb-6">
|
||||
<PageWrapper>
|
||||
<Breadcrumb
|
||||
items={[{ label: 'Home' }, { label: 'Products', href: '/products' }, { label: isEdit ? 'Edit Product' : 'Create Product' }]}
|
||||
backTo="/products"
|
||||
actions={
|
||||
step === 2 ? (
|
||||
<>
|
||||
<span className="px-2 py-0.5 text-xs font-medium text-purple-700 bg-purple-50 border border-purple-200 rounded-md">
|
||||
{MOCK_FAMILIES.find(f => f.id === selectedFamily)?.name}
|
||||
</span>
|
||||
<button className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
<Save className="w-4 h-4" /> Save Draft
|
||||
</button>
|
||||
<button className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
<Send className="w-4 h-4" /> Submit for Review
|
||||
</button>
|
||||
<button className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors">Publish</button>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="max-w-[1400px] w-full mx-auto">
|
||||
{step === 1 && (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
|
||||
@@ -795,6 +782,6 @@ export default function NewProduct() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,169 +1,117 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { CheckCircle2, Clock, Package, AlertCircle, Plus, Edit, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { CheckCircle2, Clock, Package, AlertCircle, Plus, Eye, Edit, Trash2 } 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 { ProductCard } from "../components/ProductCard";
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { ProductCard } from "../components/ProductCard";
|
||||
import { useProduct } from "../hook/useProduct";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { ProductStatus } from "../types/product.types";
|
||||
import { usePageHeader } from "../../../contexts/HeaderContext";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { Table, type TableColumn } from "../../../components/customs/Table";
|
||||
import { SearchBar, useSearch } from "../../../components/customs/SearchBar";
|
||||
import { Pagination } from "../../../components/customs/Pagination";
|
||||
import { DataTable, type DataTableColumn } from "../../../components/customs/DataTable";
|
||||
import type { Product } from "../types/product.types";
|
||||
|
||||
function ProductIcon() {
|
||||
// ── Completeness bar ───────────────────────────────────────────────────────────
|
||||
|
||||
function CompletenessBar({ pct }: { pct: number }) {
|
||||
const color = pct >= 80 ? "bg-green-500" : pct >= 60 ? "bg-amber-400" : "bg-red-400";
|
||||
return (
|
||||
<div className="w-9 h-9 rounded-lg bg-purple-100 flex items-center justify-center flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-20 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{pct}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Product thumbnail ──────────────────────────────────────────────────────────
|
||||
|
||||
function ProductThumb({ name: _name }: { name: string }) {
|
||||
return (
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center shrink-0">
|
||||
<Package className="w-4 h-4 text-purple-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
published: "Published",
|
||||
pending: "Pending Review",
|
||||
draft: "Draft",
|
||||
};
|
||||
|
||||
const getStatusForBadge = (status: string): ProductStatus => {
|
||||
if (status === "published") return "published";
|
||||
if (status === "pending") return "pending";
|
||||
if (status === "draft") return "draft";
|
||||
return "incomplete";
|
||||
};
|
||||
|
||||
function completenessColor(pct: number) {
|
||||
if (pct >= 80) return "bg-green-500";
|
||||
if (pct >= 60) return "bg-amber-400";
|
||||
return "bg-red-400";
|
||||
}
|
||||
// ── Main page ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ProductList() {
|
||||
const { canImport, canExport } = usePermissions("products.items");
|
||||
const navigate = useNavigate();
|
||||
const { products, fetchProducts, deleteProduct } = useProduct();
|
||||
const { query, setQuery } = useSearch(); // ← Using useSearch like FamilyList
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
usePageHeader("Products", `${products.length} total products in your catalog`);
|
||||
useEffect(() => { fetchProducts(); }, [fetchProducts]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
}, [fetchProducts]);
|
||||
|
||||
// Reset page when search changes (same as FamilyList)
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [query]);
|
||||
|
||||
// Filtering
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.toLowerCase().trim();
|
||||
if (!q) return products;
|
||||
|
||||
return products.filter((p) =>
|
||||
p.name?.toLowerCase().includes(q) ||
|
||||
p.sku?.toLowerCase().includes(q) ||
|
||||
p.productId?.toLowerCase().includes(q) ||
|
||||
p.category?.toLowerCase().includes(q)
|
||||
);
|
||||
}, [products, query]);
|
||||
|
||||
// Pagination
|
||||
const total = filtered.length;
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filtered.slice(start, start + pageSize);
|
||||
}, [filtered, currentPage, pageSize]);
|
||||
|
||||
// Stats for cards
|
||||
// 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 published = products.filter(p => p.status === "published").length;
|
||||
const pending = products.filter(p => p.status === "pending").length;
|
||||
const draft = products.filter(p => p.status === "draft").length;
|
||||
const avgCompleteness = products.length > 0
|
||||
? Math.round(products.reduce((sum, p) => sum + (p.completeness || 0), 0) / products.length)
|
||||
? Math.round(products.reduce((s, p) => s + (p.completeness || 0), 0) / products.length)
|
||||
: 0;
|
||||
|
||||
return { published, pending, draft, avgCompleteness };
|
||||
}, [products]);
|
||||
|
||||
// Table Columns
|
||||
const columns: TableColumn[] = [
|
||||
|
||||
|
||||
// Table columns
|
||||
const columns: DataTableColumn<Product>[] = [
|
||||
{
|
||||
key: "product",
|
||||
label: "Product",
|
||||
key: "name",
|
||||
label: "Product Info",
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<ProductIcon />
|
||||
<ProductThumb name={row.name} />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{row.name}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{row.category} · {row.subcategory}
|
||||
</div>
|
||||
<div className="font-semibold text-gray-900 text-sm">{row.name}</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">SKU: {row.sku}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sku",
|
||||
label: "SKU / ID",
|
||||
render: (_, row) => (
|
||||
<div>
|
||||
<div className="font-mono text-gray-600">{row.sku}</div>
|
||||
<div className="text-xs text-gray-400">{row.productId}</div>
|
||||
</div>
|
||||
),
|
||||
key: "category",
|
||||
label: "Category",
|
||||
sortable: true,
|
||||
render: val => <span className="text-sm text-gray-700">{val || "—"}</span>,
|
||||
},
|
||||
{
|
||||
key: "price",
|
||||
label: "Price",
|
||||
sortable: true,
|
||||
render: val => <span className="font-medium text-gray-800">{val || "—"}</span>,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
render: (_, row) => (
|
||||
<StatusBadge
|
||||
status={getStatusForBadge(row.status)}
|
||||
label={STATUS_LABEL[row.status] || row.status}
|
||||
/>
|
||||
sortable: true,
|
||||
render: (val: string) => (
|
||||
<StatusBadge status={val as any} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "completeness",
|
||||
label: "Completeness",
|
||||
render: (_, row) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-16 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${completenessColor(row.completeness)}`}
|
||||
style={{ width: `${row.completeness}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{row.completeness}%</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "stock", label: "Stock", render: (_, row) => row.stock?.toLocaleString() || 0 },
|
||||
{ key: "price", label: "Price" },
|
||||
{
|
||||
key: "channels",
|
||||
label: "Channels",
|
||||
render: (_, row) => `${row.channels?.active || 0}/${row.channels?.total || 0}`,
|
||||
sortable: true,
|
||||
render: val => <CompletenessBar pct={val ?? 0} />,
|
||||
},
|
||||
{
|
||||
key: "updatedAt",
|
||||
label: "Updated",
|
||||
render: (_, row) => (
|
||||
sortable: true,
|
||||
render: (val, row) => (
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{row.updatedAt ? new Date(row.updatedAt).toLocaleDateString() : '—'}
|
||||
<div className="text-sm text-gray-700">
|
||||
{val ? new Date(val).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }) : "—"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">by {row.updatedBy || '—'}</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">by {row.updatedBy || "—"}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -179,7 +127,7 @@ export default function ProductList() {
|
||||
{canExport && <ExportButton />}
|
||||
{canImport && <ImportButton />}
|
||||
<Can node="products.items" action="create">
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate('new')}>
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
|
||||
Create Product
|
||||
</Button>
|
||||
</Can>
|
||||
@@ -187,86 +135,52 @@ export default function ProductList() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Status Cards - Same style as FamilyList */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<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="Incomplete"
|
||||
value={`${stats.avgCompleteness}%`}
|
||||
subtitle="Overall"
|
||||
color="red"
|
||||
icon={<AlertCircle className="w-6 h-6" />}
|
||||
/>
|
||||
{/* 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" />} />
|
||||
</div>
|
||||
|
||||
{/* Table with SearchBar in header */}
|
||||
<div className="bg-white border border-gray-200 rounded-2xl overflow-hidden shadow-sm">
|
||||
<Table
|
||||
columns={columns}
|
||||
data={paginatedData}
|
||||
selectable
|
||||
selectedIds={selected}
|
||||
onSelectionChange={setSelected}
|
||||
onRowClick={(row) => navigate(`${row.id}/edit`)}
|
||||
rowIdKey="id"
|
||||
header={
|
||||
<SearchBar
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Search by name, SKU, or Product ID..."
|
||||
/>
|
||||
}
|
||||
actions={(row) => (
|
||||
<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={() => {
|
||||
if (confirm('Delete this product?')) deleteProduct(row.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Pagination - Same as FamilyList */}
|
||||
<Pagination
|
||||
page={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
resultLabel="products"
|
||||
/>
|
||||
</div>
|
||||
{/* DataTable */}
|
||||
<DataTable<Product>
|
||||
columns={columns}
|
||||
data={products}
|
||||
selectable
|
||||
selectedIds={selected}
|
||||
onSelectionChange={setSelected}
|
||||
onRowClick={row => navigate(`${row.id}/edit`)}
|
||||
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(); if (confirm("Delete this product?")) deleteProduct(row.id); }}
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import { useReport } from "../hook/useReport";
|
||||
import { reportSchema } from "../validation/reports.schema";
|
||||
import { reportsService } from "../services/reports.service";
|
||||
import type { ReportCreateRequest } from "../types/reports.types";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
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`;
|
||||
@@ -53,21 +54,17 @@ export default function NewReport() {
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<button
|
||||
onClick={() => navigate('..')}
|
||||
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{isEdit ? 'Edit Report' : 'Create Report'}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">{isEdit ? 'Update report details' : 'Configure a new report'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
|
||||
<Breadcrumb
|
||||
items={[{ label: 'Home' }, { label: 'Reports', href: '/reports' }, { label: isEdit ? 'Edit Report' : 'Create Report' }]}
|
||||
backTo="/reports"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate('..')} disabled={formik.isSubmitting}>Cancel</Button>
|
||||
<Button variant="primary" size="md" type="submit" form="report-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>{isEdit ? 'Update Report' : 'Create Report'}</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<form id="report-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">
|
||||
@@ -114,15 +111,6 @@ export default function NewReport() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate('..')} disabled={formik.isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
|
||||
{isEdit ? 'Update Report' : 'Create Report'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</PageWrapper>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "recharts";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { KPIGrid } from "../../../components/customs/KPI";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
|
||||
const publicationData = [
|
||||
{ name: "Jan", created: 1200, approved: 1100 },
|
||||
@@ -49,11 +50,8 @@ const topContributors = [
|
||||
{ init: "DL", color: "bg-purple-600", name: "David Lee", role: "Data Analyst", count: 98 },
|
||||
{ init: "JD", color: "bg-purple-600", name: "John Doe", role: "Product Manager", count: 87 },
|
||||
];
|
||||
import { usePageHeader } from "../../../contexts/HeaderContext";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
|
||||
export default function ReportList() {
|
||||
usePageHeader("Reports", "Comprehensive insights into your product catalog performance");
|
||||
return (
|
||||
<PageWrapper>
|
||||
<Breadcrumb items={[{ label: "Home" }, { label: "Reports" }]} />
|
||||
|
||||
@@ -7,7 +7,8 @@ import { useScope } from "../hook/useScope";
|
||||
import { scopeSchema } from "../validation/scopes.schema";
|
||||
import { scopesService } from "../services/scopes.service";
|
||||
import type { ScopeCreateRequest } from "../types/scopes.types";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
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`;
|
||||
@@ -53,21 +54,17 @@ export default function NewScope() {
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<button
|
||||
onClick={() => navigate('..')}
|
||||
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{isEdit ? 'Edit Scope' : 'Create Scope'}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">{isEdit ? 'Update scope details' : 'Configure a new scope'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
|
||||
<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('..')} disabled={formik.isSubmitting}>Cancel</Button>
|
||||
<Button variant="primary" size="md" type="submit" form="scope-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>{isEdit ? 'Update Scope' : 'Create Scope'}</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<form id="scope-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">
|
||||
@@ -114,15 +111,6 @@ export default function NewScope() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate('..')} disabled={formik.isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
|
||||
{isEdit ? 'Update Scope' : 'Create Scope'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</PageWrapper>
|
||||
);
|
||||
|
||||
@@ -3,29 +3,20 @@ 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 { Table } from "../../../components/customs/Table";
|
||||
import { SearchBar, useSearch } from "../../../components/customs/SearchBar";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { useScope } from "../hook/useScope";
|
||||
import type { Scope } from "../types/scopes.types";
|
||||
import { usePageHeader } from "../../../contexts/HeaderContext";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
|
||||
export default function ScopeList() {
|
||||
const navigate = useNavigate();
|
||||
const { query, setQuery } = useSearch();
|
||||
const { items, fetchItems, deleteItem } = useScope();
|
||||
|
||||
usePageHeader("Scopes", `${items.length} total scopes configured`);
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [fetchItems]);
|
||||
|
||||
const filteredItems = items.filter((item) =>
|
||||
item.name?.toLowerCase().includes(query.toLowerCase()) ||
|
||||
item.status?.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
const handleDelete = async (item: Scope) => {
|
||||
if (confirm('Delete this item?')) {
|
||||
await deleteItem(item.id);
|
||||
@@ -55,15 +46,9 @@ export default function ScopeList() {
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Table data={filteredItems} columns={columns} selectable actions={actions}
|
||||
header={
|
||||
<SearchBar
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Search scopes..."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div className="mb-8">
|
||||
<DataTable data={items} columns={columns} selectable actions={actions} searchPlaceholder="Search scopes..." />
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import { useSetting } from "../hook/useSetting";
|
||||
import { settingSchema } from "../validation/settings.schema";
|
||||
import { settingsService } from "../services/settings.service";
|
||||
import type { SettingCreateRequest } from "../types/settings.types";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
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`;
|
||||
@@ -53,21 +54,17 @@ export default function NewSetting() {
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<button
|
||||
onClick={() => navigate('..')}
|
||||
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{isEdit ? 'Edit Setting' : 'Create Setting'}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">{isEdit ? 'Update setting details' : 'Configure a new setting'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
|
||||
<Breadcrumb
|
||||
items={[{ label: 'Home' }, { label: 'Settings', href: '/settings' }, { label: isEdit ? 'Edit Setting' : 'Create Setting' }]}
|
||||
backTo="/settings"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate('..')} disabled={formik.isSubmitting}>Cancel</Button>
|
||||
<Button variant="primary" size="md" type="submit" form="setting-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>{isEdit ? 'Update Setting' : 'Create Setting'}</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<form id="setting-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">
|
||||
@@ -114,15 +111,6 @@ export default function NewSetting() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate('..')} disabled={formik.isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
|
||||
{isEdit ? 'Update Setting' : 'Create Setting'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</PageWrapper>
|
||||
);
|
||||
|
||||
@@ -2,11 +2,9 @@ import { useState } from "react";
|
||||
import { User, Bell, Shield, Plug, Key, Palette, Globe, Database, MessageSquare, Webhook, Box } from "lucide-react";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { usePageHeader } from "../../../contexts/HeaderContext";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
|
||||
export default function SettingList() {
|
||||
usePageHeader("Settings", "Configure platform settings and preferences");
|
||||
const [activeTab, setActiveTab] = useState("Integrations");
|
||||
const [requireApproval, setRequireApproval] = useState(true);
|
||||
const [autoPublish, setAutoPublish] = useState(false);
|
||||
|
||||
@@ -7,6 +7,7 @@ interface Column {
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
width?: string;
|
||||
render?: (value: any, row: any) => ReactNode;
|
||||
}
|
||||
|
||||
interface DataTableProps {
|
||||
@@ -153,7 +154,7 @@ export function DataTable({
|
||||
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} className="px-4 py-3.5 text-sm text-gray-900 whitespace-nowrap">
|
||||
{row[column.key] ?? "—"}
|
||||
{column.render ? column.render(row[column.key], row) : (row[column.key] ?? "—")}
|
||||
</td>
|
||||
))}
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Button } from '../../../components/customs/Button';
|
||||
import { unitService } from '../services/unit.service';
|
||||
import { unitSchema } from '../validation/unit.schema';
|
||||
import type { UnitStatus } from '../types/unit.types';
|
||||
import { ArrowLeft, Save } from 'lucide-react';
|
||||
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`;
|
||||
@@ -78,21 +79,17 @@ export default function NewUnit() {
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<button
|
||||
onClick={() => navigate('/units')}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">{isEdit ? 'Edit Unit' : 'Create Unit'}</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{isEdit ? 'Update unit details' : 'Add a new measurement unit'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
|
||||
<Breadcrumb
|
||||
items={[{ label: 'Home' }, { label: 'Units', href: '/units' }, { label: isEdit ? 'Edit Unit' : 'Create Unit' }]}
|
||||
backTo="/units"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate('/units')} disabled={formik.isSubmitting}>Cancel</Button>
|
||||
<Button variant="primary" size="md" type="submit" form="unit-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>{isEdit ? 'Update Unit' : 'Create Unit'}</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<form id="unit-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 Details</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@@ -225,15 +222,6 @@ export default function NewUnit() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate('/units')} disabled={formik.isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
|
||||
{isEdit ? 'Update Unit' : 'Create Unit'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</PageWrapper>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,6 @@ 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 { usePageHeader } from "../../../contexts/HeaderContext";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
|
||||
export default function UnitList() {
|
||||
@@ -25,8 +24,6 @@ export default function UnitList() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
usePageHeader("Units", `${units.length} total units configured`);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUnits();
|
||||
}, [fetchUnits]);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* UserStatsCard – a premium styled card component mirroring the visual design of ProductCard.
|
||||
* It displays a title, a prominent value, a subtitle, and an optional icon.
|
||||
* The appearance can be themed via the `color` prop which selects a soft background gradient and border.
|
||||
*/
|
||||
interface UserStatsCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle: string;
|
||||
/** Optional icon or image displayed on the right side */
|
||||
icon?: ReactNode;
|
||||
/** Colour theme for the card */
|
||||
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 UserStatsCard({ title, value, subtitle, icon, color }: UserStatsCardProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import { useUser } from "../hook/useUser";
|
||||
import { userSchema } from "../validation/users.schema";
|
||||
import { usersService } from "../services/users.service";
|
||||
import type { UserCreateRequest } from "../types/users.types";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
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`;
|
||||
@@ -53,21 +54,17 @@ export default function NewUser() {
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<button
|
||||
onClick={() => navigate('..')}
|
||||
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{isEdit ? 'Edit User' : 'Create User'}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">{isEdit ? 'Update user details' : 'Configure a new user account'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
|
||||
<Breadcrumb
|
||||
items={[{ label: 'Home' }, { label: 'Users', href: '/users' }, { label: isEdit ? 'Edit User' : 'Create User' }]}
|
||||
backTo="/users"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate('..')} disabled={formik.isSubmitting}>Cancel</Button>
|
||||
<Button variant="primary" size="md" type="submit" form="user-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>{isEdit ? 'Update User' : 'Create User'}</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<form id="user-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">
|
||||
@@ -114,15 +111,6 @@ export default function NewUser() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate('..')} disabled={formik.isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
|
||||
{isEdit ? 'Update User' : 'Create User'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</PageWrapper>
|
||||
);
|
||||
|
||||
@@ -2,11 +2,10 @@ import { useState } from "react";
|
||||
import { Plus, Edit2, Key, Trash2, 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 { Table } from "../../../components/customs/Table";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { SearchBar } from "../../../components/customs/SearchBar";
|
||||
import { usePageHeader } from "../../../contexts/HeaderContext";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { UserStatsCard } from "../components/UserStatsCard";
|
||||
|
||||
// Mock Data
|
||||
const MOCK_USERS = [
|
||||
@@ -70,7 +69,6 @@ const MOCK_ROLES = [
|
||||
|
||||
export default function UserList() {
|
||||
const [activeTab, setActiveTab] = useState("Roles & Permissions");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [roles, setRoles] = useState(MOCK_ROLES);
|
||||
const [selectedRoleId, setSelectedRoleId] = useState(MOCK_ROLES[0].id);
|
||||
const [isEditingRole, setIsEditingRole] = useState(false);
|
||||
@@ -159,8 +157,6 @@ export default function UserList() {
|
||||
</div>
|
||||
);
|
||||
|
||||
usePageHeader("Users & Roles", "Manage team members, permissions, and access controls");
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<Breadcrumb
|
||||
@@ -175,29 +171,34 @@ export default function UserList() {
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm flex flex-col justify-center">
|
||||
<h3 className="text-3xl font-bold text-gray-900">6</h3>
|
||||
<p className="text-sm text-gray-500 font-medium mt-1">Total Members</p>
|
||||
<p className="text-xs text-gray-400">4 active</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-purple-50/50 p-5 rounded-xl border border-purple-100/50 flex flex-col justify-center">
|
||||
<h3 className="text-3xl font-bold text-purple-600">4</h3>
|
||||
<p className="text-sm text-purple-700 font-medium mt-1">Roles Defined</p>
|
||||
<p className="text-xs text-purple-400">permission groups</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-emerald-50/50 p-5 rounded-xl border border-emerald-100/50 flex flex-col justify-center">
|
||||
<h3 className="text-3xl font-bold text-emerald-600">3</h3>
|
||||
<p className="text-sm text-emerald-700 font-medium mt-1">2FA Enabled</p>
|
||||
<p className="text-xs text-emerald-400">50% adoption</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50/50 p-5 rounded-xl border border-amber-100/50 flex flex-col justify-center">
|
||||
<h3 className="text-3xl font-bold text-amber-600">1</h3>
|
||||
<p className="text-sm text-amber-700 font-medium mt-1">Pending Invites</p>
|
||||
<p className="text-xs text-amber-400">awaiting signup</p>
|
||||
</div>
|
||||
<UserStatsCard
|
||||
title="Total Members"
|
||||
value={6}
|
||||
subtitle="4 active"
|
||||
color="slate"
|
||||
icon={<Users className="w-5 h-5" />}
|
||||
/>
|
||||
<UserStatsCard
|
||||
title="Roles Defined"
|
||||
value={4}
|
||||
subtitle="permission groups"
|
||||
color="purple"
|
||||
icon={<Shield className="w-5 h-5" />}
|
||||
/>
|
||||
<UserStatsCard
|
||||
title="2FA Enabled"
|
||||
value={3}
|
||||
subtitle="50% adoption"
|
||||
color="green"
|
||||
icon={<Lock className="w-5 h-5" />}
|
||||
/>
|
||||
<UserStatsCard
|
||||
title="Pending Invites"
|
||||
value={1}
|
||||
subtitle="awaiting signup"
|
||||
color="orange"
|
||||
icon={<Plus className="w-5 h-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
@@ -224,31 +225,20 @@ export default function UserList() {
|
||||
|
||||
{/* Main Content Area */}
|
||||
{activeTab === "Team Members" && (
|
||||
<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 members..." />
|
||||
</div>
|
||||
<div className="mb-8">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={MOCK_USERS}
|
||||
actions={actions}
|
||||
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">
|
||||
<option>All Roles</option>
|
||||
<option>Administrator</option>
|
||||
<option>Product Editor</option>
|
||||
<option>Content Reviewer</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
6 members
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Table
|
||||
columns={columns}
|
||||
data={MOCK_USERS}
|
||||
actions={actions}
|
||||
variant="flat"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Button } from '../../../components/customs/Button';
|
||||
import { variantService } from '../services/variant.service';
|
||||
import { variantSchema } from '../validation/variant.schema';
|
||||
import type { VariantStatus } from '../types/variant.types';
|
||||
import { ArrowLeft, Save } from 'lucide-react';
|
||||
import { Save } from 'lucide-react';
|
||||
import { Breadcrumb } from '../../../components/layouts/Breadcrumb';
|
||||
|
||||
const PRODUCT_OPTIONS = [
|
||||
{ id: '1', name: 'Wireless Headphones Pro' },
|
||||
@@ -96,21 +97,17 @@ export default function NewVariant() {
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<button
|
||||
onClick={() => navigate('/variants')}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">{isEdit ? 'Edit Variant' : 'Create Variant'}</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{isEdit ? 'Update variant details' : 'Add a new SKU variant to a product'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
|
||||
<Breadcrumb
|
||||
items={[{ label: 'Home' }, { label: 'Variants', href: '/variants' }, { label: isEdit ? 'Edit Variant' : 'Create Variant' }]}
|
||||
backTo="/variants"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate('/variants')} disabled={formik.isSubmitting}>Cancel</Button>
|
||||
<Button variant="primary" size="md" type="submit" form="variant-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>{isEdit ? 'Update Variant' : 'Create Variant'}</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<form id="variant-form" onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
|
||||
{/* Basic Info */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Basic Information</h2>
|
||||
@@ -248,15 +245,6 @@ export default function NewVariant() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
|
||||
<Button variant="outline" size="md" type="button" onClick={() => navigate('/variants')} disabled={formik.isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
|
||||
{isEdit ? 'Update Variant' : 'Create Variant'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</PageWrapper>
|
||||
);
|
||||
|
||||
@@ -1,63 +1,30 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Upload, Download, RefreshCw, Plus, Edit, Trash2 } from "lucide-react";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { useVariant } from "../hook/useVariant";
|
||||
import { Table } from "../../../components/customs/Table";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { SearchBar, useSearch } from "../../../components/customs/SearchBar";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Can } from "../../../components/customs/Can";
|
||||
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
|
||||
import { usePageHeader } from "../../../contexts/HeaderContext";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { VariantStatsCards } from "../components/VariantStatsCards";
|
||||
import { Pagination } from "../../../components/customs/Pagination"; // ← Added
|
||||
|
||||
export default function VariantList() {
|
||||
const { variants, fetchVariants, deleteVariant } = useVariant();
|
||||
const navigate = useNavigate();
|
||||
const { query, setQuery } = useSearch();
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Pagination State
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
usePageHeader(
|
||||
"Variant Management",
|
||||
"Manage all sellable SKUs across the product catalog"
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVariants();
|
||||
}, [fetchVariants]);
|
||||
|
||||
// Reset to first page when search changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [query]);
|
||||
|
||||
const activeCount = variants.filter(v => v.status === 'active').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;
|
||||
|
||||
const filteredVariants = query.trim()
|
||||
? variants.filter((v) =>
|
||||
v.sku?.toLowerCase().includes(query.toLowerCase()) ||
|
||||
v.name?.toLowerCase().includes(query.toLowerCase()) ||
|
||||
v.parentProductName?.toLowerCase().includes(query.toLowerCase()) ||
|
||||
v.status?.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
: variants;
|
||||
|
||||
// Paginated Data
|
||||
const paginatedVariants = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filteredVariants.slice(start, start + pageSize);
|
||||
}, [filteredVariants, currentPage, pageSize]);
|
||||
|
||||
const columns = [
|
||||
{ key: "sku", label: "SKU", sortable: true },
|
||||
{ key: "name", label: "Variant Name", sortable: true },
|
||||
@@ -105,22 +72,16 @@ export default function VariantList() {
|
||||
published={publishedCount}
|
||||
/>
|
||||
|
||||
{/* Table + Pagination Container */}
|
||||
<div className="bg-white border border-gray-200 rounded-2xl overflow-hidden shadow-sm">
|
||||
<Table
|
||||
{/* Table Container */}
|
||||
<div className="mb-8">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={paginatedVariants}
|
||||
data={variants}
|
||||
selectable
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={setSelectedIds}
|
||||
onRowClick={(row) => navigate(`${row.id}/edit`)}
|
||||
header={
|
||||
<SearchBar
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Search variants by SKU, name, or product..."
|
||||
/>
|
||||
}
|
||||
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={() => navigate(`${row.id}/edit`)} />
|
||||
@@ -130,15 +91,6 @@ export default function VariantList() {
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
page={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={filteredVariants.length}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
/>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
import { Download, Filter, Search, Activity, Users, Clock, CheckCircle } from "lucide-react";
|
||||
import { Download, Filter, Activity, Users, Clock, CheckCircle } from "lucide-react";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { KPI, KPIGrid } from "../../../components/customs/KPI";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
|
||||
export const AuditUsageTab = () => {
|
||||
const auditLogs = [
|
||||
@@ -11,9 +11,22 @@ export const AuditUsageTab = () => {
|
||||
{ id: 4, action: "Workflow Created", user: "Admin User", date: "2025-03-10 16:45:10", details: "Initial draft created" },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ key: "action", label: "Action", render: (val: string) => <span className="font-medium text-gray-900">{val}</span> },
|
||||
{ key: "user", label: "User", render: (val: string) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-purple-100 text-purple-700 flex items-center justify-center text-xs font-bold">
|
||||
{val.charAt(0)}
|
||||
</div>
|
||||
<span className="text-gray-600">{val}</span>
|
||||
</div>
|
||||
)},
|
||||
{ key: "date", label: "Date & Time", render: (val: string) => <span className="text-gray-500">{val}</span> },
|
||||
{ key: "details", label: "Details", render: (val: string) => <span className="text-gray-600">{val}</span> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
|
||||
<KPIGrid>
|
||||
<KPI value="1,245" label="Items Processed" icon={CheckCircle} variant="green" trendValue="All time" />
|
||||
<KPI value="34" label="Active Users" icon={Users} variant="blue" trendValue="In last 30 days" />
|
||||
@@ -21,56 +34,32 @@ export const AuditUsageTab = () => {
|
||||
<KPI value="98%" label="SLA Compliance" icon={Activity} variant="green" trendValue="Excellent" />
|
||||
</KPIGrid>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-5 border-b border-gray-100 flex justify-between items-center">
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden flex flex-col">
|
||||
<div className="px-6 py-5 border-b border-gray-100 flex justify-between items-center bg-white">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Audit Log</h2>
|
||||
<p className="text-sm text-gray-500">Track all changes made to this workflow configuration</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
className="pl-9 pr-4 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" className="border-gray-200">
|
||||
<Filter className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="outline" className="border-gray-200">
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50/50 border-b border-gray-100">
|
||||
<th className="py-3 px-6 font-medium text-gray-500">Action</th>
|
||||
<th className="py-3 px-6 font-medium text-gray-500">User</th>
|
||||
<th className="py-3 px-6 font-medium text-gray-500">Date & Time</th>
|
||||
<th className="py-3 px-6 font-medium text-gray-500">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{auditLogs.map(log => (
|
||||
<tr key={log.id} className="hover:bg-gray-50">
|
||||
<td className="py-3 px-6 font-medium text-gray-900">{log.action}</td>
|
||||
<td className="py-3 px-6 text-gray-600 flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-purple-100 text-purple-700 flex items-center justify-center text-xs font-bold">
|
||||
{log.user.charAt(0)}
|
||||
</div>
|
||||
{log.user}
|
||||
</td>
|
||||
<td className="py-3 px-6 text-gray-500">{log.date}</td>
|
||||
<td className="py-3 px-6 text-gray-600">{log.details}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="px-6 py-4 border-t border-gray-100 text-center">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={auditLogs}
|
||||
searchPlaceholder="Search logs..."
|
||||
toolbarRight={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="border-gray-200 bg-white">
|
||||
<Filter className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="outline" className="border-gray-200 bg-white">
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-100 text-center bg-white">
|
||||
<button className="text-sm text-purple-600 font-medium hover:text-purple-700">View All Logs</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
|
||||
import { Mail, MessageSquare, Bell, Plus, Settings } from "lucide-react";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
|
||||
|
||||
const MOCK_EVENTS = [
|
||||
{ id: "1", event: "Workflow Created", email: false, slack: false, inApp: true },
|
||||
{ id: "2", event: "Stage Transition Required", email: true, slack: false, inApp: true },
|
||||
{ id: "3", event: "Workflow Completed", email: true, slack: false, inApp: true },
|
||||
{ id: "4", event: "SLA Warning (24h)", email: true, slack: false, inApp: true },
|
||||
];
|
||||
|
||||
export const NotificationsTab = () => {
|
||||
const columns = [
|
||||
{ key: "event", label: "Event", render: (val: string) => <span className="text-gray-700 font-medium">{val}</span> },
|
||||
{ key: "email", label: "Email", render: (val: boolean) => <div className="flex justify-center"><input type="checkbox" className="accent-purple-600 w-4 h-4" defaultChecked={val} /></div> },
|
||||
{ key: "slack", label: "Slack", render: (val: boolean) => <div className="flex justify-center"><input type="checkbox" className="accent-purple-600 w-4 h-4" defaultChecked={val} /></div> },
|
||||
{ key: "inApp", label: "In-App", render: (val: boolean) => <div className="flex justify-center"><input type="checkbox" className="accent-purple-600 w-4 h-4" defaultChecked={val} /></div> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<div className="flex justify-between items-center bg-white rounded-xl border border-gray-200 shadow-sm p-6">
|
||||
@@ -67,44 +79,16 @@ export const NotificationsTab = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-4">Event Triggers</h3>
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 text-gray-500">
|
||||
<th className="pb-3 font-medium">Event</th>
|
||||
<th className="pb-3 font-medium text-center">Email</th>
|
||||
<th className="pb-3 font-medium text-center">Slack</th>
|
||||
<th className="pb-3 font-medium text-center">In-App</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
<tr>
|
||||
<td className="py-3 text-gray-700">Workflow Created</td>
|
||||
<td className="py-3 text-center"><input type="checkbox" className="accent-purple-600" /></td>
|
||||
<td className="py-3 text-center"><input type="checkbox" className="accent-purple-600" /></td>
|
||||
<td className="py-3 text-center"><input type="checkbox" className="accent-purple-600" defaultChecked /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 text-gray-700">Stage Transition Required</td>
|
||||
<td className="py-3 text-center"><input type="checkbox" className="accent-purple-600" defaultChecked /></td>
|
||||
<td className="py-3 text-center"><input type="checkbox" className="accent-purple-600" /></td>
|
||||
<td className="py-3 text-center"><input type="checkbox" className="accent-purple-600" defaultChecked /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 text-gray-700">Workflow Completed</td>
|
||||
<td className="py-3 text-center"><input type="checkbox" className="accent-purple-600" defaultChecked /></td>
|
||||
<td className="py-3 text-center"><input type="checkbox" className="accent-purple-600" /></td>
|
||||
<td className="py-3 text-center"><input type="checkbox" className="accent-purple-600" defaultChecked /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 text-gray-700">SLA Warning (24h)</td>
|
||||
<td className="py-3 text-center"><input type="checkbox" className="accent-purple-600" defaultChecked /></td>
|
||||
<td className="py-3 text-center"><input type="checkbox" className="accent-purple-600" /></td>
|
||||
<td className="py-3 text-center"><input type="checkbox" className="accent-purple-600" defaultChecked /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden flex flex-col">
|
||||
<div className="px-6 py-4 border-b border-gray-100">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Event Triggers</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={MOCK_EVENTS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { MOCK_ROLES, MOCK_PERMISSIONS, STAGE_COLORS } from "../data/mockData";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
|
||||
export const RolePermissionsTab = () => {
|
||||
const [permissions, setPermissions] = useState(MOCK_PERMISSIONS);
|
||||
@@ -26,57 +27,66 @@ export const RolePermissionsTab = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
{permissions.map((stageBlock, si) => (
|
||||
<div key={stageBlock.stage} className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100 flex items-center gap-3">
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-semibold border ${STAGE_COLORS[stageBlock.stage]?.bg ?? 'bg-gray-50'} ${STAGE_COLORS[stageBlock.stage]?.text ?? 'text-gray-700'} ${STAGE_COLORS[stageBlock.stage]?.border ?? 'border-gray-200'}`}
|
||||
>
|
||||
{stageBlock.stage}
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-gray-700">Stage Permissions</h3>
|
||||
{permissions.map((stageBlock, si) => {
|
||||
const columns = [
|
||||
{
|
||||
key: "name",
|
||||
label: "Action",
|
||||
render: (val: string) => <span className="text-sm font-medium text-gray-800">{val}</span>,
|
||||
},
|
||||
...MOCK_ROLES.map((role, ri) => ({
|
||||
key: `role_${ri}`,
|
||||
label: role,
|
||||
render: (_: any, action: any) => {
|
||||
const allowed = action.perms[ri];
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onClick={() => togglePerm(si, action.originalIndex, ri)}
|
||||
className={`w-5 h-5 rounded flex items-center justify-center transition-colors border ${
|
||||
allowed
|
||||
? 'bg-purple-600 border-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-white border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
{allowed && (
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
})),
|
||||
];
|
||||
|
||||
const mappedData = stageBlock.actions.map((action, ai) => ({
|
||||
...action,
|
||||
id: action.name,
|
||||
originalIndex: ai,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div key={stageBlock.stage} className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden flex flex-col">
|
||||
<div className="px-6 py-4 border-b border-gray-100 flex items-center gap-3 bg-white">
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-semibold border ${STAGE_COLORS[stageBlock.stage]?.bg ?? 'bg-gray-50'} ${STAGE_COLORS[stageBlock.stage]?.text ?? 'text-gray-700'} ${STAGE_COLORS[stageBlock.stage]?.border ?? 'border-gray-200'}`}
|
||||
>
|
||||
{stageBlock.stage}
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-gray-700">Stage Permissions</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={mappedData}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="bg-gray-50/60 border-b border-gray-100">
|
||||
<th className="py-3 px-6 text-xs font-bold text-gray-500 uppercase tracking-wider w-40">Action</th>
|
||||
{MOCK_ROLES.map((role) => (
|
||||
<th key={role} className="py-3 px-4 text-xs font-bold text-gray-500 uppercase tracking-wider text-center">
|
||||
{role}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{stageBlock.actions.map((action, ai) => (
|
||||
<tr key={action.name} className="hover:bg-gray-50">
|
||||
<td className="py-3 px-6 text-sm font-medium text-gray-800">{action.name}</td>
|
||||
{action.perms.map((allowed, ri) => (
|
||||
<td key={ri} className="py-3 px-4 text-center">
|
||||
<button
|
||||
onClick={() => togglePerm(si, ai, ri)}
|
||||
className={`w-5 h-5 rounded flex items-center justify-center mx-auto transition-colors border ${
|
||||
allowed
|
||||
? 'bg-purple-600 border-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-white border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
{allowed && (
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,51 @@
|
||||
|
||||
import { Plus, Check, Trash2 } from "lucide-react";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { MOCK_TRANSITIONS, STAGE_COLORS } from "../data/mockData";
|
||||
|
||||
export const StageTransitionsTab = () => {
|
||||
const columns = [
|
||||
{
|
||||
key: "current",
|
||||
label: "CURRENT STAGE",
|
||||
render: (val: string) => (
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${STAGE_COLORS[val].bg} ${STAGE_COLORS[val].text} ${STAGE_COLORS[val].border}`}>
|
||||
{val}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "next",
|
||||
label: "NEXT STAGE",
|
||||
render: (val: string) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${STAGE_COLORS[val].bg} ${STAGE_COLORS[val].text} ${STAGE_COLORS[val].border}`}>
|
||||
{val}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "label",
|
||||
label: "ACTION LABEL",
|
||||
render: (val: string) => <span className="text-sm text-gray-900 font-medium">{val}</span>,
|
||||
},
|
||||
{
|
||||
key: "allowed",
|
||||
label: "ALLOWED",
|
||||
render: () => (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium text-emerald-700 bg-emerald-50 border border-emerald-100">
|
||||
<Check className="w-3 h-3" /> Allowed
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
|
||||
<div className="px-6 py-5 border-b border-gray-100 flex justify-between items-center">
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden flex flex-col">
|
||||
<div className="px-6 py-5 border-b border-gray-100 flex justify-between items-center bg-white">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Stage Transitions</h2>
|
||||
<p className="text-sm text-gray-500">Define valid movement rules between stages</p>
|
||||
@@ -15,47 +54,20 @@ export const StageTransitionsTab = () => {
|
||||
<Plus className="w-4 h-4 mr-2" /> Add Transition
|
||||
</Button>
|
||||
</div>
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 bg-gray-50/50">
|
||||
<th className="py-3 px-6 text-xs font-bold text-gray-500 uppercase tracking-wider">Current Stage</th>
|
||||
<th className="py-3 px-6 text-xs font-bold text-gray-500 uppercase tracking-wider">Next Stage</th>
|
||||
<th className="py-3 px-6 text-xs font-bold text-gray-500 uppercase tracking-wider">Action Label</th>
|
||||
<th className="py-3 px-6 text-xs font-bold text-gray-500 uppercase tracking-wider">Allowed</th>
|
||||
<th className="py-3 px-6 text-xs font-bold text-gray-500 uppercase tracking-wider text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{MOCK_TRANSITIONS.map((t) => (
|
||||
<tr key={t.id} className="hover:bg-gray-50">
|
||||
<td className="py-4 px-6">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${STAGE_COLORS[t.current].bg} ${STAGE_COLORS[t.current].text} ${STAGE_COLORS[t.current].border}`}>
|
||||
{t.current}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${STAGE_COLORS[t.next].bg} ${STAGE_COLORS[t.next].text} ${STAGE_COLORS[t.next].border}`}>
|
||||
{t.next}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-6 text-sm text-gray-900 font-medium">{t.label}</td>
|
||||
<td className="py-4 px-6">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium text-emerald-700 bg-emerald-50 border border-emerald-100">
|
||||
<Check className="w-3 h-3" /> Allowed
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-6 text-right text-gray-400">
|
||||
<button className="p-1.5 hover:bg-white hover:text-red-500 rounded border border-transparent hover:border-gray-200 shadow-sm transition-all">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* WorkflowStatsCard – a premium styled card component mirroring the visual design of ProductCard.
|
||||
* It displays a title, a prominent value, a subtitle, and an optional icon.
|
||||
* The appearance can be themed via the `color` prop which selects a soft background gradient and border.
|
||||
*/
|
||||
interface WorkflowStatsCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle: string;
|
||||
/** Optional icon or image displayed on the right side */
|
||||
icon?: ReactNode;
|
||||
/** Colour theme for the card */
|
||||
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 WorkflowStatsCard({ title, value, subtitle, icon, color }: WorkflowStatsCardProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { ArrowLeft, Save, Check } from "lucide-react";
|
||||
import { Save, Check } from "lucide-react";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
|
||||
import { GeneralInfoTab } from "../components/GeneralInfoTab";
|
||||
import { WorkflowStagesTab } from "../components/WorkflowStagesTab";
|
||||
@@ -22,31 +23,20 @@ export default function NewWorkflow() {
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
{/* Breadcrumb & Header */}
|
||||
<div className="mb-6">
|
||||
<div className="text-sm text-gray-500 mb-2">
|
||||
Masters {'>'} Workflow Management {'>'} Create Workflow
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => navigate('..')} className="p-2 -ml-2 rounded-lg hover:bg-gray-100 transition-colors text-gray-500 hover:text-gray-900">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
|
||||
{isEdit ? 'Edit Workflow' : 'New Workflow'}
|
||||
<span className="px-2.5 py-1 text-xs font-semibold bg-yellow-100 text-yellow-800 rounded-full border border-yellow-200">Draft</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Breadcrumb
|
||||
items={[{ label: 'Home' }, { label: 'Workflows', href: '/workflows' }, { label: isEdit ? 'Edit Workflow' : 'New Workflow' }]}
|
||||
backTo="/workflows"
|
||||
actions={
|
||||
<>
|
||||
<select className="border border-gray-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-purple-500 text-gray-700 font-medium">
|
||||
<option>Draft</option>
|
||||
</select>
|
||||
<Button variant="outline" onClick={() => navigate('..')}>Cancel</Button>
|
||||
<Button className="bg-purple-600 hover:bg-purple-700 text-white" icon={<Save className="w-4 h-4" />}>Save</Button>
|
||||
<Button className="bg-emerald-600 hover:bg-emerald-700 text-white" icon={<Check className="w-4 h-4" />}>Publish</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-6 border-b border-gray-200 mb-6 px-2">
|
||||
@@ -65,7 +55,7 @@ export default function NewWorkflow() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6 h-[calc(100vh-280px)]">
|
||||
<div className="flex gap-6 h-[calc(100vh-220px)]">
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 overflow-y-auto pr-2">
|
||||
{activeTab === "General Information" && <GeneralInfoTab />}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Edit2, Eye, MoreHorizontal, 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 { Table } from "../../../components/customs/Table";
|
||||
import { KPI, KPIGrid } from "../../../components/customs/KPI";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { KPIGrid } from "../../../components/customs/KPI";
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { SearchBar } from "../../../components/customs/SearchBar";
|
||||
import { usePageHeader } from "../../../contexts/HeaderContext";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { WorkflowStatsCard } from "../components/WorkflowStatsCard";
|
||||
|
||||
// Mock Data
|
||||
const MOCK_WORKFLOWS = [
|
||||
@@ -20,9 +18,6 @@ const MOCK_WORKFLOWS = [
|
||||
];
|
||||
|
||||
export default function WorkflowList() {
|
||||
usePageHeader("Workflow Management", "Configure governance and approval workflows for product lifecycle management");
|
||||
const [activeTab, setActiveTab] = useState("All");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const columns = [
|
||||
@@ -123,43 +118,43 @@ export default function WorkflowList() {
|
||||
|
||||
{/* Stats Cards */}
|
||||
<KPIGrid className="mb-6">
|
||||
<KPI value="5" label="Total Workflows" icon={Activity} variant="purple" trendValue="+2 this month" trendDirection="up" />
|
||||
<KPI value="5" label="Active Workflows" icon={CheckCircle} variant="green" dots={[{ color: "green", label: "All healthy" }]} />
|
||||
<KPI value="34" label="Product Families Assigned" icon={Layers} variant="blue" trendValue="Across 5 workflows" />
|
||||
<KPI value="89" label="Pending Approvals" icon={Clock} variant="amber" trendValue="Awaiting action" />
|
||||
<WorkflowStatsCard
|
||||
title="Total Workflows"
|
||||
value="5"
|
||||
subtitle="+2 this month"
|
||||
color="purple"
|
||||
icon={<Activity className="w-5 h-5" />}
|
||||
/>
|
||||
<WorkflowStatsCard
|
||||
title="Active Workflows"
|
||||
value="5"
|
||||
subtitle="All healthy"
|
||||
color="green"
|
||||
icon={<CheckCircle className="w-5 h-5" />}
|
||||
/>
|
||||
<WorkflowStatsCard
|
||||
title="Product Families Assigned"
|
||||
value="34"
|
||||
subtitle="Across 5 workflows"
|
||||
color="blue"
|
||||
icon={<Layers className="w-5 h-5" />}
|
||||
/>
|
||||
<WorkflowStatsCard
|
||||
title="Pending Approvals"
|
||||
value="89"
|
||||
subtitle="Awaiting action"
|
||||
color="orange"
|
||||
icon={<Clock className="w-5 h-5" />}
|
||||
/>
|
||||
</KPIGrid>
|
||||
|
||||
{/* 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 workflows..." />
|
||||
</div>
|
||||
<div className="flex bg-gray-50 p-1 rounded-lg">
|
||||
{['All', 'Active', 'Draft', 'Inactive', 'Archived'].map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${activeTab === tab ? 'bg-purple-600 text-white shadow-sm' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
6 workflows
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Table
|
||||
<div className="mb-8">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={MOCK_WORKFLOWS}
|
||||
actions={actions}
|
||||
variant="flat"
|
||||
searchPlaceholder="Search workflows..."
|
||||
/>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
|
||||
+112
-11
@@ -1,24 +1,125 @@
|
||||
// src/routes/routeConfig.ts
|
||||
export interface RouteConfig {
|
||||
path: string;
|
||||
module?: string;
|
||||
action?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
// The sidebar root path to keep highlighted for this route
|
||||
sidebarRoot?: string;
|
||||
}
|
||||
|
||||
export const protectedRoutes: RouteConfig[] = [
|
||||
// Dashboard
|
||||
{ path: '/dashboard', title: 'Dashboard', description: 'Overview of your PIM platform' },
|
||||
{ path: '/products', module: 'products', action: 'read', title: 'Products', description: 'Manage product catalog' },
|
||||
{ path: '/families', module: 'families', action: 'read', title: 'Product Families', description: 'Manage product families' },
|
||||
{ path: '/variants', module: 'products', action: 'read', title: 'Variants' },
|
||||
{ path: '/categories', module: 'products', action: 'read', title: 'Categories' },
|
||||
{ path: '/attributes', module: 'products', action: 'read', title: 'Attributes' },
|
||||
{ path: '/brands', module: 'masters', action: 'read', title: 'Brands' },
|
||||
{ path: '/units', module: 'masters', action: 'read', title: 'Units' },
|
||||
|
||||
// Products
|
||||
{ path: '/products', title: 'Products', description: 'Manage your product catalog' },
|
||||
{ path: '/products/new', title: 'Create Product', description: 'Select a product family to get started', sidebarRoot: '/products' },
|
||||
{ path: '/products/:id/edit', title: 'Edit Product', description: 'Update product details', sidebarRoot: '/products' },
|
||||
|
||||
// Families
|
||||
{ path: '/families', title: 'Product Families', description: 'Manage reusable product templates' },
|
||||
{ path: '/families/new', title: 'Create Product Family', description: 'Define a reusable product template', sidebarRoot: '/families' },
|
||||
{ path: '/families/:id/edit', title: 'Edit Product Family', description: 'Update product family details', sidebarRoot: '/families' },
|
||||
|
||||
// Variants
|
||||
{ path: '/variants', title: 'Variant Management', description: 'Manage product SKU variants' },
|
||||
{ path: '/variants/new', title: 'Create Variant', description: 'Add a new SKU variant to a product', sidebarRoot: '/variants' },
|
||||
{ path: '/variants/:id/edit', title: 'Edit Variant', description: 'Update variant details', sidebarRoot: '/variants' },
|
||||
|
||||
// Categories
|
||||
{ path: '/categories', title: 'Category Trees', description: 'Manage product category hierarchies' },
|
||||
{ path: '/categories/new', title: 'Create Category', description: 'Add a new category to the tree', sidebarRoot: '/categories' },
|
||||
{ path: '/categories/:id/edit', title: 'Edit Category', description: 'Update category details', sidebarRoot: '/categories' },
|
||||
|
||||
// Attributes
|
||||
{ path: '/attributes', title: 'Attribute Registry', description: 'Manage product attributes' },
|
||||
{ path: '/attributes/new', title: 'Create Attribute', description: 'Add a new attribute to describe your products', sidebarRoot: '/attributes' },
|
||||
{ path: '/attributes/:id/edit', title: 'Edit Attribute', description: 'Update attribute details', sidebarRoot: '/attributes' },
|
||||
|
||||
// Attribute Groups
|
||||
{ path: '/attribute-groups', title: 'Attribute Groups', description: 'Organize attributes into logical groups' },
|
||||
{ path: '/attribute-groups/new', title: 'Create Attribute Group', description: 'Add a new attribute group', sidebarRoot: '/attribute-groups' },
|
||||
{ path: '/attribute-groups/:id/edit', title: 'Edit Attribute Group', description: 'Update attribute group details', sidebarRoot: '/attribute-groups' },
|
||||
|
||||
// Brands
|
||||
{ path: '/brands', title: 'Brands', description: 'Manage product brands' },
|
||||
{ path: '/brands/new', title: 'Create Brand', description: 'Add a new brand', sidebarRoot: '/brands' },
|
||||
{ path: '/brands/:id/edit', title: 'Edit Brand', description: 'Update brand details', sidebarRoot: '/brands' },
|
||||
|
||||
// Units
|
||||
{ path: '/units', title: 'Units of Measure', description: 'Manage measurement units' },
|
||||
{ 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/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' },
|
||||
|
||||
// Asset Types
|
||||
{ path: '/asset-types', title: 'Asset Types', description: 'Define types of digital assets' },
|
||||
{ path: '/asset-types/new', title: 'Create Asset Type', description: 'Add a new asset type', sidebarRoot: '/asset-types' },
|
||||
{ path: '/asset-types/:id/edit', title: 'Edit Asset Type', description: 'Update asset type details', sidebarRoot: '/asset-types' },
|
||||
|
||||
// Asset Families
|
||||
{ path: '/asset-families', title: 'Asset Families', description: 'Group asset types into families' },
|
||||
{ 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/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' },
|
||||
|
||||
// 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' },
|
||||
{ path: '/integrations/:id/edit', title: 'Edit Integration', description: 'Update integration details', sidebarRoot: '/integrations' },
|
||||
|
||||
// Users
|
||||
{ path: '/users', title: 'Users & Roles', description: 'Manage team members, permissions, and access controls' },
|
||||
{ path: '/users/new', title: 'Create User', description: 'Add a new team member', sidebarRoot: '/users' },
|
||||
{ path: '/users/:id/edit', title: 'Edit User', description: 'Update user details and permissions', sidebarRoot: '/users' },
|
||||
|
||||
// Reports
|
||||
{ path: '/reports', title: 'Reports & Analytics', description: 'Comprehensive insights into your product catalog performance' },
|
||||
{ path: '/reports/new', title: 'Create Report', description: 'Build a new analytics report', sidebarRoot: '/reports' },
|
||||
|
||||
// Settings
|
||||
{ path: '/settings', title: 'Settings', description: 'System configurations and preferences' },
|
||||
{ path: '/settings/new', title: 'New Setting', description: 'Add a new system configuration', sidebarRoot: '/settings' },
|
||||
];
|
||||
|
||||
export const publicRoutes: RouteConfig[] = [
|
||||
{ path: '/', title: 'Home' },
|
||||
{ path: '/login', title: 'Login' },
|
||||
];
|
||||
];
|
||||
|
||||
/** Match a pathname against route config patterns (supports :param segments). */
|
||||
export function matchRoute(pathname: string): RouteConfig | undefined {
|
||||
// Exact match first
|
||||
const exact = protectedRoutes.find((r) => r.path === pathname);
|
||||
if (exact) return exact;
|
||||
|
||||
// Pattern match — replace :param with a segment regex
|
||||
return protectedRoutes.find((r) => {
|
||||
if (!r.path.includes(':')) return false;
|
||||
const regex = new RegExp(
|
||||
'^' + r.path.replace(/:[^/]+/g, '[^/]+') + '$'
|
||||
);
|
||||
return regex.test(pathname);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user