feat(registratura): rework with company-prefixed numbering, directions, deadlines, attachments
- Company-specific numbering (B-0001/2026, US-0001/2026, SDT-0001/2026) - Direction: Intrat/Ieșit replaces old 3-way type - 9 document types: Contract, Ofertă, Factură, Scrisoare, etc. - Status simplified to Deschis/Închis with cascade close for linked entries - Address Book autocomplete for sender/recipient - Deadline tracking with overdue day counter - File attachment support (base64 encoding) - Linked entries system Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,10 +5,14 @@ import { Plus } from 'lucide-react';
|
|||||||
import { Button } from '@/shared/components/ui/button';
|
import { Button } from '@/shared/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
||||||
import { Badge } from '@/shared/components/ui/badge';
|
import { Badge } from '@/shared/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||||
|
} from '@/shared/components/ui/dialog';
|
||||||
import { useRegistry } from '../hooks/use-registry';
|
import { useRegistry } from '../hooks/use-registry';
|
||||||
import { RegistryFilters } from './registry-filters';
|
import { RegistryFilters } from './registry-filters';
|
||||||
import { RegistryTable } from './registry-table';
|
import { RegistryTable } from './registry-table';
|
||||||
import { RegistryEntryForm } from './registry-entry-form';
|
import { RegistryEntryForm } from './registry-entry-form';
|
||||||
|
import { getOverdueDays } from '../services/registry-service';
|
||||||
import type { RegistryEntry } from '../types';
|
import type { RegistryEntry } from '../types';
|
||||||
|
|
||||||
type ViewMode = 'list' | 'add' | 'edit';
|
type ViewMode = 'list' | 'add' | 'edit';
|
||||||
@@ -16,11 +20,12 @@ type ViewMode = 'list' | 'add' | 'edit';
|
|||||||
export function RegistraturaModule() {
|
export function RegistraturaModule() {
|
||||||
const {
|
const {
|
||||||
entries, allEntries, loading, filters, updateFilter,
|
entries, allEntries, loading, filters, updateFilter,
|
||||||
addEntry, updateEntry, removeEntry,
|
addEntry, updateEntry, removeEntry, closeEntry,
|
||||||
} = useRegistry();
|
} = useRegistry();
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||||
const [editingEntry, setEditingEntry] = useState<RegistryEntry | null>(null);
|
const [editingEntry, setEditingEntry] = useState<RegistryEntry | null>(null);
|
||||||
|
const [closingId, setClosingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleAdd = async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => {
|
const handleAdd = async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => {
|
||||||
await addEntry(data);
|
await addEntry(data);
|
||||||
@@ -43,6 +48,22 @@ export function RegistraturaModule() {
|
|||||||
await removeEntry(id);
|
await removeEntry(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCloseRequest = (id: string) => {
|
||||||
|
const entry = allEntries.find((e) => e.id === id);
|
||||||
|
if (entry && entry.linkedEntryIds.length > 0) {
|
||||||
|
setClosingId(id);
|
||||||
|
} else {
|
||||||
|
closeEntry(id, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseConfirm = (closeLinked: boolean) => {
|
||||||
|
if (closingId) {
|
||||||
|
closeEntry(closingId, closeLinked);
|
||||||
|
setClosingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setViewMode('list');
|
setViewMode('list');
|
||||||
setEditingEntry(null);
|
setEditingEntry(null);
|
||||||
@@ -50,18 +71,24 @@ export function RegistraturaModule() {
|
|||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
const total = allEntries.length;
|
const total = allEntries.length;
|
||||||
const incoming = allEntries.filter((e) => e.type === 'incoming').length;
|
const open = allEntries.filter((e) => e.status === 'deschis').length;
|
||||||
const outgoing = allEntries.filter((e) => e.type === 'outgoing').length;
|
const overdue = allEntries.filter((e) => {
|
||||||
const inProgress = allEntries.filter((e) => e.status === 'in-progress').length;
|
if (e.status !== 'deschis') return false;
|
||||||
|
const days = getOverdueDays(e.deadline);
|
||||||
|
return days !== null && days > 0;
|
||||||
|
}).length;
|
||||||
|
const intrat = allEntries.filter((e) => e.direction === 'intrat').length;
|
||||||
|
|
||||||
|
const closingEntry = closingId ? allEntries.find((e) => e.id === closingId) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<StatCard label="Total" value={total} />
|
<StatCard label="Total" value={total} />
|
||||||
<StatCard label="Intrare" value={incoming} />
|
<StatCard label="Deschise" value={open} />
|
||||||
<StatCard label="Ieșire" value={outgoing} />
|
<StatCard label="Depășite" value={overdue} variant={overdue > 0 ? 'destructive' : undefined} />
|
||||||
<StatCard label="În lucru" value={inProgress} />
|
<StatCard label="Intrate" value={intrat} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewMode === 'list' && (
|
{viewMode === 'list' && (
|
||||||
@@ -78,6 +105,7 @@ export function RegistraturaModule() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onClose={handleCloseRequest}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!loading && (
|
{!loading && (
|
||||||
@@ -97,7 +125,11 @@ export function RegistraturaModule() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<RegistryEntryForm onSubmit={handleAdd} onCancel={handleCancel} />
|
<RegistryEntryForm
|
||||||
|
allEntries={allEntries}
|
||||||
|
onSubmit={handleAdd}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -108,20 +140,51 @@ export function RegistraturaModule() {
|
|||||||
<CardTitle>Editare — {editingEntry.number}</CardTitle>
|
<CardTitle>Editare — {editingEntry.number}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<RegistryEntryForm initial={editingEntry} onSubmit={handleUpdate} onCancel={handleCancel} />
|
<RegistryEntryForm
|
||||||
|
initial={editingEntry}
|
||||||
|
allEntries={allEntries}
|
||||||
|
onSubmit={handleUpdate}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Close confirmation dialog */}
|
||||||
|
<Dialog open={closingId !== null} onOpenChange={(open) => { if (!open) setClosingId(null); }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Închide înregistrarea</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2">
|
||||||
|
<p className="text-sm">
|
||||||
|
Această înregistrare are {closingEntry?.linkedEntryIds.length ?? 0} înregistrări legate.
|
||||||
|
Vrei să le închizi și pe acestea?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setClosingId(null)}>Anulează</Button>
|
||||||
|
<Button variant="secondary" onClick={() => handleCloseConfirm(false)}>
|
||||||
|
Doar aceasta
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => handleCloseConfirm(true)}>
|
||||||
|
Închide toate legate
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ label, value }: { label: string; value: number }) {
|
function StatCard({ label, value, variant }: { label: string; value: number; variant?: 'destructive' }) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
<p className="text-2xl font-bold">{value}</p>
|
<p className={`text-2xl font-bold ${variant === 'destructive' && value > 0 ? 'text-destructive' : ''}`}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,40 +1,116 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useMemo, useRef } from 'react';
|
||||||
|
import { Paperclip, X } from 'lucide-react';
|
||||||
import type { CompanyId } from '@/core/auth/types';
|
import type { CompanyId } from '@/core/auth/types';
|
||||||
import type { RegistryEntry, RegistryEntryType, RegistryEntryStatus } from '../types';
|
import type { RegistryEntry, RegistryDirection, RegistryStatus, DocumentType, RegistryAttachment } from '../types';
|
||||||
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 { Textarea } from '@/shared/components/ui/textarea';
|
||||||
import { Button } from '@/shared/components/ui/button';
|
import { Button } from '@/shared/components/ui/button';
|
||||||
|
import { Badge } from '@/shared/components/ui/badge';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
||||||
|
import { useContacts } from '@/modules/address-book/hooks/use-contacts';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
interface RegistryEntryFormProps {
|
interface RegistryEntryFormProps {
|
||||||
initial?: RegistryEntry;
|
initial?: RegistryEntry;
|
||||||
|
allEntries?: RegistryEntry[];
|
||||||
onSubmit: (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => void;
|
onSubmit: (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RegistryEntryForm({ initial, onSubmit, onCancel }: RegistryEntryFormProps) {
|
const DOC_TYPE_LABELS: Record<DocumentType, string> = {
|
||||||
const [type, setType] = useState<RegistryEntryType>(initial?.type ?? 'incoming');
|
contract: 'Contract',
|
||||||
|
oferta: 'Ofertă',
|
||||||
|
factura: 'Factură',
|
||||||
|
scrisoare: 'Scrisoare',
|
||||||
|
aviz: 'Aviz',
|
||||||
|
'nota-de-comanda': 'Notă de comandă',
|
||||||
|
raport: 'Raport',
|
||||||
|
cerere: 'Cerere',
|
||||||
|
altele: 'Altele',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: RegistryEntryFormProps) {
|
||||||
|
const { allContacts } = useContacts();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [direction, setDirection] = useState<RegistryDirection>(initial?.direction ?? 'intrat');
|
||||||
|
const [documentType, setDocumentType] = useState<DocumentType>(initial?.documentType ?? 'scrisoare');
|
||||||
const [subject, setSubject] = useState(initial?.subject ?? '');
|
const [subject, setSubject] = useState(initial?.subject ?? '');
|
||||||
const [date, setDate] = useState(initial?.date ?? new Date().toISOString().slice(0, 10));
|
const [date, setDate] = useState(initial?.date ?? new Date().toISOString().slice(0, 10));
|
||||||
const [sender, setSender] = useState(initial?.sender ?? '');
|
const [sender, setSender] = useState(initial?.sender ?? '');
|
||||||
|
const [senderContactId, setSenderContactId] = useState(initial?.senderContactId ?? '');
|
||||||
const [recipient, setRecipient] = useState(initial?.recipient ?? '');
|
const [recipient, setRecipient] = useState(initial?.recipient ?? '');
|
||||||
|
const [recipientContactId, setRecipientContactId] = useState(initial?.recipientContactId ?? '');
|
||||||
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
|
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
|
||||||
const [status, setStatus] = useState<RegistryEntryStatus>(initial?.status ?? 'registered');
|
const [status, setStatus] = useState<RegistryStatus>(initial?.status ?? 'deschis');
|
||||||
|
const [deadline, setDeadline] = useState(initial?.deadline ?? '');
|
||||||
const [notes, setNotes] = useState(initial?.notes ?? '');
|
const [notes, setNotes] = useState(initial?.notes ?? '');
|
||||||
|
const [linkedEntryIds, setLinkedEntryIds] = useState<string[]>(initial?.linkedEntryIds ?? []);
|
||||||
|
const [attachments, setAttachments] = useState<RegistryAttachment[]>(initial?.attachments ?? []);
|
||||||
|
|
||||||
|
// ── Sender/Recipient autocomplete suggestions ──
|
||||||
|
const [senderFocused, setSenderFocused] = useState(false);
|
||||||
|
const [recipientFocused, setRecipientFocused] = useState(false);
|
||||||
|
|
||||||
|
const senderSuggestions = useMemo(() => {
|
||||||
|
if (!sender || sender.length < 2) return [];
|
||||||
|
const q = sender.toLowerCase();
|
||||||
|
return allContacts.filter((c) => c.name.toLowerCase().includes(q) || c.company.toLowerCase().includes(q)).slice(0, 5);
|
||||||
|
}, [allContacts, sender]);
|
||||||
|
|
||||||
|
const recipientSuggestions = useMemo(() => {
|
||||||
|
if (!recipient || recipient.length < 2) return [];
|
||||||
|
const q = recipient.toLowerCase();
|
||||||
|
return allContacts.filter((c) => c.name.toLowerCase().includes(q) || c.company.toLowerCase().includes(q)).slice(0, 5);
|
||||||
|
}, [allContacts, recipient]);
|
||||||
|
|
||||||
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files) return;
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const base64 = reader.result as string;
|
||||||
|
setAttachments((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: uuid(),
|
||||||
|
name: file.name,
|
||||||
|
data: base64,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
addedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAttachment = (id: string) => {
|
||||||
|
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSubmit({
|
onSubmit({
|
||||||
type,
|
direction,
|
||||||
|
documentType,
|
||||||
subject,
|
subject,
|
||||||
date,
|
date,
|
||||||
sender,
|
sender,
|
||||||
|
senderContactId: senderContactId || undefined,
|
||||||
recipient,
|
recipient,
|
||||||
|
recipientContactId: recipientContactId || undefined,
|
||||||
company,
|
company,
|
||||||
status,
|
status,
|
||||||
|
deadline: deadline || undefined,
|
||||||
|
linkedEntryIds,
|
||||||
|
attachments,
|
||||||
notes,
|
notes,
|
||||||
tags: initial?.tags ?? [],
|
tags: initial?.tags ?? [],
|
||||||
visibility: initial?.visibility ?? 'all',
|
visibility: initial?.visibility ?? 'all',
|
||||||
@@ -43,15 +119,26 @@ export function RegistryEntryForm({ initial, onSubmit, onCancel }: RegistryEntry
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
{/* Row 1: Direction + Document type + Date */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>Tip document</Label>
|
<Label>Direcție</Label>
|
||||||
<Select value={type} onValueChange={(v) => setType(v as RegistryEntryType)}>
|
<Select value={direction} onValueChange={(v) => setDirection(v as RegistryDirection)}>
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="incoming">Intrare</SelectItem>
|
<SelectItem value="intrat">Intrat</SelectItem>
|
||||||
<SelectItem value="outgoing">Ieșire</SelectItem>
|
<SelectItem value="iesit">Ieșit</SelectItem>
|
||||||
<SelectItem value="internal">Intern</SelectItem>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Tip document</Label>
|
||||||
|
<Select value={documentType} onValueChange={(v) => setDocumentType(v as DocumentType)}>
|
||||||
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.entries(DOC_TYPE_LABELS) as [DocumentType, string][]).map(([key, label]) => (
|
||||||
|
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,23 +148,78 @@ export function RegistryEntryForm({ initial, onSubmit, onCancel }: RegistryEntry
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Subject */}
|
||||||
<div>
|
<div>
|
||||||
<Label>Subiect</Label>
|
<Label>Subiect *</Label>
|
||||||
<Input value={subject} onChange={(e) => setSubject(e.target.value)} className="mt-1" required />
|
<Input value={subject} onChange={(e) => setSubject(e.target.value)} className="mt-1" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sender / Recipient with autocomplete */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div className="relative">
|
||||||
<Label>Expeditor</Label>
|
<Label>Expeditor</Label>
|
||||||
<Input value={sender} onChange={(e) => setSender(e.target.value)} className="mt-1" />
|
<Input
|
||||||
|
value={sender}
|
||||||
|
onChange={(e) => { setSender(e.target.value); setSenderContactId(''); }}
|
||||||
|
onFocus={() => setSenderFocused(true)}
|
||||||
|
onBlur={() => setTimeout(() => setSenderFocused(false), 200)}
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="Nume sau companie..."
|
||||||
|
/>
|
||||||
|
{senderFocused && senderSuggestions.length > 0 && (
|
||||||
|
<div className="absolute z-10 mt-1 w-full rounded-md border bg-popover p-1 shadow-md">
|
||||||
|
{senderSuggestions.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
|
||||||
|
onMouseDown={() => {
|
||||||
|
setSender(c.company ? `${c.name} (${c.company})` : c.name);
|
||||||
|
setSenderContactId(c.id);
|
||||||
|
setSenderFocused(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{c.name}</span>
|
||||||
|
{c.company && <span className="ml-1 text-muted-foreground text-xs">{c.company}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="relative">
|
||||||
<Label>Destinatar</Label>
|
<Label>Destinatar</Label>
|
||||||
<Input value={recipient} onChange={(e) => setRecipient(e.target.value)} className="mt-1" />
|
<Input
|
||||||
|
value={recipient}
|
||||||
|
onChange={(e) => { setRecipient(e.target.value); setRecipientContactId(''); }}
|
||||||
|
onFocus={() => setRecipientFocused(true)}
|
||||||
|
onBlur={() => setTimeout(() => setRecipientFocused(false), 200)}
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="Nume sau companie..."
|
||||||
|
/>
|
||||||
|
{recipientFocused && recipientSuggestions.length > 0 && (
|
||||||
|
<div className="absolute z-10 mt-1 w-full rounded-md border bg-popover p-1 shadow-md">
|
||||||
|
{recipientSuggestions.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
|
||||||
|
onMouseDown={() => {
|
||||||
|
setRecipient(c.company ? `${c.name} (${c.company})` : c.name);
|
||||||
|
setRecipientContactId(c.id);
|
||||||
|
setRecipientFocused(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{c.name}</span>
|
||||||
|
{c.company && <span className="ml-1 text-muted-foreground text-xs">{c.company}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
{/* Company + Status + Deadline */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>Companie</Label>
|
<Label>Companie</Label>
|
||||||
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
|
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
|
||||||
@@ -92,18 +234,85 @@ export function RegistryEntryForm({ initial, onSubmit, onCancel }: RegistryEntry
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Status</Label>
|
<Label>Status</Label>
|
||||||
<Select value={status} onValueChange={(v) => setStatus(v as RegistryEntryStatus)}>
|
<Select value={status} onValueChange={(v) => setStatus(v as RegistryStatus)}>
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="registered">Înregistrat</SelectItem>
|
<SelectItem value="deschis">Deschis</SelectItem>
|
||||||
<SelectItem value="in-progress">În lucru</SelectItem>
|
<SelectItem value="inchis">Închis</SelectItem>
|
||||||
<SelectItem value="completed">Finalizat</SelectItem>
|
|
||||||
<SelectItem value="archived">Arhivat</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Termen limită</Label>
|
||||||
|
<Input type="date" value={deadline} onChange={(e) => setDeadline(e.target.value)} className="mt-1" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Linked entries */}
|
||||||
|
{allEntries && allEntries.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label>Înregistrări legate</Label>
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||||
|
{allEntries
|
||||||
|
.filter((e) => e.id !== initial?.id)
|
||||||
|
.slice(0, 20)
|
||||||
|
.map((e) => (
|
||||||
|
<button
|
||||||
|
key={e.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setLinkedEntryIds((prev) =>
|
||||||
|
prev.includes(e.id) ? prev.filter((id) => id !== e.id) : [...prev, e.id]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className={`rounded border px-2 py-0.5 text-xs transition-colors ${
|
||||||
|
linkedEntryIds.includes(e.id)
|
||||||
|
? 'border-primary bg-primary/10 text-primary'
|
||||||
|
: 'border-muted-foreground/30 text-muted-foreground hover:border-primary/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{e.number}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attachments */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Atașamente</Label>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||||
|
<Paperclip className="mr-1 h-3.5 w-3.5" /> Adaugă fișier
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.xls,.xlsx"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{attachments.map((att) => (
|
||||||
|
<div key={att.id} className="flex items-center gap-2 rounded border px-2 py-1 text-sm">
|
||||||
|
<Paperclip className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="flex-1 truncate">{att.name}</span>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{(att.size / 1024).toFixed(0)} KB
|
||||||
|
</Badge>
|
||||||
|
<button type="button" onClick={() => removeAttachment(att.id)} className="text-destructive">
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
<div>
|
<div>
|
||||||
<Label>Note</Label>
|
<Label>Note</Label>
|
||||||
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={3} className="mt-1" />
|
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={3} className="mt-1" />
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ interface RegistryFiltersProps {
|
|||||||
onUpdate: <K extends keyof Filters>(key: K, value: Filters[K]) => void;
|
onUpdate: <K extends keyof Filters>(key: K, value: Filters[K]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||||
|
contract: 'Contract',
|
||||||
|
oferta: 'Ofertă',
|
||||||
|
factura: 'Factură',
|
||||||
|
scrisoare: 'Scrisoare',
|
||||||
|
aviz: 'Aviz',
|
||||||
|
'nota-de-comanda': 'Notă de comandă',
|
||||||
|
raport: 'Raport',
|
||||||
|
cerere: 'Cerere',
|
||||||
|
altele: 'Altele',
|
||||||
|
};
|
||||||
|
|
||||||
export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) {
|
export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
@@ -23,28 +35,37 @@ export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select value={filters.type} onValueChange={(v) => onUpdate('type', v as Filters['type'])}>
|
<Select value={filters.direction} onValueChange={(v) => onUpdate('direction', v as Filters['direction'])}>
|
||||||
<SelectTrigger className="w-[150px]">
|
<SelectTrigger className="w-[130px]">
|
||||||
<SelectValue placeholder="Tip" />
|
<SelectValue placeholder="Direcție" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Toate</SelectItem>
|
||||||
|
<SelectItem value="intrat">Intrat</SelectItem>
|
||||||
|
<SelectItem value="iesit">Ieșit</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={filters.documentType} onValueChange={(v) => onUpdate('documentType', v as Filters['documentType'])}>
|
||||||
|
<SelectTrigger className="w-[160px]">
|
||||||
|
<SelectValue placeholder="Tip document" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Toate tipurile</SelectItem>
|
<SelectItem value="all">Toate tipurile</SelectItem>
|
||||||
<SelectItem value="incoming">Intrare</SelectItem>
|
{Object.entries(DOC_TYPE_LABELS).map(([key, label]) => (
|
||||||
<SelectItem value="outgoing">Ieșire</SelectItem>
|
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||||
<SelectItem value="internal">Intern</SelectItem>
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={filters.status} onValueChange={(v) => onUpdate('status', v as Filters['status'])}>
|
<Select value={filters.status} onValueChange={(v) => onUpdate('status', v as Filters['status'])}>
|
||||||
<SelectTrigger className="w-[150px]">
|
<SelectTrigger className="w-[130px]">
|
||||||
<SelectValue placeholder="Status" />
|
<SelectValue placeholder="Status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Toate</SelectItem>
|
<SelectItem value="all">Toate</SelectItem>
|
||||||
<SelectItem value="registered">Înregistrat</SelectItem>
|
<SelectItem value="deschis">Deschis</SelectItem>
|
||||||
<SelectItem value="in-progress">În lucru</SelectItem>
|
<SelectItem value="inchis">Închis</SelectItem>
|
||||||
<SelectItem value="completed">Finalizat</SelectItem>
|
|
||||||
<SelectItem value="archived">Arhivat</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Pencil, Trash2 } from 'lucide-react';
|
import { Pencil, Trash2, CheckCircle2, Link2 } from 'lucide-react';
|
||||||
import { Button } from '@/shared/components/ui/button';
|
import { Button } from '@/shared/components/ui/button';
|
||||||
import { Badge } from '@/shared/components/ui/badge';
|
import { Badge } from '@/shared/components/ui/badge';
|
||||||
import type { RegistryEntry } from '../types';
|
import type { RegistryEntry, DocumentType } from '../types';
|
||||||
|
import { getOverdueDays } from '../services/registry-service';
|
||||||
import { cn } from '@/shared/lib/utils';
|
import { cn } from '@/shared/lib/utils';
|
||||||
|
|
||||||
interface RegistryTableProps {
|
interface RegistryTableProps {
|
||||||
@@ -11,29 +12,32 @@ interface RegistryTableProps {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
onEdit: (entry: RegistryEntry) => void;
|
onEdit: (entry: RegistryEntry) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
|
onClose: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_LABELS: Record<string, string> = {
|
const DIRECTION_LABELS: Record<string, string> = {
|
||||||
incoming: 'Intrare',
|
intrat: 'Intrat',
|
||||||
outgoing: 'Ieșire',
|
iesit: 'Ieșit',
|
||||||
internal: 'Intern',
|
};
|
||||||
|
|
||||||
|
const DOC_TYPE_LABELS: Record<DocumentType, string> = {
|
||||||
|
contract: 'Contract',
|
||||||
|
oferta: 'Ofertă',
|
||||||
|
factura: 'Factură',
|
||||||
|
scrisoare: 'Scrisoare',
|
||||||
|
aviz: 'Aviz',
|
||||||
|
'nota-de-comanda': 'Notă comandă',
|
||||||
|
raport: 'Raport',
|
||||||
|
cerere: 'Cerere',
|
||||||
|
altele: 'Altele',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
registered: 'Înregistrat',
|
deschis: 'Deschis',
|
||||||
'in-progress': 'În lucru',
|
inchis: 'Închis',
|
||||||
completed: 'Finalizat',
|
|
||||||
archived: 'Arhivat',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'outline' | 'destructive'> = {
|
export function RegistryTable({ entries, loading, onEdit, onDelete, onClose }: RegistryTableProps) {
|
||||||
registered: 'default',
|
|
||||||
'in-progress': 'secondary',
|
|
||||||
completed: 'outline',
|
|
||||||
archived: 'outline',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function RegistryTable({ entries, loading, onEdit, onDelete }: RegistryTableProps) {
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>;
|
return <p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>;
|
||||||
}
|
}
|
||||||
@@ -53,40 +57,90 @@ export function RegistryTable({ entries, loading, onEdit, onDelete }: RegistryTa
|
|||||||
<tr className="border-b bg-muted/40">
|
<tr className="border-b bg-muted/40">
|
||||||
<th className="px-3 py-2 text-left font-medium">Nr.</th>
|
<th className="px-3 py-2 text-left font-medium">Nr.</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Data</th>
|
<th className="px-3 py-2 text-left font-medium">Data</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Dir.</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Tip</th>
|
<th className="px-3 py-2 text-left font-medium">Tip</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Subiect</th>
|
<th className="px-3 py-2 text-left font-medium">Subiect</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Expeditor</th>
|
<th className="px-3 py-2 text-left font-medium">Expeditor</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Destinatar</th>
|
<th className="px-3 py-2 text-left font-medium">Destinatar</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Termen</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Status</th>
|
<th className="px-3 py-2 text-left font-medium">Status</th>
|
||||||
<th className="px-3 py-2 text-right font-medium">Acțiuni</th>
|
<th className="px-3 py-2 text-right font-medium">Acțiuni</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => {
|
||||||
<tr key={entry.id} className={cn('border-b hover:bg-muted/20 transition-colors')}>
|
const overdueDays = entry.status === 'deschis' ? getOverdueDays(entry.deadline) : null;
|
||||||
<td className="px-3 py-2 font-mono text-xs">{entry.number}</td>
|
const isOverdue = overdueDays !== null && overdueDays > 0;
|
||||||
<td className="px-3 py-2 text-xs whitespace-nowrap">{formatDate(entry.date)}</td>
|
return (
|
||||||
<td className="px-3 py-2">
|
<tr
|
||||||
<Badge variant="outline" className="text-xs">{TYPE_LABELS[entry.type]}</Badge>
|
key={entry.id}
|
||||||
</td>
|
className={cn(
|
||||||
<td className="px-3 py-2 max-w-[250px] truncate">{entry.subject}</td>
|
'border-b transition-colors hover:bg-muted/20',
|
||||||
<td className="px-3 py-2 max-w-[150px] truncate">{entry.sender}</td>
|
isOverdue && 'bg-destructive/5'
|
||||||
<td className="px-3 py-2 max-w-[150px] truncate">{entry.recipient}</td>
|
)}
|
||||||
<td className="px-3 py-2">
|
>
|
||||||
<Badge variant={STATUS_VARIANT[entry.status]}>{STATUS_LABELS[entry.status]}</Badge>
|
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">{entry.number}</td>
|
||||||
</td>
|
<td className="px-3 py-2 text-xs whitespace-nowrap">{formatDate(entry.date)}</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2">
|
||||||
<div className="flex justify-end gap-1">
|
<Badge
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onEdit(entry)}>
|
variant={entry.direction === 'intrat' ? 'default' : 'secondary'}
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
className="text-xs"
|
||||||
</Button>
|
>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => onDelete(entry.id)}>
|
{DIRECTION_LABELS[entry.direction]}
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
</Badge>
|
||||||
</Button>
|
</td>
|
||||||
</div>
|
<td className="px-3 py-2 text-xs">{DOC_TYPE_LABELS[entry.documentType]}</td>
|
||||||
</td>
|
<td className="px-3 py-2 max-w-[200px] truncate">
|
||||||
</tr>
|
{entry.subject}
|
||||||
))}
|
{entry.linkedEntryIds.length > 0 && (
|
||||||
|
<Link2 className="ml-1 inline h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{entry.attachments.length > 0 && (
|
||||||
|
<Badge variant="outline" className="ml-1 text-[10px] px-1">
|
||||||
|
{entry.attachments.length} fișiere
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 max-w-[130px] truncate">{entry.sender}</td>
|
||||||
|
<td className="px-3 py-2 max-w-[130px] truncate">{entry.recipient}</td>
|
||||||
|
<td className="px-3 py-2 text-xs whitespace-nowrap">
|
||||||
|
{entry.deadline ? (
|
||||||
|
<span className={cn(isOverdue && 'font-medium text-destructive')}>
|
||||||
|
{formatDate(entry.deadline)}
|
||||||
|
{overdueDays !== null && overdueDays > 0 && (
|
||||||
|
<span className="ml-1 text-[10px]">({overdueDays}z depășit)</span>
|
||||||
|
)}
|
||||||
|
{overdueDays !== null && overdueDays < 0 && (
|
||||||
|
<span className="ml-1 text-[10px] text-muted-foreground">({Math.abs(overdueDays)}z)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Badge variant={entry.status === 'deschis' ? 'default' : 'outline'}>
|
||||||
|
{STATUS_LABELS[entry.status]}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
{entry.status === 'deschis' && (
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-green-600" onClick={() => onClose(entry.id)} title="Închide">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onEdit(entry)}>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => onDelete(entry.id)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
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 { RegistryEntry, RegistryEntryType, RegistryEntryStatus } from '../types';
|
import type { RegistryEntry, RegistryDirection, RegistryStatus, DocumentType } from '../types';
|
||||||
import { getAllEntries, saveEntry, deleteEntry, generateRegistryNumber } from '../services/registry-service';
|
import { getAllEntries, saveEntry, deleteEntry, generateRegistryNumber } from '../services/registry-service';
|
||||||
|
|
||||||
export interface RegistryFilters {
|
export interface RegistryFilters {
|
||||||
search: string;
|
search: string;
|
||||||
type: RegistryEntryType | 'all';
|
direction: RegistryDirection | 'all';
|
||||||
status: RegistryEntryStatus | 'all';
|
status: RegistryStatus | 'all';
|
||||||
|
documentType: DocumentType | 'all';
|
||||||
company: string;
|
company: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,8 +20,9 @@ export function useRegistry() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filters, setFilters] = useState<RegistryFilters>({
|
const [filters, setFilters] = useState<RegistryFilters>({
|
||||||
search: '',
|
search: '',
|
||||||
type: 'all',
|
direction: 'all',
|
||||||
status: 'all',
|
status: 'all',
|
||||||
|
documentType: 'all',
|
||||||
company: 'all',
|
company: 'all',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,18 +38,18 @@ export function useRegistry() {
|
|||||||
|
|
||||||
const addEntry = useCallback(async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => {
|
const addEntry = useCallback(async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const nextIndex = entries.length + 1;
|
const number = generateRegistryNumber(data.company, data.date, entries);
|
||||||
const entry: RegistryEntry = {
|
const entry: RegistryEntry = {
|
||||||
...data,
|
...data,
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
number: generateRegistryNumber(data.date, nextIndex),
|
number,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
await saveEntry(storage, entry);
|
await saveEntry(storage, entry);
|
||||||
await refresh();
|
await refresh();
|
||||||
return entry;
|
return entry;
|
||||||
}, [storage, refresh, entries.length]);
|
}, [storage, refresh, entries]);
|
||||||
|
|
||||||
const updateEntry = useCallback(async (id: string, updates: Partial<RegistryEntry>) => {
|
const updateEntry = useCallback(async (id: string, updates: Partial<RegistryEntry>) => {
|
||||||
const existing = entries.find((e) => e.id === id);
|
const existing = entries.find((e) => e.id === id);
|
||||||
@@ -69,13 +71,35 @@ export function useRegistry() {
|
|||||||
await refresh();
|
await refresh();
|
||||||
}, [storage, refresh]);
|
}, [storage, refresh]);
|
||||||
|
|
||||||
|
/** Close an entry and optionally its linked entries */
|
||||||
|
const closeEntry = useCallback(async (id: string, closeLinked: boolean) => {
|
||||||
|
const entry = entries.find((e) => e.id === id);
|
||||||
|
if (!entry) return;
|
||||||
|
await updateEntry(id, { status: 'inchis' });
|
||||||
|
if (closeLinked && entry.linkedEntryIds.length > 0) {
|
||||||
|
for (const linkedId of entry.linkedEntryIds) {
|
||||||
|
const linked = entries.find((e) => e.id === linkedId);
|
||||||
|
if (linked && linked.status !== 'inchis') {
|
||||||
|
const updatedLinked: RegistryEntry = {
|
||||||
|
...linked,
|
||||||
|
status: 'inchis',
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await saveEntry(storage, updatedLinked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
}, [entries, updateEntry, storage, refresh]);
|
||||||
|
|
||||||
const updateFilter = useCallback(<K extends keyof RegistryFilters>(key: K, value: RegistryFilters[K]) => {
|
const updateFilter = useCallback(<K extends keyof RegistryFilters>(key: K, value: RegistryFilters[K]) => {
|
||||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filteredEntries = entries.filter((entry) => {
|
const filteredEntries = entries.filter((entry) => {
|
||||||
if (filters.type !== 'all' && entry.type !== filters.type) return false;
|
if (filters.direction !== 'all' && entry.direction !== filters.direction) return false;
|
||||||
if (filters.status !== 'all' && entry.status !== filters.status) return false;
|
if (filters.status !== 'all' && entry.status !== filters.status) return false;
|
||||||
|
if (filters.documentType !== 'all' && entry.documentType !== filters.documentType) return false;
|
||||||
if (filters.company !== 'all' && entry.company !== filters.company) return false;
|
if (filters.company !== 'all' && entry.company !== filters.company) return false;
|
||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
const q = filters.search.toLowerCase();
|
const q = filters.search.toLowerCase();
|
||||||
@@ -83,7 +107,7 @@ export function useRegistry() {
|
|||||||
entry.subject.toLowerCase().includes(q) ||
|
entry.subject.toLowerCase().includes(q) ||
|
||||||
entry.sender.toLowerCase().includes(q) ||
|
entry.sender.toLowerCase().includes(q) ||
|
||||||
entry.recipient.toLowerCase().includes(q) ||
|
entry.recipient.toLowerCase().includes(q) ||
|
||||||
entry.number.includes(q)
|
entry.number.toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -98,6 +122,7 @@ export function useRegistry() {
|
|||||||
addEntry,
|
addEntry,
|
||||||
updateEntry,
|
updateEntry,
|
||||||
removeEntry,
|
removeEntry,
|
||||||
|
closeEntry,
|
||||||
refresh,
|
refresh,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export { registraturaConfig } from './config';
|
export { registraturaConfig } from './config';
|
||||||
export { RegistraturaModule } from './components/registratura-module';
|
export { RegistraturaModule } from './components/registratura-module';
|
||||||
export type { RegistryEntry, RegistryEntryType, RegistryEntryStatus } from './types';
|
export type { RegistryEntry, RegistryDirection, RegistryStatus, DocumentType } from './types';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { CompanyId } from '@/core/auth/types';
|
||||||
import type { RegistryEntry } from '../types';
|
import type { RegistryEntry } from '../types';
|
||||||
|
|
||||||
const STORAGE_PREFIX = 'entry:';
|
const STORAGE_PREFIX = 'entry:';
|
||||||
@@ -30,9 +31,44 @@ export async function deleteEntry(storage: RegistryStorage, id: string): Promise
|
|||||||
await storage.delete(`${STORAGE_PREFIX}${id}`);
|
await storage.delete(`${STORAGE_PREFIX}${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateRegistryNumber(date: string, index: number): string {
|
const COMPANY_PREFIXES: Record<CompanyId, string> = {
|
||||||
|
beletage: 'B',
|
||||||
|
'urban-switch': 'US',
|
||||||
|
'studii-de-teren': 'SDT',
|
||||||
|
group: 'G',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate company-specific registry number: B-0001/2026
|
||||||
|
* Uses the next sequential number for that company in that year.
|
||||||
|
*/
|
||||||
|
export function generateRegistryNumber(
|
||||||
|
company: CompanyId,
|
||||||
|
date: string,
|
||||||
|
existingEntries: RegistryEntry[]
|
||||||
|
): string {
|
||||||
const d = new Date(date);
|
const d = new Date(date);
|
||||||
const year = d.getFullYear();
|
const year = d.getFullYear();
|
||||||
const padded = String(index).padStart(4, '0');
|
const prefix = COMPANY_PREFIXES[company];
|
||||||
return `${padded}/${year}`;
|
|
||||||
|
// Count existing entries for this company in this year
|
||||||
|
const sameCompanyYear = existingEntries.filter((e) => {
|
||||||
|
const entryYear = new Date(e.date).getFullYear();
|
||||||
|
return e.company === company && entryYear === year;
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextIndex = sameCompanyYear.length + 1;
|
||||||
|
const padded = String(nextIndex).padStart(4, '0');
|
||||||
|
return `${prefix}-${padded}/${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calculate days overdue (negative = days remaining, positive = overdue) */
|
||||||
|
export function getOverdueDays(deadline: string | undefined): number | null {
|
||||||
|
if (!deadline) return null;
|
||||||
|
const now = new Date();
|
||||||
|
now.setHours(0, 0, 0, 0);
|
||||||
|
const dl = new Date(deadline);
|
||||||
|
dl.setHours(0, 0, 0, 0);
|
||||||
|
const diff = now.getTime() - dl.getTime();
|
||||||
|
return Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,57 @@
|
|||||||
import type { Visibility } from '@/core/module-registry/types';
|
import type { Visibility } from '@/core/module-registry/types';
|
||||||
import type { CompanyId } from '@/core/auth/types';
|
import type { CompanyId } from '@/core/auth/types';
|
||||||
|
|
||||||
export type RegistryEntryType = 'incoming' | 'outgoing' | 'internal';
|
/** Document direction — simplified from the old 3-way type */
|
||||||
|
export type RegistryDirection = 'intrat' | 'iesit';
|
||||||
|
|
||||||
export type RegistryEntryStatus =
|
/** Document type categories */
|
||||||
| 'registered'
|
export type DocumentType =
|
||||||
| 'in-progress'
|
| 'contract'
|
||||||
| 'completed'
|
| 'oferta'
|
||||||
| 'archived';
|
| 'factura'
|
||||||
|
| 'scrisoare'
|
||||||
|
| 'aviz'
|
||||||
|
| 'nota-de-comanda'
|
||||||
|
| 'raport'
|
||||||
|
| 'cerere'
|
||||||
|
| 'altele';
|
||||||
|
|
||||||
|
/** Status — simplified to open/closed */
|
||||||
|
export type RegistryStatus = 'deschis' | 'inchis';
|
||||||
|
|
||||||
|
/** File attachment */
|
||||||
|
export interface RegistryAttachment {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** base64-encoded content or URL */
|
||||||
|
data: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
addedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegistryEntry {
|
export interface RegistryEntry {
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Company-specific number: B-0001/2026, US-0001/2026, SDT-0001/2026 */
|
||||||
number: string;
|
number: string;
|
||||||
date: string;
|
date: string;
|
||||||
type: RegistryEntryType;
|
direction: RegistryDirection;
|
||||||
|
documentType: DocumentType;
|
||||||
subject: string;
|
subject: string;
|
||||||
|
/** Expeditor — free text or linked contact ID */
|
||||||
sender: string;
|
sender: string;
|
||||||
|
senderContactId?: string;
|
||||||
|
/** Destinatar — free text or linked contact ID */
|
||||||
recipient: string;
|
recipient: string;
|
||||||
|
recipientContactId?: string;
|
||||||
company: CompanyId;
|
company: CompanyId;
|
||||||
status: RegistryEntryStatus;
|
status: RegistryStatus;
|
||||||
|
/** Deadline date (YYYY-MM-DD) */
|
||||||
|
deadline?: string;
|
||||||
|
/** Linked entry IDs (for closing/archiving related entries) */
|
||||||
|
linkedEntryIds: string[];
|
||||||
|
/** File attachments */
|
||||||
|
attachments: RegistryAttachment[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
notes: string;
|
notes: string;
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
|
|||||||
Reference in New Issue
Block a user