feat(digital-signatures): add version history, expiration tracking, and metadata fields

- 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>
This commit is contained in:
Marius Tarau
2026-02-18 06:35:42 +02:00
parent c3abbf1c4b
commit 455d95a8c6
4 changed files with 178 additions and 16 deletions

View File

@@ -1,13 +1,15 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { Plus, Pencil, Trash2, Search, PenTool, Stamp, Type } from 'lucide-react'; import { Plus, Pencil, Trash2, Search, PenTool, Stamp, Type, History, AlertTriangle } from 'lucide-react';
import { Button } from '@/shared/components/ui/button'; import { Button } from '@/shared/components/ui/button';
import { Input } from '@/shared/components/ui/input'; import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label'; import { Label } from '@/shared/components/ui/label';
import { Textarea } from '@/shared/components/ui/textarea';
import { Badge } from '@/shared/components/ui/badge'; import { Badge } from '@/shared/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select'; 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 { CompanyId } from '@/core/auth/types';
import type { SignatureAsset, SignatureAssetType } from '../types'; import type { SignatureAsset, SignatureAssetType } from '../types';
import { useSignatures } from '../hooks/use-signatures'; import { useSignatures } from '../hooks/use-signatures';
@@ -23,11 +25,13 @@ const TYPE_ICONS: Record<SignatureAssetType, typeof PenTool> = {
type ViewMode = 'list' | 'add' | 'edit'; type ViewMode = 'list' | 'add' | 'edit';
export function DigitalSignaturesModule() { export function DigitalSignaturesModule() {
const { assets, allAssets, loading, filters, updateFilter, addAsset, updateAsset, removeAsset } = useSignatures(); const { assets, allAssets, loading, filters, updateFilter, addAsset, updateAsset, addVersion, removeAsset } = useSignatures();
const [viewMode, setViewMode] = useState<ViewMode>('list'); const [viewMode, setViewMode] = useState<ViewMode>('list');
const [editingAsset, setEditingAsset] = useState<SignatureAsset | null>(null); 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'>) => { const handleSubmit = async (data: Omit<SignatureAsset, 'id' | 'createdAt' | 'updatedAt'>) => {
if (viewMode === 'edit' && editingAsset) { if (viewMode === 'edit' && editingAsset) {
await updateAsset(editingAsset.id, data); await updateAsset(editingAsset.id, data);
} else { } else {
@@ -37,6 +41,31 @@ export function DigitalSignaturesModule() {
setEditingAsset(null); 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Stats */} {/* Stats */}
@@ -79,14 +108,19 @@ export function DigitalSignaturesModule() {
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{assets.map((asset) => { {assets.map((asset) => {
const Icon = TYPE_ICONS[asset.type]; const Icon = TYPE_ICONS[asset.type];
const expired = isExpired(asset.expirationDate);
const expiringSoon = isExpiringSoon(asset.expirationDate);
return ( return (
<Card key={asset.id} className="group relative"> <Card key={asset.id} className={`group relative ${expired ? 'border-destructive/50' : expiringSoon ? 'border-yellow-500/50' : ''}`}>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <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'); }}> <Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingAsset(asset); setViewMode('edit'); }}>
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeAsset(asset.id)}> <Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => setDeletingId(asset.id)}>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -99,14 +133,34 @@ export function DigitalSignaturesModule() {
<Icon className="h-6 w-6 text-muted-foreground" /> <Icon className="h-6 w-6 text-muted-foreground" />
)} )}
</div> </div>
<div> <div className="min-w-0 flex-1">
<p className="font-medium">{asset.label}</p> <p className="font-medium">{asset.label}</p>
<div className="flex items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1">
<Badge variant="outline" className="text-[10px]">{TYPE_LABELS[asset.type]}</Badge> <Badge variant="outline" className="text-[10px]">{TYPE_LABELS[asset.type]}</Badge>
<span className="text-xs text-muted-foreground">{asset.owner}</span> <span className="text-xs text-muted-foreground">{asset.owner}</span>
</div> </div>
</div> </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> </CardContent>
</Card> </Card>
); );
@@ -124,13 +178,74 @@ export function DigitalSignaturesModule() {
</CardContent> </CardContent>
</Card> </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 vrei ș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> </div>
); );
} }
function AssetForm({ initial, onSubmit, onCancel }: { function AssetForm({ initial, onSubmit, onCancel }: {
initial?: SignatureAsset; initial?: SignatureAsset;
onSubmit: (data: Omit<SignatureAsset, 'id' | 'createdAt'>) => void; onSubmit: (data: Omit<SignatureAsset, 'id' | 'createdAt' | 'updatedAt'>) => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const [label, setLabel] = useState(initial?.label ?? ''); const [label, setLabel] = useState(initial?.label ?? '');
@@ -138,11 +253,23 @@ function AssetForm({ initial, onSubmit, onCancel }: {
const [imageUrl, setImageUrl] = useState(initial?.imageUrl ?? ''); const [imageUrl, setImageUrl] = useState(initial?.imageUrl ?? '');
const [owner, setOwner] = useState(initial?.owner ?? ''); const [owner, setOwner] = useState(initial?.owner ?? '');
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage'); 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 ( return (
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ label, type, imageUrl, owner, company, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all' }); }} className="space-y-4"> <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 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>Denumire *</Label><Input value={label} onChange={(e) => setLabel(e.target.value)} className="mt-1" required /></div>
<div><Label>Tip</Label> <div><Label>Tip</Label>
<Select value={type} onValueChange={(v) => setType(v as SignatureAssetType)}> <Select value={type} onValueChange={(v) => setType(v as SignatureAssetType)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> <SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
@@ -173,6 +300,11 @@ function AssetForm({ initial, onSubmit, onCancel }: {
<Input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} className="mt-1" placeholder="https://... sau data:image/png;base64,..." /> <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> <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>
<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"> <div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button> <Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button> <Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>

View File

@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useStorage } from '@/core/storage'; import { useStorage } from '@/core/storage';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import type { SignatureAsset, SignatureAssetType } from '../types'; import type { SignatureAsset, SignatureAssetType, AssetVersion } from '../types';
const PREFIX = 'sig:'; const PREFIX = 'sig:';
@@ -36,8 +36,9 @@ export function useSignatures() {
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]); useEffect(() => { refresh(); }, [refresh]);
const addAsset = useCallback(async (data: Omit<SignatureAsset, 'id' | 'createdAt'>) => { const addAsset = useCallback(async (data: Omit<SignatureAsset, 'id' | 'createdAt' | 'updatedAt'>) => {
const asset: SignatureAsset = { ...data, id: uuid(), createdAt: new Date().toISOString() }; const now = new Date().toISOString();
const asset: SignatureAsset = { ...data, id: uuid(), createdAt: now, updatedAt: now };
await storage.set(`${PREFIX}${asset.id}`, asset); await storage.set(`${PREFIX}${asset.id}`, asset);
await refresh(); await refresh();
return asset; return asset;
@@ -46,11 +47,23 @@ export function useSignatures() {
const updateAsset = useCallback(async (id: string, updates: Partial<SignatureAsset>) => { const updateAsset = useCallback(async (id: string, updates: Partial<SignatureAsset>) => {
const existing = assets.find((a) => a.id === id); const existing = assets.find((a) => a.id === id);
if (!existing) return; if (!existing) return;
const updated = { ...existing, ...updates, id: existing.id, createdAt: existing.createdAt }; const updated: SignatureAsset = {
...existing, ...updates,
id: existing.id, createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
await storage.set(`${PREFIX}${id}`, updated); await storage.set(`${PREFIX}${id}`, updated);
await refresh(); await refresh();
}, [storage, refresh, assets]); }, [storage, refresh, assets]);
const addVersion = useCallback(async (assetId: string, imageUrl: string, notes: string) => {
const existing = assets.find((a) => a.id === assetId);
if (!existing) return;
const version: AssetVersion = { id: uuid(), imageUrl, notes, createdAt: new Date().toISOString() };
const updatedVersions = [...existing.versions, version];
await updateAsset(assetId, { imageUrl, versions: updatedVersions });
}, [assets, updateAsset]);
const removeAsset = useCallback(async (id: string) => { const removeAsset = useCallback(async (id: string) => {
await storage.delete(`${PREFIX}${id}`); await storage.delete(`${PREFIX}${id}`);
await refresh(); await refresh();
@@ -69,5 +82,5 @@ export function useSignatures() {
return true; return true;
}); });
return { assets: filteredAssets, allAssets: assets, loading, filters, updateFilter, addAsset, updateAsset, removeAsset, refresh }; return { assets: filteredAssets, allAssets: assets, loading, filters, updateFilter, addAsset, updateAsset, addVersion, removeAsset, refresh };
} }

View File

@@ -1,3 +1,3 @@
export { digitalSignaturesConfig } from './config'; export { digitalSignaturesConfig } from './config';
export { DigitalSignaturesModule } from './components/digital-signatures-module'; export { DigitalSignaturesModule } from './components/digital-signatures-module';
export type { SignatureAsset, SignatureAssetType } from './types'; export type { SignatureAsset, SignatureAssetType, AssetVersion } from './types';

View File

@@ -3,6 +3,14 @@ import type { CompanyId } from '@/core/auth/types';
export type SignatureAssetType = 'signature' | 'stamp' | 'initials'; export type SignatureAssetType = 'signature' | 'stamp' | 'initials';
/** Version history entry */
export interface AssetVersion {
id: string;
imageUrl: string;
notes: string;
createdAt: string;
}
export interface SignatureAsset { export interface SignatureAsset {
id: string; id: string;
label: string; label: string;
@@ -10,7 +18,16 @@ export interface SignatureAsset {
imageUrl: string; imageUrl: string;
owner: string; owner: string;
company: CompanyId; company: CompanyId;
/** Expiration date (YYYY-MM-DD) */
expirationDate?: string;
/** Legal status description */
legalStatus: string;
/** Usage notes */
usageNotes: string;
/** Version history */
versions: AssetVersion[];
tags: string[]; tags: string[];
visibility: Visibility; visibility: Visibility;
createdAt: string; createdAt: string;
updatedAt: string;
} }