- Version history with add-version dialog and history display - Expiration date with expired/expiring-soon visual indicators - Legal status and usage notes fields - Delete confirmation dialog - updatedAt timestamp support - Image preview in version history Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
315 lines
16 KiB
TypeScript
315 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { Plus, Pencil, Trash2, Search, PenTool, Stamp, Type, History, AlertTriangle } from 'lucide-react';
|
|
import { Button } from '@/shared/components/ui/button';
|
|
import { Input } from '@/shared/components/ui/input';
|
|
import { Label } from '@/shared/components/ui/label';
|
|
import { Textarea } from '@/shared/components/ui/textarea';
|
|
import { Badge } from '@/shared/components/ui/badge';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog';
|
|
import type { CompanyId } from '@/core/auth/types';
|
|
import type { SignatureAsset, SignatureAssetType } from '../types';
|
|
import { useSignatures } from '../hooks/use-signatures';
|
|
|
|
const TYPE_LABELS: Record<SignatureAssetType, string> = {
|
|
signature: 'Semnătură', stamp: 'Ștampilă', initials: 'Inițiale',
|
|
};
|
|
|
|
const TYPE_ICONS: Record<SignatureAssetType, typeof PenTool> = {
|
|
signature: PenTool, stamp: Stamp, initials: Type,
|
|
};
|
|
|
|
type ViewMode = 'list' | 'add' | 'edit';
|
|
|
|
export function DigitalSignaturesModule() {
|
|
const { assets, allAssets, loading, filters, updateFilter, addAsset, updateAsset, addVersion, removeAsset } = useSignatures();
|
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
|
const [editingAsset, setEditingAsset] = useState<SignatureAsset | null>(null);
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
const [versionAsset, setVersionAsset] = useState<SignatureAsset | null>(null);
|
|
|
|
const handleSubmit = async (data: Omit<SignatureAsset, 'id' | 'createdAt' | 'updatedAt'>) => {
|
|
if (viewMode === 'edit' && editingAsset) {
|
|
await updateAsset(editingAsset.id, data);
|
|
} else {
|
|
await addAsset(data);
|
|
}
|
|
setViewMode('list');
|
|
setEditingAsset(null);
|
|
};
|
|
|
|
const handleDeleteConfirm = async () => {
|
|
if (deletingId) {
|
|
await removeAsset(deletingId);
|
|
setDeletingId(null);
|
|
}
|
|
};
|
|
|
|
const handleAddVersion = async (imageUrl: string, notes: string) => {
|
|
if (versionAsset) {
|
|
await addVersion(versionAsset.id, imageUrl, notes);
|
|
setVersionAsset(null);
|
|
}
|
|
};
|
|
|
|
const isExpiringSoon = (date?: string) => {
|
|
if (!date) return false;
|
|
const diff = new Date(date).getTime() - Date.now();
|
|
return diff > 0 && diff < 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
};
|
|
|
|
const isExpired = (date?: string) => {
|
|
if (!date) return false;
|
|
return new Date(date).getTime() < Date.now();
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total</p><p className="text-2xl font-bold">{allAssets.length}</p></CardContent></Card>
|
|
{(Object.keys(TYPE_LABELS) as SignatureAssetType[]).map((type) => (
|
|
<Card key={type}><CardContent className="p-4">
|
|
<p className="text-xs text-muted-foreground">{TYPE_LABELS[type]}</p>
|
|
<p className="text-2xl font-bold">{allAssets.filter((a) => a.type === type).length}</p>
|
|
</CardContent></Card>
|
|
))}
|
|
</div>
|
|
|
|
{viewMode === 'list' && (
|
|
<>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="relative min-w-[200px] flex-1">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input placeholder="Caută..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
|
|
</div>
|
|
<Select value={filters.type} onValueChange={(v) => updateFilter('type', v as SignatureAssetType | 'all')}>
|
|
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Toate tipurile</SelectItem>
|
|
{(Object.keys(TYPE_LABELS) as SignatureAssetType[]).map((t) => (
|
|
<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button onClick={() => setViewMode('add')} className="shrink-0">
|
|
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
|
</Button>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
|
|
) : assets.length === 0 ? (
|
|
<p className="py-8 text-center text-sm text-muted-foreground">Niciun element găsit. Adaugă o semnătură, ștampilă sau inițiale.</p>
|
|
) : (
|
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
{assets.map((asset) => {
|
|
const Icon = TYPE_ICONS[asset.type];
|
|
const expired = isExpired(asset.expirationDate);
|
|
const expiringSoon = isExpiringSoon(asset.expirationDate);
|
|
return (
|
|
<Card key={asset.id} className={`group relative ${expired ? 'border-destructive/50' : expiringSoon ? 'border-yellow-500/50' : ''}`}>
|
|
<CardContent className="p-4">
|
|
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
|
<Button variant="ghost" size="icon" className="h-7 w-7" title="Versiune nouă" onClick={() => setVersionAsset(asset)}>
|
|
<History className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingAsset(asset); setViewMode('edit'); }}>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => setDeletingId(asset.id)}>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg border bg-muted/30">
|
|
{asset.imageUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={asset.imageUrl} alt={asset.label} className="max-h-10 max-w-10 object-contain" />
|
|
) : (
|
|
<Icon className="h-6 w-6 text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium">{asset.label}</p>
|
|
<div className="flex flex-wrap items-center gap-1">
|
|
<Badge variant="outline" className="text-[10px]">{TYPE_LABELS[asset.type]}</Badge>
|
|
<span className="text-xs text-muted-foreground">{asset.owner}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* Metadata row */}
|
|
<div className="mt-2 space-y-1">
|
|
{asset.legalStatus && (
|
|
<p className="text-xs text-muted-foreground">Status legal: {asset.legalStatus}</p>
|
|
)}
|
|
{asset.expirationDate && (
|
|
<div className="flex items-center gap-1 text-xs">
|
|
{(expired || expiringSoon) && <AlertTriangle className="h-3 w-3 text-yellow-500" />}
|
|
<span className={expired ? 'text-destructive font-medium' : expiringSoon ? 'text-yellow-600 font-medium' : 'text-muted-foreground'}>
|
|
{expired ? 'Expirat' : expiringSoon ? 'Expiră curând' : 'Expiră'}: {asset.expirationDate}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{asset.usageNotes && (
|
|
<p className="text-xs text-muted-foreground line-clamp-1">Note: {asset.usageNotes}</p>
|
|
)}
|
|
{asset.versions.length > 0 && (
|
|
<p className="text-xs text-muted-foreground">Versiuni: {asset.versions.length + 1}</p>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{(viewMode === 'add' || viewMode === 'edit') && (
|
|
<Card>
|
|
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare' : 'Element nou'}</CardTitle></CardHeader>
|
|
<CardContent>
|
|
<AssetForm initial={editingAsset ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingAsset(null); }} />
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Delete confirmation */}
|
|
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}>
|
|
<DialogContent>
|
|
<DialogHeader><DialogTitle>Confirmare ștergere</DialogTitle></DialogHeader>
|
|
<p className="text-sm">Ești sigur că vrei să ștergi acest element? Acțiunea este ireversibilă.</p>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setDeletingId(null)}>Anulează</Button>
|
|
<Button variant="destructive" onClick={handleDeleteConfirm}>Șterge</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Add version dialog */}
|
|
<Dialog open={versionAsset !== null} onOpenChange={(open) => { if (!open) setVersionAsset(null); }}>
|
|
<DialogContent>
|
|
<DialogHeader><DialogTitle>Versiune nouă — {versionAsset?.label}</DialogTitle></DialogHeader>
|
|
<AddVersionForm
|
|
onSubmit={handleAddVersion}
|
|
onCancel={() => setVersionAsset(null)}
|
|
history={versionAsset?.versions ?? []}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AddVersionForm({ onSubmit, onCancel, history }: {
|
|
onSubmit: (imageUrl: string, notes: string) => void;
|
|
onCancel: () => void;
|
|
history: Array<{ id: string; imageUrl: string; notes: string; createdAt: string }>;
|
|
}) {
|
|
const [imageUrl, setImageUrl] = useState('');
|
|
const [notes, setNotes] = useState('');
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{history.length > 0 && (
|
|
<div className="max-h-32 space-y-1 overflow-y-auto rounded border p-2">
|
|
<p className="text-xs font-medium text-muted-foreground">Istoric versiuni</p>
|
|
{history.map((v) => (
|
|
<div key={v.id} className="flex items-center justify-between text-xs">
|
|
<span className="truncate text-muted-foreground">{v.notes || 'Fără note'}</span>
|
|
<span className="shrink-0 text-muted-foreground">{v.createdAt.slice(0, 10)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<Label>URL imagine nouă</Label>
|
|
<Input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} className="mt-1" placeholder="https://... sau data:image/png;base64,..." required />
|
|
</div>
|
|
<div>
|
|
<Label>Note versiune</Label>
|
|
<Input value={notes} onChange={(e) => setNotes(e.target.value)} className="mt-1" placeholder="Ce s-a schimbat..." />
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" onClick={onCancel}>Anulează</Button>
|
|
<Button onClick={() => { if (imageUrl.trim()) onSubmit(imageUrl, notes); }} disabled={!imageUrl.trim()}>Salvează versiune</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AssetForm({ initial, onSubmit, onCancel }: {
|
|
initial?: SignatureAsset;
|
|
onSubmit: (data: Omit<SignatureAsset, 'id' | 'createdAt' | 'updatedAt'>) => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
const [label, setLabel] = useState(initial?.label ?? '');
|
|
const [type, setType] = useState<SignatureAssetType>(initial?.type ?? 'signature');
|
|
const [imageUrl, setImageUrl] = useState(initial?.imageUrl ?? '');
|
|
const [owner, setOwner] = useState(initial?.owner ?? '');
|
|
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
|
|
const [expirationDate, setExpirationDate] = useState(initial?.expirationDate ?? '');
|
|
const [legalStatus, setLegalStatus] = useState(initial?.legalStatus ?? '');
|
|
const [usageNotes, setUsageNotes] = useState(initial?.usageNotes ?? '');
|
|
|
|
return (
|
|
<form onSubmit={(e) => {
|
|
e.preventDefault();
|
|
onSubmit({
|
|
label, type, imageUrl, owner, company,
|
|
expirationDate: expirationDate || undefined,
|
|
legalStatus, usageNotes,
|
|
versions: initial?.versions ?? [],
|
|
tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all',
|
|
});
|
|
}} className="space-y-4">
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div><Label>Denumire *</Label><Input value={label} onChange={(e) => setLabel(e.target.value)} className="mt-1" required /></div>
|
|
<div><Label>Tip</Label>
|
|
<Select value={type} onValueChange={(v) => setType(v as SignatureAssetType)}>
|
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="signature">Semnătură</SelectItem>
|
|
<SelectItem value="stamp">Ștampilă</SelectItem>
|
|
<SelectItem value="initials">Inițiale</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div><Label>Proprietar</Label><Input value={owner} onChange={(e) => setOwner(e.target.value)} className="mt-1" /></div>
|
|
<div><Label>Companie</Label>
|
|
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
|
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="beletage">Beletage</SelectItem>
|
|
<SelectItem value="urban-switch">Urban Switch</SelectItem>
|
|
<SelectItem value="studii-de-teren">Studii de Teren</SelectItem>
|
|
<SelectItem value="group">Grup</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label>URL imagine</Label>
|
|
<Input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} className="mt-1" placeholder="https://... sau data:image/png;base64,..." />
|
|
<p className="mt-1 text-xs text-muted-foreground">URL către imaginea semnăturii/ștampilei. Suportă URL-uri externe sau base64.</p>
|
|
</div>
|
|
<div className="grid gap-4 sm:grid-cols-3">
|
|
<div><Label>Data expirare</Label><Input type="date" value={expirationDate} onChange={(e) => setExpirationDate(e.target.value)} className="mt-1" /></div>
|
|
<div><Label>Status legal</Label><Input value={legalStatus} onChange={(e) => setLegalStatus(e.target.value)} className="mt-1" placeholder="Valid, Anulat..." /></div>
|
|
<div><Label>Note utilizare</Label><Input value={usageNotes} onChange={(e) => setUsageNotes(e.target.value)} className="mt-1" placeholder="Doar pentru contracte..." /></div>
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
|
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|