Merge pull request #10 from MohamedHasan07/hasan_frontend

27/6/2026 correction in ui
This commit is contained in:
MohamedHasan07
2026-06-27 17:35:38 +05:30
committed by GitHub
64 changed files with 2376 additions and 2188 deletions
-45
View File
@@ -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>
);
}
+4 -1
View File
@@ -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}`}
+257
View File
@@ -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>
);
}
+35 -55
View File
@@ -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>
);
}
}
+38 -98
View File
@@ -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))
);
};
+33 -27
View File
@@ -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
View File
@@ -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>
);
}
}
+47 -29
View File
@@ -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 && (
+3 -18
View File
@@ -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>
+15 -7
View File
@@ -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" />
+12 -26
View File
@@ -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>
);
}
+110 -117
View File
@@ -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>
);
+13 -26
View File
@@ -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>
);
+50 -72
View File
@@ -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>
)}
+13 -25
View File
@@ -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">
+11 -59
View File
@@ -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>
+15 -31
View File
@@ -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>
+14 -52
View File
@@ -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>
+12 -26
View File
@@ -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">
+7 -53
View File
@@ -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>
+14 -31
View File
@@ -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>
);
}
+48 -37
View File
@@ -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>
+20 -21
View File
@@ -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" }]} />
+29 -71
View File
@@ -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>
);
+59 -124
View File
@@ -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>
);
}
+29 -28
View File
@@ -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 */}
+13 -25
View File
@@ -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>
);
}
+27 -40
View File
@@ -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>
);
}
+106 -192
View File
@@ -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>
);
}
}
+13 -25
View File
@@ -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>
);
+1 -3
View File
@@ -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" }]} />
+13 -25
View File
@@ -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>
);
+4 -19
View File
@@ -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>
);
}
+13 -25
View File
@@ -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);
+2 -1
View File
@@ -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>
))}
+13 -25
View File
@@ -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>
);
-3
View File
@@ -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>
);
}
+13 -25
View File
@@ -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>
);
+38 -48
View File
@@ -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>
)}
+13 -25
View File
@@ -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>
);
+7 -55
View File
@@ -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>
);
}
+11 -21
View File
@@ -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 />}
+34 -39
View File
@@ -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
View File
@@ -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);
});
}