Compare commits

..

8 Commits

Author SHA1 Message Date
Marius Tarau
2c9f0bc6b7 feat(word-templates): add company pools, template cloning, typed categories, and placeholder display
- TemplateCategory union type (8 values) replacing plain string
- Company-specific filtering in template list
- Template cloning with clone badge indicator
- Placeholder display ({{VARIABLE}} markers) in card and form
- Delete confirmation dialog
- updatedAt timestamp support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 06:35:47 +02:00
Marius Tarau
455d95a8c6 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>
2026-02-18 06:35:42 +02:00
Marius Tarau
c3abbf1c4b feat(password-vault): add password generator, custom fields, and delete confirmation
- Password generator with configurable length and character types (upper/lower/digits/symbols)
- Custom fields support (key-value pairs per entry)
- Delete confirmation dialog
- Custom fields displayed as badges in list view

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 06:35:38 +02:00
Marius Tarau
f7e6cbbc65 feat(it-inventory): add IP/MAC/warranty/cost/rack/vendor/model fields and delete confirmation
- New fields: ipAddress, macAddress, warrantyExpiry, purchaseCost, rackLocation, vendor, model
- Delete confirmation dialog
- Expanded table columns for vendor/model and IP
- Search includes IP, vendor, and model
- Form layout with organized field groups

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 06:35:33 +02:00
Marius Tarau
93cf6feae2 feat(email-signature): wire SDT/US branding, address selector, color palettes, improved preview
- Per-company branding for Urban Switch and Studii de Teren (logos, websites, mottos)
- Beletage address selector (Str. Unirii vs Str. G-ral Eremia Grigorescu)
- Company-specific color palettes in configurator
- Scrollable preview with multi-level zoom (0.75x to 2.5x)
- Address override support in signature config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 06:35:28 +02:00
Marius Tarau
98eda56035 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>
2026-02-18 06:35:23 +02:00
Marius Tarau
84d9db4515 feat(address-book): rebuild with multi-contact, project links, and extended fields
- Add ContactPerson sub-entities for multi-contact per company
- Add department, role, website, secondary email/phone, projectIds fields
- Add internal contact type alongside client/supplier/institution/collaborator
- Project tag picker using core TagService project tags
- Updated search to include department and role

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 06:35:17 +02:00
Marius Tarau
f555258dcb feat(tag-manager): overhaul with 5 ordered categories, hierarchy, editing, and ManicTime seed import
- Reduce TagCategory to 5 ordered types: project, phase, activity, document-type, custom
- Add tag hierarchy (parent-child), projectCode field, updatedAt timestamp
- Add getChildren, updateTag, cascading deleteTag, searchTags, importTags to TagService
- ManicTime seed data parser (~95 Beletage projects, phases, activities, document types)
- Full UI rewrite: seed import dialog, inline editing, collapsible category sections

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 06:35:11 +02:00
39 changed files with 2319 additions and 430 deletions

View File

