feat: init drizzle implementation for backend (express) and frontend (vite-react)
This commit is contained in:
parent
fc3a21263a
commit
aca6a4da98
17 changed files with 1608 additions and 35 deletions
344
packages/ui/src/components/template/create-form.tsx
Normal file
344
packages/ui/src/components/template/create-form.tsx
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
import React, { useState } from "react";
|
||||
import { Button } from "../button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../card";
|
||||
import { cn } from "@repo/ui/lib/utils";
|
||||
|
||||
// Base interface for form fields
|
||||
export interface FormField {
|
||||
key: string;
|
||||
label: string;
|
||||
type:
|
||||
| "text"
|
||||
| "number"
|
||||
| "email"
|
||||
| "password"
|
||||
| "textarea"
|
||||
| "url"
|
||||
| "tel"
|
||||
| "date"
|
||||
| "select"
|
||||
| "checkbox";
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
options?: { value: string; label: string }[]; // For select fields
|
||||
validation?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
pattern?: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
};
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
helpText?: string;
|
||||
}
|
||||
|
||||
// Generic form data interface
|
||||
export interface FormData {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface CreateFormProps {
|
||||
title: string;
|
||||
fields: FormField[];
|
||||
onSubmit: (data: FormData) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
loading?: boolean;
|
||||
submitText?: string;
|
||||
cancelText?: string;
|
||||
className?: string;
|
||||
initialData?: FormData;
|
||||
}
|
||||
|
||||
export function CreateForm({
|
||||
title,
|
||||
fields,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
loading = false,
|
||||
submitText = "Create",
|
||||
cancelText = "Cancel",
|
||||
className,
|
||||
initialData = {},
|
||||
}: CreateFormProps) {
|
||||
const [formData, setFormData] = useState<FormData>(() => {
|
||||
const initial: FormData = {};
|
||||
fields.forEach((field) => {
|
||||
initial[field.key] =
|
||||
initialData[field.key] ||
|
||||
(field.type === "number"
|
||||
? 0
|
||||
: field.type === "checkbox"
|
||||
? false
|
||||
: "");
|
||||
});
|
||||
return initial;
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const validateField = (field: FormField, value: any): string | null => {
|
||||
if (
|
||||
field.required &&
|
||||
(!value || (typeof value === "string" && !value.trim()))
|
||||
) {
|
||||
return `${field.label} is required`;
|
||||
}
|
||||
|
||||
if (field.validation) {
|
||||
const { min, max, pattern, minLength, maxLength } =
|
||||
field.validation;
|
||||
|
||||
if (field.type === "number" && typeof value === "number") {
|
||||
if (min !== undefined && value < min) {
|
||||
return `${field.label} must be at least ${min}`;
|
||||
}
|
||||
if (max !== undefined && value > max) {
|
||||
return `${field.label} must be at most ${max}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (minLength !== undefined && value.length < minLength) {
|
||||
return `${field.label} must be at least ${minLength} characters`;
|
||||
}
|
||||
if (maxLength !== undefined && value.length > maxLength) {
|
||||
return `${field.label} must be at most ${maxLength} characters`;
|
||||
}
|
||||
if (pattern && !new RegExp(pattern).test(value)) {
|
||||
return `${field.label} format is invalid`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
fields.forEach((field) => {
|
||||
const error = validateField(field, formData[field.key]);
|
||||
if (error) {
|
||||
newErrors[field.key] = error;
|
||||
}
|
||||
});
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleInputChange = (key: string, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[key]) {
|
||||
setErrors((prev) => ({ ...prev, [key]: "" }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(formData);
|
||||
|
||||
// Reset form on success
|
||||
const resetData: FormData = {};
|
||||
fields.forEach((field) => {
|
||||
resetData[field.key] =
|
||||
field.type === "number"
|
||||
? 0
|
||||
: field.type === "checkbox"
|
||||
? false
|
||||
: "";
|
||||
});
|
||||
setFormData(resetData);
|
||||
setErrors({});
|
||||
} catch (error: any) {
|
||||
setErrors({ submit: error.message || "An error occurred" });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderField = (field: FormField) => {
|
||||
const isFieldDisabled = loading || isSubmitting || field.disabled;
|
||||
const fieldError = errors[field.key];
|
||||
|
||||
const baseInputClasses = cn(
|
||||
"w-full mt-1 p-2 border rounded-md transition-colors",
|
||||
"focus:ring-2 focus:ring-blue-500 focus:border-transparent",
|
||||
fieldError ? "border-red-500" : "border-gray-300",
|
||||
isFieldDisabled && "opacity-50 cursor-not-allowed",
|
||||
field.className
|
||||
);
|
||||
|
||||
switch (field.type) {
|
||||
case "textarea":
|
||||
return (
|
||||
<textarea
|
||||
className={baseInputClasses}
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(e) =>
|
||||
handleInputChange(field.key, e.target.value)
|
||||
}
|
||||
disabled={isFieldDisabled}
|
||||
placeholder={field.placeholder}
|
||||
rows={3}
|
||||
/>
|
||||
);
|
||||
|
||||
case "select":
|
||||
return (
|
||||
<select
|
||||
className={baseInputClasses}
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(e) =>
|
||||
handleInputChange(field.key, e.target.value)
|
||||
}
|
||||
disabled={isFieldDisabled}
|
||||
>
|
||||
<option value="">
|
||||
{field.placeholder || `Select ${field.label}`}
|
||||
</option>
|
||||
{field.options?.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
case "checkbox":
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
checked={formData[field.key] || false}
|
||||
onChange={(e) =>
|
||||
handleInputChange(field.key, e.target.checked)
|
||||
}
|
||||
disabled={isFieldDisabled}
|
||||
/>
|
||||
<label className="ml-2 text-sm text-gray-700">
|
||||
{field.placeholder || field.label}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
className={baseInputClasses}
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
field.key,
|
||||
parseFloat(e.target.value) || 0
|
||||
)
|
||||
}
|
||||
disabled={isFieldDisabled}
|
||||
placeholder={field.placeholder}
|
||||
min={field.validation?.min}
|
||||
max={field.validation?.max}
|
||||
step={field.type === "number" ? "any" : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<input
|
||||
type={field.type}
|
||||
className={baseInputClasses}
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(e) =>
|
||||
handleInputChange(field.key, e.target.value)
|
||||
}
|
||||
disabled={isFieldDisabled}
|
||||
placeholder={field.placeholder}
|
||||
pattern={field.validation?.pattern}
|
||||
minLength={field.validation?.minLength}
|
||||
maxLength={field.validation?.maxLength}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{fields.map((field) => (
|
||||
<div key={field.key}>
|
||||
{field.type !== "checkbox" && (
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{field.label}
|
||||
{field.required && (
|
||||
<span className="text-red-500 ml-1">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{renderField(field)}
|
||||
|
||||
{errors[field.key] && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{errors[field.key]}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{field.helpText && (
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{field.helpText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{errors.submit && (
|
||||
<div className="text-red-600 text-sm p-2 bg-red-50 rounded">
|
||||
{errors.submit}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1"
|
||||
>
|
||||
{cancelText}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1"
|
||||
>
|
||||
{isSubmitting ? "Saving..." : submitText}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export type { CreateFormProps };
|
||||
493
packages/ui/src/components/template/table-show.tsx
Normal file
493
packages/ui/src/components/template/table-show.tsx
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "../card";
|
||||
import { Button } from "../button";
|
||||
import { cn } from "@repo/ui/lib/utils";
|
||||
|
||||
// Base interface that all table records should extend
|
||||
export interface BaseRecord {
|
||||
id: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// Pagination information interface
|
||||
export interface PaginationInfo {
|
||||
page: number;
|
||||
limit: number;
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
}
|
||||
|
||||
// Paginated response interface
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
|
||||
// Configuration for how to display fields
|
||||
export interface FieldConfig<T = any> {
|
||||
key: keyof T;
|
||||
label: string;
|
||||
type?: "text" | "currency" | "date" | "boolean" | "image" | "status";
|
||||
format?: (value: any) => string;
|
||||
className?: string;
|
||||
hide?: boolean;
|
||||
}
|
||||
|
||||
// Configuration for actions
|
||||
export interface ActionConfig<T = any> {
|
||||
label: string;
|
||||
variant?:
|
||||
| "default"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "secondary"
|
||||
| "ghost"
|
||||
| "link";
|
||||
size?: "default" | "sm" | "lg" | "icon";
|
||||
onClick: (record: T) => void;
|
||||
className?: string;
|
||||
show?: (record: T) => boolean;
|
||||
}
|
||||
|
||||
// Pagination controls component
|
||||
interface PaginationControlsProps {
|
||||
pagination: PaginationInfo;
|
||||
onPageChange: (page: number) => void;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PaginationControls({
|
||||
pagination,
|
||||
onPageChange,
|
||||
loading = false,
|
||||
className,
|
||||
}: PaginationControlsProps) {
|
||||
const {
|
||||
page,
|
||||
totalPages,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
totalCount,
|
||||
limit,
|
||||
} = pagination;
|
||||
|
||||
// Calculate visible page numbers
|
||||
const getVisiblePages = () => {
|
||||
const delta = 2; // Number of pages to show on each side of current page
|
||||
const range = [];
|
||||
const rangeWithDots = [];
|
||||
|
||||
for (
|
||||
let i = Math.max(2, page - delta);
|
||||
i <= Math.min(totalPages - 1, page + delta);
|
||||
i++
|
||||
) {
|
||||
range.push(i);
|
||||
}
|
||||
|
||||
if (page - delta > 2) {
|
||||
rangeWithDots.push(1, "...");
|
||||
} else {
|
||||
rangeWithDots.push(1);
|
||||
}
|
||||
|
||||
rangeWithDots.push(...range);
|
||||
|
||||
if (page + delta < totalPages - 1) {
|
||||
rangeWithDots.push("...", totalPages);
|
||||
} else if (totalPages > 1) {
|
||||
rangeWithDots.push(totalPages);
|
||||
}
|
||||
|
||||
return rangeWithDots;
|
||||
};
|
||||
|
||||
const startItem = (page - 1) * limit + 1;
|
||||
const endItem = Math.min(page * limit, totalCount);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col sm:flex-row items-center justify-between gap-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Results info */}
|
||||
<div className="text-sm text-gray-700">
|
||||
Showing {startItem} to {endItem} of {totalCount} results
|
||||
</div>
|
||||
|
||||
{/* Pagination controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Previous button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={!hasPreviousPage || loading}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
{/* Page numbers */}
|
||||
<div className="flex items-center gap-1">
|
||||
{getVisiblePages().map((pageNum, index) => (
|
||||
<span key={index}>
|
||||
{pageNum === "..." ? (
|
||||
<span className="px-2 py-1 text-gray-500">
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant={
|
||||
pageNum === page ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onPageChange(pageNum as number)
|
||||
}
|
||||
disabled={loading}
|
||||
className="min-w-[2.5rem]"
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Next button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={!hasNextPage || loading}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TableShowProps<T extends BaseRecord> {
|
||||
record: T;
|
||||
title: string | ((record: T) => string);
|
||||
fields: FieldConfig<T>[];
|
||||
actions?: ActionConfig<T>[];
|
||||
className?: string;
|
||||
showId?: boolean;
|
||||
statusField?: keyof T;
|
||||
imageField?: keyof T;
|
||||
}
|
||||
|
||||
export function TableShow<T extends BaseRecord>({
|
||||
record,
|
||||
title,
|
||||
fields,
|
||||
actions = [],
|
||||
className,
|
||||
showId = true,
|
||||
statusField,
|
||||
imageField,
|
||||
}: TableShowProps<T>) {
|
||||
const getTitle = () => {
|
||||
return typeof title === "function" ? title(record) : title;
|
||||
};
|
||||
|
||||
const formatValue = (value: any, field: FieldConfig<T>) => {
|
||||
if (value === null || value === undefined) return "N/A";
|
||||
|
||||
if (field.format) {
|
||||
return field.format(value);
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
case "currency":
|
||||
return `$${Number(value).toFixed(2)}`;
|
||||
case "date":
|
||||
return new Date(value).toLocaleDateString();
|
||||
case "boolean":
|
||||
return value ? "Yes" : "No";
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusComponent = () => {
|
||||
if (!statusField) return null;
|
||||
|
||||
const status = record[statusField];
|
||||
const isPublished =
|
||||
status === true || status === "published" || status === "active";
|
||||
|
||||
if (isPublished) return null;
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded-full">
|
||||
{status === false || status === "draft"
|
||||
? "Draft"
|
||||
: String(status)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getImageComponent = () => {
|
||||
if (!imageField) return null;
|
||||
|
||||
const imageUrl = record[imageField] as string;
|
||||
if (!imageUrl) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full h-48 bg-gray-100 rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={getTitle()}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const visibleActions = actions.filter(
|
||||
(action) => !action.show || action.show(record)
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className={cn("w-full max-w-md", className)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-bold">
|
||||
{getTitle()}
|
||||
</CardTitle>
|
||||
{getStatusComponent()}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{getImageComponent()}
|
||||
|
||||
<div className="space-y-2">
|
||||
{fields
|
||||
.filter((field) => !field.hide)
|
||||
.map((field) => {
|
||||
const value = record[field.key];
|
||||
|
||||
if (field.type === "image") {
|
||||
return value ? (
|
||||
<div
|
||||
key={String(field.key)}
|
||||
className="w-full"
|
||||
>
|
||||
<img
|
||||
src={String(value)}
|
||||
alt={field.label}
|
||||
className="w-full h-32 object-cover rounded"
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={String(field.key)}
|
||||
className={cn(
|
||||
"flex justify-between items-start",
|
||||
field.className
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
{field.label}:
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
field.type === "currency" &&
|
||||
"font-bold text-green-600"
|
||||
)}
|
||||
>
|
||||
{formatValue(value, field)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{showId && (
|
||||
<div className="flex justify-between items-center pt-2 border-t">
|
||||
<span className="text-xs text-gray-500">ID:</span>
|
||||
<span className="text-xs text-gray-500 font-mono">
|
||||
{record.id}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visibleActions.length > 0 && (
|
||||
<div className="flex gap-2 pt-4">
|
||||
{visibleActions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant || "outline"}
|
||||
size={action.size || "sm"}
|
||||
onClick={() => action.onClick(record)}
|
||||
className={cn("flex-1", action.className)}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Updated Grid version for paginated records
|
||||
interface TableGridProps<T extends BaseRecord> {
|
||||
paginatedData: PaginatedResponse<T>;
|
||||
title: string | ((record: T) => string);
|
||||
fields: FieldConfig<T>[];
|
||||
actions?: ActionConfig<T>[];
|
||||
onPageChange: (page: number) => void;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
showId?: boolean;
|
||||
statusField?: keyof T;
|
||||
imageField?: keyof T;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
export function TableGrid<T extends BaseRecord>({
|
||||
paginatedData,
|
||||
title,
|
||||
fields,
|
||||
actions,
|
||||
onPageChange,
|
||||
loading = false,
|
||||
className,
|
||||
showId,
|
||||
statusField,
|
||||
imageField,
|
||||
emptyMessage = "No records found.",
|
||||
}: TableGridProps<T>) {
|
||||
const { data: records, pagination } = paginatedData;
|
||||
|
||||
if (records.length === 0 && !loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
{pagination.totalCount > 0 && (
|
||||
<PaginationControls
|
||||
pagination={pagination}
|
||||
onPageChange={onPageChange}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Loading state overlay */}
|
||||
{loading && (
|
||||
<div className="text-center py-4">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid of records */}
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6",
|
||||
loading && "opacity-50 pointer-events-none",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{records.map((record) => (
|
||||
<TableShow
|
||||
key={record.id}
|
||||
record={record}
|
||||
title={title}
|
||||
fields={fields}
|
||||
actions={actions}
|
||||
showId={showId}
|
||||
statusField={statusField}
|
||||
imageField={imageField}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination controls */}
|
||||
{pagination.totalPages > 1 && (
|
||||
<PaginationControls
|
||||
pagination={pagination}
|
||||
onPageChange={onPageChange}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple non-paginated grid for backward compatibility
|
||||
interface SimpleTableGridProps<T extends BaseRecord> {
|
||||
records: T[];
|
||||
title: string | ((record: T) => string);
|
||||
fields: FieldConfig<T>[];
|
||||
actions?: ActionConfig<T>[];
|
||||
className?: string;
|
||||
showId?: boolean;
|
||||
statusField?: keyof T;
|
||||
imageField?: keyof T;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
export function SimpleTableGrid<T extends BaseRecord>({
|
||||
records,
|
||||
title,
|
||||
fields,
|
||||
actions,
|
||||
className,
|
||||
showId,
|
||||
statusField,
|
||||
imageField,
|
||||
emptyMessage = "No records found.",
|
||||
}: SimpleTableGridProps<T>) {
|
||||
if (records.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">{emptyMessage}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{records.map((record) => (
|
||||
<TableShow
|
||||
key={record.id}
|
||||
record={record}
|
||||
title={title}
|
||||
fields={fields}
|
||||
actions={actions}
|
||||
showId={showId}
|
||||
statusField={statusField}
|
||||
imageField={imageField}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type {
|
||||
TableShowProps,
|
||||
TableGridProps,
|
||||
SimpleTableGridProps,
|
||||
PaginationControlsProps,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue