mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-29 08:14:57 +00:00
Merge pull request #339 from johndoe6345789/codex/create-new-components-for-viewers-pvcc7s
Add viewer filter and detail components
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Badge, Button, Input, Label, Switch, ToggleGroup, ToggleGroupItem } from '@/components/ui'
|
||||
import type { OperationType, ResourceType } from '@/lib/security/secure-db/types'
|
||||
import { FunnelSimple, MagnifyingGlass, X } from '@phosphor-icons/react'
|
||||
|
||||
interface AuditLogFiltersProps {
|
||||
searchTerm: string
|
||||
onSearchChange: (value: string) => void
|
||||
selectedOperations: OperationType[]
|
||||
onOperationsChange: (operations: OperationType[]) => void
|
||||
selectedResources: ResourceType[]
|
||||
onResourcesChange: (resources: ResourceType[]) => void
|
||||
showFailuresOnly: boolean
|
||||
onShowFailuresChange: (value: boolean) => void
|
||||
availableOperations?: OperationType[]
|
||||
availableResources?: ResourceType[]
|
||||
onReset?: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_OPERATIONS: OperationType[] = ['CREATE', 'READ', 'UPDATE', 'DELETE']
|
||||
const DEFAULT_RESOURCES: ResourceType[] = [
|
||||
'user',
|
||||
'workflow',
|
||||
'luaScript',
|
||||
'pageConfig',
|
||||
'modelSchema',
|
||||
'comment',
|
||||
'componentNode',
|
||||
'componentConfig',
|
||||
'cssCategory',
|
||||
'dropdownConfig',
|
||||
'tenant',
|
||||
'powerTransfer',
|
||||
'smtpConfig',
|
||||
'credential'
|
||||
]
|
||||
|
||||
export function AuditLogFilters({
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
selectedOperations,
|
||||
onOperationsChange,
|
||||
selectedResources,
|
||||
onResourcesChange,
|
||||
showFailuresOnly,
|
||||
onShowFailuresChange,
|
||||
availableOperations,
|
||||
availableResources,
|
||||
onReset
|
||||
}: AuditLogFiltersProps) {
|
||||
const operationOptions = availableOperations || DEFAULT_OPERATIONS
|
||||
const resourceOptions = useMemo(
|
||||
() => availableResources || DEFAULT_RESOURCES,
|
||||
[availableResources]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border bg-card p-4 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<FunnelSimple weight="bold" />
|
||||
<span className="text-sm font-medium">Filters</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="audit-log-search">Search</Label>
|
||||
<div className="relative">
|
||||
<MagnifyingGlass className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="audit-log-search"
|
||||
placeholder="Search by user, resource, or error message"
|
||||
value={searchTerm}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="audit-log-failures">Failures only</Label>
|
||||
<p className="text-xs text-muted-foreground">Show only unsuccessful operations</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="audit-log-failures"
|
||||
checked={showFailuresOnly}
|
||||
onCheckedChange={onShowFailuresChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Operations</Label>
|
||||
<ToggleGroup
|
||||
type="multiple"
|
||||
value={selectedOperations}
|
||||
onValueChange={(value) => onOperationsChange(value as OperationType[])}
|
||||
className="flex flex-wrap gap-2"
|
||||
>
|
||||
{operationOptions.map((operation) => (
|
||||
<ToggleGroupItem
|
||||
key={operation}
|
||||
value={operation}
|
||||
className="data-[state=on]:bg-primary data-[state=on]:text-primary-foreground"
|
||||
>
|
||||
{operation}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Resources</Label>
|
||||
<span className="text-xs text-muted-foreground">Select one or more</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{resourceOptions.map((resource) => {
|
||||
const isSelected = selectedResources.includes(resource)
|
||||
return (
|
||||
<Button
|
||||
key={resource}
|
||||
variant={isSelected ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onResourcesChange(
|
||||
isSelected
|
||||
? selectedResources.filter((value) => value !== resource)
|
||||
: [...selectedResources, resource]
|
||||
)
|
||||
}
|
||||
className="rounded-full"
|
||||
>
|
||||
<Badge
|
||||
variant={isSelected ? 'default' : 'secondary'}
|
||||
className="pointer-events-none bg-transparent px-0 text-xs capitalize"
|
||||
>
|
||||
{resource}
|
||||
</Badge>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{selectedOperations.length > 0 && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<span className="font-medium">Operations:</span>
|
||||
<span>{selectedOperations.join(', ')}</span>
|
||||
</Badge>
|
||||
)}
|
||||
{selectedResources.length > 0 && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<span className="font-medium">Resources:</span>
|
||||
<span>{selectedResources.join(', ')}</span>
|
||||
</Badge>
|
||||
)}
|
||||
{showFailuresOnly && (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<WarningIcon />
|
||||
Failures
|
||||
</Badge>
|
||||
)}
|
||||
{selectedOperations.length === 0 &&
|
||||
selectedResources.length === 0 &&
|
||||
!showFailuresOnly && (
|
||||
<span>No filters applied</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onReset && (
|
||||
<Button variant="ghost" size="sm" onClick={onReset} className="ml-auto gap-1">
|
||||
<X className="h-4 w-4" />
|
||||
Clear all
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WarningIcon() {
|
||||
return <span className="inline-block h-3 w-3 rounded-full bg-destructive" />
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Badge, Card, CardContent, CardHeader, CardTitle, ScrollArea, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
|
||||
import type { AuditLog, OperationType, ResourceType } from '@/lib/security/secure-db/types'
|
||||
import { ArrowDown, ArrowUp, ShieldCheck, User as UserIcon, WarningCircle } from '@phosphor-icons/react'
|
||||
|
||||
interface LogTableProps {
|
||||
logs: AuditLog[]
|
||||
sortField?: keyof AuditLog | null
|
||||
sortDirection?: 'asc' | 'desc'
|
||||
onSortChange?: (field: keyof AuditLog) => void
|
||||
}
|
||||
|
||||
const OPERATION_COLORS: Record<OperationType, string> = {
|
||||
CREATE: 'bg-green-100 text-green-800',
|
||||
READ: 'bg-blue-100 text-blue-800',
|
||||
UPDATE: 'bg-yellow-100 text-yellow-800',
|
||||
DELETE: 'bg-red-100 text-red-800'
|
||||
}
|
||||
|
||||
const RESOURCE_ICONS: Partial<Record<ResourceType, JSX.Element>> = {
|
||||
user: <UserIcon className="h-4 w-4" weight="bold" />,
|
||||
credential: <ShieldCheck className="h-4 w-4" weight="bold" />
|
||||
}
|
||||
|
||||
export function LogTable({ logs, sortField, sortDirection = 'asc', onSortChange }: LogTableProps) {
|
||||
const handleSort = (field: keyof AuditLog) => {
|
||||
onSortChange?.(field)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 border-b bg-muted/40 py-3">
|
||||
<CardTitle className="text-base font-semibold">Audit Log</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ScrollArea className="h-[480px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/30">
|
||||
<SortableHeader
|
||||
field="timestamp"
|
||||
label="Timestamp"
|
||||
sortField={sortField}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Operation</TableHead>
|
||||
<TableHead>Resource</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Details</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-12 text-center text-muted-foreground">
|
||||
No audit events to display
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<TableRow key={log.id} className="hover:bg-muted/20">
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(log.timestamp).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{log.username}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={OPERATION_COLORS[log.operation]}>{log.operation}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="flex items-center gap-2">
|
||||
{RESOURCE_ICONS[log.resource] || <ShieldCheck className="h-4 w-4 text-muted-foreground" />}
|
||||
<span className="capitalize">{log.resource}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.success ? (
|
||||
<Badge variant="outline" className="border-green-200 text-green-700">
|
||||
<ShieldCheck className="mr-1 h-4 w-4" />
|
||||
Success
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<WarningCircle className="h-4 w-4" />
|
||||
Failed
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{log.errorMessage || '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
interface SortableHeaderProps {
|
||||
field: keyof AuditLog
|
||||
label: string
|
||||
sortField?: keyof AuditLog | null
|
||||
sortDirection?: 'asc' | 'desc'
|
||||
onSort?: (field: keyof AuditLog) => void
|
||||
}
|
||||
|
||||
function SortableHeader({ field, label, sortField, sortDirection = 'asc', onSort }: SortableHeaderProps) {
|
||||
const isActive = sortField === field
|
||||
const Icon = sortDirection === 'asc' ? ArrowUp : ArrowDown
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
className="cursor-pointer select-none"
|
||||
onClick={() => onSort?.(field)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{label}
|
||||
{isActive && <Icon className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
</TableHead>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Badge, Button, ScrollArea, Separator, Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui'
|
||||
import type { ModelSchema } from '@/lib/schema-types'
|
||||
import { getFieldLabel } from '@/lib/schema-utils'
|
||||
|
||||
interface DetailsDrawerProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
record: any | null
|
||||
model: ModelSchema
|
||||
}
|
||||
|
||||
export function DetailsDrawer({ open, onOpenChange, record, model }: DetailsDrawerProps) {
|
||||
const renderValue = (fieldName: string) => {
|
||||
const field = model.fields.find((item) => item.name === fieldName)
|
||||
if (!field) return null
|
||||
const value = record?.[fieldName]
|
||||
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return <span className="text-muted-foreground">Not provided</span>
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
case 'boolean':
|
||||
return value ? (
|
||||
<Badge variant="outline" className="bg-emerald-50 text-emerald-700">
|
||||
Yes
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">No</Badge>
|
||||
)
|
||||
case 'date':
|
||||
case 'datetime':
|
||||
return new Date(value).toLocaleString()
|
||||
case 'json':
|
||||
return <pre className="whitespace-pre-wrap rounded-md bg-muted/60 p-3 text-xs">{JSON.stringify(value, null, 2)}</pre>
|
||||
default:
|
||||
return <span className="font-medium text-foreground">{String(value)}</span>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full sm:max-w-xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{model.label || model.name} details</SheetTitle>
|
||||
<SheetDescription>Review the full record and its attributes.</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<ScrollArea className="h-[70vh] pr-4">
|
||||
<div className="space-y-4">
|
||||
{model.fields.map((field) => (
|
||||
<div key={field.name} className="rounded-lg border bg-muted/40 p-3">
|
||||
<p className="text-xs uppercase text-muted-foreground">{getFieldLabel(field)}</p>
|
||||
<div className="mt-1 text-sm">{renderValue(field.name)}</div>
|
||||
{field.helpText && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<SheetFooter className="mt-6">
|
||||
<SheetClose asChild>
|
||||
<Button variant="outline" className="w-full sm:w-auto">Close</Button>
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
|
||||
import type { FieldSchema, ModelSchema } from '@/lib/schema-types'
|
||||
import { MagnifyingGlass } from '@phosphor-icons/react'
|
||||
|
||||
interface ModelFiltersProps {
|
||||
model: ModelSchema
|
||||
filters: Record<string, any>
|
||||
searchTerm: string
|
||||
onSearchChange: (value: string) => void
|
||||
onFilterChange: (field: string, value: any) => void
|
||||
}
|
||||
|
||||
function getFilterableFields(model: ModelSchema): FieldSchema[] {
|
||||
if (model.listFilter) {
|
||||
return model.fields.filter((field) => model.listFilter?.includes(field.name))
|
||||
}
|
||||
return model.fields.filter((field) => field.type === 'select' || field.type === 'boolean')
|
||||
}
|
||||
|
||||
export function ModelFilters({ model, filters, searchTerm, onSearchChange, onFilterChange }: ModelFiltersProps) {
|
||||
const filterFields = getFilterableFields(model)
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border bg-card p-4 shadow-sm">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-search">Search</Label>
|
||||
<div className="relative">
|
||||
<MagnifyingGlass className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="model-search"
|
||||
placeholder={`Search ${model.labelPlural || model.name}`}
|
||||
value={searchTerm}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filterFields.length > 0 && (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filterFields.map((field) => (
|
||||
<div key={field.name} className="space-y-1.5">
|
||||
<Label>{field.label || field.name}</Label>
|
||||
{field.type === 'select' ? (
|
||||
<Select
|
||||
value={filters[field.name] ?? '__all__'}
|
||||
onValueChange={(value) => onFilterChange(field.name, value === '__all__' ? null : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={field.label || field.name} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">All</SelectItem>
|
||||
{field.choices?.map((choice) => (
|
||||
<SelectItem key={choice.value} value={choice.value}>
|
||||
{choice.label || choice.value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Select
|
||||
value={filters[field.name] === true ? 'true' : filters[field.name] === false ? 'false' : '__all__'}
|
||||
onValueChange={(value) => onFilterChange(field.name, value === 'true' ? true : value === 'false' ? false : null)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={field.label || field.name} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">All</SelectItem>
|
||||
<SelectItem value="true">Yes</SelectItem>
|
||||
<SelectItem value="false">No</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Badge, Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
|
||||
import type { FieldSchema, ModelSchema } from '@/lib/schema-types'
|
||||
import { getFieldLabel } from '@/lib/schema-utils'
|
||||
import { ArrowDown, ArrowUp, Pencil, Trash } from '@phosphor-icons/react'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface ModelTableProps {
|
||||
model: ModelSchema
|
||||
records: any[]
|
||||
displayFields: string[]
|
||||
sortField?: string | null
|
||||
sortDirection?: 'asc' | 'desc'
|
||||
onSortChange?: (field: string) => void
|
||||
onEdit?: (record: any) => void
|
||||
onDelete?: (id: string) => void
|
||||
onRowClick?: (record: any) => void
|
||||
renderRelationValue?: (value: string, field: FieldSchema) => ReactNode
|
||||
}
|
||||
|
||||
export function ModelTable({
|
||||
model,
|
||||
records,
|
||||
displayFields,
|
||||
sortField,
|
||||
sortDirection = 'asc',
|
||||
onSortChange,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRowClick,
|
||||
renderRelationValue
|
||||
}: ModelTableProps) {
|
||||
const actionColumns = onEdit || onDelete ? 1 : 0
|
||||
|
||||
const renderCellValue = (record: any, fieldName: string) => {
|
||||
const field = model.fields.find((item) => item.name === fieldName)
|
||||
if (!field) return null
|
||||
|
||||
const value = record[fieldName]
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return <span className="text-muted-foreground">—</span>
|
||||
}
|
||||
|
||||
if (field.type === 'relation' && typeof value === 'string' && renderRelationValue) {
|
||||
return renderRelationValue(value, field)
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
case 'boolean':
|
||||
return value ? <Badge variant="outline">Yes</Badge> : <Badge variant="secondary">No</Badge>
|
||||
case 'date':
|
||||
case 'datetime':
|
||||
return new Date(value).toLocaleString()
|
||||
case 'json':
|
||||
return <code className="text-xs">{JSON.stringify(value)}</code>
|
||||
default:
|
||||
return typeof value === 'string' && value.length > 60 ? `${value.slice(0, 60)}…` : String(value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
{displayFields.map((fieldName) => {
|
||||
const field = model.fields.find((item) => item.name === fieldName)
|
||||
if (!field) return null
|
||||
const isSortable = field.sortable !== false
|
||||
const isActive = sortField === fieldName
|
||||
const Icon = sortDirection === 'asc' ? ArrowUp : ArrowDown
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
key={fieldName}
|
||||
className={isSortable ? 'cursor-pointer select-none' : undefined}
|
||||
onClick={() => isSortable && onSortChange?.(fieldName)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="uppercase text-xs font-semibold tracking-wide">
|
||||
{getFieldLabel(field)}
|
||||
</span>
|
||||
{isSortable && isActive && <Icon className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
{(onEdit || onDelete) && <TableHead className="w-24">Actions</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={displayFields.length + actionColumns} className="py-10 text-center text-muted-foreground">
|
||||
No records to display
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
records.map((record) => (
|
||||
<TableRow
|
||||
key={record.id}
|
||||
className="hover:bg-muted/30"
|
||||
onClick={() => onRowClick?.(record)}
|
||||
>
|
||||
{displayFields.map((fieldName) => (
|
||||
<TableCell key={fieldName} className="py-3">
|
||||
{renderCellValue(record, fieldName)}
|
||||
</TableCell>
|
||||
))}
|
||||
{(onEdit || onDelete) && (
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
{onEdit && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onEdit(record)
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onDelete(record.id)
|
||||
}}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user