@@ -1,3 +1,4 @@
export type { Tag, TagCategory, TagScope } from './types'; export type { Tag, TagCategory, TagScope } from './types';
export { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from './types';
export { TagService } from './tag-service'; export { TagService } from './tag-service';
export { useTags } from './use-tags'; export { useTags } from './use-tags';

View File

@@ -30,6 +30,10 @@ export class TagService {
}); });
} }
async getChildren(parentId: string): Promise<Tag[]> {
return this.storage.query<Tag>(NAMESPACE, (tag) => tag.parentId === parentId);
}
async createTag(data: Omit<Tag, 'id' | 'createdAt'>): Promise<Tag> { async createTag(data: Omit<Tag, 'id' | 'createdAt'>): Promise<Tag> {
const tag: Tag = { const tag: Tag = {
...data, ...data,
@@ -43,19 +47,41 @@ export class TagService {
async updateTag(id: string, updates: Partial<Omit<Tag, 'id' | 'createdAt'>>): Promise<Tag | null> { async updateTag(id: string, updates: Partial<Omit<Tag, 'id' | 'createdAt'>>): Promise<Tag | null> {
const existing = await this.storage.get<Tag>(NAMESPACE, id); const existing = await this.storage.get<Tag>(NAMESPACE, id);
if (!existing) return null; if (!existing) return null;
const updated = { ...existing, ...updates }; const updated: Tag = { ...existing, ...updates, updatedAt: new Date().toISOString() };
await this.storage.set(NAMESPACE, id, updated); await this.storage.set(NAMESPACE, id, updated);
return updated; return updated;
} }
async deleteTag(id: string): Promise<void> { async deleteTag(id: string): Promise<void> {
// Also delete children
const children = await this.getChildren(id);
for (const child of children) {
await this.storage.delete(NAMESPACE, child.id);
}
await this.storage.delete(NAMESPACE, id); await this.storage.delete(NAMESPACE, id);
} }
async searchTags(query: string): Promise<Tag[]> { async searchTags(query: string): Promise<Tag[]> {
const lower = query.toLowerCase(); const lower = query.toLowerCase();
return this.storage.query<Tag>(NAMESPACE, (tag) => return this.storage.query<Tag>(NAMESPACE, (tag) =>
tag.label.toLowerCase().includes(lower) tag.label.toLowerCase().includes(lower) ||
(tag.projectCode?.toLowerCase().includes(lower) ?? false)
); );
} }
/** Bulk import tags (for seed data). Skips tags whose label already exists in same category. */
async importTags(tags: Omit<Tag, 'id' | 'createdAt'>[]): Promise<number> {
const existing = await this.getAllTags();
const existingKeys = new Set(existing.map((t) => `${t.category}::${t.label}`));
let imported = 0;
for (const data of tags) {
const key = `${data.category}::${data.label}`;
if (!existingKeys.has(key)) {
await this.createTag(data);
existingKeys.add(key);
imported++;
}
}
return imported;
}
} }

View File

@@ -5,11 +5,25 @@ export type TagCategory =
| 'phase' | 'phase'
| 'activity' | 'activity'
| 'document-type' | 'document-type'
| 'company'
| 'priority'
| 'status'
| 'custom'; | 'custom';
/** Display order for categories — project & phase are mandatory */
export const TAG_CATEGORY_ORDER: TagCategory[] = [
'project',
'phase',
'activity',
'document-type',
'custom',
];
export const TAG_CATEGORY_LABELS: Record<TagCategory, string> = {
project: 'Proiect',
phase: 'Fază',
activity: 'Activitate',
'document-type': 'Tip document',
custom: 'Personalizat',
};
export type TagScope = 'global' | 'module' | 'company'; export type TagScope = 'global' | 'module' | 'company';
export interface Tag { export interface Tag {
@@ -21,7 +35,11 @@ export interface Tag {
scope: TagScope; scope: TagScope;
moduleId?: string; moduleId?: string;
companyId?: CompanyId; companyId?: CompanyId;
/** For hierarchy: parent tag id */
parentId?: string; parentId?: string;
/** For project tags: numbered code e.g. "B-001", "US-024" */
projectCode?: string;
metadata?: Record<string, string>; metadata?: Record<string, string>;
createdAt: string; createdAt: string;
updatedAt?: string;
} }

View File

@@ -33,6 +33,15 @@ export function useTags(category?: TagCategory) {
[service, refresh] [service, refresh]
); );
const updateTag = useCallback(
async (id: string, updates: Partial<Omit<Tag, 'id' | 'createdAt'>>) => {
const tag = await service.updateTag(id, updates);
await refresh();
return tag;
},
[service, refresh]
);
const deleteTag = useCallback( const deleteTag = useCallback(
async (id: string) => { async (id: string) => {
await service.deleteTag(id); await service.deleteTag(id);
@@ -41,5 +50,21 @@ export function useTags(category?: TagCategory) {
[service, refresh] [service, refresh]
); );
return { tags, loading, createTag, deleteTag, refresh }; const importTags = useCallback(
async (data: Omit<Tag, 'id' | 'createdAt'>[]) => {
const count = await service.importTags(data);
await refresh();
return count;
},
[service, refresh]
);
const searchTags = useCallback(
async (query: string) => {
return service.searchTags(query);
},
[service]
);
return { tags, loading, createTag, updateTag, deleteTag, importTags, searchTags, refresh, service };
} }

View File

@@ -1,19 +1,29 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { Plus, Pencil, Trash2, Search, Mail, Phone, MapPin } from 'lucide-react'; import {
Plus, Pencil, Trash2, Search, Mail, Phone, MapPin,
Globe, Building2, UserPlus, X,
} 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 { 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 {
import type { AddressContact, ContactType } from '../types'; Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/shared/components/ui/select';
import type { AddressContact, ContactType, ContactPerson } from '../types';
import { useContacts } from '../hooks/use-contacts'; import { useContacts } from '../hooks/use-contacts';
import { useTags } from '@/core/tagging';
const TYPE_LABELS: Record<ContactType, string> = { const TYPE_LABELS: Record<ContactType, string> = {
client: 'Client', supplier: 'Furnizor', institution: 'Instituție', collaborator: 'Colaborator', client: 'Client',
supplier: 'Furnizor',
institution: 'Instituție',
collaborator: 'Colaborator',
internal: 'Intern',
}; };
type ViewMode = 'list' | 'add' | 'edit'; type ViewMode = 'list' | 'add' | 'edit';
@@ -23,7 +33,7 @@ export function AddressBookModule() {
const [viewMode, setViewMode] = useState<ViewMode>('list'); const [viewMode, setViewMode] = useState<ViewMode>('list');
const [editingContact, setEditingContact] = useState<AddressContact | null>(null); const [editingContact, setEditingContact] = useState<AddressContact | null>(null);
const handleSubmit = async (data: Omit<AddressContact, 'id' | 'createdAt'>) => { const handleSubmit = async (data: Omit<AddressContact, 'id' | 'createdAt' | 'updatedAt'>) => {
if (viewMode === 'edit' && editingContact) { if (viewMode === 'edit' && editingContact) {
await updateContact(editingContact.id, data); await updateContact(editingContact.id, data);
} else { } else {
@@ -36,9 +46,9 @@ export function AddressBookModule() {
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-5">
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total</p><p className="text-2xl font-bold">{allContacts.length}</p></CardContent></Card> <Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total</p><p className="text-2xl font-bold">{allContacts.length}</p></CardContent></Card>
{(Object.keys(TYPE_LABELS) as ContactType[]).slice(0, 3).map((type) => ( {(Object.keys(TYPE_LABELS) as ContactType[]).slice(0, 4).map((type) => (
<Card key={type}><CardContent className="p-4"> <Card key={type}><CardContent className="p-4">
<p className="text-xs text-muted-foreground">{TYPE_LABELS[type]}</p> <p className="text-xs text-muted-foreground">{TYPE_LABELS[type]}</p>
<p className="text-2xl font-bold">{allContacts.filter((c) => c.type === type).length}</p> <p className="text-2xl font-bold">{allContacts.filter((c) => c.type === type).length}</p>
@@ -74,42 +84,12 @@ export function AddressBookModule() {
) : ( ) : (
<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">
{contacts.map((contact) => ( {contacts.map((contact) => (
<Card key={contact.id} className="group relative"> <ContactCard
<CardContent className="p-4"> key={contact.id}
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"> contact={contact}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingContact(contact); setViewMode('edit'); }}> onEdit={() => { setEditingContact(contact); setViewMode('edit'); }}
<Pencil className="h-3.5 w-3.5" /> onDelete={() => removeContact(contact.id)}
</Button> />
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeContact(contact.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-2">
<div>
<p className="font-medium">{contact.name}</p>
<div className="flex items-center gap-2">
{contact.company && <p className="text-xs text-muted-foreground">{contact.company}</p>}
<Badge variant="outline" className="text-[10px]">{TYPE_LABELS[contact.type]}</Badge>
</div>
</div>
{contact.email && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Mail className="h-3 w-3" /><span>{contact.email}</span>
</div>
)}
{contact.phone && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Phone className="h-3 w-3" /><span>{contact.phone}</span>
</div>
)}
{contact.address && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPin className="h-3 w-3" /><span className="truncate">{contact.address}</span>
</div>
)}
</div>
</CardContent>
</Card>
))} ))}
</div> </div>
)} )}
@@ -120,7 +100,11 @@ export function AddressBookModule() {
<Card> <Card>
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare contact' : 'Contact nou'}</CardTitle></CardHeader> <CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare contact' : 'Contact nou'}</CardTitle></CardHeader>
<CardContent> <CardContent>
<ContactForm initial={editingContact ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingContact(null); }} /> <ContactForm
initial={editingContact ?? undefined}
onSubmit={handleSubmit}
onCancel={() => { setViewMode('list'); setEditingContact(null); }}
/>
</CardContent> </CardContent>
</Card> </Card>
)} )}
@@ -128,37 +112,240 @@ export function AddressBookModule() {
); );
} }
// ── Contact Card ──
function ContactCard({ contact, onEdit, onDelete }: {
contact: AddressContact;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<Card className="group relative">
<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" onClick={onEdit}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={onDelete}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-2">
<div>
<p className="font-medium">{contact.name}</p>
<div className="flex flex-wrap items-center gap-1.5">
{contact.company && <p className="text-xs text-muted-foreground">{contact.company}</p>}
<Badge variant="outline" className="text-[10px]">{TYPE_LABELS[contact.type]}</Badge>
{contact.department && (
<Badge variant="secondary" className="text-[10px]">{contact.department}</Badge>
)}
</div>
{contact.role && (
<p className="text-xs text-muted-foreground italic">{contact.role}</p>
)}
</div>
{contact.email && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Mail className="h-3 w-3 shrink-0" /><span className="truncate">{contact.email}</span>
</div>
)}
{contact.email2 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Mail className="h-3 w-3 shrink-0" /><span className="truncate">{contact.email2}</span>
</div>
)}
{contact.phone && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Phone className="h-3 w-3 shrink-0" /><span>{contact.phone}</span>
</div>
)}
{contact.phone2 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Phone className="h-3 w-3 shrink-0" /><span>{contact.phone2}</span>
</div>
)}
{contact.address && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPin className="h-3 w-3 shrink-0" /><span className="truncate">{contact.address}</span>
</div>
)}
{contact.website && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Globe className="h-3 w-3 shrink-0" /><span className="truncate">{contact.website}</span>
</div>
)}
{contact.contactPersons.length > 0 && (
<div className="mt-1 border-t pt-1">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1">
Persoane de contact ({contact.contactPersons.length})
</p>
{contact.contactPersons.slice(0, 2).map((cp, i) => (
<p key={i} className="text-xs text-muted-foreground">
{cp.name}{cp.role ? `${cp.role}` : ''}
</p>
))}
{contact.contactPersons.length > 2 && (
<p className="text-[10px] text-muted-foreground">
+{contact.contactPersons.length - 2} altele
</p>
)}
</div>
)}
</div>
</CardContent>
</Card>
);
}
// ── Contact Form ──
function ContactForm({ initial, onSubmit, onCancel }: { function ContactForm({ initial, onSubmit, onCancel }: {
initial?: AddressContact; initial?: AddressContact;
onSubmit: (data: Omit<AddressContact, 'id' | 'createdAt'>) => void; onSubmit: (data: Omit<AddressContact, 'id' | 'createdAt' | 'updatedAt'>) => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const { tags: projectTags } = useTags('project');
const [name, setName] = useState(initial?.name ?? ''); const [name, setName] = useState(initial?.name ?? '');
const [company, setCompany] = useState(initial?.company ?? ''); const [company, setCompany] = useState(initial?.company ?? '');
const [type, setType] = useState<ContactType>(initial?.type ?? 'client'); const [type, setType] = useState<ContactType>(initial?.type ?? 'client');
const [email, setEmail] = useState(initial?.email ?? ''); const [email, setEmail] = useState(initial?.email ?? '');
const [email2, setEmail2] = useState(initial?.email2 ?? '');
const [phone, setPhone] = useState(initial?.phone ?? ''); const [phone, setPhone] = useState(initial?.phone ?? '');
const [phone2, setPhone2] = useState(initial?.phone2 ?? '');
const [address, setAddress] = useState(initial?.address ?? ''); const [address, setAddress] = useState(initial?.address ?? '');
const [department, setDepartment] = useState(initial?.department ?? '');
const [role, setRole] = useState(initial?.role ?? '');
const [website, setWebsite] = useState(initial?.website ?? '');
const [notes, setNotes] = useState(initial?.notes ?? ''); const [notes, setNotes] = useState(initial?.notes ?? '');
const [projectIds, setProjectIds] = useState<string[]>(initial?.projectIds ?? []);
const [contactPersons, setContactPersons] = useState<ContactPerson[]>(
initial?.contactPersons ?? []
);
const addContactPerson = () => {
setContactPersons([...contactPersons, { name: '', role: '', email: '', phone: '' }]);
};
const updateContactPerson = (index: number, field: keyof ContactPerson, value: string) => {
setContactPersons(contactPersons.map((cp, i) =>
i === index ? { ...cp, [field]: value } : cp
));
};
const removeContactPerson = (index: number) => {
setContactPersons(contactPersons.filter((_, i) => i !== index));
};
const toggleProject = (projectId: string) => {
setProjectIds((prev) =>
prev.includes(projectId) ? prev.filter((id) => id !== projectId) : [...prev, projectId]
);
};
return ( return (
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ name, company, type, email, phone, address, notes, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all' }); }} className="space-y-4"> <form
<div className="grid gap-4 sm:grid-cols-2"> onSubmit={(e) => {
<div><Label>Nume</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div> e.preventDefault();
<div><Label>Companie/Organizație</Label><Input value={company} onChange={(e) => setCompany(e.target.value)} className="mt-1" /></div> onSubmit({
</div> name, company, type, email, email2, phone, phone2,
address, department, role, website, notes,
projectIds,
contactPersons: contactPersons.filter((cp) => cp.name.trim()),
tags: initial?.tags ?? [],
visibility: initial?.visibility ?? 'all',
});
}}
className="space-y-4"
>
{/* Row 1: Name + Company + Type */}
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div><Label>Nume *</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div>
<div><Label>Companie/Organizație</Label><Input value={company} onChange={(e) => setCompany(e.target.value)} className="mt-1" /></div>
<div><Label>Tip</Label> <div><Label>Tip</Label>
<Select value={type} onValueChange={(v) => setType(v as ContactType)}> <Select value={type} onValueChange={(v) => setType(v as ContactType)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> <SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>{(Object.keys(TYPE_LABELS) as ContactType[]).map((t) => (<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem>))}</SelectContent> <SelectContent>
{(Object.keys(TYPE_LABELS) as ContactType[]).map((t) => (
<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem>
))}
</SelectContent>
</Select> </Select>
</div> </div>
<div><Label>Email</Label><Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="mt-1" /></div>
<div><Label>Telefon</Label><Input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} className="mt-1" /></div>
</div> </div>
{/* Row 2: Department + Role + Website */}
<div className="grid gap-4 sm:grid-cols-3">
<div><Label>Departament</Label><Input value={department} onChange={(e) => setDepartment(e.target.value)} className="mt-1" /></div>
<div><Label>Funcție/Rol</Label><Input value={role} onChange={(e) => setRole(e.target.value)} className="mt-1" /></div>
<div><Label>Website</Label><Input type="url" value={website} onChange={(e) => setWebsite(e.target.value)} className="mt-1" placeholder="https://" /></div>
</div>
{/* Row 3: Emails + Phones */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<div><Label>Email principal</Label><Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="mt-1" /></div>
<div><Label>Email secundar</Label><Input type="email" value={email2} onChange={(e) => setEmail2(e.target.value)} className="mt-1" /></div>
</div>
<div className="grid gap-2">
<div><Label>Telefon principal</Label><Input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} className="mt-1" /></div>
<div><Label>Telefon secundar</Label><Input type="tel" value={phone2} onChange={(e) => setPhone2(e.target.value)} className="mt-1" /></div>
</div>
</div>
{/* Address */}
<div><Label>Adresă</Label><Input value={address} onChange={(e) => setAddress(e.target.value)} className="mt-1" /></div> <div><Label>Adresă</Label><Input value={address} onChange={(e) => setAddress(e.target.value)} className="mt-1" /></div>
{/* Project links */}
{projectTags.length > 0 && (
<div>
<Label>Proiecte asociate</Label>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{projectTags.map((pt) => (
<button
key={pt.id}
type="button"
onClick={() => toggleProject(pt.id)}
className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${
projectIds.includes(pt.id)
? 'border-primary bg-primary/10 text-primary'
: 'border-muted-foreground/30 text-muted-foreground hover:border-primary/50'
}`}
>
{pt.projectCode ? `${pt.projectCode} ` : ''}{pt.label}
</button>
))}
</div>
</div>
)}
{/* Contact Persons */}
<div>
<div className="flex items-center justify-between">
<Label>Persoane de contact</Label>
<Button type="button" variant="outline" size="sm" onClick={addContactPerson}>
<UserPlus className="mr-1 h-3.5 w-3.5" /> Adaugă persoană
</Button>
</div>
{contactPersons.length > 0 && (
<div className="mt-2 space-y-2">
{contactPersons.map((cp, i) => (
<div key={i} className="flex flex-wrap items-start gap-2 rounded border p-2">
<Input placeholder="Nume" value={cp.name} onChange={(e) => updateContactPerson(i, 'name', e.target.value)} className="min-w-[150px] flex-1 text-sm" />
<Input placeholder="Funcție" value={cp.role} onChange={(e) => updateContactPerson(i, 'role', e.target.value)} className="w-[140px] text-sm" />
<Input placeholder="Email" value={cp.email} onChange={(e) => updateContactPerson(i, 'email', e.target.value)} className="w-[180px] text-sm" />
<Input placeholder="Telefon" value={cp.phone} onChange={(e) => updateContactPerson(i, 'phone', e.target.value)} className="w-[140px] text-sm" />
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0 text-destructive" onClick={() => removeContactPerson(i)}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
</div>
{/* Notes */}
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div> <div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></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

@@ -36,8 +36,9 @@ export function useContacts() {
// 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 addContact = useCallback(async (data: Omit<AddressContact, 'id' | 'createdAt'>) => { const addContact = useCallback(async (data: Omit<AddressContact, 'id' | 'createdAt' | 'updatedAt'>) => {
const contact: AddressContact = { ...data, id: uuid(), createdAt: new Date().toISOString() }; const now = new Date().toISOString();
const contact: AddressContact = { ...data, id: uuid(), createdAt: now, updatedAt: now };
await storage.set(`${PREFIX}${contact.id}`, contact); await storage.set(`${PREFIX}${contact.id}`, contact);
await refresh(); await refresh();
return contact; return contact;
@@ -46,7 +47,13 @@ export function useContacts() {
const updateContact = useCallback(async (id: string, updates: Partial<AddressContact>) => { const updateContact = useCallback(async (id: string, updates: Partial<AddressContact>) => {
const existing = contacts.find((c) => c.id === id); const existing = contacts.find((c) => c.id === id);
if (!existing) return; if (!existing) return;
const updated = { ...existing, ...updates, id: existing.id, createdAt: existing.createdAt }; const updated: AddressContact = {
...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, contacts]); }, [storage, refresh, contacts]);
@@ -64,7 +71,14 @@ export function useContacts() {
if (filters.type !== 'all' && c.type !== filters.type) return false; if (filters.type !== 'all' && c.type !== filters.type) return false;
if (filters.search) { if (filters.search) {
const q = filters.search.toLowerCase(); const q = filters.search.toLowerCase();
return c.name.toLowerCase().includes(q) || c.company.toLowerCase().includes(q) || c.email.toLowerCase().includes(q) || c.phone.includes(q); return (
c.name.toLowerCase().includes(q) ||
c.company.toLowerCase().includes(q) ||
c.email.toLowerCase().includes(q) ||
c.phone.includes(q) ||
c.department.toLowerCase().includes(q) ||
c.role.toLowerCase().includes(q)
);
} }
return true; return true;
}); });

View File

@@ -1,17 +1,44 @@
import type { Visibility } from '@/core/module-registry/types'; import type { Visibility } from '@/core/module-registry/types';
export type ContactType = 'client' | 'supplier' | 'institution' | 'collaborator'; export type ContactType = 'client' | 'supplier' | 'institution' | 'collaborator' | 'internal';
/** A contact person within an organization/entity */
export interface ContactPerson {
name: string;
role: string;
email: string;
phone: string;
}
export interface AddressContact { export interface AddressContact {
id: string; id: string;
/** Primary name (person or organization) */
name: string; name: string;
/** Organization/company name */
company: string; company: string;
type: ContactType; type: ContactType;
/** Primary email */
email: string; email: string;
/** Secondary email */
email2: string;
/** Primary phone */
phone: string; phone: string;
/** Secondary phone */
phone2: string;
address: string; address: string;
/** Department within the organization */
department: string;
/** Role / job title */
role: string;
/** Website URL */
website: string;
/** Linked project tag IDs */
projectIds: string[];
/** Additional contact persons for this entity */
contactPersons: ContactPerson[];
tags: string[]; tags: string[];
notes: string; notes: string;
visibility: Visibility; visibility: Visibility;
createdAt: string; createdAt: string;
updatedAt: string;
} }

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;
} }

View File

@@ -12,7 +12,7 @@ import { RotateCcw } from 'lucide-react';
export function EmailSignatureModule() { export function EmailSignatureModule() {
const { const {
config, updateField, updateColor, updateLayout, config, updateField, updateColor, updateLayout,
setVariant, setCompany, resetToDefaults, loadConfig, setVariant, setCompany, setAddress, resetToDefaults, loadConfig,
} = useSignatureConfig(); } = useSignatureConfig();
const { saved, loading, save, remove } = useSavedSignatures(); const { saved, loading, save, remove } = useSavedSignatures();
@@ -28,6 +28,7 @@ export function EmailSignatureModule() {
onUpdateLayout={updateLayout} onUpdateLayout={updateLayout}
onSetVariant={setVariant} onSetVariant={setVariant}
onSetCompany={setCompany} onSetCompany={setCompany}
onSetAddress={setAddress}
/> />
<Separator /> <Separator />
@@ -49,7 +50,7 @@ export function EmailSignatureModule() {
</Button> </Button>
</div> </div>
{/* Right panel — preview */} {/* Right panel — preview (scrollable, resizable) */}
<div> <div>
<SignaturePreview config={config} /> <SignaturePreview config={config} />
</div> </div>

View File

@@ -2,7 +2,7 @@
import type { CompanyId } from '@/core/auth/types'; import type { CompanyId } from '@/core/auth/types';
import type { SignatureConfig, SignatureColors, SignatureLayout, SignatureVariant } from '../types'; import type { SignatureConfig, SignatureColors, SignatureLayout, SignatureVariant } from '../types';
import { COMPANY_BRANDING } from '../services/company-branding'; import { COMPANY_BRANDING, BELETAGE_ADDRESSES } from '../services/company-branding';
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 { Switch } from '@/shared/components/ui/switch'; import { Switch } from '@/shared/components/ui/switch';
@@ -17,13 +17,39 @@ interface SignatureConfiguratorProps {
onUpdateLayout: (key: keyof SignatureLayout, value: number) => void; onUpdateLayout: (key: keyof SignatureLayout, value: number) => void;
onSetVariant: (variant: SignatureVariant) => void; onSetVariant: (variant: SignatureVariant) => void;
onSetCompany: (company: CompanyId) => void; onSetCompany: (company: CompanyId) => void;
onSetAddress?: (address: string[]) => void;
} }
const COLOR_PALETTE: Record<string, string> = { /** Color palette per company */
verde: '#22B5AB', const COMPANY_PALETTES: Record<CompanyId, Record<string, string>> = {
griInchis: '#54504F', beletage: {
griDeschis: '#A7A9AA', verde: '#22B5AB',
negru: '#323232', griInchis: '#54504F',
griDeschis: '#A7A9AA',
negru: '#323232',
},
'urban-switch': {
indigo: '#6366f1',
violet: '#4F46E5',
griInchis: '#2D2D2D',
griDeschis: '#6B7280',
albastru: '#3B82F6',
negru: '#1F2937',
},
'studii-de-teren': {
amber: '#f59e0b',
portocaliu: '#D97706',
griInchis: '#2D2D2D',
griDeschis: '#6B7280',
maro: '#92400E',
negru: '#1F2937',
},
group: {
gri: '#64748b',
griInchis: '#334155',
griDeschis: '#94a3b8',
negru: '#1e293b',
},
}; };
const COLOR_LABELS: Record<keyof SignatureColors, string> = { const COLOR_LABELS: Record<keyof SignatureColors, string> = {
@@ -48,8 +74,10 @@ const LAYOUT_CONTROLS: { key: keyof SignatureLayout; label: string; min: number;
]; ];
export function SignatureConfigurator({ export function SignatureConfigurator({
config, onUpdateField, onUpdateColor, onUpdateLayout, onSetVariant, onSetCompany, config, onUpdateField, onUpdateColor, onUpdateLayout, onSetVariant, onSetCompany, onSetAddress,
}: SignatureConfiguratorProps) { }: SignatureConfiguratorProps) {
const palette = COMPANY_PALETTES[config.company];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Company selector */} {/* Company selector */}
@@ -67,6 +95,26 @@ export function SignatureConfigurator({
</Select> </Select>
</div> </div>
{/* Address selector (for Beletage) */}
{config.company === 'beletage' && onSetAddress && (
<div>
<Label>Adresă birou</Label>
<Select
value={!config.addressOverride || BELETAGE_ADDRESSES.unirii.join('|') === config.addressOverride.join('|') ? 'unirii' : 'christescu'}
onValueChange={(v) => {
const key = v as keyof typeof BELETAGE_ADDRESSES;
onSetAddress(BELETAGE_ADDRESSES[key]);
}}
>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="unirii">Str. Unirii, nr. 3</SelectItem>
<SelectItem value="christescu">Str. G-ral Eremia Grigorescu, nr. 21</SelectItem>
</SelectContent>
</Select>
</div>
)}
<Separator /> <Separator />
{/* Personal data */} {/* Personal data */}
@@ -113,14 +161,14 @@ export function SignatureConfigurator({
<Separator /> <Separator />
{/* Colors */} {/* Colors — company-specific palette */}
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-sm font-semibold">Culori text</h3> <h3 className="text-sm font-semibold">Culori text</h3>
{(Object.keys(COLOR_LABELS) as (keyof SignatureColors)[]).map((colorKey) => ( {(Object.keys(COLOR_LABELS) as (keyof SignatureColors)[]).map((colorKey) => (
<div key={colorKey} className="flex items-center justify-between"> <div key={colorKey} className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{COLOR_LABELS[colorKey]}</span> <span className="text-sm text-muted-foreground">{COLOR_LABELS[colorKey]}</span>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
{Object.values(COLOR_PALETTE).map((color) => ( {Object.values(palette).map((color) => (
<button <button
key={color} key={color}
type="button" type="button"

View File

@@ -6,15 +6,19 @@ import { Button } from '@/shared/components/ui/button';
import type { SignatureConfig } from '../types'; import type { SignatureConfig } from '../types';
import { generateSignatureHtml, downloadSignatureHtml } from '../services/signature-builder'; import { generateSignatureHtml, downloadSignatureHtml } from '../services/signature-builder';
const ZOOM_LEVELS = [0.75, 1, 1.5, 2, 2.5];
interface SignaturePreviewProps { interface SignaturePreviewProps {
config: SignatureConfig; config: SignatureConfig;
} }
export function SignaturePreview({ config }: SignaturePreviewProps) { export function SignaturePreview({ config }: SignaturePreviewProps) {
const [zoom, setZoom] = useState(1); const [zoomIndex, setZoomIndex] = useState(1); // start at 100%
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const previewRef = useRef<HTMLDivElement>(null); const previewRef = useRef<HTMLDivElement>(null);
const zoom = ZOOM_LEVELS[zoomIndex] ?? 1;
const html = useMemo(() => generateSignatureHtml(config), [config]); const html = useMemo(() => generateSignatureHtml(config), [config]);
const handleDownload = () => { const handleDownload = () => {
@@ -32,17 +36,23 @@ export function SignaturePreview({ config }: SignaturePreviewProps) {
} }
}; };
const toggleZoom = () => setZoom((z) => (z === 1 ? 2 : 1)); const zoomIn = () => setZoomIndex((i) => Math.min(i + 1, ZOOM_LEVELS.length - 1));
const zoomOut = () => setZoomIndex((i) => Math.max(i - 1, 0));
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Previzualizare</h2> <h2 className="text-lg font-semibold">Previzualizare</h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={toggleZoom}> <div className="flex items-center rounded-md border">
{zoom === 1 ? <ZoomIn className="mr-1 h-4 w-4" /> : <ZoomOut className="mr-1 h-4 w-4" />} <Button variant="ghost" size="icon" className="h-8 w-8 rounded-r-none" onClick={zoomOut} disabled={zoomIndex <= 0}>
{zoom === 1 ? '200%' : '100%'} <ZoomOut className="h-4 w-4" />
</Button> </Button>
<span className="w-14 text-center text-sm font-medium">{Math.round(zoom * 100)}%</span>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-l-none" onClick={zoomIn} disabled={zoomIndex >= ZOOM_LEVELS.length - 1}>
<ZoomIn className="h-4 w-4" />
</Button>
</div>
<Button variant="outline" size="sm" onClick={handleCopy}> <Button variant="outline" size="sm" onClick={handleCopy}>
<Copy className="mr-1 h-4 w-4" /> <Copy className="mr-1 h-4 w-4" />
{copied ? 'Copiat!' : 'Copiază HTML'} {copied ? 'Copiat!' : 'Copiază HTML'}
@@ -54,7 +64,7 @@ export function SignaturePreview({ config }: SignaturePreviewProps) {
</div> </div>
</div> </div>
<div className="overflow-auto rounded-lg border bg-white p-6"> <div className="max-h-[calc(100vh-14rem)] overflow-auto rounded-lg border bg-white p-6">
<div <div
ref={previewRef} ref={previewRef}
style={{ transform: `scale(${zoom})`, transformOrigin: 'top left' }} style={{ transform: `scale(${zoom})`, transformOrigin: 'top left' }}

View File

@@ -68,6 +68,10 @@ export function useSignatureConfig(initialCompany: CompanyId = 'beletage') {
})); }));
}, []); }, []);
const setAddress = useCallback((address: string[]) => {
setConfig((prev) => ({ ...prev, addressOverride: address }));
}, []);
const resetToDefaults = useCallback(() => { const resetToDefaults = useCallback(() => {
setConfig(createDefaultConfig(config.company)); setConfig(createDefaultConfig(config.company));
}, [config.company]); }, [config.company]);
@@ -83,7 +87,8 @@ export function useSignatureConfig(initialCompany: CompanyId = 'beletage') {
updateLayout, updateLayout,
setVariant, setVariant,
setCompany, setCompany,
setAddress,
resetToDefaults, resetToDefaults,
loadConfig, loadConfig,
}), [config, updateField, updateColor, updateLayout, setVariant, setCompany, resetToDefaults, loadConfig]); }), [config, updateField, updateColor, updateLayout, setVariant, setCompany, setAddress, resetToDefaults, loadConfig]);
} }

View File

@@ -12,25 +12,34 @@ const BELETAGE_COLORS: SignatureColors = {
}; };
const URBAN_SWITCH_COLORS: SignatureColors = { const URBAN_SWITCH_COLORS: SignatureColors = {
prefix: '#3B3B3B', prefix: '#2D2D2D',
name: '#3B3B3B', name: '#2D2D2D',
title: '#8B8B8B', title: '#6B7280',
address: '#8B8B8B', address: '#6B7280',
phone: '#3B3B3B', phone: '#2D2D2D',
website: '#3B3B3B', website: '#4F46E5',
motto: '#6366f1', motto: '#6366f1',
}; };
const STUDII_COLORS: SignatureColors = { const STUDII_COLORS: SignatureColors = {
prefix: '#3B3B3B', prefix: '#2D2D2D',
name: '#3B3B3B', name: '#2D2D2D',
title: '#8B8B8B', title: '#6B7280',
address: '#8B8B8B', address: '#6B7280',
phone: '#3B3B3B', phone: '#2D2D2D',
website: '#3B3B3B', website: '#D97706',
motto: '#f59e0b', motto: '#f59e0b',
}; };
const ADDR_UNIRII = ['str. Unirii, nr. 3, ap. 26', 'Cluj-Napoca, Cluj 400417', 'România'] as const;
const ADDR_CHRISTESCU = ['str. G-ral Eremia Grigorescu, nr. 21', 'Cluj-Napoca, Cluj 400304', 'România'] as const;
/** Available address options for Beletage (toggle between offices) */
export const BELETAGE_ADDRESSES: { unirii: string[]; christescu: string[] } = {
unirii: [...ADDR_UNIRII],
christescu: [...ADDR_CHRISTESCU],
};
export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = { export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
beletage: { beletage: {
id: 'beletage', id: 'beletage',
@@ -48,7 +57,7 @@ export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
png: 'https://beletage.ro/img/Green-slash.png', png: 'https://beletage.ro/img/Green-slash.png',
svg: 'https://beletage.ro/img/Green-slash.svg', svg: 'https://beletage.ro/img/Green-slash.svg',
}, },
address: ['str. Unirii, nr. 3, ap. 26', 'Cluj-Napoca, Cluj 400417', 'România'], address: [...ADDR_UNIRII],
website: 'www.beletage.ro', website: 'www.beletage.ro',
motto: 'we make complex simple', motto: 'we make complex simple',
defaultColors: BELETAGE_COLORS, defaultColors: BELETAGE_COLORS,
@@ -58,20 +67,20 @@ export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
name: 'Urban Switch SRL', name: 'Urban Switch SRL',
accent: '#6366f1', accent: '#6366f1',
logo: { logo: {
png: '', png: '/logos/logo-us-dark.svg',
svg: '', svg: '/logos/logo-us-dark.svg',
}, },
slashGrey: { slashGrey: {
png: 'https://beletage.ro/img/Grey-slash.png', png: 'https://beletage.ro/img/Grey-slash.png',
svg: 'https://beletage.ro/img/Grey-slash.svg', svg: 'https://beletage.ro/img/Grey-slash.svg',
}, },
slashAccent: { slashAccent: {
png: '', png: '/logos/logo-us-light.svg',
svg: '', svg: '/logos/logo-us-light.svg',
}, },
address: ['Cluj-Napoca', 'România'], address: ['str. Unirii, nr. 3, ap. 26', 'Cluj-Napoca, Cluj 400417', 'România'],
website: '', website: 'www.urbanswitch.ro',
motto: '', motto: 'shaping urban futures',
defaultColors: URBAN_SWITCH_COLORS, defaultColors: URBAN_SWITCH_COLORS,
}, },
'studii-de-teren': { 'studii-de-teren': {
@@ -79,20 +88,20 @@ export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
name: 'Studii de Teren SRL', name: 'Studii de Teren SRL',
accent: '#f59e0b', accent: '#f59e0b',
logo: { logo: {
png: '', png: '/logos/logo-sdt-dark.svg',
svg: '', svg: '/logos/logo-sdt-dark.svg',
}, },
slashGrey: { slashGrey: {
png: 'https://beletage.ro/img/Grey-slash.png', png: 'https://beletage.ro/img/Grey-slash.png',
svg: 'https://beletage.ro/img/Grey-slash.svg', svg: 'https://beletage.ro/img/Grey-slash.svg',
}, },
slashAccent: { slashAccent: {
png: '', png: '/logos/logo-sdt-light.svg',
svg: '', svg: '/logos/logo-sdt-light.svg',
}, },
address: ['Cluj-Napoca', 'România'], address: ['str. Unirii, nr. 3, ap. 26', 'Cluj-Napoca, Cluj 400417', 'România'],
website: '', website: 'www.studiideteren.ro',
motto: '', motto: 'ground truth, measured right',
defaultColors: STUDII_COLORS, defaultColors: STUDII_COLORS,
}, },
group: { group: {
@@ -100,9 +109,12 @@ export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
name: 'Grup Companii', name: 'Grup Companii',
accent: '#64748b', accent: '#64748b',
logo: { png: '', svg: '' }, logo: { png: '', svg: '' },
slashGrey: { png: '', svg: '' }, slashGrey: {
png: 'https://beletage.ro/img/Grey-slash.png',
svg: 'https://beletage.ro/img/Grey-slash.svg',
},
slashAccent: { png: '', svg: '' }, slashAccent: { png: '', svg: '' },
address: ['Cluj-Napoca', 'România'], address: ['Cluj-Napoca, Cluj', 'România'],
website: '', website: '',
motto: '', motto: '',
defaultColors: BELETAGE_COLORS, defaultColors: BELETAGE_COLORS,

View File

@@ -14,6 +14,7 @@ export function formatPhone(raw: string): { display: string; link: string } {
export function generateSignatureHtml(config: SignatureConfig): string { export function generateSignatureHtml(config: SignatureConfig): string {
const branding = getBranding(config.company); const branding = getBranding(config.company);
const address = config.addressOverride ?? branding.address;
const { display: phone, link: phoneLink } = formatPhone(config.phone); const { display: phone, link: phoneLink } = formatPhone(config.phone);
const images = config.useSvg const images = config.useSvg
? { logo: branding.logo.svg, greySlash: branding.slashGrey.svg, accentSlash: branding.slashAccent.svg } ? { logo: branding.logo.svg, greySlash: branding.slashGrey.svg, accentSlash: branding.slashAccent.svg }
@@ -71,7 +72,7 @@ export function generateSignatureHtml(config: SignatureConfig): string {
</td> </td>
<td width="${spacerWidth}" style="width:${spacerWidth}px; font-size:0; line-height:0;"></td> <td width="${spacerWidth}" style="width:${spacerWidth}px; font-size:0; line-height:0;"></td>
<td style="vertical-align:top; padding:0 0 0 ${textPaddingLeft}px;"> <td style="vertical-align:top; padding:0 0 0 ${textPaddingLeft}px;">
<span style="color:${colors.address}; text-decoration:none;">${branding.address.join('<br>')}</span> <span style="color:${colors.address}; text-decoration:none;">${address.join('<br>')}</span>
</td> </td>
</tr> </tr>
<tr> <tr>

View File

@@ -48,6 +48,8 @@ export interface SignatureConfig {
layout: SignatureLayout; layout: SignatureLayout;
variant: SignatureVariant; variant: SignatureVariant;
useSvg: boolean; useSvg: boolean;
/** Override the default company address */
addressOverride?: string[];
} }
export interface SavedSignature { export interface SavedSignature {

View File

@@ -9,6 +9,7 @@ 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 { InventoryItem, InventoryItemType, InventoryItemStatus } from '../types'; import type { InventoryItem, InventoryItemType, InventoryItemStatus } from '../types';
import { useInventory } from '../hooks/use-inventory'; import { useInventory } from '../hooks/use-inventory';
@@ -28,8 +29,9 @@ export function ItInventoryModule() {
const { items, allItems, loading, filters, updateFilter, addItem, updateItem, removeItem } = useInventory(); const { items, allItems, loading, filters, updateFilter, addItem, updateItem, removeItem } = useInventory();
const [viewMode, setViewMode] = useState<ViewMode>('list'); const [viewMode, setViewMode] = useState<ViewMode>('list');
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null); const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const handleSubmit = async (data: Omit<InventoryItem, 'id' | 'createdAt'>) => { const handleSubmit = async (data: Omit<InventoryItem, 'id' | 'createdAt' | 'updatedAt'>) => {
if (viewMode === 'edit' && editingItem) { if (viewMode === 'edit' && editingItem) {
await updateItem(editingItem.id, data); await updateItem(editingItem.id, data);
} else { } else {
@@ -39,6 +41,13 @@ export function ItInventoryModule() {
setEditingItem(null); setEditingItem(null);
}; };
const handleDeleteConfirm = async () => {
if (deletingId) {
await removeItem(deletingId);
setDeletingId(null);
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Stats */} {/* Stats */}
@@ -51,7 +60,6 @@ export function ItInventoryModule() {
{viewMode === 'list' && ( {viewMode === 'list' && (
<> <>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1"> <div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
@@ -80,7 +88,6 @@ export function ItInventoryModule() {
</Button> </Button>
</div> </div>
{/* Table */}
{loading ? ( {loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p> <p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
) : items.length === 0 ? ( ) : items.length === 0 ? (
@@ -91,7 +98,9 @@ export function ItInventoryModule() {
<thead><tr className="border-b bg-muted/40"> <thead><tr className="border-b bg-muted/40">
<th className="px-3 py-2 text-left font-medium">Nume</th> <th className="px-3 py-2 text-left font-medium">Nume</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">Vendor/Model</th>
<th className="px-3 py-2 text-left font-medium">S/N</th> <th className="px-3 py-2 text-left font-medium">S/N</th>
<th className="px-3 py-2 text-left font-medium">IP</th>
<th className="px-3 py-2 text-left font-medium">Atribuit</th> <th className="px-3 py-2 text-left font-medium">Atribuit</th>
<th className="px-3 py-2 text-left font-medium">Locație</th> <th className="px-3 py-2 text-left font-medium">Locație</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>
@@ -102,16 +111,22 @@ export function ItInventoryModule() {
<tr key={item.id} className="border-b hover:bg-muted/20 transition-colors"> <tr key={item.id} className="border-b hover:bg-muted/20 transition-colors">
<td className="px-3 py-2 font-medium">{item.name}</td> <td className="px-3 py-2 font-medium">{item.name}</td>
<td className="px-3 py-2"><Badge variant="outline">{TYPE_LABELS[item.type]}</Badge></td> <td className="px-3 py-2"><Badge variant="outline">{TYPE_LABELS[item.type]}</Badge></td>
<td className="px-3 py-2 text-xs">
{item.vendor && <span>{item.vendor}</span>}
{item.vendor && item.model && <span className="text-muted-foreground"> / </span>}
{item.model && <span className="text-muted-foreground">{item.model}</span>}
</td>
<td className="px-3 py-2 font-mono text-xs">{item.serialNumber}</td> <td className="px-3 py-2 font-mono text-xs">{item.serialNumber}</td>
<td className="px-3 py-2 font-mono text-xs">{item.ipAddress}</td>
<td className="px-3 py-2">{item.assignedTo}</td> <td className="px-3 py-2">{item.assignedTo}</td>
<td className="px-3 py-2">{item.location}</td> <td className="px-3 py-2 text-xs">{item.rackLocation || item.location}</td>
<td className="px-3 py-2"><Badge variant="secondary">{STATUS_LABELS[item.status]}</Badge></td> <td className="px-3 py-2"><Badge variant="secondary">{STATUS_LABELS[item.status]}</Badge></td>
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingItem(item); setViewMode('edit'); }}> <Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingItem(item); 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={() => removeItem(item.id)}> <Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => setDeletingId(item.id)}>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -137,13 +152,25 @@ export function ItInventoryModule() {
</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 echipament? Acțiunea este ireversibilă.</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingId(null)}>Anulează</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>Șterge</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
function InventoryForm({ initial, onSubmit, onCancel }: { function InventoryForm({ initial, onSubmit, onCancel }: {
initial?: InventoryItem; initial?: InventoryItem;
onSubmit: (data: Omit<InventoryItem, 'id' | 'createdAt'>) => void; onSubmit: (data: Omit<InventoryItem, 'id' | 'createdAt' | 'updatedAt'>) => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const [name, setName] = useState(initial?.name ?? ''); const [name, setName] = useState(initial?.name ?? '');
@@ -154,12 +181,26 @@ function InventoryForm({ initial, onSubmit, onCancel }: {
const [location, setLocation] = useState(initial?.location ?? ''); const [location, setLocation] = useState(initial?.location ?? '');
const [purchaseDate, setPurchaseDate] = useState(initial?.purchaseDate ?? ''); const [purchaseDate, setPurchaseDate] = useState(initial?.purchaseDate ?? '');
const [status, setStatus] = useState<InventoryItemStatus>(initial?.status ?? 'active'); const [status, setStatus] = useState<InventoryItemStatus>(initial?.status ?? 'active');
const [ipAddress, setIpAddress] = useState(initial?.ipAddress ?? '');
const [macAddress, setMacAddress] = useState(initial?.macAddress ?? '');
const [warrantyExpiry, setWarrantyExpiry] = useState(initial?.warrantyExpiry ?? '');
const [purchaseCost, setPurchaseCost] = useState(initial?.purchaseCost ?? '');
const [rackLocation, setRackLocation] = useState(initial?.rackLocation ?? '');
const [vendor, setVendor] = useState(initial?.vendor ?? '');
const [model, setModel] = useState(initial?.model ?? '');
const [notes, setNotes] = useState(initial?.notes ?? ''); const [notes, setNotes] = useState(initial?.notes ?? '');
return ( return (
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ name, type, serialNumber, assignedTo, company, location, purchaseDate, status, notes, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all' }); }} className="space-y-4"> <form onSubmit={(e) => {
e.preventDefault();
onSubmit({
name, type, serialNumber, assignedTo, company, location, purchaseDate, status,
ipAddress, macAddress, warrantyExpiry, purchaseCost, rackLocation, vendor, model,
notes, 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>Nume echipament</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div> <div><Label>Nume echipament *</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div>
<div><Label>Tip</Label> <div><Label>Tip</Label>
<Select value={type} onValueChange={(v) => setType(v as InventoryItemType)}> <Select value={type} onValueChange={(v) => setType(v as InventoryItemType)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> <SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
@@ -167,11 +208,17 @@ function InventoryForm({ initial, onSubmit, onCancel }: {
</Select> </Select>
</div> </div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-3">
<div><Label>Vendor</Label><Input value={vendor} onChange={(e) => setVendor(e.target.value)} className="mt-1" placeholder="Dell, HP, Lenovo..." /></div>
<div><Label>Model</Label><Input value={model} onChange={(e) => setModel(e.target.value)} className="mt-1" /></div>
<div><Label>Număr serie</Label><Input value={serialNumber} onChange={(e) => setSerialNumber(e.target.value)} className="mt-1" /></div> <div><Label>Număr serie</Label><Input value={serialNumber} onChange={(e) => setSerialNumber(e.target.value)} className="mt-1" /></div>
<div><Label>Atribuit</Label><Input value={assignedTo} onChange={(e) => setAssignedTo(e.target.value)} className="mt-1" /></div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div><Label>Adresă IP</Label><Input value={ipAddress} onChange={(e) => setIpAddress(e.target.value)} className="mt-1" placeholder="192.168.1.x" /></div>
<div><Label>Adresă MAC</Label><Input value={macAddress} onChange={(e) => setMacAddress(e.target.value)} className="mt-1" placeholder="AA:BB:CC:DD:EE:FF" /></div>
<div><Label>Atribuit</Label><Input value={assignedTo} onChange={(e) => setAssignedTo(e.target.value)} className="mt-1" /></div>
</div>
<div className="grid gap-4 sm:grid-cols-4">
<div><Label>Companie</Label> <div><Label>Companie</Label>
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}> <Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> <SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
@@ -183,18 +230,21 @@ function InventoryForm({ initial, onSubmit, onCancel }: {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div><Label>Locație</Label><Input value={location} onChange={(e) => setLocation(e.target.value)} className="mt-1" /></div> <div><Label>Locație / Cameră</Label><Input value={location} onChange={(e) => setLocation(e.target.value)} className="mt-1" /></div>
<div><Label>Data achiziție</Label><Input type="date" value={purchaseDate} onChange={(e) => setPurchaseDate(e.target.value)} className="mt-1" /></div> <div><Label>Rack / Poziție</Label><Input value={rackLocation} onChange={(e) => setRackLocation(e.target.value)} className="mt-1" /></div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div><Label>Status</Label> <div><Label>Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v as InventoryItemStatus)}> <Select value={status} onValueChange={(v) => setStatus(v as InventoryItemStatus)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> <SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>{(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map((s) => (<SelectItem key={s} value={s}>{STATUS_LABELS[s]}</SelectItem>))}</SelectContent> <SelectContent>{(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map((s) => (<SelectItem key={s} value={s}>{STATUS_LABELS[s]}</SelectItem>))}</SelectContent>
</Select> </Select>
</div> </div>
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-3">
<div><Label>Data achiziție</Label><Input type="date" value={purchaseDate} onChange={(e) => setPurchaseDate(e.target.value)} className="mt-1" /></div>
<div><Label>Cost achiziție (RON)</Label><Input type="number" value={purchaseCost} onChange={(e) => setPurchaseCost(e.target.value)} className="mt-1" /></div>
<div><Label>Expirare garanție</Label><Input type="date" value={warrantyExpiry} onChange={(e) => setWarrantyExpiry(e.target.value)} className="mt-1" /></div>
</div>
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></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

@@ -40,8 +40,9 @@ export function useInventory() {
// 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 addItem = useCallback(async (data: Omit<InventoryItem, 'id' | 'createdAt'>) => { const addItem = useCallback(async (data: Omit<InventoryItem, 'id' | 'createdAt' | 'updatedAt'>) => {
const item: InventoryItem = { ...data, id: uuid(), createdAt: new Date().toISOString() }; const now = new Date().toISOString();
const item: InventoryItem = { ...data, id: uuid(), createdAt: now, updatedAt: now };
await storage.set(`${PREFIX}${item.id}`, item); await storage.set(`${PREFIX}${item.id}`, item);
await refresh(); await refresh();
return item; return item;
@@ -50,7 +51,11 @@ export function useInventory() {
const updateItem = useCallback(async (id: string, updates: Partial<InventoryItem>) => { const updateItem = useCallback(async (id: string, updates: Partial<InventoryItem>) => {
const existing = items.find((i) => i.id === id); const existing = items.find((i) => i.id === id);
if (!existing) return; if (!existing) return;
const updated = { ...existing, ...updates, id: existing.id, createdAt: existing.createdAt }; const updated: InventoryItem = {
...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, items]); }, [storage, refresh, items]);
@@ -70,7 +75,14 @@ export function useInventory() {
if (filters.company !== 'all' && item.company !== filters.company) return false; if (filters.company !== 'all' && item.company !== filters.company) return false;
if (filters.search) { if (filters.search) {
const q = filters.search.toLowerCase(); const q = filters.search.toLowerCase();
return item.name.toLowerCase().includes(q) || item.serialNumber.toLowerCase().includes(q) || item.assignedTo.toLowerCase().includes(q); return (
item.name.toLowerCase().includes(q) ||
item.serialNumber.toLowerCase().includes(q) ||
item.assignedTo.toLowerCase().includes(q) ||
item.ipAddress.toLowerCase().includes(q) ||
item.vendor.toLowerCase().includes(q) ||
item.model.toLowerCase().includes(q)
);
} }
return true; return true;
}); });

View File

@@ -28,8 +28,23 @@ export interface InventoryItem {
location: string; location: string;
purchaseDate: string; purchaseDate: string;
status: InventoryItemStatus; status: InventoryItemStatus;
/** IP address */
ipAddress: string;
/** MAC address */
macAddress: string;
/** Warranty expiry date (YYYY-MM-DD) */
warrantyExpiry: string;
/** Purchase cost (RON) */
purchaseCost: string;
/** Room / rack position */
rackLocation: string;
/** Vendor / manufacturer */
vendor: string;
/** Model name/number */
model: string;
tags: string[]; tags: string[];
notes: string; notes: string;
visibility: Visibility; visibility: Visibility;
createdAt: string; createdAt: string;
updatedAt: string;
} }

View File

@@ -1,7 +1,10 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { Plus, Pencil, Trash2, Search, Eye, EyeOff, Copy, ExternalLink } from 'lucide-react'; import {
Plus, Pencil, Trash2, Search, Eye, EyeOff, Copy, ExternalLink,
KeyRound, X,
} 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';
@@ -9,7 +12,9 @@ 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 type { VaultEntry, VaultEntryCategory } from '../types'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog';
import { Switch } from '@/shared/components/ui/switch';
import type { VaultEntry, VaultEntryCategory, CustomField } from '../types';
import { useVault } from '../hooks/use-vault'; import { useVault } from '../hooks/use-vault';
const CATEGORY_LABELS: Record<VaultEntryCategory, string> = { const CATEGORY_LABELS: Record<VaultEntryCategory, string> = {
@@ -18,12 +23,28 @@ const CATEGORY_LABELS: Record<VaultEntryCategory, string> = {
type ViewMode = 'list' | 'add' | 'edit'; type ViewMode = 'list' | 'add' | 'edit';
/** Generate a random password */
function generatePassword(length: number, options: { upper: boolean; lower: boolean; digits: boolean; symbols: boolean }): string {
let chars = '';
if (options.upper) chars += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
if (options.lower) chars += 'abcdefghijklmnopqrstuvwxyz';
if (options.digits) chars += '0123456789';
if (options.symbols) chars += '!@#$%^&*()-_=+[]{}|;:,.<>?';
if (!chars) chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
export function PasswordVaultModule() { export function PasswordVaultModule() {
const { entries, allEntries, loading, filters, updateFilter, addEntry, updateEntry, removeEntry } = useVault(); const { entries, allEntries, loading, filters, updateFilter, addEntry, updateEntry, removeEntry } = useVault();
const [viewMode, setViewMode] = useState<ViewMode>('list'); const [viewMode, setViewMode] = useState<ViewMode>('list');
const [editingEntry, setEditingEntry] = useState<VaultEntry | null>(null); const [editingEntry, setEditingEntry] = useState<VaultEntry | null>(null);
const [visiblePasswords, setVisiblePasswords] = useState<Set<string>>(new Set()); const [visiblePasswords, setVisiblePasswords] = useState<Set<string>>(new Set());
const [copiedId, setCopiedId] = useState<string | null>(null); const [copiedId, setCopiedId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const togglePassword = (id: string) => { const togglePassword = (id: string) => {
setVisiblePasswords((prev) => { setVisiblePasswords((prev) => {
@@ -51,6 +72,13 @@ export function PasswordVaultModule() {
setEditingEntry(null); setEditingEntry(null);
}; };
const handleDeleteConfirm = async () => {
if (deletingId) {
await removeEntry(deletingId);
setDeletingId(null);
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-2 text-xs text-amber-700 dark:text-amber-400"> <div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-2 text-xs text-amber-700 dark:text-amber-400">
@@ -118,12 +146,21 @@ export function PasswordVaultModule() {
<ExternalLink className="h-3 w-3" /> {entry.url} <ExternalLink className="h-3 w-3" /> {entry.url}
</p> </p>
)} )}
{entry.customFields && entry.customFields.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{entry.customFields.map((cf, i) => (
<Badge key={i} variant="secondary" className="text-[10px]">
{cf.key}: {cf.value}
</Badge>
))}
</div>
)}
</div> </div>
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingEntry(entry); setViewMode('edit'); }}> <Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingEntry(entry); 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={() => removeEntry(entry.id)}> <Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => setDeletingId(entry.id)}>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -143,6 +180,18 @@ export function PasswordVaultModule() {
</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 această intrare? Acțiunea este ireversibilă.</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingId(null)}>Anulează</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>Șterge</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
@@ -158,11 +207,42 @@ function VaultForm({ initial, onSubmit, onCancel }: {
const [url, setUrl] = useState(initial?.url ?? ''); const [url, setUrl] = useState(initial?.url ?? '');
const [category, setCategory] = useState<VaultEntryCategory>(initial?.category ?? 'web'); const [category, setCategory] = useState<VaultEntryCategory>(initial?.category ?? 'web');
const [notes, setNotes] = useState(initial?.notes ?? ''); const [notes, setNotes] = useState(initial?.notes ?? '');
const [customFields, setCustomFields] = useState<CustomField[]>(initial?.customFields ?? []);
// Password generator state
const [genLength, setGenLength] = useState(16);
const [genUpper, setGenUpper] = useState(true);
const [genLower, setGenLower] = useState(true);
const [genDigits, setGenDigits] = useState(true);
const [genSymbols, setGenSymbols] = useState(true);
const handleGenerate = () => {
setPassword(generatePassword(genLength, { upper: genUpper, lower: genLower, digits: genDigits, symbols: genSymbols }));
};
const addCustomField = () => {
setCustomFields([...customFields, { key: '', value: '' }]);
};
const updateCustomField = (index: number, field: keyof CustomField, value: string) => {
setCustomFields(customFields.map((cf, i) => i === index ? { ...cf, [field]: value } : cf));
};
const removeCustomField = (index: number) => {
setCustomFields(customFields.filter((_, i) => i !== index));
};
return ( return (
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ label, username, encryptedPassword: password, url, category, notes, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'admin' }); }} className="space-y-4"> <form onSubmit={(e) => {
e.preventDefault();
onSubmit({
label, username, encryptedPassword: password, url, category, notes,
customFields: customFields.filter((cf) => cf.key.trim()),
tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'admin',
});
}} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div><Label>Nume/Etichetă</Label><Input value={label} onChange={(e) => setLabel(e.target.value)} className="mt-1" required /></div> <div><Label>Nume/Etichetă *</Label><Input value={label} onChange={(e) => setLabel(e.target.value)} className="mt-1" required /></div>
<div><Label>Categorie</Label> <div><Label>Categorie</Label>
<Select value={category} onValueChange={(v) => setCategory(v as VaultEntryCategory)}> <Select value={category} onValueChange={(v) => setCategory(v as VaultEntryCategory)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> <SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
@@ -172,9 +252,60 @@ function VaultForm({ initial, onSubmit, onCancel }: {
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div><Label>Utilizator</Label><Input value={username} onChange={(e) => setUsername(e.target.value)} className="mt-1" /></div> <div><Label>Utilizator</Label><Input value={username} onChange={(e) => setUsername(e.target.value)} className="mt-1" /></div>
<div><Label>Parolă</Label><Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="mt-1" /></div> <div>
<Label>Parolă</Label>
<div className="mt-1 flex gap-1.5">
<Input type="text" value={password} onChange={(e) => setPassword(e.target.value)} className="flex-1 font-mono text-sm" />
<Button type="button" variant="outline" size="icon" onClick={handleGenerate} title="Generează parolă">
<KeyRound className="h-4 w-4" />
</Button>
</div>
</div>
</div> </div>
{/* Password generator options */}
<div className="rounded border p-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground">Generator parolă</p>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Label className="text-xs">Lungime:</Label>
<Input type="number" value={genLength} onChange={(e) => setGenLength(parseInt(e.target.value, 10) || 8)} className="w-16 text-sm" min={4} max={64} />
</div>
<div className="flex items-center gap-1.5"><Switch checked={genUpper} onCheckedChange={setGenUpper} id="gen-upper" /><Label htmlFor="gen-upper" className="text-xs cursor-pointer">A-Z</Label></div>
<div className="flex items-center gap-1.5"><Switch checked={genLower} onCheckedChange={setGenLower} id="gen-lower" /><Label htmlFor="gen-lower" className="text-xs cursor-pointer">a-z</Label></div>
<div className="flex items-center gap-1.5"><Switch checked={genDigits} onCheckedChange={setGenDigits} id="gen-digits" /><Label htmlFor="gen-digits" className="text-xs cursor-pointer">0-9</Label></div>
<div className="flex items-center gap-1.5"><Switch checked={genSymbols} onCheckedChange={setGenSymbols} id="gen-symbols" /><Label htmlFor="gen-symbols" className="text-xs cursor-pointer">!@#$</Label></div>
<Button type="button" variant="secondary" size="sm" onClick={handleGenerate}>
<KeyRound className="mr-1 h-3 w-3" /> Generează
</Button>
</div>
</div>
<div><Label>URL</Label><Input value={url} onChange={(e) => setUrl(e.target.value)} className="mt-1" placeholder="https://..." /></div> <div><Label>URL</Label><Input value={url} onChange={(e) => setUrl(e.target.value)} className="mt-1" placeholder="https://..." /></div>
{/* Custom fields */}
<div>
<div className="flex items-center justify-between">
<Label>Câmpuri personalizate</Label>
<Button type="button" variant="outline" size="sm" onClick={addCustomField}>
<Plus className="mr-1 h-3 w-3" /> Adaugă câmp
</Button>
</div>
{customFields.length > 0 && (
<div className="mt-2 space-y-1.5">
{customFields.map((cf, i) => (
<div key={i} className="flex items-center gap-2">
<Input placeholder="Cheie" value={cf.key} onChange={(e) => updateCustomField(i, 'key', e.target.value)} className="w-[140px] text-sm" />
<Input placeholder="Valoare" value={cf.value} onChange={(e) => updateCustomField(i, 'value', e.target.value)} className="flex-1 text-sm" />
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeCustomField(i)}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
</div>
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div> <div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></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>

View File

@@ -8,6 +8,12 @@ export type VaultEntryCategory =
| 'api' | 'api'
| 'other'; | 'other';
/** Custom key-value field */
export interface CustomField {
key: string;
value: string;
}
export interface VaultEntry { export interface VaultEntry {
id: string; id: string;
label: string; label: string;
@@ -15,6 +21,8 @@ export interface VaultEntry {
encryptedPassword: string; encryptedPassword: string;
url: string; url: string;
category: VaultEntryCategory; category: VaultEntryCategory;
/** Custom key-value fields */
customFields: CustomField[];
notes: string; notes: string;
tags: string[]; tags: string[];
visibility: Visibility; visibility: Visibility;

View File

@@ -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 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>
); );

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
}; };
} }

View File

@@ -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';

View File

@@ -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));
} }

View File

@@ -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;

View File

@@ -1,27 +1,27 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { Plus, Trash2, Tag as TagIcon } from 'lucide-react'; import {
Plus, Trash2, Pencil, Check, X, Download, ChevronDown, ChevronRight,
Tag as TagIcon, Search, FolderTree,
} 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 { 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 { useTags } from '@/core/tagging'; import { useTags } from '@/core/tagging';
import type { TagCategory, TagScope } from '@/core/tagging/types'; import type { Tag, TagCategory, TagScope } from '@/core/tagging/types';
import { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from '@/core/tagging/types';
import type { CompanyId } from '@/core/auth/types';
import { cn } from '@/shared/lib/utils'; import { cn } from '@/shared/lib/utils';
import { getManicTimeSeedTags } from '../services/seed-data';
const CATEGORY_LABELS: Record<TagCategory, string> = {
project: 'Proiect',
phase: 'Fază',
activity: 'Activitate',
'document-type': 'Tip document',
company: 'Companie',
priority: 'Prioritate',
status: 'Status',
custom: 'Personalizat',
};
const SCOPE_LABELS: Record<TagScope, string> = { const SCOPE_LABELS: Record<TagScope, string> = {
global: 'Global', global: 'Global',
@@ -29,20 +29,102 @@ const SCOPE_LABELS: Record<TagScope, string> = {
company: 'Companie', company: 'Companie',
}; };
const COMPANY_LABELS: Record<CompanyId, string> = {
beletage: 'Beletage',
'urban-switch': 'Urban Switch',
'studii-de-teren': 'Studii de Teren',
group: 'Grup',
};
const TAG_COLORS = [ const TAG_COLORS = [
'#ef4444', '#f97316', '#f59e0b', '#84cc16', '#ef4444', '#f97316', '#f59e0b', '#84cc16',
'#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6', '#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6',
'#ec4899', '#64748b', '#ec4899', '#64748b', '#22B5AB', '#6366f1',
]; ];
export function TagManagerModule() { export function TagManagerModule() {
const { tags, loading, createTag, deleteTag } = useTags(); const { tags, loading, createTag, updateTag, deleteTag, importTags } = useTags();
// ── Create form state ──
const [newLabel, setNewLabel] = useState(''); const [newLabel, setNewLabel] = useState('');
const [newCategory, setNewCategory] = useState<TagCategory>('custom'); const [newCategory, setNewCategory] = useState<TagCategory>('custom');
const [newScope, setNewScope] = useState<TagScope>('global'); const [newScope, setNewScope] = useState<TagScope>('global');
const [newColor, setNewColor] = useState(TAG_COLORS[5]); const [newColor, setNewColor] = useState('#3b82f6');
const [filterCategory, setFilterCategory] = useState<TagCategory | 'all'>('all'); const [newCompanyId, setNewCompanyId] = useState<CompanyId>('beletage');
const [newProjectCode, setNewProjectCode] = useState('');
const [newParentId, setNewParentId] = useState('');
// ── Filter / search state ──
const [filterCategory, setFilterCategory] = useState<TagCategory | 'all'>('all');
const [searchQuery, setSearchQuery] = useState('');
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
() => new Set(TAG_CATEGORY_ORDER)
);
// ── Edit state ──
const [editingTag, setEditingTag] = useState<Tag | null>(null);
const [editLabel, setEditLabel] = useState('');
const [editColor, setEditColor] = useState('');
const [editProjectCode, setEditProjectCode] = useState('');
const [editScope, setEditScope] = useState<TagScope>('global');
const [editCompanyId, setEditCompanyId] = useState<CompanyId>('beletage');
// ── Seed import state ──
const [showSeedDialog, setShowSeedDialog] = useState(false);
const [seedImporting, setSeedImporting] = useState(false);
const [seedResult, setSeedResult] = useState<string | null>(null);
// ── Computed ──
const filteredTags = useMemo(() => {
let result = tags;
if (filterCategory !== 'all') {
result = result.filter((t) => t.category === filterCategory);
}
if (searchQuery) {
const q = searchQuery.toLowerCase();
result = result.filter(
(t) =>
t.label.toLowerCase().includes(q) ||
(t.projectCode?.toLowerCase().includes(q) ?? false)
);
}
return result;
}, [tags, filterCategory, searchQuery]);
const groupedByCategory = useMemo(() => {
const groups: Record<string, Tag[]> = {};
for (const cat of TAG_CATEGORY_ORDER) {
const catTags = filteredTags.filter((t) => t.category === cat);
if (catTags.length > 0) {
groups[cat] = catTags;
}
}
return groups;
}, [filteredTags]);
/** Build a parent→children map for hierarchy display */
const childrenMap = useMemo(() => {
const map: Record<string, Tag[]> = {};
for (const tag of tags) {
if (tag.parentId) {
const existing = map[tag.parentId];
if (existing) {
existing.push(tag);
} else {
map[tag.parentId] = [tag];
}
}
}
return map;
}, [tags]);
const parentCandidates = useMemo(() => {
return tags.filter(
(t) => t.category === newCategory && !t.parentId
);
}, [tags, newCategory]);
// ── Handlers ──
const handleCreate = async () => { const handleCreate = async () => {
if (!newLabel.trim()) return; if (!newLabel.trim()) return;
await createTag({ await createTag({
@@ -50,158 +132,475 @@ export function TagManagerModule() {
category: newCategory, category: newCategory,
scope: newScope, scope: newScope,
color: newColor, color: newColor,
companyId: newScope === 'company' ? newCompanyId : undefined,
projectCode: newCategory === 'project' && newProjectCode ? newProjectCode : undefined,
parentId: newParentId || undefined,
}); });
setNewLabel(''); setNewLabel('');
setNewProjectCode('');
setNewParentId('');
}; };
const filteredTags = filterCategory === 'all' const startEdit = (tag: Tag) => {
? tags setEditingTag(tag);
: tags.filter((t) => t.category === filterCategory); setEditLabel(tag.label);
setEditColor(tag.color ?? '#3b82f6');
setEditProjectCode(tag.projectCode ?? '');
setEditScope(tag.scope);
setEditCompanyId(tag.companyId ?? 'beletage');
};
const groupedByCategory = filteredTags.reduce<Record<string, typeof tags>>((acc, tag) => { const saveEdit = async () => {
const key = tag.category; if (!editingTag || !editLabel.trim()) return;
if (!acc[key]) acc[key] = []; await updateTag(editingTag.id, {
acc[key].push(tag); label: editLabel.trim(),
return acc; color: editColor,
}, {}); projectCode: editingTag.category === 'project' && editProjectCode ? editProjectCode : undefined,
scope: editScope,
companyId: editScope === 'company' ? editCompanyId : undefined,
});
setEditingTag(null);
};
const cancelEdit = () => setEditingTag(null);
const handleSeedImport = async () => {
setSeedImporting(true);
setSeedResult(null);
const seedTags = getManicTimeSeedTags();
const count = await importTags(seedTags);
setSeedResult(`${count} etichete importate din ${seedTags.length} disponibile.`);
setSeedImporting(false);
};
const toggleCategory = (cat: string) => {
setExpandedCategories((prev) => {
const next = new Set(prev);
if (next.has(cat)) next.delete(cat);
else next.add(cat);
return next;
});
};
// ── Stats ──
const projectCount = tags.filter((t) => t.category === 'project').length;
const phaseCount = tags.filter((t) => t.category === 'phase').length;
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-5">
<Card><CardContent className="p-4"> <Card><CardContent className="p-4">
<p className="text-xs text-muted-foreground">Total etichete</p> <p className="text-xs text-muted-foreground">Total etichete</p>
<p className="text-2xl font-bold">{tags.length}</p> <p className="text-2xl font-bold">{tags.length}</p>
</CardContent></Card> </CardContent></Card>
<Card><CardContent className="p-4"> {TAG_CATEGORY_ORDER.map((cat) => (
<p className="text-xs text-muted-foreground">Categorii folosite</p> <Card key={cat}><CardContent className="p-4">
<p className="text-2xl font-bold">{new Set(tags.map((t) => t.category)).size}</p> <p className="text-xs text-muted-foreground">{TAG_CATEGORY_LABELS[cat]}</p>
</CardContent></Card> <p className="text-2xl font-bold">
<Card><CardContent className="p-4"> {tags.filter((t) => t.category === cat).length}
<p className="text-xs text-muted-foreground">Globale</p> </p>
<p className="text-2xl font-bold">{tags.filter((t) => t.scope === 'global').length}</p> </CardContent></Card>
</CardContent></Card> ))}
<Card><CardContent className="p-4">
<p className="text-xs text-muted-foreground">Personalizate</p>
<p className="text-2xl font-bold">{tags.filter((t) => t.category === 'custom').length}</p>
</CardContent></Card>
</div> </div>
{/* Seed import banner */}
{tags.length === 0 && !loading && (
<Card className="border-dashed border-2">
<CardContent className="flex items-center justify-between p-4">
<div>
<p className="font-medium">Nicio etichetă găsită</p>
<p className="text-sm text-muted-foreground">
Importă datele din ManicTime pentru a popula proiectele, fazele și activitățile.
</p>
</div>
<Button onClick={() => setShowSeedDialog(true)}>
<Download className="mr-1.5 h-4 w-4" /> Importă date inițiale
</Button>
</CardContent>
</Card>
)}
{/* Create new tag */} {/* Create new tag */}
<Card> <Card>
<CardHeader><CardTitle className="text-base">Etichetă nouă</CardTitle></CardHeader> <CardHeader><CardTitle className="text-base">Etichetă nouă</CardTitle></CardHeader>
<CardContent> <CardContent>
<div className="flex flex-wrap items-end gap-3"> <div className="space-y-3">
<div className="min-w-[200px] flex-1"> <div className="flex flex-wrap items-end gap-3">
<Label>Nume</Label> <div className="min-w-[200px] flex-1">
<Input <Label>Nume</Label>
value={newLabel} <Input
onChange={(e) => setNewLabel(e.target.value)} value={newLabel}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()} onChange={(e) => setNewLabel(e.target.value)}
placeholder="Numele etichetei..." onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
className="mt-1" placeholder="Numele etichetei..."
/> className="mt-1"
</div> />
<div className="w-[160px]">
<Label>Categorie</Label>
<Select value={newCategory} onValueChange={(v) => setNewCategory(v as TagCategory)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{(Object.keys(CATEGORY_LABELS) as TagCategory[]).map((cat) => (
<SelectItem key={cat} value={cat}>{CATEGORY_LABELS[cat]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-[130px]">
<Label>Vizibilitate</Label>
<Select value={newScope} onValueChange={(v) => setNewScope(v as TagScope)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{(Object.keys(SCOPE_LABELS) as TagScope[]).map((s) => (
<SelectItem key={s} value={s}>{SCOPE_LABELS[s]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block">Culoare</Label>
<div className="flex gap-1">
{TAG_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setNewColor(color)}
className={cn(
'h-7 w-7 rounded-full border-2 transition-all',
newColor === color ? 'border-primary scale-110' : 'border-transparent hover:scale-105'
)}
style={{ backgroundColor: color }}
/>
))}
</div> </div>
<div className="w-[160px]">
<Label>Categorie</Label>
<Select value={newCategory} onValueChange={(v) => setNewCategory(v as TagCategory)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{TAG_CATEGORY_ORDER.map((cat) => (
<SelectItem key={cat} value={cat}>{TAG_CATEGORY_LABELS[cat]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-[140px]">
<Label>Vizibilitate</Label>
<Select value={newScope} onValueChange={(v) => setNewScope(v as TagScope)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{(Object.keys(SCOPE_LABELS) as TagScope[]).map((s) => (
<SelectItem key={s} value={s}>{SCOPE_LABELS[s]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{newScope === 'company' && (
<div className="w-[150px]">
<Label>Companie</Label>
<Select value={newCompanyId} onValueChange={(v) => setNewCompanyId(v as CompanyId)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{(Object.keys(COMPANY_LABELS) as CompanyId[]).map((c) => (
<SelectItem key={c} value={c}>{COMPANY_LABELS[c]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
<div className="flex flex-wrap items-end gap-3">
{newCategory === 'project' && (
<div className="w-[140px]">
<Label>Cod proiect</Label>
<Input
value={newProjectCode}
onChange={(e) => setNewProjectCode(e.target.value)}
placeholder="B-001"
className="mt-1 font-mono"
/>
</div>
)}
{parentCandidates.length > 0 && (
<div className="w-[200px]">
<Label>Tag părinte (opțional)</Label>
<Select value={newParentId || '__none__'} onValueChange={(v) => setNewParentId(v === '__none__' ? '' : v)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Niciun părinte </SelectItem>
{parentCandidates.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.projectCode ? `${p.projectCode} ` : ''}{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<Label className="mb-1.5 block">Culoare</Label>
<div className="flex gap-1">
{TAG_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setNewColor(color)}
className={cn(
'h-7 w-7 rounded-full border-2 transition-all',
newColor === color ? 'border-primary scale-110' : 'border-transparent hover:scale-105'
)}
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
<Button onClick={handleCreate} disabled={!newLabel.trim()}>
<Plus className="mr-1 h-4 w-4" /> Adaugă
</Button>
</div> </div>
<Button onClick={handleCreate} disabled={!newLabel.trim()}>
<Plus className="mr-1 h-4 w-4" /> Adaugă
</Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Filter */} {/* Search + Filter bar */}
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Label>Filtrează:</Label> <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ă etichete..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Select value={filterCategory} onValueChange={(v) => setFilterCategory(v as TagCategory | 'all')}> <Select value={filterCategory} onValueChange={(v) => setFilterCategory(v as TagCategory | 'all')}>
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger> <SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Toate categoriile</SelectItem> <SelectItem value="all">Toate categoriile</SelectItem>
{(Object.keys(CATEGORY_LABELS) as TagCategory[]).map((cat) => ( {TAG_CATEGORY_ORDER.map((cat) => (
<SelectItem key={cat} value={cat}>{CATEGORY_LABELS[cat]}</SelectItem> <SelectItem key={cat} value={cat}>{TAG_CATEGORY_LABELS[cat]}</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{tags.length > 0 && (
<Button variant="outline" size="sm" onClick={() => setShowSeedDialog(true)}>
<Download className="mr-1 h-3.5 w-3.5" /> Importă ManicTime
</Button>
)}
</div> </div>
{/* Tag list by category */} {/* Tag list by category with hierarchy */}
{loading ? ( {loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p> <p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
) : Object.keys(groupedByCategory).length === 0 ? ( ) : Object.keys(groupedByCategory).length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Nicio etichetă găsită. Creează prima etichetă.</p> <p className="py-8 text-center text-sm text-muted-foreground">
Nicio etichetă găsită. Creează prima etichetă sau importă datele inițiale.
</p>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-3">
{Object.entries(groupedByCategory).map(([category, catTags]) => ( {Object.entries(groupedByCategory).map(([category, catTags]) => {
<Card key={category}> const isExpanded = expandedCategories.has(category);
<CardHeader className="pb-3"> const rootTags = catTags.filter((t) => !t.parentId);
<CardTitle className="flex items-center gap-2 text-sm"> return (
<TagIcon className="h-4 w-4" /> <Card key={category}>
{CATEGORY_LABELS[category as TagCategory] ?? category} <CardHeader
<Badge variant="secondary" className="ml-1">{catTags.length}</Badge> className="cursor-pointer pb-3"
</CardTitle> onClick={() => toggleCategory(category)}
</CardHeader> >
<CardContent> <CardTitle className="flex items-center gap-2 text-sm">
<div className="flex flex-wrap gap-2"> {isExpanded
{catTags.map((tag) => ( ? <ChevronDown className="h-4 w-4" />
<div : <ChevronRight className="h-4 w-4" />}
key={tag.id} <TagIcon className="h-4 w-4" />
className="group flex items-center gap-1.5 rounded-full border py-1 pl-3 pr-1.5 text-sm" {TAG_CATEGORY_LABELS[category as TagCategory] ?? category}
> <Badge variant="secondary" className="ml-1">{catTags.length}</Badge>
{tag.color && ( {(category === 'project' || category === 'phase') && (
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: tag.color }} /> <Badge variant="default" className="ml-1 text-[10px]">obligatoriu</Badge>
)} )}
<span>{tag.label}</span> </CardTitle>
<Badge variant="outline" className="text-[10px] px-1">{SCOPE_LABELS[tag.scope]}</Badge> </CardHeader>
<button {isExpanded && (
type="button" <CardContent>
onClick={() => deleteTag(tag.id)} <div className="space-y-1">
className="ml-0.5 rounded-full p-0.5 opacity-0 transition-opacity hover:bg-destructive/10 group-hover:opacity-100" {rootTags.map((tag) => (
> <TagRow
<Trash2 className="h-3 w-3 text-destructive" /> key={tag.id}
</button> tag={tag}
children={childrenMap[tag.id]}
editingTag={editingTag}
editLabel={editLabel}
editColor={editColor}
editProjectCode={editProjectCode}
editScope={editScope}
editCompanyId={editCompanyId}
onStartEdit={startEdit}
onSaveEdit={saveEdit}
onCancelEdit={cancelEdit}
onDelete={deleteTag}
setEditLabel={setEditLabel}
setEditColor={setEditColor}
setEditProjectCode={setEditProjectCode}
setEditScope={setEditScope}
setEditCompanyId={setEditCompanyId}
/>
))}
</div> </div>
))} </CardContent>
</div> )}
</CardContent> </Card>
</Card> );
})}
</div>
)}
{/* Seed Import Dialog */}
<Dialog open={showSeedDialog} onOpenChange={setShowSeedDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Importă date inițiale ManicTime</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<p className="text-sm text-muted-foreground">
Aceasta va importa proiectele Beletage, fazele, activitățile și tipurile de documente
din lista ManicTime. Etichetele existente nu vor fi duplicate.
</p>
{seedResult && (
<p className="rounded bg-muted p-2 text-sm font-medium">{seedResult}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowSeedDialog(false)}>Închide</Button>
<Button onClick={handleSeedImport} disabled={seedImporting}>
{seedImporting ? 'Se importă...' : 'Importă'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ── Tag Row with inline editing ──
interface TagRowProps {
tag: Tag;
children?: Tag[];
editingTag: Tag | null;
editLabel: string;
editColor: string;
editProjectCode: string;
editScope: TagScope;
editCompanyId: CompanyId;
onStartEdit: (tag: Tag) => void;
onSaveEdit: () => void;
onCancelEdit: () => void;
onDelete: (id: string) => void;
setEditLabel: (v: string) => void;
setEditColor: (v: string) => void;
setEditProjectCode: (v: string) => void;
setEditScope: (v: TagScope) => void;
setEditCompanyId: (v: CompanyId) => void;
}
function TagRow({
tag, children, editingTag, editLabel, editColor, editProjectCode,
editScope, editCompanyId,
onStartEdit, onSaveEdit, onCancelEdit, onDelete,
setEditLabel, setEditColor, setEditProjectCode, setEditScope, setEditCompanyId,
}: TagRowProps) {
const isEditing = editingTag?.id === tag.id;
const [showChildren, setShowChildren] = useState(false);
const hasChildren = children && children.length > 0;
if (isEditing) {
return (
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-2">
{tag.category === 'project' && (
<Input
value={editProjectCode}
onChange={(e) => setEditProjectCode(e.target.value)}
className="w-[100px] font-mono text-xs"
placeholder="B-001"
/>
)}
<Input
value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') onSaveEdit(); if (e.key === 'Escape') onCancelEdit(); }}
className="min-w-[200px] flex-1"
autoFocus
/>
<Select value={editScope} onValueChange={(v) => setEditScope(v as TagScope)}>
<SelectTrigger className="w-[120px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="global">Global</SelectItem>
<SelectItem value="module">Modul</SelectItem>
<SelectItem value="company">Companie</SelectItem>
</SelectContent>
</Select>
{editScope === 'company' && (
<Select value={editCompanyId} onValueChange={(v) => setEditCompanyId(v as CompanyId)}>
<SelectTrigger className="w-[140px]"><SelectValue /></SelectTrigger>
<SelectContent>
{(Object.keys(COMPANY_LABELS) as CompanyId[]).map((c) => (
<SelectItem key={c} value={c}>{COMPANY_LABELS[c]}</SelectItem>
))}
</SelectContent>
</Select>
)}
<div className="flex gap-1">
{TAG_COLORS.slice(0, 6).map((c) => (
<button
key={c}
type="button"
onClick={() => setEditColor(c)}
className={cn(
'h-5 w-5 rounded-full border-2 transition-all',
editColor === c ? 'border-primary scale-110' : 'border-transparent'
)}
style={{ backgroundColor: c }}
/>
))}
</div>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onSaveEdit}>
<Check className="h-4 w-4 text-green-600" />
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onCancelEdit}>
<X className="h-4 w-4" />
</Button>
</div>
);
}
return (
<div>
<div className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/30">
{hasChildren && (
<button type="button" onClick={() => setShowChildren(!showChildren)} className="p-0.5">
{showChildren
? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
: <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />}
</button>
)}
{!hasChildren && <span className="w-5" />}
{tag.color && (
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: tag.color }} />
)}
{tag.projectCode && (
<span className="font-mono text-xs text-muted-foreground">{tag.projectCode}</span>
)}
<span className="flex-1 text-sm">{tag.label}</span>
{tag.companyId && (
<Badge variant="outline" className="text-[10px] px-1.5">
{COMPANY_LABELS[tag.companyId]}
</Badge>
)}
<Badge variant="outline" className="text-[10px] px-1">
{SCOPE_LABELS[tag.scope]}
</Badge>
<div className="flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
onClick={() => onStartEdit(tag)}
className="rounded p-1 hover:bg-muted"
>
<Pencil className="h-3 w-3 text-muted-foreground" />
</button>
<button
type="button"
onClick={() => onDelete(tag.id)}
className="rounded p-1 hover:bg-destructive/10"
>
<Trash2 className="h-3 w-3 text-destructive" />
</button>
</div>
</div>
{hasChildren && showChildren && (
<div className="ml-6 border-l pl-2">
{children.map((child) => (
<div key={child.id} className="group flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/30">
<FolderTree className="h-3 w-3 text-muted-foreground" />
{child.color && (
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: child.color }} />
)}
{child.projectCode && (
<span className="font-mono text-[11px] text-muted-foreground">{child.projectCode}</span>
)}
<span className="flex-1 text-sm">{child.label}</span>
<div className="flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<button type="button" onClick={() => onStartEdit(child)} className="rounded p-1 hover:bg-muted">
<Pencil className="h-3 w-3 text-muted-foreground" />
</button>
<button type="button" onClick={() => onDelete(child.id)} className="rounded p-1 hover:bg-destructive/10">
<Trash2 className="h-3 w-3 text-destructive" />
</button>
</div>
</div>
))} ))}
</div> </div>
)} )}

View File

@@ -1,3 +1,4 @@
export { tagManagerConfig } from './config'; export { tagManagerConfig } from './config';
export { TagManagerModule } from './components/tag-manager-module'; export { TagManagerModule } from './components/tag-manager-module';
export type { Tag, TagCategory, TagScope } from './types'; export type { Tag, TagCategory, TagScope } from './types';
export { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from './types';

View File

@@ -0,0 +1,188 @@
import type { Tag, TagCategory } from '@/core/tagging/types';
import type { CompanyId } from '@/core/auth/types';
type SeedTag = Omit<Tag, 'id' | 'createdAt'>;
/** Parse project line like "000 Farmacie" → { code: "B-000", label: "Farmacie" } */
function parseProjectLine(line: string, prefix: string): { code: string; label: string } | null {
const match = line.match(/^(\w?\d+)\s+(.+)$/);
if (!match?.[1] || !match[2]) return null;
const num = match[1];
const label = match[2].trim();
const padded = num.replace(/^[A-Z]/, '').padStart(3, '0');
const codePrefix = num.startsWith('L') ? `${prefix}L` : prefix;
return { code: `${codePrefix}-${padded}`, label };
}
export function getManicTimeSeedTags(): SeedTag[] {
const tags: SeedTag[] = [];
// ── Beletage projects ──
const beletageProjects = [
'000 Farmacie',
'002 Cladire birouri Stratec',
'003 PUZ Bellavista',
'007 Design Apartament Teodora',
'010 Casa Doinei',
'016 Duplex Eremia',
'024 Bloc Petofi',
'028 PUZ Borhanci-Sopor',
'033 Mansardare Branului',
'039 Cabinete Stoma Scala',
'041 Imobil mixt Progresului',
'045 Casa Andrei Muresanu',
'052 PUZ Carpenului',
'059 PUZ Nordului',
'064 Casa Salicea',
'066 Terasa Gherase',
'070 Bloc Fanatelor',
'073 Case Frumoasa',
'074 PUG Cosbuc',
'076 Casa Copernicus',
'077 PUZ Schimbare destinatie Brancusi',
'078 Service auto Linistei',
'079 Amenajare drum Servitute Eremia',
'080 Bloc Tribunul',
'081 Extindere casa Gherase',
'083 Modificari casa Zsigmund 18',
'084 Mansardare Petofi 21',
'085 Container CT Spital Tabacarilor',
'086 Imprejmuire casa sat Gheorgheni',
'087 Duplex Oasului fn',
'089 PUZ A-Liu Sopor',
'090 VR MedEvents',
'091 Reclama Caparol',
'092 Imobil birouri 13 Septembrie',
'093 Casa Salistea Noua',
'094 PUD Casa Rediu',
'095 Duplex Vanatorului',
'096 Design apartament Sopor',
'097 Cabana Gilau',
'101 PUZ Gilau',
'102 PUZ Ghimbav',
'103 Piscine Lunca Noua',
'104 PUZ REGHIN',
'105 CUT&Crust',
'106 PUZ Mihai Romanu Nord',
'108 Reabilitare Bloc Beiusului',
'109 Case Samboleni',
'110 Penny Crasna',
'111 Anexa Piscina Borhanci',
'112 PUZ Blocuri Bistrita',
'113 PUZ VARATEC-FIRIZA',
'114 PUG Husi',
'115 PUG Josenii Bargaului',
'116 PUG Monor',
'117 Schimbare Destinatie Mihai Viteazu 2',
'120 Anexa Brasov',
'121 Imprejurare imobil Mesterul Manole 9',
'122 Fastfood Bashar',
'123 PUD Rediu 2',
'127 Casa Socaciu Ciurila',
'128 Schimbare de destinatie Danubius',
'129 (re) Casa Sarca-Sorescu',
'130 Casa Suta-Wonderland',
'131 PUD Oasului Hufi',
'132 Reabilitare Camin Cultural Baciu',
'133 PUG Feldru',
'134 DALI Blocuri Murfatlar',
'135 Case de vacanta Dianei',
'136 PUG BROSTENI',
'139 Casa Turda',
'140 Releveu Bistrita (Morariu)',
'141 PUZ Janovic Jeno',
'142 Penny Borhanci',
'143 Pavilion Politie Radauti',
'149 Duplex Sorescu 31-33',
'150 DALI SF Scoala Baciu',
'151 Casa Alexandru Bohatiel 17',
'152 PUZ Penny Tautii Magheraus',
'153 PUG Banita',
'155 PT Scoala Floresti',
'156 Case Sorescu',
'157 Gradi-Cresa Baciu',
'158 Duplex Sorescu 21-23',
'159 Amenajare Spatiu Grenke PBC',
'160 Etajare Primaria Baciu',
'161 Extindere Ap Baciu',
'164 SD salon Aurel Vlaicu',
'165 Reclama Marasti',
'166 Catei Apahida',
'167 Apartament Mircea Zaciu 13-15',
'169 Casa PETRILA 37',
'170 Cabana Campeni AB',
'171 Camin Apahida',
'L089 PUZ TUSA-BOJAN',
'172 Design casa Iugoslaviei 18',
'173 Reabilitare spitale Sighetu',
'174 StudX UMFST',
'176 - 2025 - ReAC Ansamblu rezi Bibescu',
];
for (const line of beletageProjects) {
const parsed = parseProjectLine(line, 'B');
if (parsed) {
tags.push({
label: parsed.label,
category: 'project',
scope: 'company',
companyId: 'beletage' as CompanyId,
projectCode: parsed.code,
color: '#22B5AB',
});
}
}
// ── Phase tags ──
const phases = [
'CU', 'Schita', 'Avize', 'PUD', 'AO', 'PUZ', 'PUG',
'DTAD', 'DTAC', 'PT', 'Detalii de Executie', 'Studii de fundamentare',
'Regulament', 'Parte desenata', 'Parte scrisa',
'Consultanta client', 'Macheta', 'Consultanta receptie',
'Redactare', 'Depunere', 'Ridicare', 'Verificare proiect',
'Vizita santier',
];
for (const phase of phases) {
tags.push({
label: phase,
category: 'phase',
scope: 'global',
color: '#3b82f6',
});
}
// ── Activity tags ──
const activities = [
'Ofertare', 'Configurari', 'Organizare initiala', 'Pregatire Portofoliu',
'Website', 'Documentare', 'Design grafic', 'Design interior',
'Design exterior', 'Releveu', 'Reclama', 'Master MATDR',
'Pauza de masa', 'Timp personal', 'Concediu', 'Compensare overtime',
];
for (const activity of activities) {
tags.push({
label: activity,
category: 'activity',
scope: 'global',
color: '#8b5cf6',
});
}
// ── Document type tags ──
const docTypes = [
'Contract', 'Ofertă', 'Factură', 'Scrisoare',
'Aviz', 'Notă de comandă', 'Raport', 'Cerere', 'Altele',
];
for (const dt of docTypes) {
tags.push({
label: dt,
category: 'document-type',
scope: 'global',
color: '#f59e0b',
});
}
return tags;
}

View File

@@ -1 +1,2 @@
export type { Tag, TagCategory, TagScope } from '@/core/tagging/types'; export type { Tag, TagCategory, TagScope } from '@/core/tagging/types';
export { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from '@/core/tagging/types';

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { Plus, Pencil, Trash2, Search, FileText, ExternalLink } from 'lucide-react'; import { Plus, Pencil, Trash2, Search, FileText, ExternalLink, Copy } 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';
@@ -9,22 +9,31 @@ 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 { WordTemplate } from '../types'; import type { WordTemplate, TemplateCategory } from '../types';
import { useTemplates } from '../hooks/use-templates'; import { useTemplates } from '../hooks/use-templates';
const TEMPLATE_CATEGORIES = [ const CATEGORY_LABELS: Record<TemplateCategory, string> = {
'Contract', 'Memoriu tehnic', 'Ofertă', 'Factură', 'Raport', 'Deviz', 'Proces-verbal', 'Altele', contract: 'Contract',
]; memoriu: 'Memoriu tehnic',
oferta: 'Ofertă',
raport: 'Raport',
cerere: 'Cerere',
aviz: 'Aviz',
scrisoare: 'Scrisoare',
altele: 'Altele',
};
type ViewMode = 'list' | 'add' | 'edit'; type ViewMode = 'list' | 'add' | 'edit';
export function WordTemplatesModule() { export function WordTemplatesModule() {
const { templates, allTemplates, allCategories, loading, filters, updateFilter, addTemplate, updateTemplate, removeTemplate } = useTemplates(); const { templates, allTemplates, loading, filters, updateFilter, addTemplate, updateTemplate, cloneTemplate, removeTemplate } = useTemplates();
const [viewMode, setViewMode] = useState<ViewMode>('list'); const [viewMode, setViewMode] = useState<ViewMode>('list');
const [editingTemplate, setEditingTemplate] = useState<WordTemplate | null>(null); const [editingTemplate, setEditingTemplate] = useState<WordTemplate | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const handleSubmit = async (data: Omit<WordTemplate, 'id' | 'createdAt'>) => { const handleSubmit = async (data: Omit<WordTemplate, 'id' | 'createdAt' | 'updatedAt'>) => {
if (viewMode === 'edit' && editingTemplate) { if (viewMode === 'edit' && editingTemplate) {
await updateTemplate(editingTemplate.id, data); await updateTemplate(editingTemplate.id, data);
} else { } else {
@@ -34,16 +43,21 @@ export function WordTemplatesModule() {
setEditingTemplate(null); setEditingTemplate(null);
}; };
const filterCategories = allCategories.length > 0 ? allCategories : TEMPLATE_CATEGORIES; const handleDeleteConfirm = async () => {
if (deletingId) {
await removeTemplate(deletingId);
setDeletingId(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">
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total șabloane</p><p className="text-2xl font-bold">{allTemplates.length}</p></CardContent></Card> <Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total șabloane</p><p className="text-2xl font-bold">{allTemplates.length}</p></CardContent></Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Categorii</p><p className="text-2xl font-bold">{allCategories.length}</p></CardContent></Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Beletage</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'beletage').length}</p></CardContent></Card> <Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Beletage</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'beletage').length}</p></CardContent></Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Urban Switch</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'urban-switch').length}</p></CardContent></Card> <Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Urban Switch</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'urban-switch').length}</p></CardContent></Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Studii de Teren</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'studii-de-teren').length}</p></CardContent></Card>
</div> </div>
{viewMode === 'list' && ( {viewMode === 'list' && (
@@ -53,15 +67,25 @@ export function WordTemplatesModule() {
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Caută șablon..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" /> <Input placeholder="Caută șablon..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
</div> </div>
<Select value={filters.category} onValueChange={(v) => updateFilter('category', v)}> <Select value={filters.category} onValueChange={(v) => updateFilter('category', v as TemplateCategory | 'all')}>
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger> <SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Toate</SelectItem> <SelectItem value="all">Toate categoriile</SelectItem>
{filterCategories.map((c) => ( {(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem> <SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={filters.company} onValueChange={(v) => updateFilter('company', v)}>
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate companiile</SelectItem>
<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>
<Button onClick={() => setViewMode('add')} className="shrink-0"> <Button onClick={() => setViewMode('add')} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă <Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button> </Button>
@@ -70,19 +94,20 @@ export function WordTemplatesModule() {
{loading ? ( {loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p> <p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
) : templates.length === 0 ? ( ) : templates.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground"> <p className="py-8 text-center text-sm text-muted-foreground">Niciun șablon găsit. Adaugă primul șablon Word.</p>
Niciun șablon găsit. Adaugă primul șablon Word.
</p>
) : ( ) : (
<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">
{templates.map((tpl) => ( {templates.map((tpl) => (
<Card key={tpl.id} className="group relative"> <Card key={tpl.id} className="group relative">
<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="Clonează" onClick={() => cloneTemplate(tpl.id)}>
<Copy className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingTemplate(tpl); setViewMode('edit'); }}> <Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingTemplate(tpl); 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={() => removeTemplate(tpl.id)}> <Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => setDeletingId(tpl.id)}>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -94,9 +119,18 @@ export function WordTemplatesModule() {
<p className="font-medium">{tpl.name}</p> <p className="font-medium">{tpl.name}</p>
{tpl.description && <p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">{tpl.description}</p>} {tpl.description && <p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">{tpl.description}</p>}
<div className="mt-1.5 flex flex-wrap gap-1"> <div className="mt-1.5 flex flex-wrap gap-1">
{tpl.category && <Badge variant="outline" className="text-[10px]">{tpl.category}</Badge>} <Badge variant="outline" className="text-[10px]">{CATEGORY_LABELS[tpl.category]}</Badge>
<Badge variant="secondary" className="text-[10px]">v{tpl.version}</Badge> <Badge variant="secondary" className="text-[10px]">v{tpl.version}</Badge>
{tpl.clonedFrom && <Badge variant="secondary" className="text-[10px]">Clonă</Badge>}
</div> </div>
{/* Placeholders display */}
{tpl.placeholders.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{tpl.placeholders.map((p) => (
<span key={p} className="rounded bg-muted px-1 py-0.5 font-mono text-[10px] text-muted-foreground">{`{{${p}}}`}</span>
))}
</div>
)}
{tpl.fileUrl && ( {tpl.fileUrl && (
<a href={tpl.fileUrl} target="_blank" rel="noopener noreferrer" className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline"> <a href={tpl.fileUrl} target="_blank" rel="noopener noreferrer" className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline">
<ExternalLink className="h-3 w-3" /> Deschide fișier <ExternalLink className="h-3 w-3" /> Deschide fișier
@@ -120,30 +154,58 @@ export function WordTemplatesModule() {
</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 șablon? Acțiunea este ireversibilă.</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingId(null)}>Anulează</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>Șterge</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
function TemplateForm({ initial, onSubmit, onCancel }: { function TemplateForm({ initial, onSubmit, onCancel }: {
initial?: WordTemplate; initial?: WordTemplate;
onSubmit: (data: Omit<WordTemplate, 'id' | 'createdAt'>) => void; onSubmit: (data: Omit<WordTemplate, 'id' | 'createdAt' | 'updatedAt'>) => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const [name, setName] = useState(initial?.name ?? ''); const [name, setName] = useState(initial?.name ?? '');
const [description, setDescription] = useState(initial?.description ?? ''); const [description, setDescription] = useState(initial?.description ?? '');
const [category, setCategory] = useState(initial?.category ?? 'Contract'); const [category, setCategory] = useState<TemplateCategory>(initial?.category ?? 'contract');
const [fileUrl, setFileUrl] = useState(initial?.fileUrl ?? ''); const [fileUrl, setFileUrl] = useState(initial?.fileUrl ?? '');
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage'); const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
const [version, setVersion] = useState(initial?.version ?? '1.0.0'); const [version, setVersion] = useState(initial?.version ?? '1.0.0');
const [placeholdersText, setPlaceholdersText] = useState(initial?.placeholders.join(', ') ?? '');
return ( return (
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ name, description, category, fileUrl, company, version, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all' }); }} className="space-y-4"> <form onSubmit={(e) => {
e.preventDefault();
const placeholders = placeholdersText
.split(',')
.map((p) => p.trim())
.filter((p) => p.length > 0);
onSubmit({
name, description, category, fileUrl, company, version, placeholders,
clonedFrom: initial?.clonedFrom,
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>Nume șablon</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div> <div><Label>Nume șablon *</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div>
<div><Label>Categorie</Label> <div><Label>Categorie</Label>
<Select value={category} onValueChange={setCategory}> <Select value={category} onValueChange={(v) => setCategory(v as TemplateCategory)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> <SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>{TEMPLATE_CATEGORIES.map((c) => (<SelectItem key={c} value={c}>{c}</SelectItem>))}</SelectContent> <SelectContent>
{(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map((c) => (
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>
))}
</SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
@@ -163,6 +225,11 @@ function TemplateForm({ initial, onSubmit, onCancel }: {
<div><Label>Versiune</Label><Input value={version} onChange={(e) => setVersion(e.target.value)} className="mt-1" /></div> <div><Label>Versiune</Label><Input value={version} onChange={(e) => setVersion(e.target.value)} className="mt-1" /></div>
<div><Label>URL fișier</Label><Input value={fileUrl} onChange={(e) => setFileUrl(e.target.value)} className="mt-1" placeholder="https://..." /></div> <div><Label>URL fișier</Label><Input value={fileUrl} onChange={(e) => setFileUrl(e.target.value)} className="mt-1" placeholder="https://..." /></div>
</div> </div>
<div>
<Label>Placeholder-e (separate prin virgulă)</Label>
<Input value={placeholdersText} onChange={(e) => setPlaceholdersText(e.target.value)} className="mt-1" placeholder="NUME_BENEFICIAR, DATA_CONTRACT, NR_PROIECT..." />
<p className="mt-1 text-xs text-muted-foreground">Variabilele din șablon, de forma {'{{VARIABILA}}'}, separate prin virgulă.</p>
</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,20 +3,21 @@
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 { WordTemplate } from '../types'; import type { WordTemplate, TemplateCategory } from '../types';
const PREFIX = 'tpl:'; const PREFIX = 'tpl:';
export interface TemplateFilters { export interface TemplateFilters {
search: string; search: string;
category: string; category: TemplateCategory | 'all';
company: string;
} }
export function useTemplates() { export function useTemplates() {
const storage = useStorage('word-templates'); const storage = useStorage('word-templates');
const [templates, setTemplates] = useState<WordTemplate[]>([]); const [templates, setTemplates] = useState<WordTemplate[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<TemplateFilters>({ search: '', category: 'all' }); const [filters, setFilters] = useState<TemplateFilters>({ search: '', category: 'all', company: 'all' });
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -36,8 +37,9 @@ export function useTemplates() {
// 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 addTemplate = useCallback(async (data: Omit<WordTemplate, 'id' | 'createdAt'>) => { const addTemplate = useCallback(async (data: Omit<WordTemplate, 'id' | 'createdAt' | 'updatedAt'>) => {
const template: WordTemplate = { ...data, id: uuid(), createdAt: new Date().toISOString() }; const now = new Date().toISOString();
const template: WordTemplate = { ...data, id: uuid(), createdAt: now, updatedAt: now };
await storage.set(`${PREFIX}${template.id}`, template); await storage.set(`${PREFIX}${template.id}`, template);
await refresh(); await refresh();
return template; return template;
@@ -46,11 +48,32 @@ export function useTemplates() {
const updateTemplate = useCallback(async (id: string, updates: Partial<WordTemplate>) => { const updateTemplate = useCallback(async (id: string, updates: Partial<WordTemplate>) => {
const existing = templates.find((t) => t.id === id); const existing = templates.find((t) => t.id === id);
if (!existing) return; if (!existing) return;
const updated = { ...existing, ...updates, id: existing.id, createdAt: existing.createdAt }; const updated: WordTemplate = {
...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, templates]); }, [storage, refresh, templates]);
const cloneTemplate = useCallback(async (id: string) => {
const existing = templates.find((t) => t.id === id);
if (!existing) return;
const now = new Date().toISOString();
const cloned: WordTemplate = {
...existing,
id: uuid(),
name: `${existing.name} (copie)`,
clonedFrom: existing.id,
createdAt: now,
updatedAt: now,
};
await storage.set(`${PREFIX}${cloned.id}`, cloned);
await refresh();
return cloned;
}, [storage, refresh, templates]);
const removeTemplate = useCallback(async (id: string) => { const removeTemplate = useCallback(async (id: string) => {
await storage.delete(`${PREFIX}${id}`); await storage.delete(`${PREFIX}${id}`);
await refresh(); await refresh();
@@ -60,10 +83,9 @@ export function useTemplates() {
setFilters((prev) => ({ ...prev, [key]: value })); setFilters((prev) => ({ ...prev, [key]: value }));
}, []); }, []);
const allCategories = [...new Set(templates.map((t) => t.category).filter(Boolean))];
const filteredTemplates = templates.filter((t) => { const filteredTemplates = templates.filter((t) => {
if (filters.category !== 'all' && t.category !== filters.category) return false; if (filters.category !== 'all' && t.category !== filters.category) return false;
if (filters.company !== 'all' && t.company !== filters.company) return false;
if (filters.search) { if (filters.search) {
const q = filters.search.toLowerCase(); const q = filters.search.toLowerCase();
return t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q); return t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q);
@@ -71,5 +93,5 @@ export function useTemplates() {
return true; return true;
}); });
return { templates: filteredTemplates, allTemplates: templates, allCategories, loading, filters, updateFilter, addTemplate, updateTemplate, removeTemplate, refresh }; return { templates: filteredTemplates, allTemplates: templates, loading, filters, updateFilter, addTemplate, updateTemplate, cloneTemplate, removeTemplate, refresh };
} }

View File

@@ -1,3 +1,3 @@
export { wordTemplatesConfig } from './config'; export { wordTemplatesConfig } from './config';
export { WordTemplatesModule } from './components/word-templates-module'; export { WordTemplatesModule } from './components/word-templates-module';
export type { WordTemplate } from './types'; export type { WordTemplate, TemplateCategory } from './types';

View File

@@ -1,15 +1,30 @@
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 TemplateCategory =
| 'contract'
| 'memoriu'
| 'oferta'
| 'raport'
| 'cerere'
| 'aviz'
| 'scrisoare'
| 'altele';
export interface WordTemplate { export interface WordTemplate {
id: string; id: string;
name: string; name: string;
description: string; description: string;
category: string; category: TemplateCategory;
fileUrl: string; fileUrl: string;
company: CompanyId; company: CompanyId;
/** Detected placeholders in template */
placeholders: string[];
/** Cloned from template ID */
clonedFrom?: string;
tags: string[]; tags: string[];
version: string; version: string;
visibility: Visibility; visibility: Visibility;
createdAt: string; createdAt: string;
updatedAt: string;
} }