131 lines
4.8 KiB
TypeScript
131 lines
4.8 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||
|
|
import { Badge } from '@/components/ui/badge';
|
||
|
|
import { Calendar, Check, X, FileText, Copy, CheckCircle2 } from 'lucide-react';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import { useState } from 'react';
|
||
|
|
|
||
|
|
interface DetailModalProps {
|
||
|
|
open: boolean;
|
||
|
|
onOpenChange: (open: boolean) => void;
|
||
|
|
title: string;
|
||
|
|
data: Record<string, any> | null;
|
||
|
|
fields?: Array<{
|
||
|
|
key: string;
|
||
|
|
label: string;
|
||
|
|
render?: (value: any) => React.ReactNode;
|
||
|
|
}>;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function DetailModal({ open, onOpenChange, title, data, fields }: DetailModalProps) {
|
||
|
|
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||
|
|
|
||
|
|
if (!data) return null;
|
||
|
|
|
||
|
|
const copyToClipboard = (text: string, fieldKey: string) => {
|
||
|
|
navigator.clipboard.writeText(text);
|
||
|
|
setCopiedField(fieldKey);
|
||
|
|
setTimeout(() => setCopiedField(null), 2000);
|
||
|
|
};
|
||
|
|
|
||
|
|
const renderValue = (value: any): React.ReactNode => {
|
||
|
|
if (value === null || value === undefined) {
|
||
|
|
return (
|
||
|
|
<span className="inline-flex items-center gap-1.5 text-muted-foreground italic text-xs">
|
||
|
|
<X className="w-3 h-3" />
|
||
|
|
null
|
||
|
|
</span>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (typeof value === 'boolean') {
|
||
|
|
return (
|
||
|
|
<Badge variant={value ? 'default' : 'secondary'} className="gap-1">
|
||
|
|
{value ? <Check className="w-3 h-3" /> : <X className="w-3 h-3" />}
|
||
|
|
{value ? 'Yes' : 'No'}
|
||
|
|
</Badge>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (value instanceof Date || (typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}/))) {
|
||
|
|
return (
|
||
|
|
<span className="inline-flex items-center gap-1.5 text-sm">
|
||
|
|
<Calendar className="w-3.5 h-3.5 text-muted-foreground" />
|
||
|
|
{new Date(value).toLocaleString()}
|
||
|
|
</span>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (typeof value === 'object') {
|
||
|
|
return (
|
||
|
|
<pre className="text-xs bg-muted p-3 rounded-md overflow-x-auto border">
|
||
|
|
{JSON.stringify(value, null, 2)}
|
||
|
|
</pre>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return <span className="text-sm">{String(value)}</span>;
|
||
|
|
};
|
||
|
|
|
||
|
|
const displayFields = fields || Object.keys(data).map(key => ({ key, label: key, render: undefined }));
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
|
|
<DialogContent className="max-w-4xl max-h-[85vh] overflow-hidden flex flex-col">
|
||
|
|
<DialogHeader className="pb-4 border-b">
|
||
|
|
<div className="flex items-start justify-between gap-4">
|
||
|
|
<div>
|
||
|
|
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
||
|
|
<DialogDescription className="mt-1.5">
|
||
|
|
Detailed view of record • {displayFields.length} fields
|
||
|
|
</DialogDescription>
|
||
|
|
</div>
|
||
|
|
<Badge variant="outline" className="shrink-0">
|
||
|
|
ID: {data.id}
|
||
|
|
</Badge>
|
||
|
|
</div>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
<div className="flex-1 overflow-y-auto pr-2 -mr-2">
|
||
|
|
<div className="space-y-1 py-4">
|
||
|
|
{displayFields.map((field, index) => {
|
||
|
|
const value = data[field.key];
|
||
|
|
const stringValue = value !== null && value !== undefined ? String(value) : '';
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
key={field.key}
|
||
|
|
className="group grid grid-cols-[200px_1fr] gap-6 py-3 px-4 rounded-lg hover:bg-muted/50 transition-colors"
|
||
|
|
>
|
||
|
|
<div className="flex items-start gap-2">
|
||
|
|
<FileText className="w-4 h-4 text-muted-foreground mt-0.5 shrink-0" />
|
||
|
|
<div className="font-medium text-sm text-muted-foreground">
|
||
|
|
{field.label}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-start justify-between gap-3">
|
||
|
|
<div className="flex-1 break-words min-w-0">
|
||
|
|
{field.render ? field.render(value) : renderValue(value)}
|
||
|
|
</div>
|
||
|
|
{stringValue && (
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||
|
|
onClick={() => copyToClipboard(stringValue, field.key)}
|
||
|
|
>
|
||
|
|
{copiedField === field.key ? (
|
||
|
|
<CheckCircle2 className="w-3.5 h-3.5 text-green-500" />
|
||
|
|
) : (
|
||
|
|
<Copy className="w-3.5 h-3.5" />
|
||
|
|
)}
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
);
|
||
|
|
}
|