Initial commit - Route Commerce platform
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
type Column<T> = {
|
||||
key: keyof T | string;
|
||||
header: string;
|
||||
render?: (item: T) => React.ReactNode;
|
||||
className?: string;
|
||||
align?: "left" | "right" | "center";
|
||||
};
|
||||
|
||||
type DataTableProps<T> = {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
keyExtractor: (item: T) => string;
|
||||
emptyMessage?: string;
|
||||
onRowClick?: (item: T) => void;
|
||||
rowClassName?: string;
|
||||
paginate?: boolean;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
export default function DataTable<T>({
|
||||
data,
|
||||
columns,
|
||||
keyExtractor,
|
||||
emptyMessage = "No data found",
|
||||
onRowClick,
|
||||
rowClassName,
|
||||
paginate = false,
|
||||
pageSize = 50,
|
||||
}: DataTableProps<T>) {
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const displayed = paginate ? data.slice(page * pageSize, (page + 1) * pageSize) : data;
|
||||
const totalPages = Math.ceil(data.length / pageSize);
|
||||
|
||||
const alignClass = (align?: "left" | "right" | "center") => {
|
||||
switch (align) {
|
||||
case "right": return "text-right";
|
||||
case "center": return "text-center";
|
||||
default: return "text-left";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg bg-white border border-stone-200 shadow-sm">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-stone-200 bg-stone-50">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={String(col.key)}
|
||||
className={`px-4 py-3 font-semibold text-stone-500 ${alignClass(col.align)} ${col.className ?? ""}`}
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayed.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-16 text-center text-sm text-stone-500">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
displayed.map((item) => (
|
||||
<tr
|
||||
key={keyExtractor(item)}
|
||||
className={`border-b border-stone-100 last:border-0 hover:bg-stone-50 transition-colors ${
|
||||
onRowClick ? "cursor-pointer" : ""
|
||||
} ${rowClassName ?? ""}`}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={String(col.key)}
|
||||
className={`px-4 py-3 ${alignClass(col.align)} ${col.className ?? ""}`}
|
||||
>
|
||||
{col.render
|
||||
? col.render(item)
|
||||
: String((item as Record<string, unknown>)[col.key as string] ?? "")}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{paginate && totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-stone-200">
|
||||
<span className="text-xs text-stone-500">
|
||||
Page {page + 1} of {totalPages}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
type FilterOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type FilterBarProps = {
|
||||
searchPlaceholder?: string;
|
||||
searchValue: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
filters?: {
|
||||
label?: string;
|
||||
options: FilterOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}[];
|
||||
tabs?: {
|
||||
label: string;
|
||||
value: string;
|
||||
count?: number;
|
||||
}[];
|
||||
activeTab?: string;
|
||||
onTabChange?: (value: string) => void;
|
||||
actions?: React.ReactNode;
|
||||
resultCount?: number;
|
||||
showCount?: boolean;
|
||||
};
|
||||
|
||||
export default function FilterBar({
|
||||
searchPlaceholder = "Search...",
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
filters = [],
|
||||
tabs = [],
|
||||
activeTab,
|
||||
onTabChange,
|
||||
actions,
|
||||
resultCount,
|
||||
showCount = true,
|
||||
}: FilterBarProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Search + Filters Row */}
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<div className="flex-1 min-w-48">
|
||||
<input
|
||||
type="search"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-emerald-500 placeholder:text-stone-400 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filters.map((filter, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
{filter.label && (
|
||||
<label className="text-xs text-stone-500 font-medium pl-1">{filter.label}</label>
|
||||
)}
|
||||
<select
|
||||
value={filter.value}
|
||||
onChange={(e) => filter.onChange(e.target.value)}
|
||||
className="rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-700 outline-none focus:border-emerald-500 transition-colors min-w-[140px]"
|
||||
>
|
||||
{filter.options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
|
||||
{showCount && resultCount !== undefined && (
|
||||
<span className="text-sm text-stone-500 px-2 py-2">{resultCount} results</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs Row */}
|
||||
{tabs.length > 0 && (
|
||||
<div className="flex gap-1 border-b border-stone-200 pb-0">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
onClick={() => onTabChange?.(tab.value)}
|
||||
className={`
|
||||
px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px
|
||||
${activeTab === tab.value
|
||||
? "text-emerald-600 border-emerald-600"
|
||||
: "text-stone-500 border-transparent hover:text-stone-700 hover:border-stone-300"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.count !== undefined && (
|
||||
<span className={`ml-2 text-xs ${activeTab === tab.value ? "text-emerald-500" : "text-stone-400"}`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
type PageHeaderProps = {
|
||||
breadcrumb?: { label: string; href?: string }[];
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function PageHeader({ breadcrumb, title, description, action }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
{breadcrumb && breadcrumb.length > 0 && (
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-4">
|
||||
{breadcrumb.map((crumb, i) => (
|
||||
<span key={i} className="flex items-center gap-2">
|
||||
{crumb.href ? (
|
||||
<Link href={crumb.href} className="hover:text-stone-700 transition-colors">
|
||||
{crumb.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-stone-600">{crumb.label}</span>
|
||||
)}
|
||||
{i < breadcrumb.length - 1 && <span>/</span>}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-stone-950 tracking-tight">{title}</h1>
|
||||
{description && (
|
||||
<p className="mt-1.5 text-sm text-stone-500">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
type StatusBadgeProps = {
|
||||
status: string;
|
||||
size?: "sm" | "md";
|
||||
};
|
||||
|
||||
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
||||
// Active/Enabled states
|
||||
active: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Active" },
|
||||
enabled: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Enabled" },
|
||||
published: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Published" },
|
||||
completed: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Completed" },
|
||||
picked_up: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Picked Up" },
|
||||
delivered: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Delivered" },
|
||||
|
||||
// Pending/Warning states
|
||||
pending: { bg: "bg-amber-100", text: "text-amber-700", label: "Pending" },
|
||||
draft: { bg: "bg-amber-100", text: "text-amber-700", label: "Draft" },
|
||||
processing: { bg: "bg-amber-100", text: "text-amber-700", label: "Processing" },
|
||||
open: { bg: "bg-amber-100", text: "text-amber-700", label: "Open" },
|
||||
in_transit: { bg: "bg-amber-100", text: "text-amber-700", label: "In Transit" },
|
||||
|
||||
// Inactive/Disabled states
|
||||
inactive: { bg: "bg-stone-100", text: "text-stone-600", label: "Inactive" },
|
||||
disabled: { bg: "bg-stone-100", text: "text-stone-600", label: "Disabled" },
|
||||
archived: { bg: "bg-stone-100", text: "text-stone-600", label: "Archived" },
|
||||
|
||||
// Info states
|
||||
at_shed: { bg: "bg-blue-100", text: "text-blue-700", label: "At Shed" },
|
||||
packed: { bg: "bg-purple-100", text: "text-purple-700", label: "Packed" },
|
||||
square: { bg: "bg-purple-100", text: "text-purple-700", label: "Square" },
|
||||
|
||||
// Special states
|
||||
core: { bg: "bg-emerald-50", text: "text-emerald-600", label: "Core" },
|
||||
addon: { bg: "bg-amber-50", text: "text-amber-600", label: "Add-on" },
|
||||
};
|
||||
|
||||
export default function StatusBadge({ status, size = "md" }: StatusBadgeProps) {
|
||||
const config = STATUS_STYLES[status] ?? {
|
||||
bg: "bg-stone-100",
|
||||
text: "text-stone-600",
|
||||
label: status.replace(/_/g, " "),
|
||||
};
|
||||
|
||||
const padding = size === "sm" ? "px-2 py-0.5 text-[10px]" : "px-3 py-1 text-xs";
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full border font-medium ${config.bg} ${config.text} border-transparent ${padding}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as PageHeader } from "./PageHeader";
|
||||
export { default as StatusBadge } from "./StatusBadge";
|
||||
export { default as FilterBar } from "./FilterBar";
|
||||
export { default as DataTable } from "./DataTable";
|
||||
Reference in New Issue
Block a user