Files
lcbp3/frontend/components/admin/reference/generic-crud-table.tsx
admin 2473c4c474
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
251211:1622 Frontend: refactor Dashboard (not finish)
2025-12-11 16:22:50 +07:00

328 lines
9.8 KiB
TypeScript

"use client";
import { useState } from "react";
import { DataTable } from "@/components/common/data-table";
import { ColumnDef } from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Plus, Pencil, Trash2, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Skeleton } from "@/components/ui/skeleton";
interface FieldConfig {
name: string;
label: string;
type: "text" | "textarea" | "checkbox" | "select";
required?: boolean;
options?: { label: string; value: any }[];
}
interface GenericCrudTableProps {
entityName: string;
queryKey: string[];
fetchFn: () => Promise<any>;
createFn: (data: any) => Promise<any>;
updateFn: (id: number, data: any) => Promise<any>;
deleteFn: (id: number) => Promise<any>;
columns: ColumnDef<any>[];
fields: FieldConfig[];
title?: string;
description?: string;
filters?: React.ReactNode;
}
export function GenericCrudTable({
entityName,
queryKey,
fetchFn,
createFn,
updateFn,
deleteFn,
columns,
fields,
title,
description,
filters,
}: GenericCrudTableProps) {
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState(false);
const [editingItem, setEditingItem] = useState<any>(null);
const [formData, setFormData] = useState<any>({});
// Delete Dialog State
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<number | null>(null);
const { data, isLoading, refetch } = useQuery({
queryKey,
queryFn: fetchFn,
});
const createMutation = useMutation({
mutationFn: createFn,
onSuccess: () => {
toast.success(`${entityName} created successfully`);
queryClient.invalidateQueries({ queryKey });
handleClose();
},
onError: () => toast.error(`Failed to create ${entityName}`),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: any }) => updateFn(id, data),
onSuccess: () => {
toast.success(`${entityName} updated successfully`);
queryClient.invalidateQueries({ queryKey });
handleClose();
},
onError: () => toast.error(`Failed to update ${entityName}`),
});
const deleteMutation = useMutation({
mutationFn: deleteFn,
onSuccess: () => {
toast.success(`${entityName} deleted successfully`);
queryClient.invalidateQueries({ queryKey });
setDeleteDialogOpen(false);
setItemToDelete(null);
},
onError: () => toast.error(`Failed to delete ${entityName}`),
});
const handleCreate = () => {
setEditingItem(null);
setFormData({});
setIsOpen(true);
};
const handleEdit = (item: any) => {
setEditingItem(item);
setFormData({ ...item });
setIsOpen(true);
};
const handleDeleteClick = (id: number) => {
setItemToDelete(id);
setDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (itemToDelete) {
deleteMutation.mutate(itemToDelete);
}
};
const handleClose = () => {
setIsOpen(false);
setEditingItem(null);
setFormData({});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (editingItem) {
updateMutation.mutate({ id: editingItem.id, data: formData });
} else {
createMutation.mutate(formData);
}
};
const handleChange = (field: string, value: any) => {
setFormData((prev: any) => ({ ...prev, [field]: value }));
};
// Add default Actions column if not present
const tableColumns = [
...columns,
{
id: "actions",
header: "Actions",
cell: ({ row }: { row: any }) => (
<div className="flex gap-2 justify-end">
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(row.original)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive"
onClick={() => handleDeleteClick(row.original.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
),
},
];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
{title && <h2 className="text-xl font-bold">{title}</h2>}
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
<div className="flex gap-2 items-center">
{filters}
<Button
variant="outline"
size="icon"
onClick={() => refetch()}
disabled={isLoading}
>
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
</Button>
<Button onClick={handleCreate}>
<Plus className="h-4 w-4 mr-2" />
Add {entityName}
</Button>
</div>
</div>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center space-x-4">
<Skeleton className="h-12 w-full" />
</div>
))}
</div>
) : (
<DataTable columns={tableColumns} data={data || []} />
)}
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingItem ? `Edit ${entityName}` : `New ${entityName}`}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{fields.map((field) => (
<div key={field.name} className="space-y-2">
<Label htmlFor={field.name}>{field.label}</Label>
{field.type === "textarea" ? (
<Textarea
id={field.name}
value={formData[field.name] || ""}
onChange={(e) => handleChange(field.name, e.target.value)}
required={field.required}
/>
) : field.type === "checkbox" ? (
<div className="flex items-center space-x-2">
<Checkbox
id={field.name}
checked={!!formData[field.name]}
onCheckedChange={(checked) =>
handleChange(field.name, checked)
}
/>
<label htmlFor={field.name} className="text-sm">
Enabled
</label>
</div>
) : field.type === "select" ? (
<Select
value={formData[field.name]?.toString() || ""}
onValueChange={(value) => handleChange(field.name, value)}
>
<SelectTrigger>
<SelectValue placeholder={`Select ${field.label}`} />
</SelectTrigger>
<SelectContent>
{field.options?.map((opt) => (
<SelectItem key={opt.value} value={opt.value.toString()}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id={field.name}
type="text"
value={formData[field.name] || ""}
onChange={(e) => handleChange(field.name, e.target.value)}
required={field.required}
/>
)}
</div>
))}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={createMutation.isPending || updateMutation.isPending}
>
Cancel
</Button>
<Button
type="submit"
disabled={createMutation.isPending || updateMutation.isPending}
>
{editingItem ? "Update" : "Create"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the {entityName.toLowerCase()} and remove it from the system.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-red-600 hover:bg-red-700"
>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}