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
|
|
@ -1,6 +1,17 @@
|
|||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config({ path: ".env" });
|
||||
|
||||
const client = postgres(process.env.DATABASE_URL!);
|
||||
let db_url = process.env.DATABASE_URL!;
|
||||
try {
|
||||
db_url = decodeURI(db_url);
|
||||
} catch (e) {
|
||||
console.warn("Failed to decode DATABASE_URL, using as is.");
|
||||
}
|
||||
|
||||
console.log("Connecting to database URL:", db_url);
|
||||
|
||||
const client = postgres(db_url!);
|
||||
|
||||
export const db = drizzle(client);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { count, desc } from "drizzle-orm";
|
||||
import { db } from "../database";
|
||||
import { InsertProduct, products } from "../schema/products";
|
||||
|
||||
|
|
@ -5,6 +6,28 @@ export async function createProduct(data: InsertProduct) {
|
|||
await db.insert(products).values(data);
|
||||
}
|
||||
|
||||
export async function getProducts() {
|
||||
return db.select().from(products);
|
||||
export async function getProducts(page: number = 1, limit: number = 10) {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const totalCountResult = await db.select({ count: count() }).from(products);
|
||||
const totalCount = totalCountResult[0].count;
|
||||
|
||||
const productsList = await db
|
||||
.select()
|
||||
.from(products)
|
||||
.orderBy(desc(products.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return {
|
||||
data: productsList,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
totalCount,
|
||||
totalPages: Math.ceil(totalCount / limit),
|
||||
hasNextPage: page < Math.ceil(totalCount / limit),
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
import { db } from "../database";
|
||||
import { InsertUser, user } from "../schema/users";
|
||||
import { InsertUser, users } from "../schema/users";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export async function createUser(data: InsertUser) {
|
||||
await db.insert(user).values(data);
|
||||
return await db.insert(users).values(data);
|
||||
}
|
||||
|
||||
export async function getUsers() {
|
||||
return await db.select().from(users);
|
||||
}
|
||||
export async function getUserById(id: string) {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, id))
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
|
|
|||
130
packages/ui/src/components/products/create.tsx
Normal file
130
packages/ui/src/components/products/create.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import React from "react";
|
||||
import { CreateForm, FormField, FormData } from "../template/create-form";
|
||||
|
||||
// Product-specific form fields configuration based on database schema
|
||||
const productFields: FormField[] = [
|
||||
{
|
||||
key: "name",
|
||||
label: "Product Name",
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "Enter product name",
|
||||
validation: {
|
||||
minLength: 2,
|
||||
maxLength: 255,
|
||||
},
|
||||
helpText: "A clear, descriptive name for your product",
|
||||
},
|
||||
{
|
||||
key: "price",
|
||||
label: "Price",
|
||||
type: "number",
|
||||
required: true,
|
||||
placeholder: "0.00",
|
||||
validation: {
|
||||
min: 0,
|
||||
max: 99999999.99, // Based on numeric(10, 2) from schema
|
||||
},
|
||||
helpText: "Price in USD (up to 8 digits before decimal, 2 after)",
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
label: "Description",
|
||||
type: "textarea",
|
||||
placeholder: "Enter detailed product description",
|
||||
validation: {
|
||||
maxLength: 2000,
|
||||
},
|
||||
helpText: "Detailed description of the product features and benefits",
|
||||
},
|
||||
{
|
||||
key: "imageUrl",
|
||||
label: "Image URL",
|
||||
type: "url",
|
||||
placeholder: "https://example.com/product-image.jpg",
|
||||
helpText: "URL to the main product image",
|
||||
},
|
||||
{
|
||||
key: "isPublished",
|
||||
label: "Published Status",
|
||||
type: "checkbox",
|
||||
placeholder: "Make this product visible to customers",
|
||||
helpText: "Uncheck to save as draft (defaults to false)",
|
||||
},
|
||||
];
|
||||
|
||||
export interface ProductCreateData extends FormData {
|
||||
name: string;
|
||||
price: number;
|
||||
description?: string | null;
|
||||
imageUrl?: string | null;
|
||||
isPublished?: boolean | null;
|
||||
}
|
||||
|
||||
interface ProductCreateProps {
|
||||
onSuccess?: (product: ProductCreateData) => void;
|
||||
onCancel?: () => void;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
apiEndpoint?: string;
|
||||
}
|
||||
|
||||
export function ProductCreate({
|
||||
onSuccess,
|
||||
onCancel,
|
||||
loading = false,
|
||||
className,
|
||||
apiEndpoint = "/api/products",
|
||||
}: ProductCreateProps) {
|
||||
const handleSubmit = async (data: FormData): Promise<void> => {
|
||||
// Transform and validate the data according to database schema
|
||||
const productData: ProductCreateData = {
|
||||
name: data.name as string,
|
||||
price: Number(data.price),
|
||||
description: (data.description as string) || null,
|
||||
imageUrl: (data.imageUrl as string) || null,
|
||||
isPublished: data.isPublished ? Boolean(data.isPublished) : false,
|
||||
};
|
||||
|
||||
// Make API call
|
||||
const response = await fetch(apiEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(productData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.message ||
|
||||
`Failed to create product (${response.status})`
|
||||
);
|
||||
}
|
||||
|
||||
const createdProduct = await response.json();
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(createdProduct);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CreateForm
|
||||
title="Create New Product"
|
||||
fields={productFields}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={onCancel}
|
||||
loading={loading}
|
||||
submitText="Create Product"
|
||||
cancelText="Cancel"
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Export individual field configurations for reuse
|
||||
export const productFormFields = productFields;
|
||||
|
||||
export type { ProductCreateProps };
|
||||
169
packages/ui/src/components/products/index.tsx
Normal file
169
packages/ui/src/components/products/index.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import {
|
||||
TableShow,
|
||||
TableGrid,
|
||||
SimpleTableGrid,
|
||||
FieldConfig,
|
||||
ActionConfig,
|
||||
BaseRecord,
|
||||
PaginatedResponse,
|
||||
} from "../template/table-show";
|
||||
|
||||
export interface Product extends BaseRecord {
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
imageUrl?: string;
|
||||
isPublished?: boolean;
|
||||
}
|
||||
|
||||
const productFields: FieldConfig<Product>[] = [
|
||||
{ key: "description", label: "Description", type: "text" },
|
||||
{ key: "price", label: "Price", type: "currency" },
|
||||
];
|
||||
|
||||
interface ProductShowProps {
|
||||
product: Product;
|
||||
onEdit?: (product: Product) => void;
|
||||
onDelete?: (productId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProductShow({
|
||||
product,
|
||||
onEdit,
|
||||
onDelete,
|
||||
className,
|
||||
}: ProductShowProps) {
|
||||
const actions: ActionConfig<Product>[] = [
|
||||
...(onEdit
|
||||
? [
|
||||
{
|
||||
label: "Edit",
|
||||
variant: "outline" as const,
|
||||
onClick: onEdit,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(onDelete
|
||||
? [
|
||||
{
|
||||
label: "Delete",
|
||||
variant: "destructive" as const,
|
||||
onClick: (p: Product) => onDelete(p.id),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<TableShow
|
||||
record={product}
|
||||
title={(p) => p.name}
|
||||
fields={productFields}
|
||||
actions={actions}
|
||||
className={className}
|
||||
statusField="isPublished"
|
||||
imageField="imageUrl"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Updated paginated ProductGrid
|
||||
export function ProductGrid({
|
||||
paginatedProducts,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onPageChange,
|
||||
loading,
|
||||
className,
|
||||
}: {
|
||||
paginatedProducts: PaginatedResponse<Product>;
|
||||
onEdit?: (product: Product) => void;
|
||||
onDelete?: (productId: string) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const actions: ActionConfig<Product>[] = [
|
||||
...(onEdit
|
||||
? [
|
||||
{
|
||||
label: "Edit",
|
||||
variant: "outline" as const,
|
||||
onClick: onEdit,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(onDelete
|
||||
? [
|
||||
{
|
||||
label: "Delete",
|
||||
variant: "destructive" as const,
|
||||
onClick: (p: Product) => onDelete(p.id),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<TableGrid
|
||||
paginatedData={paginatedProducts}
|
||||
title={(p) => p.name}
|
||||
fields={productFields}
|
||||
actions={actions}
|
||||
onPageChange={onPageChange}
|
||||
loading={loading}
|
||||
className={className}
|
||||
statusField="isPublished"
|
||||
imageField="imageUrl"
|
||||
emptyMessage="No products found."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple non-paginated version for backward compatibility
|
||||
export function SimpleProductGrid({
|
||||
products,
|
||||
onEdit,
|
||||
onDelete,
|
||||
className,
|
||||
}: {
|
||||
products: Product[];
|
||||
onEdit?: (product: Product) => void;
|
||||
onDelete?: (productId: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const actions: ActionConfig<Product>[] = [
|
||||
...(onEdit
|
||||
? [
|
||||
{
|
||||
label: "Edit",
|
||||
variant: "outline" as const,
|
||||
onClick: onEdit,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(onDelete
|
||||
? [
|
||||
{
|
||||
label: "Delete",
|
||||
variant: "destructive" as const,
|
||||
onClick: (p: Product) => onDelete(p.id),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<SimpleTableGrid
|
||||
records={products}
|
||||
title={(p) => p.name}
|
||||
fields={productFields}
|
||||
actions={actions}
|
||||
className={className}
|
||||
statusField="isPublished"
|
||||
imageField="imageUrl"
|
||||
emptyMessage="No products found."
|
||||
/>
|
||||
);
|
||||
}
|
||||
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,
|
||||
};
|
||||
69
packages/ui/src/components/users/index.tsx
Normal file
69
packages/ui/src/components/users/index.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import React from "react";
|
||||
import {
|
||||
TableShow,
|
||||
TableGrid,
|
||||
FieldConfig,
|
||||
ActionConfig,
|
||||
BaseRecord,
|
||||
} from "../template/table-show";
|
||||
|
||||
export interface User extends BaseRecord {
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
isActive?: boolean;
|
||||
lastLogin?: string;
|
||||
}
|
||||
|
||||
const userFields: FieldConfig<User>[] = [
|
||||
{ key: "email", label: "Email", type: "text" },
|
||||
{ key: "role", label: "Role", type: "text" },
|
||||
{ key: "lastLogin", label: "Last Login", type: "date" },
|
||||
];
|
||||
|
||||
interface UserShowProps {
|
||||
user: User;
|
||||
onEdit?: (user: User) => void;
|
||||
onDeactivate?: (userId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function UserShow({
|
||||
user,
|
||||
onEdit,
|
||||
onDeactivate,
|
||||
className,
|
||||
}: UserShowProps) {
|
||||
const actions: ActionConfig<User>[] = [
|
||||
...(onEdit
|
||||
? [
|
||||
{
|
||||
label: "Edit",
|
||||
variant: "outline" as const,
|
||||
onClick: onEdit,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(onDeactivate
|
||||
? [
|
||||
{
|
||||
label: "Deactivate",
|
||||
variant: "destructive" as const,
|
||||
onClick: (u: User) => onDeactivate(u.id),
|
||||
show: (u: User) => u.isActive !== false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<TableShow
|
||||
record={user}
|
||||
title={(u) => u.name}
|
||||
fields={userFields}
|
||||
actions={actions}
|
||||
className={className}
|
||||
statusField="isActive"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue