Compare commits
8 Commits
cb5e01b189
...
2c9f0bc6b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c9f0bc6b7 | ||
|
|
455d95a8c6 | ||
|
|
c3abbf1c4b | ||
|
|
f7e6cbbc65 | ||
|
|
93cf6feae2 | ||
|
|
98eda56035 | ||
|
|
84d9db4515 | ||
|
|
f555258dcb |
@@ -1,3 +1,4 @@
|
||||
export type { Tag, TagCategory, TagScope } from './types';
|
||||
export { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from './types';
|
||||
export { TagService } from './tag-service';
|
||||
export { useTags } from './use-tags';
|
||||
|
||||
@@ -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> {
|
||||
const tag: Tag = {
|
||||
...data,
|
||||
@@ -43,19 +47,41 @@ export class TagService {
|
||||
async updateTag(id: string, updates: Partial<Omit<Tag, 'id' | 'createdAt'>>): Promise<Tag | null> {
|
||||
const existing = await this.storage.get<Tag>(NAMESPACE, id);
|
||||
if (!existing) return null;
|
||||
const updated = { ...existing, ...updates };
|
||||
const updated: Tag = { ...existing, ...updates, updatedAt: new Date().toISOString() };
|
||||
await this.storage.set(NAMESPACE, id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async searchTags(query: string): Promise<Tag[]> {
|
||||
const lower = query.toLowerCase();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,25 @@ export type TagCategory =
|
||||
| 'phase'
|
||||
| 'activity'
|
||||
| 'document-type'
|
||||
| 'company'
|
||||
| 'priority'
|
||||
| 'status'
|
||||
| '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 interface Tag {
|
||||
@@ -21,7 +35,11 @@ export interface Tag {
|
||||
scope: TagScope;
|
||||
moduleId?: string;
|
||||
companyId?: CompanyId;
|
||||
/** For hierarchy: parent tag id */
|
||||
parentId?: string;
|
||||
/** For project tags: numbered code e.g. "B-001", "US-024" */
|
||||
projectCode?: string;
|
||||
metadata?: Record<string, string>;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,15 @@ export function useTags(category?: TagCategory) {
|
||||
[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(
|
||||
async (id: string) => {
|
||||
await service.deleteTag(id);
|
||||
@@ -41,5 +50,21 @@ export function useTags(category?: TagCategory) {
|
||||
[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 };
|
||||
}
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
'use client';
|
||||
|
||||
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 { Input } from '@/shared/components/ui/input';
|
||||
import { Label } from '@/shared/components/ui/label';
|
||||
import { Textarea } from '@/shared/components/ui/textarea';
|
||||
import { Badge } from '@/shared/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
||||
import type { AddressContact, ContactType } from '../types';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/shared/components/ui/select';
|
||||
import type { AddressContact, ContactType, ContactPerson } from '../types';
|
||||
import { useContacts } from '../hooks/use-contacts';
|
||||
import { useTags } from '@/core/tagging';
|
||||
|
||||
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';
|
||||
@@ -23,7 +33,7 @@ export function AddressBookModule() {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
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) {
|
||||
await updateContact(editingContact.id, data);
|
||||
} else {
|
||||
@@ -36,9 +46,9 @@ export function AddressBookModule() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 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>
|
||||
{(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">
|
||||
<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>
|
||||
@@ -74,42 +84,12 @@ export function AddressBookModule() {
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{contacts.map((contact) => (
|
||||
<Card key={contact.id} 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={() => { setEditingContact(contact); setViewMode('edit'); }}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</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>
|
||||
<ContactCard
|
||||
key={contact.id}
|
||||
contact={contact}
|
||||
onEdit={() => { setEditingContact(contact); setViewMode('edit'); }}
|
||||
onDelete={() => removeContact(contact.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -120,7 +100,11 @@ export function AddressBookModule() {
|
||||
<Card>
|
||||
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare contact' : 'Contact nou'}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<ContactForm initial={editingContact ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingContact(null); }} />
|
||||
<ContactForm
|
||||
initial={editingContact ?? undefined}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => { setViewMode('list'); setEditingContact(null); }}
|
||||
/>
|
||||
</CardContent>
|
||||
</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 }: {
|
||||
initial?: AddressContact;
|
||||
onSubmit: (data: Omit<AddressContact, 'id' | 'createdAt'>) => void;
|
||||
onSubmit: (data: Omit<AddressContact, 'id' | 'createdAt' | 'updatedAt'>) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { tags: projectTags } = useTags('project');
|
||||
const [name, setName] = useState(initial?.name ?? '');
|
||||
const [company, setCompany] = useState(initial?.company ?? '');
|
||||
const [type, setType] = useState<ContactType>(initial?.type ?? 'client');
|
||||
const [email, setEmail] = useState(initial?.email ?? '');
|
||||
const [email2, setEmail2] = useState(initial?.email2 ?? '');
|
||||
const [phone, setPhone] = useState(initial?.phone ?? '');
|
||||
const [phone2, setPhone2] = useState(initial?.phone2 ?? '');
|
||||
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 [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 (
|
||||
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ name, company, type, email, phone, address, notes, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all' }); }} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<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>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit({
|
||||
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><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>
|
||||
<Select value={type} onValueChange={(v) => setType(v as ContactType)}>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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 className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
||||
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
|
||||
|
||||
@@ -36,8 +36,9 @@ export function useContacts() {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
const addContact = useCallback(async (data: Omit<AddressContact, 'id' | 'createdAt'>) => {
|
||||
const contact: AddressContact = { ...data, id: uuid(), createdAt: new Date().toISOString() };
|
||||
const addContact = useCallback(async (data: Omit<AddressContact, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
const now = new Date().toISOString();
|
||||
const contact: AddressContact = { ...data, id: uuid(), createdAt: now, updatedAt: now };
|
||||
await storage.set(`${PREFIX}${contact.id}`, contact);
|
||||
await refresh();
|
||||
return contact;
|
||||
@@ -46,7 +47,13 @@ export function useContacts() {
|
||||
const updateContact = useCallback(async (id: string, updates: Partial<AddressContact>) => {
|
||||
const existing = contacts.find((c) => c.id === id);
|
||||
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 refresh();
|
||||
}, [storage, refresh, contacts]);
|
||||
@@ -64,7 +71,14 @@ export function useContacts() {
|
||||
if (filters.type !== 'all' && c.type !== filters.type) return false;
|
||||
if (filters.search) {
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -1,17 +1,44 @@
|
||||
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 {
|
||||
id: string;
|
||||
/** Primary name (person or organization) */
|
||||
name: string;
|
||||
/** Organization/company name */
|
||||
company: string;
|
||||
type: ContactType;
|
||||
/** Primary email */
|
||||
email: string;
|
||||
/** Secondary email */
|
||||
email2: string;
|
||||
/** Primary phone */
|
||||
phone: string;
|
||||
/** Secondary phone */
|
||||
phone2: 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[];
|
||||
notes: string;
|
||||
visibility: Visibility;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
'use client';
|
||||
|
||||
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 { Input } from '@/shared/components/ui/input';
|
||||
import { Label } from '@/shared/components/ui/label';
|
||||
import { Textarea } from '@/shared/components/ui/textarea';
|
||||
import { Badge } from '@/shared/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog';
|
||||
import type { CompanyId } from '@/core/auth/types';
|
||||
import type { SignatureAsset, SignatureAssetType } from '../types';
|
||||
import { useSignatures } from '../hooks/use-signatures';
|
||||
@@ -23,11 +25,13 @@ const TYPE_ICONS: Record<SignatureAssetType, typeof PenTool> = {
|
||||
type ViewMode = 'list' | 'add' | 'edit';
|
||||
|
||||
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 [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) {
|
||||
await updateAsset(editingAsset.id, data);
|
||||
} else {
|
||||
@@ -37,6 +41,31 @@ export function DigitalSignaturesModule() {
|
||||
setEditingAsset(null);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (deletingId) {
|
||||
await removeAsset(deletingId);
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddVersion = async (imageUrl: string, notes: string) => {
|
||||
if (versionAsset) {
|
||||
await addVersion(versionAsset.id, imageUrl, notes);
|
||||
setVersionAsset(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isExpiringSoon = (date?: string) => {
|
||||
if (!date) return false;
|
||||
const diff = new Date(date).getTime() - Date.now();
|
||||
return diff > 0 && diff < 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
};
|
||||
|
||||
const isExpired = (date?: string) => {
|
||||
if (!date) return false;
|
||||
return new Date(date).getTime() < Date.now();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats */}
|
||||
@@ -79,14 +108,19 @@ export function DigitalSignaturesModule() {
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{assets.map((asset) => {
|
||||
const Icon = TYPE_ICONS[asset.type];
|
||||
const expired = isExpired(asset.expirationDate);
|
||||
const expiringSoon = isExpiringSoon(asset.expirationDate);
|
||||
return (
|
||||
<Card key={asset.id} className="group relative">
|
||||
<Card key={asset.id} className={`group relative ${expired ? 'border-destructive/50' : expiringSoon ? 'border-yellow-500/50' : ''}`}>
|
||||
<CardContent className="p-4">
|
||||
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" title="Versiune nouă" onClick={() => setVersionAsset(asset)}>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingAsset(asset); setViewMode('edit'); }}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => 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" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -99,14 +133,34 @@ export function DigitalSignaturesModule() {
|
||||
<Icon className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<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>
|
||||
<span className="text-xs text-muted-foreground">{asset.owner}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Metadata row */}
|
||||
<div className="mt-2 space-y-1">
|
||||
{asset.legalStatus && (
|
||||
<p className="text-xs text-muted-foreground">Status legal: {asset.legalStatus}</p>
|
||||
)}
|
||||
{asset.expirationDate && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{(expired || expiringSoon) && <AlertTriangle className="h-3 w-3 text-yellow-500" />}
|
||||
<span className={expired ? 'text-destructive font-medium' : expiringSoon ? 'text-yellow-600 font-medium' : 'text-muted-foreground'}>
|
||||
{expired ? 'Expirat' : expiringSoon ? 'Expiră curând' : 'Expiră'}: {asset.expirationDate}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{asset.usageNotes && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">Note: {asset.usageNotes}</p>
|
||||
)}
|
||||
{asset.versions.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">Versiuni: {asset.versions.length + 1}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -124,13 +178,74 @@ export function DigitalSignaturesModule() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Confirmare ștergere</DialogTitle></DialogHeader>
|
||||
<p className="text-sm">Ești sigur că vrei să ștergi acest element? Acțiunea este ireversibilă.</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeletingId(null)}>Anulează</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteConfirm}>Șterge</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Add version dialog */}
|
||||
<Dialog open={versionAsset !== null} onOpenChange={(open) => { if (!open) setVersionAsset(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Versiune nouă — {versionAsset?.label}</DialogTitle></DialogHeader>
|
||||
<AddVersionForm
|
||||
onSubmit={handleAddVersion}
|
||||
onCancel={() => setVersionAsset(null)}
|
||||
history={versionAsset?.versions ?? []}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddVersionForm({ onSubmit, onCancel, history }: {
|
||||
onSubmit: (imageUrl: string, notes: string) => void;
|
||||
onCancel: () => void;
|
||||
history: Array<{ id: string; imageUrl: string; notes: string; createdAt: string }>;
|
||||
}) {
|
||||
const [imageUrl, setImageUrl] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{history.length > 0 && (
|
||||
<div className="max-h-32 space-y-1 overflow-y-auto rounded border p-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Istoric versiuni</p>
|
||||
{history.map((v) => (
|
||||
<div key={v.id} className="flex items-center justify-between text-xs">
|
||||
<span className="truncate text-muted-foreground">{v.notes || 'Fără note'}</span>
|
||||
<span className="shrink-0 text-muted-foreground">{v.createdAt.slice(0, 10)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label>URL imagine nouă</Label>
|
||||
<Input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} className="mt-1" placeholder="https://... sau data:image/png;base64,..." required />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Note versiune</Label>
|
||||
<Input value={notes} onChange={(e) => setNotes(e.target.value)} className="mt-1" placeholder="Ce s-a schimbat..." />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onCancel}>Anulează</Button>
|
||||
<Button onClick={() => { if (imageUrl.trim()) onSubmit(imageUrl, notes); }} disabled={!imageUrl.trim()}>Salvează versiune</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetForm({ initial, onSubmit, onCancel }: {
|
||||
initial?: SignatureAsset;
|
||||
onSubmit: (data: Omit<SignatureAsset, 'id' | 'createdAt'>) => void;
|
||||
onSubmit: (data: Omit<SignatureAsset, 'id' | 'createdAt' | 'updatedAt'>) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [label, setLabel] = useState(initial?.label ?? '');
|
||||
@@ -138,11 +253,23 @@ function AssetForm({ initial, onSubmit, onCancel }: {
|
||||
const [imageUrl, setImageUrl] = useState(initial?.imageUrl ?? '');
|
||||
const [owner, setOwner] = useState(initial?.owner ?? '');
|
||||
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
|
||||
const [expirationDate, setExpirationDate] = useState(initial?.expirationDate ?? '');
|
||||
const [legalStatus, setLegalStatus] = useState(initial?.legalStatus ?? '');
|
||||
const [usageNotes, setUsageNotes] = useState(initial?.usageNotes ?? '');
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ label, type, imageUrl, owner, company, 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><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>
|
||||
<Select value={type} onValueChange={(v) => setType(v as SignatureAssetType)}>
|
||||
<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,..." />
|
||||
<p className="mt-1 text-xs text-muted-foreground">URL către imaginea semnăturii/ștampilei. Suportă URL-uri externe sau base64.</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div><Label>Data expirare</Label><Input type="date" value={expirationDate} onChange={(e) => setExpirationDate(e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>Status legal</Label><Input value={legalStatus} onChange={(e) => setLegalStatus(e.target.value)} className="mt-1" placeholder="Valid, Anulat..." /></div>
|
||||
<div><Label>Note utilizare</Label><Input value={usageNotes} onChange={(e) => setUsageNotes(e.target.value)} className="mt-1" placeholder="Doar pentru contracte..." /></div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
||||
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useStorage } from '@/core/storage';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { SignatureAsset, SignatureAssetType } from '../types';
|
||||
import type { SignatureAsset, SignatureAssetType, AssetVersion } from '../types';
|
||||
|
||||
const PREFIX = 'sig:';
|
||||
|
||||
@@ -36,8 +36,9 @@ export function useSignatures() {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
const addAsset = useCallback(async (data: Omit<SignatureAsset, 'id' | 'createdAt'>) => {
|
||||
const asset: SignatureAsset = { ...data, id: uuid(), createdAt: new Date().toISOString() };
|
||||
const addAsset = useCallback(async (data: Omit<SignatureAsset, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
const now = new Date().toISOString();
|
||||
const asset: SignatureAsset = { ...data, id: uuid(), createdAt: now, updatedAt: now };
|
||||
await storage.set(`${PREFIX}${asset.id}`, asset);
|
||||
await refresh();
|
||||
return asset;
|
||||
@@ -46,11 +47,23 @@ export function useSignatures() {
|
||||
const updateAsset = useCallback(async (id: string, updates: Partial<SignatureAsset>) => {
|
||||
const existing = assets.find((a) => a.id === id);
|
||||
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 refresh();
|
||||
}, [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) => {
|
||||
await storage.delete(`${PREFIX}${id}`);
|
||||
await refresh();
|
||||
@@ -69,5 +82,5 @@ export function useSignatures() {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { digitalSignaturesConfig } from './config';
|
||||
export { DigitalSignaturesModule } from './components/digital-signatures-module';
|
||||
export type { SignatureAsset, SignatureAssetType } from './types';
|
||||
export type { SignatureAsset, SignatureAssetType, AssetVersion } from './types';
|
||||
|
||||
@@ -3,6 +3,14 @@ import type { CompanyId } from '@/core/auth/types';
|
||||
|
||||
export type SignatureAssetType = 'signature' | 'stamp' | 'initials';
|
||||
|
||||
/** Version history entry */
|
||||
export interface AssetVersion {
|
||||
id: string;
|
||||
imageUrl: string;
|
||||
notes: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SignatureAsset {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -10,7 +18,16 @@ export interface SignatureAsset {
|
||||
imageUrl: string;
|
||||
owner: string;
|
||||
company: CompanyId;
|
||||
/** Expiration date (YYYY-MM-DD) */
|
||||
expirationDate?: string;
|
||||
/** Legal status description */
|
||||
legalStatus: string;
|
||||
/** Usage notes */
|
||||
usageNotes: string;
|
||||
/** Version history */
|
||||
versions: AssetVersion[];
|
||||
tags: string[];
|
||||
visibility: Visibility;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { RotateCcw } from 'lucide-react';
|
||||
export function EmailSignatureModule() {
|
||||
const {
|
||||
config, updateField, updateColor, updateLayout,
|
||||
setVariant, setCompany, resetToDefaults, loadConfig,
|
||||
setVariant, setCompany, setAddress, resetToDefaults, loadConfig,
|
||||
} = useSignatureConfig();
|
||||
|
||||
const { saved, loading, save, remove } = useSavedSignatures();
|
||||
@@ -28,6 +28,7 @@ export function EmailSignatureModule() {
|
||||
onUpdateLayout={updateLayout}
|
||||
onSetVariant={setVariant}
|
||||
onSetCompany={setCompany}
|
||||
onSetAddress={setAddress}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
@@ -49,7 +50,7 @@ export function EmailSignatureModule() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Right panel — preview */}
|
||||
{/* Right panel — preview (scrollable, resizable) */}
|
||||
<div>
|
||||
<SignaturePreview config={config} />
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { CompanyId } from '@/core/auth/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 { Label } from '@/shared/components/ui/label';
|
||||
import { Switch } from '@/shared/components/ui/switch';
|
||||
@@ -17,13 +17,39 @@ interface SignatureConfiguratorProps {
|
||||
onUpdateLayout: (key: keyof SignatureLayout, value: number) => void;
|
||||
onSetVariant: (variant: SignatureVariant) => void;
|
||||
onSetCompany: (company: CompanyId) => void;
|
||||
onSetAddress?: (address: string[]) => void;
|
||||
}
|
||||
|
||||
const COLOR_PALETTE: Record<string, string> = {
|
||||
verde: '#22B5AB',
|
||||
griInchis: '#54504F',
|
||||
griDeschis: '#A7A9AA',
|
||||
negru: '#323232',
|
||||
/** Color palette per company */
|
||||
const COMPANY_PALETTES: Record<CompanyId, Record<string, string>> = {
|
||||
beletage: {
|
||||
verde: '#22B5AB',
|
||||
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> = {
|
||||
@@ -48,8 +74,10 @@ const LAYOUT_CONTROLS: { key: keyof SignatureLayout; label: string; min: number;
|
||||
];
|
||||
|
||||
export function SignatureConfigurator({
|
||||
config, onUpdateField, onUpdateColor, onUpdateLayout, onSetVariant, onSetCompany,
|
||||
config, onUpdateField, onUpdateColor, onUpdateLayout, onSetVariant, onSetCompany, onSetAddress,
|
||||
}: SignatureConfiguratorProps) {
|
||||
const palette = COMPANY_PALETTES[config.company];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Company selector */}
|
||||
@@ -67,6 +95,26 @@ export function SignatureConfigurator({
|
||||
</Select>
|
||||
</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 />
|
||||
|
||||
{/* Personal data */}
|
||||
@@ -113,14 +161,14 @@ export function SignatureConfigurator({
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Colors */}
|
||||
{/* Colors — company-specific palette */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">Culori text</h3>
|
||||
{(Object.keys(COLOR_LABELS) as (keyof SignatureColors)[]).map((colorKey) => (
|
||||
<div key={colorKey} className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">{COLOR_LABELS[colorKey]}</span>
|
||||
<div className="flex gap-1.5">
|
||||
{Object.values(COLOR_PALETTE).map((color) => (
|
||||
{Object.values(palette).map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
|
||||
@@ -6,15 +6,19 @@ import { Button } from '@/shared/components/ui/button';
|
||||
import type { SignatureConfig } from '../types';
|
||||
import { generateSignatureHtml, downloadSignatureHtml } from '../services/signature-builder';
|
||||
|
||||
const ZOOM_LEVELS = [0.75, 1, 1.5, 2, 2.5];
|
||||
|
||||
interface SignaturePreviewProps {
|
||||
config: SignatureConfig;
|
||||
}
|
||||
|
||||
export function SignaturePreview({ config }: SignaturePreviewProps) {
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [zoomIndex, setZoomIndex] = useState(1); // start at 100%
|
||||
const [copied, setCopied] = useState(false);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const zoom = ZOOM_LEVELS[zoomIndex] ?? 1;
|
||||
|
||||
const html = useMemo(() => generateSignatureHtml(config), [config]);
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Previzualizare</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={toggleZoom}>
|
||||
{zoom === 1 ? <ZoomIn className="mr-1 h-4 w-4" /> : <ZoomOut className="mr-1 h-4 w-4" />}
|
||||
{zoom === 1 ? '200%' : '100%'}
|
||||
</Button>
|
||||
<div className="flex items-center rounded-md border">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-r-none" onClick={zoomOut} disabled={zoomIndex <= 0}>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</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}>
|
||||
<Copy className="mr-1 h-4 w-4" />
|
||||
{copied ? 'Copiat!' : 'Copiază HTML'}
|
||||
@@ -54,7 +64,7 @@ export function SignaturePreview({ config }: SignaturePreviewProps) {
|
||||
</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
|
||||
ref={previewRef}
|
||||
style={{ transform: `scale(${zoom})`, transformOrigin: 'top left' }}
|
||||
|
||||
@@ -68,6 +68,10 @@ export function useSignatureConfig(initialCompany: CompanyId = 'beletage') {
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setAddress = useCallback((address: string[]) => {
|
||||
setConfig((prev) => ({ ...prev, addressOverride: address }));
|
||||
}, []);
|
||||
|
||||
const resetToDefaults = useCallback(() => {
|
||||
setConfig(createDefaultConfig(config.company));
|
||||
}, [config.company]);
|
||||
@@ -83,7 +87,8 @@ export function useSignatureConfig(initialCompany: CompanyId = 'beletage') {
|
||||
updateLayout,
|
||||
setVariant,
|
||||
setCompany,
|
||||
setAddress,
|
||||
resetToDefaults,
|
||||
loadConfig,
|
||||
}), [config, updateField, updateColor, updateLayout, setVariant, setCompany, resetToDefaults, loadConfig]);
|
||||
}), [config, updateField, updateColor, updateLayout, setVariant, setCompany, setAddress, resetToDefaults, loadConfig]);
|
||||
}
|
||||
|
||||
@@ -12,25 +12,34 @@ const BELETAGE_COLORS: SignatureColors = {
|
||||
};
|
||||
|
||||
const URBAN_SWITCH_COLORS: SignatureColors = {
|
||||
prefix: '#3B3B3B',
|
||||
name: '#3B3B3B',
|
||||
title: '#8B8B8B',
|
||||
address: '#8B8B8B',
|
||||
phone: '#3B3B3B',
|
||||
website: '#3B3B3B',
|
||||
prefix: '#2D2D2D',
|
||||
name: '#2D2D2D',
|
||||
title: '#6B7280',
|
||||
address: '#6B7280',
|
||||
phone: '#2D2D2D',
|
||||
website: '#4F46E5',
|
||||
motto: '#6366f1',
|
||||
};
|
||||
|
||||
const STUDII_COLORS: SignatureColors = {
|
||||
prefix: '#3B3B3B',
|
||||
name: '#3B3B3B',
|
||||
title: '#8B8B8B',
|
||||
address: '#8B8B8B',
|
||||
phone: '#3B3B3B',
|
||||
website: '#3B3B3B',
|
||||
prefix: '#2D2D2D',
|
||||
name: '#2D2D2D',
|
||||
title: '#6B7280',
|
||||
address: '#6B7280',
|
||||
phone: '#2D2D2D',
|
||||
website: '#D97706',
|
||||
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> = {
|
||||
beletage: {
|
||||
id: 'beletage',
|
||||
@@ -48,7 +57,7 @@ export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
|
||||
png: 'https://beletage.ro/img/Green-slash.png',
|
||||
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',
|
||||
motto: 'we make complex simple',
|
||||
defaultColors: BELETAGE_COLORS,
|
||||
@@ -58,20 +67,20 @@ export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
|
||||
name: 'Urban Switch SRL',
|
||||
accent: '#6366f1',
|
||||
logo: {
|
||||
png: '',
|
||||
svg: '',
|
||||
png: '/logos/logo-us-dark.svg',
|
||||
svg: '/logos/logo-us-dark.svg',
|
||||
},
|
||||
slashGrey: {
|
||||
png: 'https://beletage.ro/img/Grey-slash.png',
|
||||
svg: 'https://beletage.ro/img/Grey-slash.svg',
|
||||
},
|
||||
slashAccent: {
|
||||
png: '',
|
||||
svg: '',
|
||||
png: '/logos/logo-us-light.svg',
|
||||
svg: '/logos/logo-us-light.svg',
|
||||
},
|
||||
address: ['Cluj-Napoca', 'România'],
|
||||
website: '',
|
||||
motto: '',
|
||||
address: ['str. Unirii, nr. 3, ap. 26', 'Cluj-Napoca, Cluj 400417', 'România'],
|
||||
website: 'www.urbanswitch.ro',
|
||||
motto: 'shaping urban futures',
|
||||
defaultColors: URBAN_SWITCH_COLORS,
|
||||
},
|
||||
'studii-de-teren': {
|
||||
@@ -79,20 +88,20 @@ export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
|
||||
name: 'Studii de Teren SRL',
|
||||
accent: '#f59e0b',
|
||||
logo: {
|
||||
png: '',
|
||||
svg: '',
|
||||
png: '/logos/logo-sdt-dark.svg',
|
||||
svg: '/logos/logo-sdt-dark.svg',
|
||||
},
|
||||
slashGrey: {
|
||||
png: 'https://beletage.ro/img/Grey-slash.png',
|
||||
svg: 'https://beletage.ro/img/Grey-slash.svg',
|
||||
},
|
||||
slashAccent: {
|
||||
png: '',
|
||||
svg: '',
|
||||
png: '/logos/logo-sdt-light.svg',
|
||||
svg: '/logos/logo-sdt-light.svg',
|
||||
},
|
||||
address: ['Cluj-Napoca', 'România'],
|
||||
website: '',
|
||||
motto: '',
|
||||
address: ['str. Unirii, nr. 3, ap. 26', 'Cluj-Napoca, Cluj 400417', 'România'],
|
||||
website: 'www.studiideteren.ro',
|
||||
motto: 'ground truth, measured right',
|
||||
defaultColors: STUDII_COLORS,
|
||||
},
|
||||
group: {
|
||||
@@ -100,9 +109,12 @@ export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
|
||||
name: 'Grup Companii',
|
||||
accent: '#64748b',
|
||||
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: '' },
|
||||
address: ['Cluj-Napoca', 'România'],
|
||||
address: ['Cluj-Napoca, Cluj', 'România'],
|
||||
website: '',
|
||||
motto: '',
|
||||
defaultColors: BELETAGE_COLORS,
|
||||
|
||||
@@ -14,6 +14,7 @@ export function formatPhone(raw: string): { display: string; link: string } {
|
||||
|
||||
export function generateSignatureHtml(config: SignatureConfig): string {
|
||||
const branding = getBranding(config.company);
|
||||
const address = config.addressOverride ?? branding.address;
|
||||
const { display: phone, link: phoneLink } = formatPhone(config.phone);
|
||||
const images = config.useSvg
|
||||
? { logo: branding.logo.svg, greySlash: branding.slashGrey.svg, accentSlash: branding.slashAccent.svg }
|
||||
@@ -71,7 +72,7 @@ export function generateSignatureHtml(config: SignatureConfig): string {
|
||||
</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;">
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface SignatureConfig {
|
||||
layout: SignatureLayout;
|
||||
variant: SignatureVariant;
|
||||
useSvg: boolean;
|
||||
/** Override the default company address */
|
||||
addressOverride?: string[];
|
||||
}
|
||||
|
||||
export interface SavedSignature {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Textarea } from '@/shared/components/ui/textarea';
|
||||
import { Badge } from '@/shared/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog';
|
||||
import type { CompanyId } from '@/core/auth/types';
|
||||
import type { InventoryItem, InventoryItemType, InventoryItemStatus } from '../types';
|
||||
import { useInventory } from '../hooks/use-inventory';
|
||||
@@ -28,8 +29,9 @@ export function ItInventoryModule() {
|
||||
const { items, allItems, loading, filters, updateFilter, addItem, updateItem, removeItem } = useInventory();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
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) {
|
||||
await updateItem(editingItem.id, data);
|
||||
} else {
|
||||
@@ -39,6 +41,13 @@ export function ItInventoryModule() {
|
||||
setEditingItem(null);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (deletingId) {
|
||||
await removeItem(deletingId);
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats */}
|
||||
@@ -51,7 +60,6 @@ export function ItInventoryModule() {
|
||||
|
||||
{viewMode === 'list' && (
|
||||
<>
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative min-w-[200px] flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
@@ -80,7 +88,6 @@ export function ItInventoryModule() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
|
||||
) : items.length === 0 ? (
|
||||
@@ -91,7 +98,9 @@ export function ItInventoryModule() {
|
||||
<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">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">IP</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">Status</th>
|
||||
@@ -102,16 +111,22 @@ export function ItInventoryModule() {
|
||||
<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"><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.ipAddress}</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 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingItem(item); setViewMode('edit'); }}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</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" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -137,13 +152,25 @@ export function ItInventoryModule() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Confirmare ștergere</DialogTitle></DialogHeader>
|
||||
<p className="text-sm">Ești sigur că vrei să ștergi acest 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>
|
||||
);
|
||||
}
|
||||
|
||||
function InventoryForm({ initial, onSubmit, onCancel }: {
|
||||
initial?: InventoryItem;
|
||||
onSubmit: (data: Omit<InventoryItem, 'id' | 'createdAt'>) => void;
|
||||
onSubmit: (data: Omit<InventoryItem, 'id' | 'createdAt' | 'updatedAt'>) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState(initial?.name ?? '');
|
||||
@@ -154,12 +181,26 @@ function InventoryForm({ initial, onSubmit, onCancel }: {
|
||||
const [location, setLocation] = useState(initial?.location ?? '');
|
||||
const [purchaseDate, setPurchaseDate] = useState(initial?.purchaseDate ?? '');
|
||||
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 ?? '');
|
||||
|
||||
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><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>
|
||||
<Select value={type} onValueChange={(v) => setType(v as InventoryItemType)}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
@@ -167,11 +208,17 @@ function InventoryForm({ initial, onSubmit, onCancel }: {
|
||||
</Select>
|
||||
</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>Atribuit</Label><Input value={assignedTo} onChange={(e) => setAssignedTo(e.target.value)} className="mt-1" /></div>
|
||||
</div>
|
||||
<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>
|
||||
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
@@ -183,18 +230,21 @@ function InventoryForm({ initial, onSubmit, onCancel }: {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>Locație</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>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div><Label>Locație / Cameră</Label><Input value={location} onChange={(e) => setLocation(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><Label>Status</Label>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as InventoryItemStatus)}>
|
||||
<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>
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></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">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
||||
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
|
||||
|
||||
@@ -40,8 +40,9 @@ export function useInventory() {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
const addItem = useCallback(async (data: Omit<InventoryItem, 'id' | 'createdAt'>) => {
|
||||
const item: InventoryItem = { ...data, id: uuid(), createdAt: new Date().toISOString() };
|
||||
const addItem = useCallback(async (data: Omit<InventoryItem, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
const now = new Date().toISOString();
|
||||
const item: InventoryItem = { ...data, id: uuid(), createdAt: now, updatedAt: now };
|
||||
await storage.set(`${PREFIX}${item.id}`, item);
|
||||
await refresh();
|
||||
return item;
|
||||
@@ -50,7 +51,11 @@ export function useInventory() {
|
||||
const updateItem = useCallback(async (id: string, updates: Partial<InventoryItem>) => {
|
||||
const existing = items.find((i) => i.id === id);
|
||||
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 refresh();
|
||||
}, [storage, refresh, items]);
|
||||
@@ -70,7 +75,14 @@ export function useInventory() {
|
||||
if (filters.company !== 'all' && item.company !== filters.company) return false;
|
||||
if (filters.search) {
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -28,8 +28,23 @@ export interface InventoryItem {
|
||||
location: string;
|
||||
purchaseDate: string;
|
||||
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[];
|
||||
notes: string;
|
||||
visibility: Visibility;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
'use client';
|
||||
|
||||
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 { Input } from '@/shared/components/ui/input';
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
||||
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';
|
||||
|
||||
const CATEGORY_LABELS: Record<VaultEntryCategory, string> = {
|
||||
@@ -18,12 +23,28 @@ const CATEGORY_LABELS: Record<VaultEntryCategory, string> = {
|
||||
|
||||
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() {
|
||||
const { entries, allEntries, loading, filters, updateFilter, addEntry, updateEntry, removeEntry } = useVault();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [editingEntry, setEditingEntry] = useState<VaultEntry | null>(null);
|
||||
const [visiblePasswords, setVisiblePasswords] = useState<Set<string>>(new Set());
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const togglePassword = (id: string) => {
|
||||
setVisiblePasswords((prev) => {
|
||||
@@ -51,6 +72,13 @@ export function PasswordVaultModule() {
|
||||
setEditingEntry(null);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (deletingId) {
|
||||
await removeEntry(deletingId);
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -118,12 +146,21 @@ export function PasswordVaultModule() {
|
||||
<ExternalLink className="h-3 w-3" /> {entry.url}
|
||||
</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 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'); }}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</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" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -143,6 +180,18 @@ export function PasswordVaultModule() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Confirmare ștergere</DialogTitle></DialogHeader>
|
||||
<p className="text-sm">Ești sigur că vrei să ștergi 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>
|
||||
);
|
||||
}
|
||||
@@ -158,11 +207,42 @@ function VaultForm({ initial, onSubmit, onCancel }: {
|
||||
const [url, setUrl] = useState(initial?.url ?? '');
|
||||
const [category, setCategory] = useState<VaultEntryCategory>(initial?.category ?? 'web');
|
||||
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 (
|
||||
<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><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>
|
||||
<Select value={category} onValueChange={(v) => setCategory(v as VaultEntryCategory)}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
@@ -172,9 +252,60 @@ function VaultForm({ initial, onSubmit, onCancel }: {
|
||||
</div>
|
||||
<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>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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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 className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
||||
|
||||
@@ -8,6 +8,12 @@ export type VaultEntryCategory =
|
||||
| 'api'
|
||||
| 'other';
|
||||
|
||||
/** Custom key-value field */
|
||||
export interface CustomField {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface VaultEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -15,6 +21,8 @@ export interface VaultEntry {
|
||||
encryptedPassword: string;
|
||||
url: string;
|
||||
category: VaultEntryCategory;
|
||||
/** Custom key-value fields */
|
||||
customFields: CustomField[];
|
||||
notes: string;
|
||||
tags: string[];
|
||||
visibility: Visibility;
|
||||
|
||||
@@ -5,10 +5,14 @@ import { Plus } from 'lucide-react';
|
||||
import { Button } from '@/shared/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
||||
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 { RegistryFilters } from './registry-filters';
|
||||
import { RegistryTable } from './registry-table';
|
||||
import { RegistryEntryForm } from './registry-entry-form';
|
||||
import { getOverdueDays } from '../services/registry-service';
|
||||
import type { RegistryEntry } from '../types';
|
||||
|
||||
type ViewMode = 'list' | 'add' | 'edit';
|
||||
@@ -16,11 +20,12 @@ type ViewMode = 'list' | 'add' | 'edit';
|
||||
export function RegistraturaModule() {
|
||||
const {
|
||||
entries, allEntries, loading, filters, updateFilter,
|
||||
addEntry, updateEntry, removeEntry,
|
||||
addEntry, updateEntry, removeEntry, closeEntry,
|
||||
} = useRegistry();
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [editingEntry, setEditingEntry] = useState<RegistryEntry | null>(null);
|
||||
const [closingId, setClosingId] = useState<string | null>(null);
|
||||
|
||||
const handleAdd = async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => {
|
||||
await addEntry(data);
|
||||
@@ -43,6 +48,22 @@ export function RegistraturaModule() {
|
||||
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 = () => {
|
||||
setViewMode('list');
|
||||
setEditingEntry(null);
|
||||
@@ -50,18 +71,24 @@ export function RegistraturaModule() {
|
||||
|
||||
// Stats
|
||||
const total = allEntries.length;
|
||||
const incoming = allEntries.filter((e) => e.type === 'incoming').length;
|
||||
const outgoing = allEntries.filter((e) => e.type === 'outgoing').length;
|
||||
const inProgress = allEntries.filter((e) => e.status === 'in-progress').length;
|
||||
const open = allEntries.filter((e) => e.status === 'deschis').length;
|
||||
const overdue = allEntries.filter((e) => {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatCard label="Total" value={total} />
|
||||
<StatCard label="Intrare" value={incoming} />
|
||||
<StatCard label="Ieșire" value={outgoing} />
|
||||
<StatCard label="În lucru" value={inProgress} />
|
||||
<StatCard label="Deschise" value={open} />
|
||||
<StatCard label="Depășite" value={overdue} variant={overdue > 0 ? 'destructive' : undefined} />
|
||||
<StatCard label="Intrate" value={intrat} />
|
||||
</div>
|
||||
|
||||
{viewMode === 'list' && (
|
||||
@@ -78,6 +105,7 @@ export function RegistraturaModule() {
|
||||
loading={loading}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onClose={handleCloseRequest}
|
||||
/>
|
||||
|
||||
{!loading && (
|
||||
@@ -97,7 +125,11 @@ export function RegistraturaModule() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RegistryEntryForm onSubmit={handleAdd} onCancel={handleCancel} />
|
||||
<RegistryEntryForm
|
||||
allEntries={allEntries}
|
||||
onSubmit={handleAdd}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -108,20 +140,51 @@ export function RegistraturaModule() {
|
||||
<CardTitle>Editare — {editingEntry.number}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RegistryEntryForm initial={editingEntry} onSubmit={handleUpdate} onCancel={handleCancel} />
|
||||
<RegistryEntryForm
|
||||
initial={editingEntry}
|
||||
allEntries={allEntries}
|
||||
onSubmit={handleUpdate}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Close confirmation dialog */}
|
||||
<Dialog open={closingId !== null} onOpenChange={(open) => { if (!open) setClosingId(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Închide înregistrarea</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<p className="text-sm">
|
||||
Această înregistrare are {closingEntry?.linkedEntryIds.length ?? 0} înregistrări legate.
|
||||
Vrei să le închizi și pe acestea?
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setClosingId(null)}>Anulează</Button>
|
||||
<Button variant="secondary" onClick={() => handleCloseConfirm(false)}>
|
||||
Doar aceasta
|
||||
</Button>
|
||||
<Button onClick={() => handleCloseConfirm(true)}>
|
||||
Închide toate legate
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: number }) {
|
||||
function StatCard({ label, value, variant }: { label: string; value: number; variant?: 'destructive' }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<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>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,40 +1,116 @@
|
||||
'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 { RegistryEntry, RegistryEntryType, RegistryEntryStatus } from '../types';
|
||||
import type { RegistryEntry, RegistryDirection, RegistryStatus, DocumentType, RegistryAttachment } from '../types';
|
||||
import { Input } from '@/shared/components/ui/input';
|
||||
import { Label } from '@/shared/components/ui/label';
|
||||
import { Textarea } from '@/shared/components/ui/textarea';
|
||||
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 { useContacts } from '@/modules/address-book/hooks/use-contacts';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
interface RegistryEntryFormProps {
|
||||
initial?: RegistryEntry;
|
||||
allEntries?: RegistryEntry[];
|
||||
onSubmit: (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function RegistryEntryForm({ initial, onSubmit, onCancel }: RegistryEntryFormProps) {
|
||||
const [type, setType] = useState<RegistryEntryType>(initial?.type ?? 'incoming');
|
||||
const DOC_TYPE_LABELS: Record<DocumentType, 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 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 [date, setDate] = useState(initial?.date ?? new Date().toISOString().slice(0, 10));
|
||||
const [sender, setSender] = useState(initial?.sender ?? '');
|
||||
const [senderContactId, setSenderContactId] = useState(initial?.senderContactId ?? '');
|
||||
const [recipient, setRecipient] = useState(initial?.recipient ?? '');
|
||||
const [recipientContactId, setRecipientContactId] = useState(initial?.recipientContactId ?? '');
|
||||
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 [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) => {
|
||||
e.preventDefault();
|
||||
onSubmit({
|
||||
type,
|
||||
direction,
|
||||
documentType,
|
||||
subject,
|
||||
date,
|
||||
sender,
|
||||
senderContactId: senderContactId || undefined,
|
||||
recipient,
|
||||
recipientContactId: recipientContactId || undefined,
|
||||
company,
|
||||
status,
|
||||
deadline: deadline || undefined,
|
||||
linkedEntryIds,
|
||||
attachments,
|
||||
notes,
|
||||
tags: initial?.tags ?? [],
|
||||
visibility: initial?.visibility ?? 'all',
|
||||
@@ -43,15 +119,26 @@ export function RegistryEntryForm({ initial, onSubmit, onCancel }: RegistryEntry
|
||||
|
||||
return (
|
||||
<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>
|
||||
<Label>Tip document</Label>
|
||||
<Select value={type} onValueChange={(v) => setType(v as RegistryEntryType)}>
|
||||
<Label>Direcție</Label>
|
||||
<Select value={direction} onValueChange={(v) => setDirection(v as RegistryDirection)}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="incoming">Intrare</SelectItem>
|
||||
<SelectItem value="outgoing">Ieșire</SelectItem>
|
||||
<SelectItem value="internal">Intern</SelectItem>
|
||||
<SelectItem value="intrat">Intrat</SelectItem>
|
||||
<SelectItem value="iesit">Ieșit</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>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -61,23 +148,78 @@ export function RegistryEntryForm({ initial, onSubmit, onCancel }: RegistryEntry
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<Label>Subiect</Label>
|
||||
<Label>Subiect *</Label>
|
||||
<Input value={subject} onChange={(e) => setSubject(e.target.value)} className="mt-1" required />
|
||||
</div>
|
||||
|
||||
{/* Sender / Recipient with autocomplete */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="relative">
|
||||
<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 className="relative">
|
||||
<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 className="grid gap-4 sm:grid-cols-2">
|
||||
{/* Company + Status + Deadline */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<Label>Companie</Label>
|
||||
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
|
||||
@@ -92,18 +234,85 @@ export function RegistryEntryForm({ initial, onSubmit, onCancel }: RegistryEntry
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<SelectContent>
|
||||
<SelectItem value="registered">Înregistrat</SelectItem>
|
||||
<SelectItem value="in-progress">În lucru</SelectItem>
|
||||
<SelectItem value="completed">Finalizat</SelectItem>
|
||||
<SelectItem value="archived">Arhivat</SelectItem>
|
||||
<SelectItem value="deschis">Deschis</SelectItem>
|
||||
<SelectItem value="inchis">Închis</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Termen limită</Label>
|
||||
<Input type="date" value={deadline} onChange={(e) => setDeadline(e.target.value)} className="mt-1" />
|
||||
</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>
|
||||
<Label>Note</Label>
|
||||
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={3} className="mt-1" />
|
||||
|
||||
@@ -10,6 +10,18 @@ interface RegistryFiltersProps {
|
||||
onUpdate: <K extends keyof Filters>(key: K, value: Filters[K]) => void;
|
||||
}
|
||||
|
||||
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) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
@@ -23,28 +35,37 @@ export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={filters.type} onValueChange={(v) => onUpdate('type', v as Filters['type'])}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Tip" />
|
||||
<Select value={filters.direction} onValueChange={(v) => onUpdate('direction', v as Filters['direction'])}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<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>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toate tipurile</SelectItem>
|
||||
<SelectItem value="incoming">Intrare</SelectItem>
|
||||
<SelectItem value="outgoing">Ieșire</SelectItem>
|
||||
<SelectItem value="internal">Intern</SelectItem>
|
||||
{Object.entries(DOC_TYPE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={filters.status} onValueChange={(v) => onUpdate('status', v as Filters['status'])}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toate</SelectItem>
|
||||
<SelectItem value="registered">Înregistrat</SelectItem>
|
||||
<SelectItem value="in-progress">În lucru</SelectItem>
|
||||
<SelectItem value="completed">Finalizat</SelectItem>
|
||||
<SelectItem value="archived">Arhivat</SelectItem>
|
||||
<SelectItem value="deschis">Deschis</SelectItem>
|
||||
<SelectItem value="inchis">Închis</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { Pencil, Trash2, CheckCircle2, Link2 } from 'lucide-react';
|
||||
import { Button } from '@/shared/components/ui/button';
|
||||
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';
|
||||
|
||||
interface RegistryTableProps {
|
||||
@@ -11,29 +12,32 @@ interface RegistryTableProps {
|
||||
loading: boolean;
|
||||
onEdit: (entry: RegistryEntry) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onClose: (id: string) => void;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
incoming: 'Intrare',
|
||||
outgoing: 'Ieșire',
|
||||
internal: 'Intern',
|
||||
const DIRECTION_LABELS: Record<string, string> = {
|
||||
intrat: 'Intrat',
|
||||
iesit: 'Ieșit',
|
||||
};
|
||||
|
||||
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> = {
|
||||
registered: 'Înregistrat',
|
||||
'in-progress': 'În lucru',
|
||||
completed: 'Finalizat',
|
||||
archived: 'Arhivat',
|
||||
deschis: 'Deschis',
|
||||
inchis: 'Închis',
|
||||
};
|
||||
|
||||
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'outline' | 'destructive'> = {
|
||||
registered: 'default',
|
||||
'in-progress': 'secondary',
|
||||
completed: 'outline',
|
||||
archived: 'outline',
|
||||
};
|
||||
|
||||
export function RegistryTable({ entries, loading, onEdit, onDelete }: RegistryTableProps) {
|
||||
export function RegistryTable({ entries, loading, onEdit, onDelete, onClose }: RegistryTableProps) {
|
||||
if (loading) {
|
||||
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">
|
||||
<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">Dir.</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">Expeditor</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-right font-medium">Acțiuni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.id} className={cn('border-b hover:bg-muted/20 transition-colors')}>
|
||||
<td className="px-3 py-2 font-mono text-xs">{entry.number}</td>
|
||||
<td className="px-3 py-2 text-xs whitespace-nowrap">{formatDate(entry.date)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant="outline" className="text-xs">{TYPE_LABELS[entry.type]}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 max-w-[250px] truncate">{entry.subject}</td>
|
||||
<td className="px-3 py-2 max-w-[150px] truncate">{entry.sender}</td>
|
||||
<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>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<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>
|
||||
))}
|
||||
{entries.map((entry) => {
|
||||
const overdueDays = entry.status === 'deschis' ? getOverdueDays(entry.deadline) : null;
|
||||
const isOverdue = overdueDays !== null && overdueDays > 0;
|
||||
return (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className={cn(
|
||||
'border-b transition-colors hover:bg-muted/20',
|
||||
isOverdue && 'bg-destructive/5'
|
||||
)}
|
||||
>
|
||||
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">{entry.number}</td>
|
||||
<td className="px-3 py-2 text-xs whitespace-nowrap">{formatDate(entry.date)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge
|
||||
variant={entry.direction === 'intrat' ? 'default' : 'secondary'}
|
||||
className="text-xs"
|
||||
>
|
||||
{DIRECTION_LABELS[entry.direction]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">{DOC_TYPE_LABELS[entry.documentType]}</td>
|
||||
<td className="px-3 py-2 max-w-[200px] truncate">
|
||||
{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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useStorage } from '@/core/storage';
|
||||
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';
|
||||
|
||||
export interface RegistryFilters {
|
||||
search: string;
|
||||
type: RegistryEntryType | 'all';
|
||||
status: RegistryEntryStatus | 'all';
|
||||
direction: RegistryDirection | 'all';
|
||||
status: RegistryStatus | 'all';
|
||||
documentType: DocumentType | 'all';
|
||||
company: string;
|
||||
}
|
||||
|
||||
@@ -19,8 +20,9 @@ export function useRegistry() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState<RegistryFilters>({
|
||||
search: '',
|
||||
type: 'all',
|
||||
direction: 'all',
|
||||
status: 'all',
|
||||
documentType: 'all',
|
||||
company: 'all',
|
||||
});
|
||||
|
||||
@@ -36,18 +38,18 @@ export function useRegistry() {
|
||||
|
||||
const addEntry = useCallback(async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => {
|
||||
const now = new Date().toISOString();
|
||||
const nextIndex = entries.length + 1;
|
||||
const number = generateRegistryNumber(data.company, data.date, entries);
|
||||
const entry: RegistryEntry = {
|
||||
...data,
|
||||
id: uuid(),
|
||||
number: generateRegistryNumber(data.date, nextIndex),
|
||||
number,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await saveEntry(storage, entry);
|
||||
await refresh();
|
||||
return entry;
|
||||
}, [storage, refresh, entries.length]);
|
||||
}, [storage, refresh, entries]);
|
||||
|
||||
const updateEntry = useCallback(async (id: string, updates: Partial<RegistryEntry>) => {
|
||||
const existing = entries.find((e) => e.id === id);
|
||||
@@ -69,13 +71,35 @@ export function useRegistry() {
|
||||
await 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]) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
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.documentType !== 'all' && entry.documentType !== filters.documentType) return false;
|
||||
if (filters.company !== 'all' && entry.company !== filters.company) return false;
|
||||
if (filters.search) {
|
||||
const q = filters.search.toLowerCase();
|
||||
@@ -83,7 +107,7 @@ export function useRegistry() {
|
||||
entry.subject.toLowerCase().includes(q) ||
|
||||
entry.sender.toLowerCase().includes(q) ||
|
||||
entry.recipient.toLowerCase().includes(q) ||
|
||||
entry.number.includes(q)
|
||||
entry.number.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
@@ -98,6 +122,7 @@ export function useRegistry() {
|
||||
addEntry,
|
||||
updateEntry,
|
||||
removeEntry,
|
||||
closeEntry,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { registraturaConfig } from './config';
|
||||
export { RegistraturaModule } from './components/registratura-module';
|
||||
export type { RegistryEntry, RegistryEntryType, RegistryEntryStatus } from './types';
|
||||
export type { RegistryEntry, RegistryDirection, RegistryStatus, DocumentType } from './types';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CompanyId } from '@/core/auth/types';
|
||||
import type { RegistryEntry } from '../types';
|
||||
|
||||
const STORAGE_PREFIX = 'entry:';
|
||||
@@ -30,9 +31,44 @@ export async function deleteEntry(storage: RegistryStorage, id: string): Promise
|
||||
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 year = d.getFullYear();
|
||||
const padded = String(index).padStart(4, '0');
|
||||
return `${padded}/${year}`;
|
||||
const prefix = COMPANY_PREFIXES[company];
|
||||
|
||||
// Count existing entries for this company in this year
|
||||
const sameCompanyYear = existingEntries.filter((e) => {
|
||||
const entryYear = new Date(e.date).getFullYear();
|
||||
return e.company === company && entryYear === year;
|
||||
});
|
||||
|
||||
const nextIndex = sameCompanyYear.length + 1;
|
||||
const padded = String(nextIndex).padStart(4, '0');
|
||||
return `${prefix}-${padded}/${year}`;
|
||||
}
|
||||
|
||||
/** Calculate days overdue (negative = days remaining, positive = overdue) */
|
||||
export function getOverdueDays(deadline: string | undefined): number | null {
|
||||
if (!deadline) return null;
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
const dl = new Date(deadline);
|
||||
dl.setHours(0, 0, 0, 0);
|
||||
const diff = now.getTime() - dl.getTime();
|
||||
return Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
@@ -1,24 +1,57 @@
|
||||
import type { Visibility } from '@/core/module-registry/types';
|
||||
import type { 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 =
|
||||
| 'registered'
|
||||
| 'in-progress'
|
||||
| 'completed'
|
||||
| 'archived';
|
||||
/** Document type categories */
|
||||
export type DocumentType =
|
||||
| 'contract'
|
||||
| 'oferta'
|
||||
| '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 {
|
||||
id: string;
|
||||
/** Company-specific number: B-0001/2026, US-0001/2026, SDT-0001/2026 */
|
||||
number: string;
|
||||
date: string;
|
||||
type: RegistryEntryType;
|
||||
direction: RegistryDirection;
|
||||
documentType: DocumentType;
|
||||
subject: string;
|
||||
/** Expeditor — free text or linked contact ID */
|
||||
sender: string;
|
||||
senderContactId?: string;
|
||||
/** Destinatar — free text or linked contact ID */
|
||||
recipient: string;
|
||||
recipientContactId?: string;
|
||||
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[];
|
||||
notes: string;
|
||||
visibility: Visibility;
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Plus, Trash2, Tag as TagIcon } from 'lucide-react';
|
||||
import { useState, useMemo } from '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 { Input } from '@/shared/components/ui/input';
|
||||
import { Label } from '@/shared/components/ui/label';
|
||||
import { Badge } from '@/shared/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
||||
import {
|
||||
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 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';
|
||||
|
||||
const CATEGORY_LABELS: Record<TagCategory, string> = {
|
||||
project: 'Proiect',
|
||||
phase: 'Fază',
|
||||
activity: 'Activitate',
|
||||
'document-type': 'Tip document',
|
||||
company: 'Companie',
|
||||
priority: 'Prioritate',
|
||||
status: 'Status',
|
||||
custom: 'Personalizat',
|
||||
};
|
||||
import { getManicTimeSeedTags } from '../services/seed-data';
|
||||
|
||||
const SCOPE_LABELS: Record<TagScope, string> = {
|
||||
global: 'Global',
|
||||
@@ -29,20 +29,102 @@ const SCOPE_LABELS: Record<TagScope, string> = {
|
||||
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 = [
|
||||
'#ef4444', '#f97316', '#f59e0b', '#84cc16',
|
||||
'#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6',
|
||||
'#ec4899', '#64748b',
|
||||
'#ec4899', '#64748b', '#22B5AB', '#6366f1',
|
||||
];
|
||||
|
||||
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 [newCategory, setNewCategory] = useState<TagCategory>('custom');
|
||||
const [newScope, setNewScope] = useState<TagScope>('global');
|
||||
const [newColor, setNewColor] = useState(TAG_COLORS[5]);
|
||||
const [filterCategory, setFilterCategory] = useState<TagCategory | 'all'>('all');
|
||||
const [newColor, setNewColor] = useState('#3b82f6');
|
||||
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 () => {
|
||||
if (!newLabel.trim()) return;
|
||||
await createTag({
|
||||
@@ -50,158 +132,475 @@ export function TagManagerModule() {
|
||||
category: newCategory,
|
||||
scope: newScope,
|
||||
color: newColor,
|
||||
companyId: newScope === 'company' ? newCompanyId : undefined,
|
||||
projectCode: newCategory === 'project' && newProjectCode ? newProjectCode : undefined,
|
||||
parentId: newParentId || undefined,
|
||||
});
|
||||
setNewLabel('');
|
||||
setNewProjectCode('');
|
||||
setNewParentId('');
|
||||
};
|
||||
|
||||
const filteredTags = filterCategory === 'all'
|
||||
? tags
|
||||
: tags.filter((t) => t.category === filterCategory);
|
||||
const startEdit = (tag: Tag) => {
|
||||
setEditingTag(tag);
|
||||
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 key = tag.category;
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(tag);
|
||||
return acc;
|
||||
}, {});
|
||||
const saveEdit = async () => {
|
||||
if (!editingTag || !editLabel.trim()) return;
|
||||
await updateTag(editingTag.id, {
|
||||
label: editLabel.trim(),
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* 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 etichete</p>
|
||||
<p className="text-2xl font-bold">{tags.length}</p>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">Categorii folosite</p>
|
||||
<p className="text-2xl font-bold">{new Set(tags.map((t) => t.category)).size}</p>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">Globale</p>
|
||||
<p className="text-2xl font-bold">{tags.filter((t) => t.scope === 'global').length}</p>
|
||||
</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>
|
||||
{TAG_CATEGORY_ORDER.map((cat) => (
|
||||
<Card key={cat}><CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">{TAG_CATEGORY_LABELS[cat]}</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{tags.filter((t) => t.category === cat).length}
|
||||
</p>
|
||||
</CardContent></Card>
|
||||
))}
|
||||
</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 */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Etichetă nouă</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="min-w-[200px] flex-1">
|
||||
<Label>Nume</Label>
|
||||
<Input
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
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 className="space-y-3">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="min-w-[200px] flex-1">
|
||||
<Label>Nume</Label>
|
||||
<Input
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
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>
|
||||
{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>
|
||||
<Button onClick={handleCreate} disabled={!newLabel.trim()}>
|
||||
<Plus className="mr-1 h-4 w-4" /> Adaugă
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Label>Filtrează:</Label>
|
||||
{/* Search + Filter bar */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative min-w-[200px] flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Caută etichete..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={filterCategory} onValueChange={(v) => setFilterCategory(v as TagCategory | 'all')}>
|
||||
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toate categoriile</SelectItem>
|
||||
{(Object.keys(CATEGORY_LABELS) as TagCategory[]).map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>{CATEGORY_LABELS[cat]}</SelectItem>
|
||||
{TAG_CATEGORY_ORDER.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>{TAG_CATEGORY_LABELS[cat]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</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>
|
||||
|
||||
{/* Tag list by category */}
|
||||
{/* Tag list by category with hierarchy */}
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
|
||||
) : 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">
|
||||
{Object.entries(groupedByCategory).map(([category, catTags]) => (
|
||||
<Card key={category}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<TagIcon className="h-4 w-4" />
|
||||
{CATEGORY_LABELS[category as TagCategory] ?? category}
|
||||
<Badge variant="secondary" className="ml-1">{catTags.length}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{catTags.map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="group flex items-center gap-1.5 rounded-full border py-1 pl-3 pr-1.5 text-sm"
|
||||
>
|
||||
{tag.color && (
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: tag.color }} />
|
||||
)}
|
||||
<span>{tag.label}</span>
|
||||
<Badge variant="outline" className="text-[10px] px-1">{SCOPE_LABELS[tag.scope]}</Badge>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteTag(tag.id)}
|
||||
className="ml-0.5 rounded-full p-0.5 opacity-0 transition-opacity hover:bg-destructive/10 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
</button>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(groupedByCategory).map(([category, catTags]) => {
|
||||
const isExpanded = expandedCategories.has(category);
|
||||
const rootTags = catTags.filter((t) => !t.parentId);
|
||||
return (
|
||||
<Card key={category}>
|
||||
<CardHeader
|
||||
className="cursor-pointer pb-3"
|
||||
onClick={() => toggleCategory(category)}
|
||||
>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
{isExpanded
|
||||
? <ChevronDown className="h-4 w-4" />
|
||||
: <ChevronRight className="h-4 w-4" />}
|
||||
<TagIcon className="h-4 w-4" />
|
||||
{TAG_CATEGORY_LABELS[category as TagCategory] ?? category}
|
||||
<Badge variant="secondary" className="ml-1">{catTags.length}</Badge>
|
||||
{(category === 'project' || category === 'phase') && (
|
||||
<Badge variant="default" className="ml-1 text-[10px]">obligatoriu</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{isExpanded && (
|
||||
<CardContent>
|
||||
<div className="space-y-1">
|
||||
{rootTags.map((tag) => (
|
||||
<TagRow
|
||||
key={tag.id}
|
||||
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>
|
||||
</Card>
|
||||
</CardContent>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { tagManagerConfig } from './config';
|
||||
export { TagManagerModule } from './components/tag-manager-module';
|
||||
export type { Tag, TagCategory, TagScope } from './types';
|
||||
export { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from './types';
|
||||
|
||||
188
src/modules/tag-manager/services/seed-data.ts
Normal file
188
src/modules/tag-manager/services/seed-data.ts
Normal 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;
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export type { Tag, TagCategory, TagScope } from '@/core/tagging/types';
|
||||
export { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from '@/core/tagging/types';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
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 { Input } from '@/shared/components/ui/input';
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog';
|
||||
import type { CompanyId } from '@/core/auth/types';
|
||||
import type { WordTemplate } from '../types';
|
||||
import type { WordTemplate, TemplateCategory } from '../types';
|
||||
import { useTemplates } from '../hooks/use-templates';
|
||||
|
||||
const TEMPLATE_CATEGORIES = [
|
||||
'Contract', 'Memoriu tehnic', 'Ofertă', 'Factură', 'Raport', 'Deviz', 'Proces-verbal', 'Altele',
|
||||
];
|
||||
const CATEGORY_LABELS: Record<TemplateCategory, string> = {
|
||||
contract: 'Contract',
|
||||
memoriu: 'Memoriu tehnic',
|
||||
oferta: 'Ofertă',
|
||||
raport: 'Raport',
|
||||
cerere: 'Cerere',
|
||||
aviz: 'Aviz',
|
||||
scrisoare: 'Scrisoare',
|
||||
altele: 'Altele',
|
||||
};
|
||||
|
||||
type ViewMode = 'list' | 'add' | 'edit';
|
||||
|
||||
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 [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) {
|
||||
await updateTemplate(editingTemplate.id, data);
|
||||
} else {
|
||||
@@ -34,16 +43,21 @@ export function WordTemplatesModule() {
|
||||
setEditingTemplate(null);
|
||||
};
|
||||
|
||||
const filterCategories = allCategories.length > 0 ? allCategories : TEMPLATE_CATEGORIES;
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (deletingId) {
|
||||
await removeTemplate(deletingId);
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total ș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">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>
|
||||
|
||||
{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" />
|
||||
<Input placeholder="Caută șablon..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
|
||||
</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>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toate</SelectItem>
|
||||
{filterCategories.map((c) => (
|
||||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
||||
<SelectItem value="all">Toate categoriile</SelectItem>
|
||||
{(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map((c) => (
|
||||
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</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">
|
||||
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
||||
</Button>
|
||||
@@ -70,19 +94,20 @@ export function WordTemplatesModule() {
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
|
||||
) : templates.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
Niciun șablon găsit. Adaugă primul șablon Word.
|
||||
</p>
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Niciun șablon găsit. Adaugă primul șablon Word.</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{templates.map((tpl) => (
|
||||
<Card key={tpl.id} 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" 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'); }}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</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" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -94,9 +119,18 @@ export function WordTemplatesModule() {
|
||||
<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>}
|
||||
<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>
|
||||
{tpl.clonedFrom && <Badge variant="secondary" className="text-[10px]">Clonă</Badge>}
|
||||
</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 && (
|
||||
<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
|
||||
@@ -120,30 +154,58 @@ export function WordTemplatesModule() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Confirmare ștergere</DialogTitle></DialogHeader>
|
||||
<p className="text-sm">Ești sigur că vrei să ștergi acest ș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>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateForm({ initial, onSubmit, onCancel }: {
|
||||
initial?: WordTemplate;
|
||||
onSubmit: (data: Omit<WordTemplate, 'id' | 'createdAt'>) => void;
|
||||
onSubmit: (data: Omit<WordTemplate, 'id' | 'createdAt' | 'updatedAt'>) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState(initial?.name ?? '');
|
||||
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 [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
|
||||
const [version, setVersion] = useState(initial?.version ?? '1.0.0');
|
||||
const [placeholdersText, setPlaceholdersText] = useState(initial?.placeholders.join(', ') ?? '');
|
||||
|
||||
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><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>
|
||||
<Select value={category} onValueChange={setCategory}>
|
||||
<Select value={category} onValueChange={(v) => setCategory(v as TemplateCategory)}>
|
||||
<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>
|
||||
</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>URL fișier</Label><Input value={fileUrl} onChange={(e) => setFileUrl(e.target.value)} className="mt-1" placeholder="https://..." /></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">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
||||
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
|
||||
|
||||
@@ -3,20 +3,21 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useStorage } from '@/core/storage';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { WordTemplate } from '../types';
|
||||
import type { WordTemplate, TemplateCategory } from '../types';
|
||||
|
||||
const PREFIX = 'tpl:';
|
||||
|
||||
export interface TemplateFilters {
|
||||
search: string;
|
||||
category: string;
|
||||
category: TemplateCategory | 'all';
|
||||
company: string;
|
||||
}
|
||||
|
||||
export function useTemplates() {
|
||||
const storage = useStorage('word-templates');
|
||||
const [templates, setTemplates] = useState<WordTemplate[]>([]);
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
@@ -36,8 +37,9 @@ export function useTemplates() {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
const addTemplate = useCallback(async (data: Omit<WordTemplate, 'id' | 'createdAt'>) => {
|
||||
const template: WordTemplate = { ...data, id: uuid(), createdAt: new Date().toISOString() };
|
||||
const addTemplate = useCallback(async (data: Omit<WordTemplate, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
const now = new Date().toISOString();
|
||||
const template: WordTemplate = { ...data, id: uuid(), createdAt: now, updatedAt: now };
|
||||
await storage.set(`${PREFIX}${template.id}`, template);
|
||||
await refresh();
|
||||
return template;
|
||||
@@ -46,11 +48,32 @@ export function useTemplates() {
|
||||
const updateTemplate = useCallback(async (id: string, updates: Partial<WordTemplate>) => {
|
||||
const existing = templates.find((t) => t.id === id);
|
||||
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 refresh();
|
||||
}, [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) => {
|
||||
await storage.delete(`${PREFIX}${id}`);
|
||||
await refresh();
|
||||
@@ -60,10 +83,9 @@ export function useTemplates() {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const allCategories = [...new Set(templates.map((t) => t.category).filter(Boolean))];
|
||||
|
||||
const filteredTemplates = templates.filter((t) => {
|
||||
if (filters.category !== 'all' && t.category !== filters.category) return false;
|
||||
if (filters.company !== 'all' && t.company !== filters.company) return false;
|
||||
if (filters.search) {
|
||||
const q = filters.search.toLowerCase();
|
||||
return t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q);
|
||||
@@ -71,5 +93,5 @@ export function useTemplates() {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { wordTemplatesConfig } from './config';
|
||||
export { WordTemplatesModule } from './components/word-templates-module';
|
||||
export type { WordTemplate } from './types';
|
||||
export type { WordTemplate, TemplateCategory } from './types';
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import type { Visibility } from '@/core/module-registry/types';
|
||||
import type { CompanyId } from '@/core/auth/types';
|
||||
|
||||
export type TemplateCategory =
|
||||
| 'contract'
|
||||
| 'memoriu'
|
||||
| 'oferta'
|
||||
| 'raport'
|
||||
| 'cerere'
|
||||
| 'aviz'
|
||||
| 'scrisoare'
|
||||
| 'altele';
|
||||
|
||||
export interface WordTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
category: TemplateCategory;
|
||||
fileUrl: string;
|
||||
company: CompanyId;
|
||||
/** Detected placeholders in template */
|
||||
placeholders: string[];
|
||||
/** Cloned from template ID */
|
||||
clonedFrom?: string;
|
||||
tags: string[];
|
||||
version: string;
|
||||
visibility: Visibility;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user