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 type { Tag, TagCategory, TagScope } from './types';
|
||||||
|
export { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from './types';
|
||||||
export { TagService } from './tag-service';
|
export { TagService } from './tag-service';
|
||||||
export { useTags } from './use-tags';
|
export { useTags } from './use-tags';
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export class TagService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getChildren(parentId: string): Promise<Tag[]> {
|
||||||
|
return this.storage.query<Tag>(NAMESPACE, (tag) => tag.parentId === parentId);
|
||||||
|
}
|
||||||
|
|
||||||
async createTag(data: Omit<Tag, 'id' | 'createdAt'>): Promise<Tag> {
|
async createTag(data: Omit<Tag, 'id' | 'createdAt'>): Promise<Tag> {
|
||||||
const tag: Tag = {
|
const tag: Tag = {
|
||||||
...data,
|
...data,
|
||||||
@@ -43,19 +47,41 @@ export class TagService {
|
|||||||
async updateTag(id: string, updates: Partial<Omit<Tag, 'id' | 'createdAt'>>): Promise<Tag | null> {
|
async updateTag(id: string, updates: Partial<Omit<Tag, 'id' | 'createdAt'>>): Promise<Tag | null> {
|
||||||
const existing = await this.storage.get<Tag>(NAMESPACE, id);
|
const existing = await this.storage.get<Tag>(NAMESPACE, id);
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
const updated = { ...existing, ...updates };
|
const updated: Tag = { ...existing, ...updates, updatedAt: new Date().toISOString() };
|
||||||
await this.storage.set(NAMESPACE, id, updated);
|
await this.storage.set(NAMESPACE, id, updated);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteTag(id: string): Promise<void> {
|
async deleteTag(id: string): Promise<void> {
|
||||||
|
// Also delete children
|
||||||
|
const children = await this.getChildren(id);
|
||||||
|
for (const child of children) {
|
||||||
|
await this.storage.delete(NAMESPACE, child.id);
|
||||||
|
}
|
||||||
await this.storage.delete(NAMESPACE, id);
|
await this.storage.delete(NAMESPACE, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchTags(query: string): Promise<Tag[]> {
|
async searchTags(query: string): Promise<Tag[]> {
|
||||||
const lower = query.toLowerCase();
|
const lower = query.toLowerCase();
|
||||||
return this.storage.query<Tag>(NAMESPACE, (tag) =>
|
return this.storage.query<Tag>(NAMESPACE, (tag) =>
|
||||||
tag.label.toLowerCase().includes(lower)
|
tag.label.toLowerCase().includes(lower) ||
|
||||||
|
(tag.projectCode?.toLowerCase().includes(lower) ?? false)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bulk import tags (for seed data). Skips tags whose label already exists in same category. */
|
||||||
|
async importTags(tags: Omit<Tag, 'id' | 'createdAt'>[]): Promise<number> {
|
||||||
|
const existing = await this.getAllTags();
|
||||||
|
const existingKeys = new Set(existing.map((t) => `${t.category}::${t.label}`));
|
||||||
|
let imported = 0;
|
||||||
|
for (const data of tags) {
|
||||||
|
const key = `${data.category}::${data.label}`;
|
||||||
|
if (!existingKeys.has(key)) {
|
||||||
|
await this.createTag(data);
|
||||||
|
existingKeys.add(key);
|
||||||
|
imported++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return imported;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,25 @@ export type TagCategory =
|
|||||||
| 'phase'
|
| 'phase'
|
||||||
| 'activity'
|
| 'activity'
|
||||||
| 'document-type'
|
| 'document-type'
|
||||||
| 'company'
|
|
||||||
| 'priority'
|
|
||||||
| 'status'
|
|
||||||
| 'custom';
|
| 'custom';
|
||||||
|
|
||||||
|
/** Display order for categories — project & phase are mandatory */
|
||||||
|
export const TAG_CATEGORY_ORDER: TagCategory[] = [
|
||||||
|
'project',
|
||||||
|
'phase',
|
||||||
|
'activity',
|
||||||
|
'document-type',
|
||||||
|
'custom',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TAG_CATEGORY_LABELS: Record<TagCategory, string> = {
|
||||||
|
project: 'Proiect',
|
||||||
|
phase: 'Fază',
|
||||||
|
activity: 'Activitate',
|
||||||
|
'document-type': 'Tip document',
|
||||||
|
custom: 'Personalizat',
|
||||||
|
};
|
||||||
|
|
||||||
export type TagScope = 'global' | 'module' | 'company';
|
export type TagScope = 'global' | 'module' | 'company';
|
||||||
|
|
||||||
export interface Tag {
|
export interface Tag {
|
||||||
@@ -21,7 +35,11 @@ export interface Tag {
|
|||||||
scope: TagScope;
|
scope: TagScope;
|
||||||
moduleId?: string;
|
moduleId?: string;
|
||||||
companyId?: CompanyId;
|
companyId?: CompanyId;
|
||||||
|
/** For hierarchy: parent tag id */
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
|
/** For project tags: numbered code e.g. "B-001", "US-024" */
|
||||||
|
projectCode?: string;
|
||||||
metadata?: Record<string, string>;
|
metadata?: Record<string, string>;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ export function useTags(category?: TagCategory) {
|
|||||||
[service, refresh]
|
[service, refresh]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const updateTag = useCallback(
|
||||||
|
async (id: string, updates: Partial<Omit<Tag, 'id' | 'createdAt'>>) => {
|
||||||
|
const tag = await service.updateTag(id, updates);
|
||||||
|
await refresh();
|
||||||
|
return tag;
|
||||||
|
},
|
||||||
|
[service, refresh]
|
||||||
|
);
|
||||||
|
|
||||||
const deleteTag = useCallback(
|
const deleteTag = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
await service.deleteTag(id);
|
await service.deleteTag(id);
|
||||||
@@ -41,5 +50,21 @@ export function useTags(category?: TagCategory) {
|
|||||||
[service, refresh]
|
[service, refresh]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { tags, loading, createTag, deleteTag, refresh };
|
const importTags = useCallback(
|
||||||
|
async (data: Omit<Tag, 'id' | 'createdAt'>[]) => {
|
||||||
|
const count = await service.importTags(data);
|
||||||
|
await refresh();
|
||||||
|
return count;
|
||||||
|
},
|
||||||
|
[service, refresh]
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchTags = useCallback(
|
||||||
|
async (query: string) => {
|
||||||
|
return service.searchTags(query);
|
||||||
|
},
|
||||||
|
[service]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { tags, loading, createTag, updateTag, deleteTag, importTags, searchTags, refresh, service };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Plus, Pencil, Trash2, Search, Mail, Phone, MapPin } from 'lucide-react';
|
import {
|
||||||
|
Plus, Pencil, Trash2, Search, Mail, Phone, MapPin,
|
||||||
|
Globe, Building2, UserPlus, X,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Button } from '@/shared/components/ui/button';
|
import { Button } from '@/shared/components/ui/button';
|
||||||
import { Input } from '@/shared/components/ui/input';
|
import { Input } from '@/shared/components/ui/input';
|
||||||
import { Label } from '@/shared/components/ui/label';
|
import { Label } from '@/shared/components/ui/label';
|
||||||
import { Textarea } from '@/shared/components/ui/textarea';
|
import { Textarea } from '@/shared/components/ui/textarea';
|
||||||
import { Badge } from '@/shared/components/ui/badge';
|
import { Badge } from '@/shared/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
import {
|
||||||
import type { AddressContact, ContactType } from '../types';
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from '@/shared/components/ui/select';
|
||||||
|
import type { AddressContact, ContactType, ContactPerson } from '../types';
|
||||||
import { useContacts } from '../hooks/use-contacts';
|
import { useContacts } from '../hooks/use-contacts';
|
||||||
|
import { useTags } from '@/core/tagging';
|
||||||
|
|
||||||
const TYPE_LABELS: Record<ContactType, string> = {
|
const TYPE_LABELS: Record<ContactType, string> = {
|
||||||
client: 'Client', supplier: 'Furnizor', institution: 'Instituție', collaborator: 'Colaborator',
|
client: 'Client',
|
||||||
|
supplier: 'Furnizor',
|
||||||
|
institution: 'Instituție',
|
||||||
|
collaborator: 'Colaborator',
|
||||||
|
internal: 'Intern',
|
||||||
};
|
};
|
||||||
|
|
||||||
type ViewMode = 'list' | 'add' | 'edit';
|
type ViewMode = 'list' | 'add' | 'edit';
|
||||||
@@ -23,7 +33,7 @@ export function AddressBookModule() {
|
|||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||||
const [editingContact, setEditingContact] = useState<AddressContact | null>(null);
|
const [editingContact, setEditingContact] = useState<AddressContact | null>(null);
|
||||||
|
|
||||||
const handleSubmit = async (data: Omit<AddressContact, 'id' | 'createdAt'>) => {
|
const handleSubmit = async (data: Omit<AddressContact, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||||
if (viewMode === 'edit' && editingContact) {
|
if (viewMode === 'edit' && editingContact) {
|
||||||
await updateContact(editingContact.id, data);
|
await updateContact(editingContact.id, data);
|
||||||
} else {
|
} else {
|
||||||
@@ -36,9 +46,9 @@ export function AddressBookModule() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total</p><p className="text-2xl font-bold">{allContacts.length}</p></CardContent></Card>
|
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total</p><p className="text-2xl font-bold">{allContacts.length}</p></CardContent></Card>
|
||||||
{(Object.keys(TYPE_LABELS) as ContactType[]).slice(0, 3).map((type) => (
|
{(Object.keys(TYPE_LABELS) as ContactType[]).slice(0, 4).map((type) => (
|
||||||
<Card key={type}><CardContent className="p-4">
|
<Card key={type}><CardContent className="p-4">
|
||||||
<p className="text-xs text-muted-foreground">{TYPE_LABELS[type]}</p>
|
<p className="text-xs text-muted-foreground">{TYPE_LABELS[type]}</p>
|
||||||
<p className="text-2xl font-bold">{allContacts.filter((c) => c.type === type).length}</p>
|
<p className="text-2xl font-bold">{allContacts.filter((c) => c.type === type).length}</p>
|
||||||
@@ -74,42 +84,12 @@ export function AddressBookModule() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{contacts.map((contact) => (
|
{contacts.map((contact) => (
|
||||||
<Card key={contact.id} className="group relative">
|
<ContactCard
|
||||||
<CardContent className="p-4">
|
key={contact.id}
|
||||||
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
contact={contact}
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingContact(contact); setViewMode('edit'); }}>
|
onEdit={() => { setEditingContact(contact); setViewMode('edit'); }}
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
onDelete={() => removeContact(contact.id)}
|
||||||
</Button>
|
/>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeContact(contact.id)}>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{contact.name}</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{contact.company && <p className="text-xs text-muted-foreground">{contact.company}</p>}
|
|
||||||
<Badge variant="outline" className="text-[10px]">{TYPE_LABELS[contact.type]}</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{contact.email && (
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
||||||
<Mail className="h-3 w-3" /><span>{contact.email}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{contact.phone && (
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
||||||
<Phone className="h-3 w-3" /><span>{contact.phone}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{contact.address && (
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
||||||
<MapPin className="h-3 w-3" /><span className="truncate">{contact.address}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -120,7 +100,11 @@ export function AddressBookModule() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare contact' : 'Contact nou'}</CardTitle></CardHeader>
|
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare contact' : 'Contact nou'}</CardTitle></CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ContactForm initial={editingContact ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingContact(null); }} />
|
<ContactForm
|
||||||
|
initial={editingContact ?? undefined}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={() => { setViewMode('list'); setEditingContact(null); }}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -128,37 +112,240 @@ export function AddressBookModule() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Contact Card ──
|
||||||
|
|
||||||
|
function ContactCard({ contact, onEdit, onDelete }: {
|
||||||
|
contact: AddressContact;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="group relative">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onEdit}>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={onDelete}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{contact.name}</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{contact.company && <p className="text-xs text-muted-foreground">{contact.company}</p>}
|
||||||
|
<Badge variant="outline" className="text-[10px]">{TYPE_LABELS[contact.type]}</Badge>
|
||||||
|
{contact.department && (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">{contact.department}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{contact.role && (
|
||||||
|
<p className="text-xs text-muted-foreground italic">{contact.role}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{contact.email && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Mail className="h-3 w-3 shrink-0" /><span className="truncate">{contact.email}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contact.email2 && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Mail className="h-3 w-3 shrink-0" /><span className="truncate">{contact.email2}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contact.phone && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Phone className="h-3 w-3 shrink-0" /><span>{contact.phone}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contact.phone2 && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Phone className="h-3 w-3 shrink-0" /><span>{contact.phone2}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contact.address && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<MapPin className="h-3 w-3 shrink-0" /><span className="truncate">{contact.address}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contact.website && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Globe className="h-3 w-3 shrink-0" /><span className="truncate">{contact.website}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contact.contactPersons.length > 0 && (
|
||||||
|
<div className="mt-1 border-t pt-1">
|
||||||
|
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1">
|
||||||
|
Persoane de contact ({contact.contactPersons.length})
|
||||||
|
</p>
|
||||||
|
{contact.contactPersons.slice(0, 2).map((cp, i) => (
|
||||||
|
<p key={i} className="text-xs text-muted-foreground">
|
||||||
|
{cp.name}{cp.role ? ` — ${cp.role}` : ''}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{contact.contactPersons.length > 2 && (
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
+{contact.contactPersons.length - 2} altele
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contact Form ──
|
||||||
|
|
||||||
function ContactForm({ initial, onSubmit, onCancel }: {
|
function ContactForm({ initial, onSubmit, onCancel }: {
|
||||||
initial?: AddressContact;
|
initial?: AddressContact;
|
||||||
onSubmit: (data: Omit<AddressContact, 'id' | 'createdAt'>) => void;
|
onSubmit: (data: Omit<AddressContact, 'id' | 'createdAt' | 'updatedAt'>) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { tags: projectTags } = useTags('project');
|
||||||
const [name, setName] = useState(initial?.name ?? '');
|
const [name, setName] = useState(initial?.name ?? '');
|
||||||
const [company, setCompany] = useState(initial?.company ?? '');
|
const [company, setCompany] = useState(initial?.company ?? '');
|
||||||
const [type, setType] = useState<ContactType>(initial?.type ?? 'client');
|
const [type, setType] = useState<ContactType>(initial?.type ?? 'client');
|
||||||
const [email, setEmail] = useState(initial?.email ?? '');
|
const [email, setEmail] = useState(initial?.email ?? '');
|
||||||
|
const [email2, setEmail2] = useState(initial?.email2 ?? '');
|
||||||
const [phone, setPhone] = useState(initial?.phone ?? '');
|
const [phone, setPhone] = useState(initial?.phone ?? '');
|
||||||
|
const [phone2, setPhone2] = useState(initial?.phone2 ?? '');
|
||||||
const [address, setAddress] = useState(initial?.address ?? '');
|
const [address, setAddress] = useState(initial?.address ?? '');
|
||||||
|
const [department, setDepartment] = useState(initial?.department ?? '');
|
||||||
|
const [role, setRole] = useState(initial?.role ?? '');
|
||||||
|
const [website, setWebsite] = useState(initial?.website ?? '');
|
||||||
const [notes, setNotes] = useState(initial?.notes ?? '');
|
const [notes, setNotes] = useState(initial?.notes ?? '');
|
||||||
|
const [projectIds, setProjectIds] = useState<string[]>(initial?.projectIds ?? []);
|
||||||
|
const [contactPersons, setContactPersons] = useState<ContactPerson[]>(
|
||||||
|
initial?.contactPersons ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
const addContactPerson = () => {
|
||||||
|
setContactPersons([...contactPersons, { name: '', role: '', email: '', phone: '' }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateContactPerson = (index: number, field: keyof ContactPerson, value: string) => {
|
||||||
|
setContactPersons(contactPersons.map((cp, i) =>
|
||||||
|
i === index ? { ...cp, [field]: value } : cp
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeContactPerson = (index: number) => {
|
||||||
|
setContactPersons(contactPersons.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleProject = (projectId: string) => {
|
||||||
|
setProjectIds((prev) =>
|
||||||
|
prev.includes(projectId) ? prev.filter((id) => id !== projectId) : [...prev, projectId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ name, company, type, email, phone, address, notes, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all' }); }} className="space-y-4">
|
<form
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
onSubmit={(e) => {
|
||||||
<div><Label>Nume</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div>
|
e.preventDefault();
|
||||||
<div><Label>Companie/Organizație</Label><Input value={company} onChange={(e) => setCompany(e.target.value)} className="mt-1" /></div>
|
onSubmit({
|
||||||
</div>
|
name, company, type, email, email2, phone, phone2,
|
||||||
|
address, department, role, website, notes,
|
||||||
|
projectIds,
|
||||||
|
contactPersons: contactPersons.filter((cp) => cp.name.trim()),
|
||||||
|
tags: initial?.tags ?? [],
|
||||||
|
visibility: initial?.visibility ?? 'all',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
{/* Row 1: Name + Company + Type */}
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div><Label>Nume *</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div>
|
||||||
|
<div><Label>Companie/Organizație</Label><Input value={company} onChange={(e) => setCompany(e.target.value)} className="mt-1" /></div>
|
||||||
<div><Label>Tip</Label>
|
<div><Label>Tip</Label>
|
||||||
<Select value={type} onValueChange={(v) => setType(v as ContactType)}>
|
<Select value={type} onValueChange={(v) => setType(v as ContactType)}>
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{(Object.keys(TYPE_LABELS) as ContactType[]).map((t) => (<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem>))}</SelectContent>
|
<SelectContent>
|
||||||
|
{(Object.keys(TYPE_LABELS) as ContactType[]).map((t) => (
|
||||||
|
<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div><Label>Email</Label><Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="mt-1" /></div>
|
|
||||||
<div><Label>Telefon</Label><Input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} className="mt-1" /></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Department + Role + Website */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div><Label>Departament</Label><Input value={department} onChange={(e) => setDepartment(e.target.value)} className="mt-1" /></div>
|
||||||
|
<div><Label>Funcție/Rol</Label><Input value={role} onChange={(e) => setRole(e.target.value)} className="mt-1" /></div>
|
||||||
|
<div><Label>Website</Label><Input type="url" value={website} onChange={(e) => setWebsite(e.target.value)} className="mt-1" placeholder="https://" /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: Emails + Phones */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div><Label>Email principal</Label><Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="mt-1" /></div>
|
||||||
|
<div><Label>Email secundar</Label><Input type="email" value={email2} onChange={(e) => setEmail2(e.target.value)} className="mt-1" /></div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div><Label>Telefon principal</Label><Input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} className="mt-1" /></div>
|
||||||
|
<div><Label>Telefon secundar</Label><Input type="tel" value={phone2} onChange={(e) => setPhone2(e.target.value)} className="mt-1" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
<div><Label>Adresă</Label><Input value={address} onChange={(e) => setAddress(e.target.value)} className="mt-1" /></div>
|
<div><Label>Adresă</Label><Input value={address} onChange={(e) => setAddress(e.target.value)} className="mt-1" /></div>
|
||||||
|
|
||||||
|
{/* Project links */}
|
||||||
|
{projectTags.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label>Proiecte asociate</Label>
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||||
|
{projectTags.map((pt) => (
|
||||||
|
<button
|
||||||
|
key={pt.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleProject(pt.id)}
|
||||||
|
className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${
|
||||||
|
projectIds.includes(pt.id)
|
||||||
|
? 'border-primary bg-primary/10 text-primary'
|
||||||
|
: 'border-muted-foreground/30 text-muted-foreground hover:border-primary/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pt.projectCode ? `${pt.projectCode} ` : ''}{pt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contact Persons */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Persoane de contact</Label>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addContactPerson}>
|
||||||
|
<UserPlus className="mr-1 h-3.5 w-3.5" /> Adaugă persoană
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{contactPersons.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{contactPersons.map((cp, i) => (
|
||||||
|
<div key={i} className="flex flex-wrap items-start gap-2 rounded border p-2">
|
||||||
|
<Input placeholder="Nume" value={cp.name} onChange={(e) => updateContactPerson(i, 'name', e.target.value)} className="min-w-[150px] flex-1 text-sm" />
|
||||||
|
<Input placeholder="Funcție" value={cp.role} onChange={(e) => updateContactPerson(i, 'role', e.target.value)} className="w-[140px] text-sm" />
|
||||||
|
<Input placeholder="Email" value={cp.email} onChange={(e) => updateContactPerson(i, 'email', e.target.value)} className="w-[180px] text-sm" />
|
||||||
|
<Input placeholder="Telefon" value={cp.phone} onChange={(e) => updateContactPerson(i, 'phone', e.target.value)} className="w-[140px] text-sm" />
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0 text-destructive" onClick={() => removeContactPerson(i)}>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div>
|
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
||||||
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
|
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ export function useContacts() {
|
|||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
useEffect(() => { refresh(); }, [refresh]);
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
const addContact = useCallback(async (data: Omit<AddressContact, 'id' | 'createdAt'>) => {
|
const addContact = useCallback(async (data: Omit<AddressContact, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||||
const contact: AddressContact = { ...data, id: uuid(), createdAt: new Date().toISOString() };
|
const now = new Date().toISOString();
|
||||||
|
const contact: AddressContact = { ...data, id: uuid(), createdAt: now, updatedAt: now };
|
||||||
await storage.set(`${PREFIX}${contact.id}`, contact);
|
await storage.set(`${PREFIX}${contact.id}`, contact);
|
||||||
await refresh();
|
await refresh();
|
||||||
return contact;
|
return contact;
|
||||||
@@ -46,7 +47,13 @@ export function useContacts() {
|
|||||||
const updateContact = useCallback(async (id: string, updates: Partial<AddressContact>) => {
|
const updateContact = useCallback(async (id: string, updates: Partial<AddressContact>) => {
|
||||||
const existing = contacts.find((c) => c.id === id);
|
const existing = contacts.find((c) => c.id === id);
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
const updated = { ...existing, ...updates, id: existing.id, createdAt: existing.createdAt };
|
const updated: AddressContact = {
|
||||||
|
...existing,
|
||||||
|
...updates,
|
||||||
|
id: existing.id,
|
||||||
|
createdAt: existing.createdAt,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
await storage.set(`${PREFIX}${id}`, updated);
|
await storage.set(`${PREFIX}${id}`, updated);
|
||||||
await refresh();
|
await refresh();
|
||||||
}, [storage, refresh, contacts]);
|
}, [storage, refresh, contacts]);
|
||||||
@@ -64,7 +71,14 @@ export function useContacts() {
|
|||||||
if (filters.type !== 'all' && c.type !== filters.type) return false;
|
if (filters.type !== 'all' && c.type !== filters.type) return false;
|
||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
const q = filters.search.toLowerCase();
|
const q = filters.search.toLowerCase();
|
||||||
return c.name.toLowerCase().includes(q) || c.company.toLowerCase().includes(q) || c.email.toLowerCase().includes(q) || c.phone.includes(q);
|
return (
|
||||||
|
c.name.toLowerCase().includes(q) ||
|
||||||
|
c.company.toLowerCase().includes(q) ||
|
||||||
|
c.email.toLowerCase().includes(q) ||
|
||||||
|
c.phone.includes(q) ||
|
||||||
|
c.department.toLowerCase().includes(q) ||
|
||||||
|
c.role.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,44 @@
|
|||||||
import type { Visibility } from '@/core/module-registry/types';
|
import type { Visibility } from '@/core/module-registry/types';
|
||||||
|
|
||||||
export type ContactType = 'client' | 'supplier' | 'institution' | 'collaborator';
|
export type ContactType = 'client' | 'supplier' | 'institution' | 'collaborator' | 'internal';
|
||||||
|
|
||||||
|
/** A contact person within an organization/entity */
|
||||||
|
export interface ContactPerson {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AddressContact {
|
export interface AddressContact {
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Primary name (person or organization) */
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Organization/company name */
|
||||||
company: string;
|
company: string;
|
||||||
type: ContactType;
|
type: ContactType;
|
||||||
|
/** Primary email */
|
||||||
email: string;
|
email: string;
|
||||||
|
/** Secondary email */
|
||||||
|
email2: string;
|
||||||
|
/** Primary phone */
|
||||||
phone: string;
|
phone: string;
|
||||||
|
/** Secondary phone */
|
||||||
|
phone2: string;
|
||||||
address: string;
|
address: string;
|
||||||
|
/** Department within the organization */
|
||||||
|
department: string;
|
||||||
|
/** Role / job title */
|
||||||
|
role: string;
|
||||||
|
/** Website URL */
|
||||||
|
website: string;
|
||||||
|
/** Linked project tag IDs */
|
||||||
|
projectIds: string[];
|
||||||
|
/** Additional contact persons for this entity */
|
||||||
|
contactPersons: ContactPerson[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
notes: string;
|
notes: string;
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Plus, Pencil, Trash2, Search, PenTool, Stamp, Type } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Search, PenTool, Stamp, Type, History, AlertTriangle } from 'lucide-react';
|
||||||
import { Button } from '@/shared/components/ui/button';
|
import { Button } from '@/shared/components/ui/button';
|
||||||
import { Input } from '@/shared/components/ui/input';
|
import { Input } from '@/shared/components/ui/input';
|
||||||
import { Label } from '@/shared/components/ui/label';
|
import { Label } from '@/shared/components/ui/label';
|
||||||
|
import { Textarea } from '@/shared/components/ui/textarea';
|
||||||
import { Badge } from '@/shared/components/ui/badge';
|
import { Badge } from '@/shared/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog';
|
||||||
import type { CompanyId } from '@/core/auth/types';
|
import type { CompanyId } from '@/core/auth/types';
|
||||||
import type { SignatureAsset, SignatureAssetType } from '../types';
|
import type { SignatureAsset, SignatureAssetType } from '../types';
|
||||||
import { useSignatures } from '../hooks/use-signatures';
|
import { useSignatures } from '../hooks/use-signatures';
|
||||||
@@ -23,11 +25,13 @@ const TYPE_ICONS: Record<SignatureAssetType, typeof PenTool> = {
|
|||||||
type ViewMode = 'list' | 'add' | 'edit';
|
type ViewMode = 'list' | 'add' | 'edit';
|
||||||
|
|
||||||
export function DigitalSignaturesModule() {
|
export function DigitalSignaturesModule() {
|
||||||
const { assets, allAssets, loading, filters, updateFilter, addAsset, updateAsset, removeAsset } = useSignatures();
|
const { assets, allAssets, loading, filters, updateFilter, addAsset, updateAsset, addVersion, removeAsset } = useSignatures();
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||||
const [editingAsset, setEditingAsset] = useState<SignatureAsset | null>(null);
|
const [editingAsset, setEditingAsset] = useState<SignatureAsset | null>(null);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const [versionAsset, setVersionAsset] = useState<SignatureAsset | null>(null);
|
||||||
|
|
||||||
const handleSubmit = async (data: Omit<SignatureAsset, 'id' | 'createdAt'>) => {
|
const handleSubmit = async (data: Omit<SignatureAsset, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||||
if (viewMode === 'edit' && editingAsset) {
|
if (viewMode === 'edit' && editingAsset) {
|
||||||
await updateAsset(editingAsset.id, data);
|
await updateAsset(editingAsset.id, data);
|
||||||
} else {
|
} else {
|
||||||
@@ -37,6 +41,31 @@ export function DigitalSignaturesModule() {
|
|||||||
setEditingAsset(null);
|
setEditingAsset(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (deletingId) {
|
||||||
|
await removeAsset(deletingId);
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddVersion = async (imageUrl: string, notes: string) => {
|
||||||
|
if (versionAsset) {
|
||||||
|
await addVersion(versionAsset.id, imageUrl, notes);
|
||||||
|
setVersionAsset(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isExpiringSoon = (date?: string) => {
|
||||||
|
if (!date) return false;
|
||||||
|
const diff = new Date(date).getTime() - Date.now();
|
||||||
|
return diff > 0 && diff < 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
|
};
|
||||||
|
|
||||||
|
const isExpired = (date?: string) => {
|
||||||
|
if (!date) return false;
|
||||||
|
return new Date(date).getTime() < Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
@@ -79,14 +108,19 @@ export function DigitalSignaturesModule() {
|
|||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{assets.map((asset) => {
|
{assets.map((asset) => {
|
||||||
const Icon = TYPE_ICONS[asset.type];
|
const Icon = TYPE_ICONS[asset.type];
|
||||||
|
const expired = isExpired(asset.expirationDate);
|
||||||
|
const expiringSoon = isExpiringSoon(asset.expirationDate);
|
||||||
return (
|
return (
|
||||||
<Card key={asset.id} className="group relative">
|
<Card key={asset.id} className={`group relative ${expired ? 'border-destructive/50' : expiringSoon ? 'border-yellow-500/50' : ''}`}>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" title="Versiune nouă" onClick={() => setVersionAsset(asset)}>
|
||||||
|
<History className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingAsset(asset); setViewMode('edit'); }}>
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingAsset(asset); setViewMode('edit'); }}>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeAsset(asset.id)}>
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => setDeletingId(asset.id)}>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,14 +133,34 @@ export function DigitalSignaturesModule() {
|
|||||||
<Icon className="h-6 w-6 text-muted-foreground" />
|
<Icon className="h-6 w-6 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-medium">{asset.label}</p>
|
<p className="font-medium">{asset.label}</p>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
<Badge variant="outline" className="text-[10px]">{TYPE_LABELS[asset.type]}</Badge>
|
<Badge variant="outline" className="text-[10px]">{TYPE_LABELS[asset.type]}</Badge>
|
||||||
<span className="text-xs text-muted-foreground">{asset.owner}</span>
|
<span className="text-xs text-muted-foreground">{asset.owner}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Metadata row */}
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{asset.legalStatus && (
|
||||||
|
<p className="text-xs text-muted-foreground">Status legal: {asset.legalStatus}</p>
|
||||||
|
)}
|
||||||
|
{asset.expirationDate && (
|
||||||
|
<div className="flex items-center gap-1 text-xs">
|
||||||
|
{(expired || expiringSoon) && <AlertTriangle className="h-3 w-3 text-yellow-500" />}
|
||||||
|
<span className={expired ? 'text-destructive font-medium' : expiringSoon ? 'text-yellow-600 font-medium' : 'text-muted-foreground'}>
|
||||||
|
{expired ? 'Expirat' : expiringSoon ? 'Expiră curând' : 'Expiră'}: {asset.expirationDate}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{asset.usageNotes && (
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-1">Note: {asset.usageNotes}</p>
|
||||||
|
)}
|
||||||
|
{asset.versions.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">Versiuni: {asset.versions.length + 1}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -124,13 +178,74 @@ export function DigitalSignaturesModule() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>Confirmare ștergere</DialogTitle></DialogHeader>
|
||||||
|
<p className="text-sm">Ești sigur 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssetForm({ initial, onSubmit, onCancel }: {
|
function AssetForm({ initial, onSubmit, onCancel }: {
|
||||||
initial?: SignatureAsset;
|
initial?: SignatureAsset;
|
||||||
onSubmit: (data: Omit<SignatureAsset, 'id' | 'createdAt'>) => void;
|
onSubmit: (data: Omit<SignatureAsset, 'id' | 'createdAt' | 'updatedAt'>) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [label, setLabel] = useState(initial?.label ?? '');
|
const [label, setLabel] = useState(initial?.label ?? '');
|
||||||
@@ -138,11 +253,23 @@ function AssetForm({ initial, onSubmit, onCancel }: {
|
|||||||
const [imageUrl, setImageUrl] = useState(initial?.imageUrl ?? '');
|
const [imageUrl, setImageUrl] = useState(initial?.imageUrl ?? '');
|
||||||
const [owner, setOwner] = useState(initial?.owner ?? '');
|
const [owner, setOwner] = useState(initial?.owner ?? '');
|
||||||
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
|
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
|
||||||
|
const [expirationDate, setExpirationDate] = useState(initial?.expirationDate ?? '');
|
||||||
|
const [legalStatus, setLegalStatus] = useState(initial?.legalStatus ?? '');
|
||||||
|
const [usageNotes, setUsageNotes] = useState(initial?.usageNotes ?? '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ label, type, imageUrl, owner, company, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all' }); }} className="space-y-4">
|
<form onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit({
|
||||||
|
label, type, imageUrl, owner, company,
|
||||||
|
expirationDate: expirationDate || undefined,
|
||||||
|
legalStatus, usageNotes,
|
||||||
|
versions: initial?.versions ?? [],
|
||||||
|
tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all',
|
||||||
|
});
|
||||||
|
}} className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div><Label>Denumire</Label><Input value={label} onChange={(e) => setLabel(e.target.value)} className="mt-1" required /></div>
|
<div><Label>Denumire *</Label><Input value={label} onChange={(e) => setLabel(e.target.value)} className="mt-1" required /></div>
|
||||||
<div><Label>Tip</Label>
|
<div><Label>Tip</Label>
|
||||||
<Select value={type} onValueChange={(v) => setType(v as SignatureAssetType)}>
|
<Select value={type} onValueChange={(v) => setType(v as SignatureAssetType)}>
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
@@ -173,6 +300,11 @@ function AssetForm({ initial, onSubmit, onCancel }: {
|
|||||||
<Input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} className="mt-1" placeholder="https://... sau data:image/png;base64,..." />
|
<Input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} className="mt-1" placeholder="https://... sau data:image/png;base64,..." />
|
||||||
<p className="mt-1 text-xs text-muted-foreground">URL către imaginea semnăturii/ștampilei. Suportă URL-uri externe sau base64.</p>
|
<p className="mt-1 text-xs text-muted-foreground">URL către imaginea semnăturii/ștampilei. Suportă URL-uri externe sau base64.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div><Label>Data expirare</Label><Input type="date" value={expirationDate} onChange={(e) => setExpirationDate(e.target.value)} className="mt-1" /></div>
|
||||||
|
<div><Label>Status legal</Label><Input value={legalStatus} onChange={(e) => setLegalStatus(e.target.value)} className="mt-1" placeholder="Valid, Anulat..." /></div>
|
||||||
|
<div><Label>Note utilizare</Label><Input value={usageNotes} onChange={(e) => setUsageNotes(e.target.value)} className="mt-1" placeholder="Doar pentru contracte..." /></div>
|
||||||
|
</div>
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
||||||
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
|
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useStorage } from '@/core/storage';
|
import { useStorage } from '@/core/storage';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import type { SignatureAsset, SignatureAssetType } from '../types';
|
import type { SignatureAsset, SignatureAssetType, AssetVersion } from '../types';
|
||||||
|
|
||||||
const PREFIX = 'sig:';
|
const PREFIX = 'sig:';
|
||||||
|
|
||||||
@@ -36,8 +36,9 @@ export function useSignatures() {
|
|||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
useEffect(() => { refresh(); }, [refresh]);
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
const addAsset = useCallback(async (data: Omit<SignatureAsset, 'id' | 'createdAt'>) => {
|
const addAsset = useCallback(async (data: Omit<SignatureAsset, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||||
const asset: SignatureAsset = { ...data, id: uuid(), createdAt: new Date().toISOString() };
|
const now = new Date().toISOString();
|
||||||
|
const asset: SignatureAsset = { ...data, id: uuid(), createdAt: now, updatedAt: now };
|
||||||
await storage.set(`${PREFIX}${asset.id}`, asset);
|
await storage.set(`${PREFIX}${asset.id}`, asset);
|
||||||
await refresh();
|
await refresh();
|
||||||
return asset;
|
return asset;
|
||||||
@@ -46,11 +47,23 @@ export function useSignatures() {
|
|||||||
const updateAsset = useCallback(async (id: string, updates: Partial<SignatureAsset>) => {
|
const updateAsset = useCallback(async (id: string, updates: Partial<SignatureAsset>) => {
|
||||||
const existing = assets.find((a) => a.id === id);
|
const existing = assets.find((a) => a.id === id);
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
const updated = { ...existing, ...updates, id: existing.id, createdAt: existing.createdAt };
|
const updated: SignatureAsset = {
|
||||||
|
...existing, ...updates,
|
||||||
|
id: existing.id, createdAt: existing.createdAt,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
await storage.set(`${PREFIX}${id}`, updated);
|
await storage.set(`${PREFIX}${id}`, updated);
|
||||||
await refresh();
|
await refresh();
|
||||||
}, [storage, refresh, assets]);
|
}, [storage, refresh, assets]);
|
||||||
|
|
||||||
|
const addVersion = useCallback(async (assetId: string, imageUrl: string, notes: string) => {
|
||||||
|
const existing = assets.find((a) => a.id === assetId);
|
||||||
|
if (!existing) return;
|
||||||
|
const version: AssetVersion = { id: uuid(), imageUrl, notes, createdAt: new Date().toISOString() };
|
||||||
|
const updatedVersions = [...existing.versions, version];
|
||||||
|
await updateAsset(assetId, { imageUrl, versions: updatedVersions });
|
||||||
|
}, [assets, updateAsset]);
|
||||||
|
|
||||||
const removeAsset = useCallback(async (id: string) => {
|
const removeAsset = useCallback(async (id: string) => {
|
||||||
await storage.delete(`${PREFIX}${id}`);
|
await storage.delete(`${PREFIX}${id}`);
|
||||||
await refresh();
|
await refresh();
|
||||||
@@ -69,5 +82,5 @@ export function useSignatures() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { assets: filteredAssets, allAssets: assets, loading, filters, updateFilter, addAsset, updateAsset, removeAsset, refresh };
|
return { assets: filteredAssets, allAssets: assets, loading, filters, updateFilter, addAsset, updateAsset, addVersion, removeAsset, refresh };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export { digitalSignaturesConfig } from './config';
|
export { digitalSignaturesConfig } from './config';
|
||||||
export { DigitalSignaturesModule } from './components/digital-signatures-module';
|
export { DigitalSignaturesModule } from './components/digital-signatures-module';
|
||||||
export type { SignatureAsset, SignatureAssetType } from './types';
|
export type { SignatureAsset, SignatureAssetType, AssetVersion } from './types';
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ import type { CompanyId } from '@/core/auth/types';
|
|||||||
|
|
||||||
export type SignatureAssetType = 'signature' | 'stamp' | 'initials';
|
export type SignatureAssetType = 'signature' | 'stamp' | 'initials';
|
||||||
|
|
||||||
|
/** Version history entry */
|
||||||
|
export interface AssetVersion {
|
||||||
|
id: string;
|
||||||
|
imageUrl: string;
|
||||||
|
notes: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SignatureAsset {
|
export interface SignatureAsset {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -10,7 +18,16 @@ export interface SignatureAsset {
|
|||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
owner: string;
|
owner: string;
|
||||||
company: CompanyId;
|
company: CompanyId;
|
||||||
|
/** Expiration date (YYYY-MM-DD) */
|
||||||
|
expirationDate?: string;
|
||||||
|
/** Legal status description */
|
||||||
|
legalStatus: string;
|
||||||
|
/** Usage notes */
|
||||||
|
usageNotes: string;
|
||||||
|
/** Version history */
|
||||||
|
versions: AssetVersion[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { RotateCcw } from 'lucide-react';
|
|||||||
export function EmailSignatureModule() {
|
export function EmailSignatureModule() {
|
||||||
const {
|
const {
|
||||||
config, updateField, updateColor, updateLayout,
|
config, updateField, updateColor, updateLayout,
|
||||||
setVariant, setCompany, resetToDefaults, loadConfig,
|
setVariant, setCompany, setAddress, resetToDefaults, loadConfig,
|
||||||
} = useSignatureConfig();
|
} = useSignatureConfig();
|
||||||
|
|
||||||
const { saved, loading, save, remove } = useSavedSignatures();
|
const { saved, loading, save, remove } = useSavedSignatures();
|
||||||
@@ -28,6 +28,7 @@ export function EmailSignatureModule() {
|
|||||||
onUpdateLayout={updateLayout}
|
onUpdateLayout={updateLayout}
|
||||||
onSetVariant={setVariant}
|
onSetVariant={setVariant}
|
||||||
onSetCompany={setCompany}
|
onSetCompany={setCompany}
|
||||||
|
onSetAddress={setAddress}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -49,7 +50,7 @@ export function EmailSignatureModule() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right panel — preview */}
|
{/* Right panel — preview (scrollable, resizable) */}
|
||||||
<div>
|
<div>
|
||||||
<SignaturePreview config={config} />
|
<SignaturePreview config={config} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type { CompanyId } from '@/core/auth/types';
|
import type { CompanyId } from '@/core/auth/types';
|
||||||
import type { SignatureConfig, SignatureColors, SignatureLayout, SignatureVariant } from '../types';
|
import type { SignatureConfig, SignatureColors, SignatureLayout, SignatureVariant } from '../types';
|
||||||
import { COMPANY_BRANDING } from '../services/company-branding';
|
import { COMPANY_BRANDING, BELETAGE_ADDRESSES } from '../services/company-branding';
|
||||||
import { Input } from '@/shared/components/ui/input';
|
import { Input } from '@/shared/components/ui/input';
|
||||||
import { Label } from '@/shared/components/ui/label';
|
import { Label } from '@/shared/components/ui/label';
|
||||||
import { Switch } from '@/shared/components/ui/switch';
|
import { Switch } from '@/shared/components/ui/switch';
|
||||||
@@ -17,13 +17,39 @@ interface SignatureConfiguratorProps {
|
|||||||
onUpdateLayout: (key: keyof SignatureLayout, value: number) => void;
|
onUpdateLayout: (key: keyof SignatureLayout, value: number) => void;
|
||||||
onSetVariant: (variant: SignatureVariant) => void;
|
onSetVariant: (variant: SignatureVariant) => void;
|
||||||
onSetCompany: (company: CompanyId) => void;
|
onSetCompany: (company: CompanyId) => void;
|
||||||
|
onSetAddress?: (address: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLOR_PALETTE: Record<string, string> = {
|
/** Color palette per company */
|
||||||
verde: '#22B5AB',
|
const COMPANY_PALETTES: Record<CompanyId, Record<string, string>> = {
|
||||||
griInchis: '#54504F',
|
beletage: {
|
||||||
griDeschis: '#A7A9AA',
|
verde: '#22B5AB',
|
||||||
negru: '#323232',
|
griInchis: '#54504F',
|
||||||
|
griDeschis: '#A7A9AA',
|
||||||
|
negru: '#323232',
|
||||||
|
},
|
||||||
|
'urban-switch': {
|
||||||
|
indigo: '#6366f1',
|
||||||
|
violet: '#4F46E5',
|
||||||
|
griInchis: '#2D2D2D',
|
||||||
|
griDeschis: '#6B7280',
|
||||||
|
albastru: '#3B82F6',
|
||||||
|
negru: '#1F2937',
|
||||||
|
},
|
||||||
|
'studii-de-teren': {
|
||||||
|
amber: '#f59e0b',
|
||||||
|
portocaliu: '#D97706',
|
||||||
|
griInchis: '#2D2D2D',
|
||||||
|
griDeschis: '#6B7280',
|
||||||
|
maro: '#92400E',
|
||||||
|
negru: '#1F2937',
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
gri: '#64748b',
|
||||||
|
griInchis: '#334155',
|
||||||
|
griDeschis: '#94a3b8',
|
||||||
|
negru: '#1e293b',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const COLOR_LABELS: Record<keyof SignatureColors, string> = {
|
const COLOR_LABELS: Record<keyof SignatureColors, string> = {
|
||||||
@@ -48,8 +74,10 @@ const LAYOUT_CONTROLS: { key: keyof SignatureLayout; label: string; min: number;
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function SignatureConfigurator({
|
export function SignatureConfigurator({
|
||||||
config, onUpdateField, onUpdateColor, onUpdateLayout, onSetVariant, onSetCompany,
|
config, onUpdateField, onUpdateColor, onUpdateLayout, onSetVariant, onSetCompany, onSetAddress,
|
||||||
}: SignatureConfiguratorProps) {
|
}: SignatureConfiguratorProps) {
|
||||||
|
const palette = COMPANY_PALETTES[config.company];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Company selector */}
|
{/* Company selector */}
|
||||||
@@ -67,6 +95,26 @@ export function SignatureConfigurator({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Address selector (for Beletage) */}
|
||||||
|
{config.company === 'beletage' && onSetAddress && (
|
||||||
|
<div>
|
||||||
|
<Label>Adresă birou</Label>
|
||||||
|
<Select
|
||||||
|
value={!config.addressOverride || BELETAGE_ADDRESSES.unirii.join('|') === config.addressOverride.join('|') ? 'unirii' : 'christescu'}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
const key = v as keyof typeof BELETAGE_ADDRESSES;
|
||||||
|
onSetAddress(BELETAGE_ADDRESSES[key]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="unirii">Str. Unirii, nr. 3</SelectItem>
|
||||||
|
<SelectItem value="christescu">Str. G-ral Eremia Grigorescu, nr. 21</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Personal data */}
|
{/* Personal data */}
|
||||||
@@ -113,14 +161,14 @@ export function SignatureConfigurator({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Colors */}
|
{/* Colors — company-specific palette */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold">Culori text</h3>
|
<h3 className="text-sm font-semibold">Culori text</h3>
|
||||||
{(Object.keys(COLOR_LABELS) as (keyof SignatureColors)[]).map((colorKey) => (
|
{(Object.keys(COLOR_LABELS) as (keyof SignatureColors)[]).map((colorKey) => (
|
||||||
<div key={colorKey} className="flex items-center justify-between">
|
<div key={colorKey} className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">{COLOR_LABELS[colorKey]}</span>
|
<span className="text-sm text-muted-foreground">{COLOR_LABELS[colorKey]}</span>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
{Object.values(COLOR_PALETTE).map((color) => (
|
{Object.values(palette).map((color) => (
|
||||||
<button
|
<button
|
||||||
key={color}
|
key={color}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -6,15 +6,19 @@ import { Button } from '@/shared/components/ui/button';
|
|||||||
import type { SignatureConfig } from '../types';
|
import type { SignatureConfig } from '../types';
|
||||||
import { generateSignatureHtml, downloadSignatureHtml } from '../services/signature-builder';
|
import { generateSignatureHtml, downloadSignatureHtml } from '../services/signature-builder';
|
||||||
|
|
||||||
|
const ZOOM_LEVELS = [0.75, 1, 1.5, 2, 2.5];
|
||||||
|
|
||||||
interface SignaturePreviewProps {
|
interface SignaturePreviewProps {
|
||||||
config: SignatureConfig;
|
config: SignatureConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SignaturePreview({ config }: SignaturePreviewProps) {
|
export function SignaturePreview({ config }: SignaturePreviewProps) {
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoomIndex, setZoomIndex] = useState(1); // start at 100%
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const previewRef = useRef<HTMLDivElement>(null);
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const zoom = ZOOM_LEVELS[zoomIndex] ?? 1;
|
||||||
|
|
||||||
const html = useMemo(() => generateSignatureHtml(config), [config]);
|
const html = useMemo(() => generateSignatureHtml(config), [config]);
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
@@ -32,17 +36,23 @@ export function SignaturePreview({ config }: SignaturePreviewProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleZoom = () => setZoom((z) => (z === 1 ? 2 : 1));
|
const zoomIn = () => setZoomIndex((i) => Math.min(i + 1, ZOOM_LEVELS.length - 1));
|
||||||
|
const zoomOut = () => setZoomIndex((i) => Math.max(i - 1, 0));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold">Previzualizare</h2>
|
<h2 className="text-lg font-semibold">Previzualizare</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={toggleZoom}>
|
<div className="flex items-center rounded-md border">
|
||||||
{zoom === 1 ? <ZoomIn className="mr-1 h-4 w-4" /> : <ZoomOut className="mr-1 h-4 w-4" />}
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-r-none" onClick={zoomOut} disabled={zoomIndex <= 0}>
|
||||||
{zoom === 1 ? '200%' : '100%'}
|
<ZoomOut className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<span className="w-14 text-center text-sm font-medium">{Math.round(zoom * 100)}%</span>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-l-none" onClick={zoomIn} disabled={zoomIndex >= ZOOM_LEVELS.length - 1}>
|
||||||
|
<ZoomIn className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={handleCopy}>
|
<Button variant="outline" size="sm" onClick={handleCopy}>
|
||||||
<Copy className="mr-1 h-4 w-4" />
|
<Copy className="mr-1 h-4 w-4" />
|
||||||
{copied ? 'Copiat!' : 'Copiază HTML'}
|
{copied ? 'Copiat!' : 'Copiază HTML'}
|
||||||
@@ -54,7 +64,7 @@ export function SignaturePreview({ config }: SignaturePreviewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-auto rounded-lg border bg-white p-6">
|
<div className="max-h-[calc(100vh-14rem)] overflow-auto rounded-lg border bg-white p-6">
|
||||||
<div
|
<div
|
||||||
ref={previewRef}
|
ref={previewRef}
|
||||||
style={{ transform: `scale(${zoom})`, transformOrigin: 'top left' }}
|
style={{ transform: `scale(${zoom})`, transformOrigin: 'top left' }}
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ export function useSignatureConfig(initialCompany: CompanyId = 'beletage') {
|
|||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setAddress = useCallback((address: string[]) => {
|
||||||
|
setConfig((prev) => ({ ...prev, addressOverride: address }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const resetToDefaults = useCallback(() => {
|
const resetToDefaults = useCallback(() => {
|
||||||
setConfig(createDefaultConfig(config.company));
|
setConfig(createDefaultConfig(config.company));
|
||||||
}, [config.company]);
|
}, [config.company]);
|
||||||
@@ -83,7 +87,8 @@ export function useSignatureConfig(initialCompany: CompanyId = 'beletage') {
|
|||||||
updateLayout,
|
updateLayout,
|
||||||
setVariant,
|
setVariant,
|
||||||
setCompany,
|
setCompany,
|
||||||
|
setAddress,
|
||||||
resetToDefaults,
|
resetToDefaults,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
}), [config, updateField, updateColor, updateLayout, setVariant, setCompany, resetToDefaults, loadConfig]);
|
}), [config, updateField, updateColor, updateLayout, setVariant, setCompany, setAddress, resetToDefaults, loadConfig]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,25 +12,34 @@ const BELETAGE_COLORS: SignatureColors = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const URBAN_SWITCH_COLORS: SignatureColors = {
|
const URBAN_SWITCH_COLORS: SignatureColors = {
|
||||||
prefix: '#3B3B3B',
|
prefix: '#2D2D2D',
|
||||||
name: '#3B3B3B',
|
name: '#2D2D2D',
|
||||||
title: '#8B8B8B',
|
title: '#6B7280',
|
||||||
address: '#8B8B8B',
|
address: '#6B7280',
|
||||||
phone: '#3B3B3B',
|
phone: '#2D2D2D',
|
||||||
website: '#3B3B3B',
|
website: '#4F46E5',
|
||||||
motto: '#6366f1',
|
motto: '#6366f1',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STUDII_COLORS: SignatureColors = {
|
const STUDII_COLORS: SignatureColors = {
|
||||||
prefix: '#3B3B3B',
|
prefix: '#2D2D2D',
|
||||||
name: '#3B3B3B',
|
name: '#2D2D2D',
|
||||||
title: '#8B8B8B',
|
title: '#6B7280',
|
||||||
address: '#8B8B8B',
|
address: '#6B7280',
|
||||||
phone: '#3B3B3B',
|
phone: '#2D2D2D',
|
||||||
website: '#3B3B3B',
|
website: '#D97706',
|
||||||
motto: '#f59e0b',
|
motto: '#f59e0b',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ADDR_UNIRII = ['str. Unirii, nr. 3, ap. 26', 'Cluj-Napoca, Cluj 400417', 'România'] as const;
|
||||||
|
const ADDR_CHRISTESCU = ['str. G-ral Eremia Grigorescu, nr. 21', 'Cluj-Napoca, Cluj 400304', 'România'] as const;
|
||||||
|
|
||||||
|
/** Available address options for Beletage (toggle between offices) */
|
||||||
|
export const BELETAGE_ADDRESSES: { unirii: string[]; christescu: string[] } = {
|
||||||
|
unirii: [...ADDR_UNIRII],
|
||||||
|
christescu: [...ADDR_CHRISTESCU],
|
||||||
|
};
|
||||||
|
|
||||||
export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
|
export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
|
||||||
beletage: {
|
beletage: {
|
||||||
id: 'beletage',
|
id: 'beletage',
|
||||||
@@ -48,7 +57,7 @@ export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
|
|||||||
png: 'https://beletage.ro/img/Green-slash.png',
|
png: 'https://beletage.ro/img/Green-slash.png',
|
||||||
svg: 'https://beletage.ro/img/Green-slash.svg',
|
svg: 'https://beletage.ro/img/Green-slash.svg',
|
||||||
},
|
},
|
||||||
address: ['str. Unirii, nr. 3, ap. 26', 'Cluj-Napoca, Cluj 400417', 'România'],
|
address: [...ADDR_UNIRII],
|
||||||
website: 'www.beletage.ro',
|
website: 'www.beletage.ro',
|
||||||
motto: 'we make complex simple',
|
motto: 'we make complex simple',
|
||||||
defaultColors: BELETAGE_COLORS,
|
defaultColors: BELETAGE_COLORS,
|
||||||
@@ -58,20 +67,20 @@ export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
|
|||||||
name: 'Urban Switch SRL',
|
name: 'Urban Switch SRL',
|
||||||
accent: '#6366f1',
|
accent: '#6366f1',
|
||||||
logo: {
|
logo: {
|
||||||
png: '',
|
png: '/logos/logo-us-dark.svg',
|
||||||
svg: '',
|
svg: '/logos/logo-us-dark.svg',
|
||||||
},
|
},
|
||||||
slashGrey: {
|
slashGrey: {
|
||||||
png: 'https://beletage.ro/img/Grey-slash.png',
|
png: 'https://beletage.ro/img/Grey-slash.png',
|
||||||
svg: 'https://beletage.ro/img/Grey-slash.svg',
|
svg: 'https://beletage.ro/img/Grey-slash.svg',
|
||||||
},
|
},
|
||||||
slashAccent: {
|
slashAccent: {
|
||||||
png: '',
|
png: '/logos/logo-us-light.svg',
|
||||||
svg: '',
|
svg: '/logos/logo-us-light.svg',
|
||||||
},
|
},
|
||||||
address: ['Cluj-Napoca', 'România'],
|
address: ['str. Unirii, nr. 3, ap. 26', 'Cluj-Napoca, Cluj 400417', 'România'],
|
||||||
website: '',
|
website: 'www.urbanswitch.ro',
|
||||||
motto: '',
|
motto: 'shaping urban futures',
|
||||||
defaultColors: URBAN_SWITCH_COLORS,
|
defaultColors: URBAN_SWITCH_COLORS,
|
||||||
},
|
},
|
||||||
'studii-de-teren': {
|
'studii-de-teren': {
|
||||||
@@ -79,20 +88,20 @@ export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
|
|||||||
name: 'Studii de Teren SRL',
|
name: 'Studii de Teren SRL',
|
||||||
accent: '#f59e0b',
|
accent: '#f59e0b',
|
||||||
logo: {
|
logo: {
|
||||||
png: '',
|
png: '/logos/logo-sdt-dark.svg',
|
||||||
svg: '',
|
svg: '/logos/logo-sdt-dark.svg',
|
||||||
},
|
},
|
||||||
slashGrey: {
|
slashGrey: {
|
||||||
png: 'https://beletage.ro/img/Grey-slash.png',
|
png: 'https://beletage.ro/img/Grey-slash.png',
|
||||||
svg: 'https://beletage.ro/img/Grey-slash.svg',
|
svg: 'https://beletage.ro/img/Grey-slash.svg',
|
||||||
},
|
},
|
||||||
slashAccent: {
|
slashAccent: {
|
||||||
png: '',
|
png: '/logos/logo-sdt-light.svg',
|
||||||
svg: '',
|
svg: '/logos/logo-sdt-light.svg',
|
||||||
},
|
},
|
||||||
address: ['Cluj-Napoca', 'România'],
|
address: ['str. Unirii, nr. 3, ap. 26', 'Cluj-Napoca, Cluj 400417', 'România'],
|
||||||
website: '',
|
website: 'www.studiideteren.ro',
|
||||||
motto: '',
|
motto: 'ground truth, measured right',
|
||||||
defaultColors: STUDII_COLORS,
|
defaultColors: STUDII_COLORS,
|
||||||
},
|
},
|
||||||
group: {
|
group: {
|
||||||
@@ -100,9 +109,12 @@ export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
|
|||||||
name: 'Grup Companii',
|
name: 'Grup Companii',
|
||||||
accent: '#64748b',
|
accent: '#64748b',
|
||||||
logo: { png: '', svg: '' },
|
logo: { png: '', svg: '' },
|
||||||
slashGrey: { png: '', svg: '' },
|
slashGrey: {
|
||||||
|
png: 'https://beletage.ro/img/Grey-slash.png',
|
||||||
|
svg: 'https://beletage.ro/img/Grey-slash.svg',
|
||||||
|
},
|
||||||
slashAccent: { png: '', svg: '' },
|
slashAccent: { png: '', svg: '' },
|
||||||
address: ['Cluj-Napoca', 'România'],
|
address: ['Cluj-Napoca, Cluj', 'România'],
|
||||||
website: '',
|
website: '',
|
||||||
motto: '',
|
motto: '',
|
||||||
defaultColors: BELETAGE_COLORS,
|
defaultColors: BELETAGE_COLORS,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export function formatPhone(raw: string): { display: string; link: string } {
|
|||||||
|
|
||||||
export function generateSignatureHtml(config: SignatureConfig): string {
|
export function generateSignatureHtml(config: SignatureConfig): string {
|
||||||
const branding = getBranding(config.company);
|
const branding = getBranding(config.company);
|
||||||
|
const address = config.addressOverride ?? branding.address;
|
||||||
const { display: phone, link: phoneLink } = formatPhone(config.phone);
|
const { display: phone, link: phoneLink } = formatPhone(config.phone);
|
||||||
const images = config.useSvg
|
const images = config.useSvg
|
||||||
? { logo: branding.logo.svg, greySlash: branding.slashGrey.svg, accentSlash: branding.slashAccent.svg }
|
? { logo: branding.logo.svg, greySlash: branding.slashGrey.svg, accentSlash: branding.slashAccent.svg }
|
||||||
@@ -71,7 +72,7 @@ export function generateSignatureHtml(config: SignatureConfig): string {
|
|||||||
</td>
|
</td>
|
||||||
<td width="${spacerWidth}" style="width:${spacerWidth}px; font-size:0; line-height:0;"></td>
|
<td width="${spacerWidth}" style="width:${spacerWidth}px; font-size:0; line-height:0;"></td>
|
||||||
<td style="vertical-align:top; padding:0 0 0 ${textPaddingLeft}px;">
|
<td style="vertical-align:top; padding:0 0 0 ${textPaddingLeft}px;">
|
||||||
<span style="color:${colors.address}; text-decoration:none;">${branding.address.join('<br>')}</span>
|
<span style="color:${colors.address}; text-decoration:none;">${address.join('<br>')}</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export interface SignatureConfig {
|
|||||||
layout: SignatureLayout;
|
layout: SignatureLayout;
|
||||||
variant: SignatureVariant;
|
variant: SignatureVariant;
|
||||||
useSvg: boolean;
|
useSvg: boolean;
|
||||||
|
/** Override the default company address */
|
||||||
|
addressOverride?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SavedSignature {
|
export interface SavedSignature {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Textarea } from '@/shared/components/ui/textarea';
|
|||||||
import { Badge } from '@/shared/components/ui/badge';
|
import { Badge } from '@/shared/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog';
|
||||||
import type { CompanyId } from '@/core/auth/types';
|
import type { CompanyId } from '@/core/auth/types';
|
||||||
import type { InventoryItem, InventoryItemType, InventoryItemStatus } from '../types';
|
import type { InventoryItem, InventoryItemType, InventoryItemStatus } from '../types';
|
||||||
import { useInventory } from '../hooks/use-inventory';
|
import { useInventory } from '../hooks/use-inventory';
|
||||||
@@ -28,8 +29,9 @@ export function ItInventoryModule() {
|
|||||||
const { items, allItems, loading, filters, updateFilter, addItem, updateItem, removeItem } = useInventory();
|
const { items, allItems, loading, filters, updateFilter, addItem, updateItem, removeItem } = useInventory();
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||||
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
|
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleSubmit = async (data: Omit<InventoryItem, 'id' | 'createdAt'>) => {
|
const handleSubmit = async (data: Omit<InventoryItem, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||||
if (viewMode === 'edit' && editingItem) {
|
if (viewMode === 'edit' && editingItem) {
|
||||||
await updateItem(editingItem.id, data);
|
await updateItem(editingItem.id, data);
|
||||||
} else {
|
} else {
|
||||||
@@ -39,6 +41,13 @@ export function ItInventoryModule() {
|
|||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (deletingId) {
|
||||||
|
await removeItem(deletingId);
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
@@ -51,7 +60,6 @@ export function ItInventoryModule() {
|
|||||||
|
|
||||||
{viewMode === 'list' && (
|
{viewMode === 'list' && (
|
||||||
<>
|
<>
|
||||||
{/* Filters */}
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="relative min-w-[200px] flex-1">
|
<div className="relative min-w-[200px] flex-1">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
@@ -80,7 +88,6 @@ export function ItInventoryModule() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
|
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
@@ -91,7 +98,9 @@ export function ItInventoryModule() {
|
|||||||
<thead><tr className="border-b bg-muted/40">
|
<thead><tr className="border-b bg-muted/40">
|
||||||
<th className="px-3 py-2 text-left font-medium">Nume</th>
|
<th className="px-3 py-2 text-left font-medium">Nume</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Tip</th>
|
<th className="px-3 py-2 text-left font-medium">Tip</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Vendor/Model</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">S/N</th>
|
<th className="px-3 py-2 text-left font-medium">S/N</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">IP</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Atribuit</th>
|
<th className="px-3 py-2 text-left font-medium">Atribuit</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Locație</th>
|
<th className="px-3 py-2 text-left font-medium">Locație</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Status</th>
|
<th className="px-3 py-2 text-left font-medium">Status</th>
|
||||||
@@ -102,16 +111,22 @@ export function ItInventoryModule() {
|
|||||||
<tr key={item.id} className="border-b hover:bg-muted/20 transition-colors">
|
<tr key={item.id} className="border-b hover:bg-muted/20 transition-colors">
|
||||||
<td className="px-3 py-2 font-medium">{item.name}</td>
|
<td className="px-3 py-2 font-medium">{item.name}</td>
|
||||||
<td className="px-3 py-2"><Badge variant="outline">{TYPE_LABELS[item.type]}</Badge></td>
|
<td className="px-3 py-2"><Badge variant="outline">{TYPE_LABELS[item.type]}</Badge></td>
|
||||||
|
<td className="px-3 py-2 text-xs">
|
||||||
|
{item.vendor && <span>{item.vendor}</span>}
|
||||||
|
{item.vendor && item.model && <span className="text-muted-foreground"> / </span>}
|
||||||
|
{item.model && <span className="text-muted-foreground">{item.model}</span>}
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2 font-mono text-xs">{item.serialNumber}</td>
|
<td className="px-3 py-2 font-mono text-xs">{item.serialNumber}</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs">{item.ipAddress}</td>
|
||||||
<td className="px-3 py-2">{item.assignedTo}</td>
|
<td className="px-3 py-2">{item.assignedTo}</td>
|
||||||
<td className="px-3 py-2">{item.location}</td>
|
<td className="px-3 py-2 text-xs">{item.rackLocation || item.location}</td>
|
||||||
<td className="px-3 py-2"><Badge variant="secondary">{STATUS_LABELS[item.status]}</Badge></td>
|
<td className="px-3 py-2"><Badge variant="secondary">{STATUS_LABELS[item.status]}</Badge></td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingItem(item); setViewMode('edit'); }}>
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingItem(item); setViewMode('edit'); }}>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeItem(item.id)}>
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => setDeletingId(item.id)}>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,13 +152,25 @@ export function ItInventoryModule() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>Confirmare ștergere</DialogTitle></DialogHeader>
|
||||||
|
<p className="text-sm">Ești sigur 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InventoryForm({ initial, onSubmit, onCancel }: {
|
function InventoryForm({ initial, onSubmit, onCancel }: {
|
||||||
initial?: InventoryItem;
|
initial?: InventoryItem;
|
||||||
onSubmit: (data: Omit<InventoryItem, 'id' | 'createdAt'>) => void;
|
onSubmit: (data: Omit<InventoryItem, 'id' | 'createdAt' | 'updatedAt'>) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState(initial?.name ?? '');
|
const [name, setName] = useState(initial?.name ?? '');
|
||||||
@@ -154,12 +181,26 @@ function InventoryForm({ initial, onSubmit, onCancel }: {
|
|||||||
const [location, setLocation] = useState(initial?.location ?? '');
|
const [location, setLocation] = useState(initial?.location ?? '');
|
||||||
const [purchaseDate, setPurchaseDate] = useState(initial?.purchaseDate ?? '');
|
const [purchaseDate, setPurchaseDate] = useState(initial?.purchaseDate ?? '');
|
||||||
const [status, setStatus] = useState<InventoryItemStatus>(initial?.status ?? 'active');
|
const [status, setStatus] = useState<InventoryItemStatus>(initial?.status ?? 'active');
|
||||||
|
const [ipAddress, setIpAddress] = useState(initial?.ipAddress ?? '');
|
||||||
|
const [macAddress, setMacAddress] = useState(initial?.macAddress ?? '');
|
||||||
|
const [warrantyExpiry, setWarrantyExpiry] = useState(initial?.warrantyExpiry ?? '');
|
||||||
|
const [purchaseCost, setPurchaseCost] = useState(initial?.purchaseCost ?? '');
|
||||||
|
const [rackLocation, setRackLocation] = useState(initial?.rackLocation ?? '');
|
||||||
|
const [vendor, setVendor] = useState(initial?.vendor ?? '');
|
||||||
|
const [model, setModel] = useState(initial?.model ?? '');
|
||||||
const [notes, setNotes] = useState(initial?.notes ?? '');
|
const [notes, setNotes] = useState(initial?.notes ?? '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ name, type, serialNumber, assignedTo, company, location, purchaseDate, status, notes, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all' }); }} className="space-y-4">
|
<form onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit({
|
||||||
|
name, type, serialNumber, assignedTo, company, location, purchaseDate, status,
|
||||||
|
ipAddress, macAddress, warrantyExpiry, purchaseCost, rackLocation, vendor, model,
|
||||||
|
notes, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all',
|
||||||
|
});
|
||||||
|
}} className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div><Label>Nume echipament</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div>
|
<div><Label>Nume echipament *</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div>
|
||||||
<div><Label>Tip</Label>
|
<div><Label>Tip</Label>
|
||||||
<Select value={type} onValueChange={(v) => setType(v as InventoryItemType)}>
|
<Select value={type} onValueChange={(v) => setType(v as InventoryItemType)}>
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
@@ -167,11 +208,17 @@ function InventoryForm({ initial, onSubmit, onCancel }: {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div><Label>Vendor</Label><Input value={vendor} onChange={(e) => setVendor(e.target.value)} className="mt-1" placeholder="Dell, HP, Lenovo..." /></div>
|
||||||
|
<div><Label>Model</Label><Input value={model} onChange={(e) => setModel(e.target.value)} className="mt-1" /></div>
|
||||||
<div><Label>Număr serie</Label><Input value={serialNumber} onChange={(e) => setSerialNumber(e.target.value)} className="mt-1" /></div>
|
<div><Label>Număr serie</Label><Input value={serialNumber} onChange={(e) => setSerialNumber(e.target.value)} className="mt-1" /></div>
|
||||||
<div><Label>Atribuit</Label><Input value={assignedTo} onChange={(e) => setAssignedTo(e.target.value)} className="mt-1" /></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div><Label>Adresă IP</Label><Input value={ipAddress} onChange={(e) => setIpAddress(e.target.value)} className="mt-1" placeholder="192.168.1.x" /></div>
|
||||||
|
<div><Label>Adresă MAC</Label><Input value={macAddress} onChange={(e) => setMacAddress(e.target.value)} className="mt-1" placeholder="AA:BB:CC:DD:EE:FF" /></div>
|
||||||
|
<div><Label>Atribuit</Label><Input value={assignedTo} onChange={(e) => setAssignedTo(e.target.value)} className="mt-1" /></div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-4">
|
||||||
<div><Label>Companie</Label>
|
<div><Label>Companie</Label>
|
||||||
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
|
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
@@ -183,18 +230,21 @@ function InventoryForm({ initial, onSubmit, onCancel }: {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div><Label>Locație</Label><Input value={location} onChange={(e) => setLocation(e.target.value)} className="mt-1" /></div>
|
<div><Label>Locație / Cameră</Label><Input value={location} onChange={(e) => setLocation(e.target.value)} className="mt-1" /></div>
|
||||||
<div><Label>Data achiziție</Label><Input type="date" value={purchaseDate} onChange={(e) => setPurchaseDate(e.target.value)} className="mt-1" /></div>
|
<div><Label>Rack / Poziție</Label><Input value={rackLocation} onChange={(e) => setRackLocation(e.target.value)} className="mt-1" /></div>
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div><Label>Status</Label>
|
<div><Label>Status</Label>
|
||||||
<Select value={status} onValueChange={(v) => setStatus(v as InventoryItemStatus)}>
|
<Select value={status} onValueChange={(v) => setStatus(v as InventoryItemStatus)}>
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map((s) => (<SelectItem key={s} value={s}>{STATUS_LABELS[s]}</SelectItem>))}</SelectContent>
|
<SelectContent>{(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map((s) => (<SelectItem key={s} value={s}>{STATUS_LABELS[s]}</SelectItem>))}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div><Label>Data achiziție</Label><Input type="date" value={purchaseDate} onChange={(e) => setPurchaseDate(e.target.value)} className="mt-1" /></div>
|
||||||
|
<div><Label>Cost achiziție (RON)</Label><Input type="number" value={purchaseCost} onChange={(e) => setPurchaseCost(e.target.value)} className="mt-1" /></div>
|
||||||
|
<div><Label>Expirare garanție</Label><Input type="date" value={warrantyExpiry} onChange={(e) => setWarrantyExpiry(e.target.value)} className="mt-1" /></div>
|
||||||
|
</div>
|
||||||
|
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div>
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
||||||
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
|
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ export function useInventory() {
|
|||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
useEffect(() => { refresh(); }, [refresh]);
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
const addItem = useCallback(async (data: Omit<InventoryItem, 'id' | 'createdAt'>) => {
|
const addItem = useCallback(async (data: Omit<InventoryItem, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||||
const item: InventoryItem = { ...data, id: uuid(), createdAt: new Date().toISOString() };
|
const now = new Date().toISOString();
|
||||||
|
const item: InventoryItem = { ...data, id: uuid(), createdAt: now, updatedAt: now };
|
||||||
await storage.set(`${PREFIX}${item.id}`, item);
|
await storage.set(`${PREFIX}${item.id}`, item);
|
||||||
await refresh();
|
await refresh();
|
||||||
return item;
|
return item;
|
||||||
@@ -50,7 +51,11 @@ export function useInventory() {
|
|||||||
const updateItem = useCallback(async (id: string, updates: Partial<InventoryItem>) => {
|
const updateItem = useCallback(async (id: string, updates: Partial<InventoryItem>) => {
|
||||||
const existing = items.find((i) => i.id === id);
|
const existing = items.find((i) => i.id === id);
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
const updated = { ...existing, ...updates, id: existing.id, createdAt: existing.createdAt };
|
const updated: InventoryItem = {
|
||||||
|
...existing, ...updates,
|
||||||
|
id: existing.id, createdAt: existing.createdAt,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
await storage.set(`${PREFIX}${id}`, updated);
|
await storage.set(`${PREFIX}${id}`, updated);
|
||||||
await refresh();
|
await refresh();
|
||||||
}, [storage, refresh, items]);
|
}, [storage, refresh, items]);
|
||||||
@@ -70,7 +75,14 @@ export function useInventory() {
|
|||||||
if (filters.company !== 'all' && item.company !== filters.company) return false;
|
if (filters.company !== 'all' && item.company !== filters.company) return false;
|
||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
const q = filters.search.toLowerCase();
|
const q = filters.search.toLowerCase();
|
||||||
return item.name.toLowerCase().includes(q) || item.serialNumber.toLowerCase().includes(q) || item.assignedTo.toLowerCase().includes(q);
|
return (
|
||||||
|
item.name.toLowerCase().includes(q) ||
|
||||||
|
item.serialNumber.toLowerCase().includes(q) ||
|
||||||
|
item.assignedTo.toLowerCase().includes(q) ||
|
||||||
|
item.ipAddress.toLowerCase().includes(q) ||
|
||||||
|
item.vendor.toLowerCase().includes(q) ||
|
||||||
|
item.model.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,8 +28,23 @@ export interface InventoryItem {
|
|||||||
location: string;
|
location: string;
|
||||||
purchaseDate: string;
|
purchaseDate: string;
|
||||||
status: InventoryItemStatus;
|
status: InventoryItemStatus;
|
||||||
|
/** IP address */
|
||||||
|
ipAddress: string;
|
||||||
|
/** MAC address */
|
||||||
|
macAddress: string;
|
||||||
|
/** Warranty expiry date (YYYY-MM-DD) */
|
||||||
|
warrantyExpiry: string;
|
||||||
|
/** Purchase cost (RON) */
|
||||||
|
purchaseCost: string;
|
||||||
|
/** Room / rack position */
|
||||||
|
rackLocation: string;
|
||||||
|
/** Vendor / manufacturer */
|
||||||
|
vendor: string;
|
||||||
|
/** Model name/number */
|
||||||
|
model: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
notes: string;
|
notes: string;
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Plus, Pencil, Trash2, Search, Eye, EyeOff, Copy, ExternalLink } from 'lucide-react';
|
import {
|
||||||
|
Plus, Pencil, Trash2, Search, Eye, EyeOff, Copy, ExternalLink,
|
||||||
|
KeyRound, X,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Button } from '@/shared/components/ui/button';
|
import { Button } from '@/shared/components/ui/button';
|
||||||
import { Input } from '@/shared/components/ui/input';
|
import { Input } from '@/shared/components/ui/input';
|
||||||
import { Label } from '@/shared/components/ui/label';
|
import { Label } from '@/shared/components/ui/label';
|
||||||
@@ -9,7 +12,9 @@ import { Textarea } from '@/shared/components/ui/textarea';
|
|||||||
import { Badge } from '@/shared/components/ui/badge';
|
import { Badge } from '@/shared/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
||||||
import type { VaultEntry, VaultEntryCategory } from '../types';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog';
|
||||||
|
import { Switch } from '@/shared/components/ui/switch';
|
||||||
|
import type { VaultEntry, VaultEntryCategory, CustomField } from '../types';
|
||||||
import { useVault } from '../hooks/use-vault';
|
import { useVault } from '../hooks/use-vault';
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<VaultEntryCategory, string> = {
|
const CATEGORY_LABELS: Record<VaultEntryCategory, string> = {
|
||||||
@@ -18,12 +23,28 @@ const CATEGORY_LABELS: Record<VaultEntryCategory, string> = {
|
|||||||
|
|
||||||
type ViewMode = 'list' | 'add' | 'edit';
|
type ViewMode = 'list' | 'add' | 'edit';
|
||||||
|
|
||||||
|
/** Generate a random password */
|
||||||
|
function generatePassword(length: number, options: { upper: boolean; lower: boolean; digits: boolean; symbols: boolean }): string {
|
||||||
|
let chars = '';
|
||||||
|
if (options.upper) chars += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
if (options.lower) chars += 'abcdefghijklmnopqrstuvwxyz';
|
||||||
|
if (options.digits) chars += '0123456789';
|
||||||
|
if (options.symbols) chars += '!@#$%^&*()-_=+[]{}|;:,.<>?';
|
||||||
|
if (!chars) chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function PasswordVaultModule() {
|
export function PasswordVaultModule() {
|
||||||
const { entries, allEntries, loading, filters, updateFilter, addEntry, updateEntry, removeEntry } = useVault();
|
const { entries, allEntries, loading, filters, updateFilter, addEntry, updateEntry, removeEntry } = useVault();
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||||
const [editingEntry, setEditingEntry] = useState<VaultEntry | null>(null);
|
const [editingEntry, setEditingEntry] = useState<VaultEntry | null>(null);
|
||||||
const [visiblePasswords, setVisiblePasswords] = useState<Set<string>>(new Set());
|
const [visiblePasswords, setVisiblePasswords] = useState<Set<string>>(new Set());
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const togglePassword = (id: string) => {
|
const togglePassword = (id: string) => {
|
||||||
setVisiblePasswords((prev) => {
|
setVisiblePasswords((prev) => {
|
||||||
@@ -51,6 +72,13 @@ export function PasswordVaultModule() {
|
|||||||
setEditingEntry(null);
|
setEditingEntry(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (deletingId) {
|
||||||
|
await removeEntry(deletingId);
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-2 text-xs text-amber-700 dark:text-amber-400">
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-2 text-xs text-amber-700 dark:text-amber-400">
|
||||||
@@ -118,12 +146,21 @@ export function PasswordVaultModule() {
|
|||||||
<ExternalLink className="h-3 w-3" /> {entry.url}
|
<ExternalLink className="h-3 w-3" /> {entry.url}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{entry.customFields && entry.customFields.length > 0 && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{entry.customFields.map((cf, i) => (
|
||||||
|
<Badge key={i} variant="secondary" className="text-[10px]">
|
||||||
|
{cf.key}: {cf.value}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingEntry(entry); setViewMode('edit'); }}>
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingEntry(entry); setViewMode('edit'); }}>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeEntry(entry.id)}>
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => setDeletingId(entry.id)}>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,6 +180,18 @@ export function PasswordVaultModule() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>Confirmare ștergere</DialogTitle></DialogHeader>
|
||||||
|
<p className="text-sm">Ești sigur 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -158,11 +207,42 @@ function VaultForm({ initial, onSubmit, onCancel }: {
|
|||||||
const [url, setUrl] = useState(initial?.url ?? '');
|
const [url, setUrl] = useState(initial?.url ?? '');
|
||||||
const [category, setCategory] = useState<VaultEntryCategory>(initial?.category ?? 'web');
|
const [category, setCategory] = useState<VaultEntryCategory>(initial?.category ?? 'web');
|
||||||
const [notes, setNotes] = useState(initial?.notes ?? '');
|
const [notes, setNotes] = useState(initial?.notes ?? '');
|
||||||
|
const [customFields, setCustomFields] = useState<CustomField[]>(initial?.customFields ?? []);
|
||||||
|
|
||||||
|
// Password generator state
|
||||||
|
const [genLength, setGenLength] = useState(16);
|
||||||
|
const [genUpper, setGenUpper] = useState(true);
|
||||||
|
const [genLower, setGenLower] = useState(true);
|
||||||
|
const [genDigits, setGenDigits] = useState(true);
|
||||||
|
const [genSymbols, setGenSymbols] = useState(true);
|
||||||
|
|
||||||
|
const handleGenerate = () => {
|
||||||
|
setPassword(generatePassword(genLength, { upper: genUpper, lower: genLower, digits: genDigits, symbols: genSymbols }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCustomField = () => {
|
||||||
|
setCustomFields([...customFields, { key: '', value: '' }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCustomField = (index: number, field: keyof CustomField, value: string) => {
|
||||||
|
setCustomFields(customFields.map((cf, i) => i === index ? { ...cf, [field]: value } : cf));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCustomField = (index: number) => {
|
||||||
|
setCustomFields(customFields.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ label, username, encryptedPassword: password, url, category, notes, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'admin' }); }} className="space-y-4">
|
<form onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit({
|
||||||
|
label, username, encryptedPassword: password, url, category, notes,
|
||||||
|
customFields: customFields.filter((cf) => cf.key.trim()),
|
||||||
|
tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'admin',
|
||||||
|
});
|
||||||
|
}} className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div><Label>Nume/Etichetă</Label><Input value={label} onChange={(e) => setLabel(e.target.value)} className="mt-1" required /></div>
|
<div><Label>Nume/Etichetă *</Label><Input value={label} onChange={(e) => setLabel(e.target.value)} className="mt-1" required /></div>
|
||||||
<div><Label>Categorie</Label>
|
<div><Label>Categorie</Label>
|
||||||
<Select value={category} onValueChange={(v) => setCategory(v as VaultEntryCategory)}>
|
<Select value={category} onValueChange={(v) => setCategory(v as VaultEntryCategory)}>
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
@@ -172,9 +252,60 @@ function VaultForm({ initial, onSubmit, onCancel }: {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div><Label>Utilizator</Label><Input value={username} onChange={(e) => setUsername(e.target.value)} className="mt-1" /></div>
|
<div><Label>Utilizator</Label><Input value={username} onChange={(e) => setUsername(e.target.value)} className="mt-1" /></div>
|
||||||
<div><Label>Parolă</Label><Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="mt-1" /></div>
|
<div>
|
||||||
|
<Label>Parolă</Label>
|
||||||
|
<div className="mt-1 flex gap-1.5">
|
||||||
|
<Input type="text" value={password} onChange={(e) => setPassword(e.target.value)} className="flex-1 font-mono text-sm" />
|
||||||
|
<Button type="button" variant="outline" size="icon" onClick={handleGenerate} title="Generează parolă">
|
||||||
|
<KeyRound className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Password generator options */}
|
||||||
|
<div className="rounded border p-3 space-y-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Generator parolă</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-xs">Lungime:</Label>
|
||||||
|
<Input type="number" value={genLength} onChange={(e) => setGenLength(parseInt(e.target.value, 10) || 8)} className="w-16 text-sm" min={4} max={64} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5"><Switch checked={genUpper} onCheckedChange={setGenUpper} id="gen-upper" /><Label htmlFor="gen-upper" className="text-xs cursor-pointer">A-Z</Label></div>
|
||||||
|
<div className="flex items-center gap-1.5"><Switch checked={genLower} onCheckedChange={setGenLower} id="gen-lower" /><Label htmlFor="gen-lower" className="text-xs cursor-pointer">a-z</Label></div>
|
||||||
|
<div className="flex items-center gap-1.5"><Switch checked={genDigits} onCheckedChange={setGenDigits} id="gen-digits" /><Label htmlFor="gen-digits" className="text-xs cursor-pointer">0-9</Label></div>
|
||||||
|
<div className="flex items-center gap-1.5"><Switch checked={genSymbols} onCheckedChange={setGenSymbols} id="gen-symbols" /><Label htmlFor="gen-symbols" className="text-xs cursor-pointer">!@#$</Label></div>
|
||||||
|
<Button type="button" variant="secondary" size="sm" onClick={handleGenerate}>
|
||||||
|
<KeyRound className="mr-1 h-3 w-3" /> Generează
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div><Label>URL</Label><Input value={url} onChange={(e) => setUrl(e.target.value)} className="mt-1" placeholder="https://..." /></div>
|
<div><Label>URL</Label><Input value={url} onChange={(e) => setUrl(e.target.value)} className="mt-1" placeholder="https://..." /></div>
|
||||||
|
|
||||||
|
{/* Custom fields */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Câmpuri personalizate</Label>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addCustomField}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" /> Adaugă câmp
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{customFields.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1.5">
|
||||||
|
{customFields.map((cf, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<Input placeholder="Cheie" value={cf.key} onChange={(e) => updateCustomField(i, 'key', e.target.value)} className="w-[140px] text-sm" />
|
||||||
|
<Input placeholder="Valoare" value={cf.value} onChange={(e) => updateCustomField(i, 'value', e.target.value)} className="flex-1 text-sm" />
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeCustomField(i)}>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div>
|
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div>
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ export type VaultEntryCategory =
|
|||||||
| 'api'
|
| 'api'
|
||||||
| 'other';
|
| 'other';
|
||||||
|
|
||||||
|
/** Custom key-value field */
|
||||||
|
export interface CustomField {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VaultEntry {
|
export interface VaultEntry {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -15,6 +21,8 @@ export interface VaultEntry {
|
|||||||
encryptedPassword: string;
|
encryptedPassword: string;
|
||||||
url: string;
|
url: string;
|
||||||
category: VaultEntryCategory;
|
category: VaultEntryCategory;
|
||||||
|
/** Custom key-value fields */
|
||||||
|
customFields: CustomField[];
|
||||||
notes: string;
|
notes: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import { Plus } from 'lucide-react';
|
|||||||
import { Button } from '@/shared/components/ui/button';
|
import { Button } from '@/shared/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
||||||
import { Badge } from '@/shared/components/ui/badge';
|
import { Badge } from '@/shared/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||||
|
} from '@/shared/components/ui/dialog';
|
||||||
import { useRegistry } from '../hooks/use-registry';
|
import { useRegistry } from '../hooks/use-registry';
|
||||||
import { RegistryFilters } from './registry-filters';
|
import { RegistryFilters } from './registry-filters';
|
||||||
import { RegistryTable } from './registry-table';
|
import { RegistryTable } from './registry-table';
|
||||||
import { RegistryEntryForm } from './registry-entry-form';
|
import { RegistryEntryForm } from './registry-entry-form';
|
||||||
|
import { getOverdueDays } from '../services/registry-service';
|
||||||
import type { RegistryEntry } from '../types';
|
import type { RegistryEntry } from '../types';
|
||||||
|
|
||||||
type ViewMode = 'list' | 'add' | 'edit';
|
type ViewMode = 'list' | 'add' | 'edit';
|
||||||
@@ -16,11 +20,12 @@ type ViewMode = 'list' | 'add' | 'edit';
|
|||||||
export function RegistraturaModule() {
|
export function RegistraturaModule() {
|
||||||
const {
|
const {
|
||||||
entries, allEntries, loading, filters, updateFilter,
|
entries, allEntries, loading, filters, updateFilter,
|
||||||
addEntry, updateEntry, removeEntry,
|
addEntry, updateEntry, removeEntry, closeEntry,
|
||||||
} = useRegistry();
|
} = useRegistry();
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||||
const [editingEntry, setEditingEntry] = useState<RegistryEntry | null>(null);
|
const [editingEntry, setEditingEntry] = useState<RegistryEntry | null>(null);
|
||||||
|
const [closingId, setClosingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleAdd = async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => {
|
const handleAdd = async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => {
|
||||||
await addEntry(data);
|
await addEntry(data);
|
||||||
@@ -43,6 +48,22 @@ export function RegistraturaModule() {
|
|||||||
await removeEntry(id);
|
await removeEntry(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCloseRequest = (id: string) => {
|
||||||
|
const entry = allEntries.find((e) => e.id === id);
|
||||||
|
if (entry && entry.linkedEntryIds.length > 0) {
|
||||||
|
setClosingId(id);
|
||||||
|
} else {
|
||||||
|
closeEntry(id, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseConfirm = (closeLinked: boolean) => {
|
||||||
|
if (closingId) {
|
||||||
|
closeEntry(closingId, closeLinked);
|
||||||
|
setClosingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setViewMode('list');
|
setViewMode('list');
|
||||||
setEditingEntry(null);
|
setEditingEntry(null);
|
||||||
@@ -50,18 +71,24 @@ export function RegistraturaModule() {
|
|||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
const total = allEntries.length;
|
const total = allEntries.length;
|
||||||
const incoming = allEntries.filter((e) => e.type === 'incoming').length;
|
const open = allEntries.filter((e) => e.status === 'deschis').length;
|
||||||
const outgoing = allEntries.filter((e) => e.type === 'outgoing').length;
|
const overdue = allEntries.filter((e) => {
|
||||||
const inProgress = allEntries.filter((e) => e.status === 'in-progress').length;
|
if (e.status !== 'deschis') return false;
|
||||||
|
const days = getOverdueDays(e.deadline);
|
||||||
|
return days !== null && days > 0;
|
||||||
|
}).length;
|
||||||
|
const intrat = allEntries.filter((e) => e.direction === 'intrat').length;
|
||||||
|
|
||||||
|
const closingEntry = closingId ? allEntries.find((e) => e.id === closingId) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<StatCard label="Total" value={total} />
|
<StatCard label="Total" value={total} />
|
||||||
<StatCard label="Intrare" value={incoming} />
|
<StatCard label="Deschise" value={open} />
|
||||||
<StatCard label="Ieșire" value={outgoing} />
|
<StatCard label="Depășite" value={overdue} variant={overdue > 0 ? 'destructive' : undefined} />
|
||||||
<StatCard label="În lucru" value={inProgress} />
|
<StatCard label="Intrate" value={intrat} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewMode === 'list' && (
|
{viewMode === 'list' && (
|
||||||
@@ -78,6 +105,7 @@ export function RegistraturaModule() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onClose={handleCloseRequest}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!loading && (
|
{!loading && (
|
||||||
@@ -97,7 +125,11 @@ export function RegistraturaModule() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<RegistryEntryForm onSubmit={handleAdd} onCancel={handleCancel} />
|
<RegistryEntryForm
|
||||||
|
allEntries={allEntries}
|
||||||
|
onSubmit={handleAdd}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -108,20 +140,51 @@ export function RegistraturaModule() {
|
|||||||
<CardTitle>Editare — {editingEntry.number}</CardTitle>
|
<CardTitle>Editare — {editingEntry.number}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<RegistryEntryForm initial={editingEntry} onSubmit={handleUpdate} onCancel={handleCancel} />
|
<RegistryEntryForm
|
||||||
|
initial={editingEntry}
|
||||||
|
allEntries={allEntries}
|
||||||
|
onSubmit={handleUpdate}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Close confirmation dialog */}
|
||||||
|
<Dialog open={closingId !== null} onOpenChange={(open) => { if (!open) setClosingId(null); }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Închide înregistrarea</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2">
|
||||||
|
<p className="text-sm">
|
||||||
|
Această înregistrare are {closingEntry?.linkedEntryIds.length ?? 0} înregistrări legate.
|
||||||
|
Vrei să le închizi și pe acestea?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setClosingId(null)}>Anulează</Button>
|
||||||
|
<Button variant="secondary" onClick={() => handleCloseConfirm(false)}>
|
||||||
|
Doar aceasta
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => handleCloseConfirm(true)}>
|
||||||
|
Închide toate legate
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ label, value }: { label: string; value: number }) {
|
function StatCard({ label, value, variant }: { label: string; value: number; variant?: 'destructive' }) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
<p className="text-2xl font-bold">{value}</p>
|
<p className={`text-2xl font-bold ${variant === 'destructive' && value > 0 ? 'text-destructive' : ''}`}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,40 +1,116 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useMemo, useRef } from 'react';
|
||||||
|
import { Paperclip, X } from 'lucide-react';
|
||||||
import type { CompanyId } from '@/core/auth/types';
|
import type { CompanyId } from '@/core/auth/types';
|
||||||
import type { RegistryEntry, RegistryEntryType, RegistryEntryStatus } from '../types';
|
import type { RegistryEntry, RegistryDirection, RegistryStatus, DocumentType, RegistryAttachment } from '../types';
|
||||||
import { Input } from '@/shared/components/ui/input';
|
import { Input } from '@/shared/components/ui/input';
|
||||||
import { Label } from '@/shared/components/ui/label';
|
import { Label } from '@/shared/components/ui/label';
|
||||||
import { Textarea } from '@/shared/components/ui/textarea';
|
import { Textarea } from '@/shared/components/ui/textarea';
|
||||||
import { Button } from '@/shared/components/ui/button';
|
import { Button } from '@/shared/components/ui/button';
|
||||||
|
import { Badge } from '@/shared/components/ui/badge';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
||||||
|
import { useContacts } from '@/modules/address-book/hooks/use-contacts';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
interface RegistryEntryFormProps {
|
interface RegistryEntryFormProps {
|
||||||
initial?: RegistryEntry;
|
initial?: RegistryEntry;
|
||||||
|
allEntries?: RegistryEntry[];
|
||||||
onSubmit: (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => void;
|
onSubmit: (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RegistryEntryForm({ initial, onSubmit, onCancel }: RegistryEntryFormProps) {
|
const DOC_TYPE_LABELS: Record<DocumentType, string> = {
|
||||||
const [type, setType] = useState<RegistryEntryType>(initial?.type ?? 'incoming');
|
contract: 'Contract',
|
||||||
|
oferta: 'Ofertă',
|
||||||
|
factura: 'Factură',
|
||||||
|
scrisoare: 'Scrisoare',
|
||||||
|
aviz: 'Aviz',
|
||||||
|
'nota-de-comanda': 'Notă de comandă',
|
||||||
|
raport: 'Raport',
|
||||||
|
cerere: 'Cerere',
|
||||||
|
altele: 'Altele',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: RegistryEntryFormProps) {
|
||||||
|
const { allContacts } = useContacts();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [direction, setDirection] = useState<RegistryDirection>(initial?.direction ?? 'intrat');
|
||||||
|
const [documentType, setDocumentType] = useState<DocumentType>(initial?.documentType ?? 'scrisoare');
|
||||||
const [subject, setSubject] = useState(initial?.subject ?? '');
|
const [subject, setSubject] = useState(initial?.subject ?? '');
|
||||||
const [date, setDate] = useState(initial?.date ?? new Date().toISOString().slice(0, 10));
|
const [date, setDate] = useState(initial?.date ?? new Date().toISOString().slice(0, 10));
|
||||||
const [sender, setSender] = useState(initial?.sender ?? '');
|
const [sender, setSender] = useState(initial?.sender ?? '');
|
||||||
|
const [senderContactId, setSenderContactId] = useState(initial?.senderContactId ?? '');
|
||||||
const [recipient, setRecipient] = useState(initial?.recipient ?? '');
|
const [recipient, setRecipient] = useState(initial?.recipient ?? '');
|
||||||
|
const [recipientContactId, setRecipientContactId] = useState(initial?.recipientContactId ?? '');
|
||||||
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
|
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
|
||||||
const [status, setStatus] = useState<RegistryEntryStatus>(initial?.status ?? 'registered');
|
const [status, setStatus] = useState<RegistryStatus>(initial?.status ?? 'deschis');
|
||||||
|
const [deadline, setDeadline] = useState(initial?.deadline ?? '');
|
||||||
const [notes, setNotes] = useState(initial?.notes ?? '');
|
const [notes, setNotes] = useState(initial?.notes ?? '');
|
||||||
|
const [linkedEntryIds, setLinkedEntryIds] = useState<string[]>(initial?.linkedEntryIds ?? []);
|
||||||
|
const [attachments, setAttachments] = useState<RegistryAttachment[]>(initial?.attachments ?? []);
|
||||||
|
|
||||||
|
// ── Sender/Recipient autocomplete suggestions ──
|
||||||
|
const [senderFocused, setSenderFocused] = useState(false);
|
||||||
|
const [recipientFocused, setRecipientFocused] = useState(false);
|
||||||
|
|
||||||
|
const senderSuggestions = useMemo(() => {
|
||||||
|
if (!sender || sender.length < 2) return [];
|
||||||
|
const q = sender.toLowerCase();
|
||||||
|
return allContacts.filter((c) => c.name.toLowerCase().includes(q) || c.company.toLowerCase().includes(q)).slice(0, 5);
|
||||||
|
}, [allContacts, sender]);
|
||||||
|
|
||||||
|
const recipientSuggestions = useMemo(() => {
|
||||||
|
if (!recipient || recipient.length < 2) return [];
|
||||||
|
const q = recipient.toLowerCase();
|
||||||
|
return allContacts.filter((c) => c.name.toLowerCase().includes(q) || c.company.toLowerCase().includes(q)).slice(0, 5);
|
||||||
|
}, [allContacts, recipient]);
|
||||||
|
|
||||||
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files) return;
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const base64 = reader.result as string;
|
||||||
|
setAttachments((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: uuid(),
|
||||||
|
name: file.name,
|
||||||
|
data: base64,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
addedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAttachment = (id: string) => {
|
||||||
|
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSubmit({
|
onSubmit({
|
||||||
type,
|
direction,
|
||||||
|
documentType,
|
||||||
subject,
|
subject,
|
||||||
date,
|
date,
|
||||||
sender,
|
sender,
|
||||||
|
senderContactId: senderContactId || undefined,
|
||||||
recipient,
|
recipient,
|
||||||
|
recipientContactId: recipientContactId || undefined,
|
||||||
company,
|
company,
|
||||||
status,
|
status,
|
||||||
|
deadline: deadline || undefined,
|
||||||
|
linkedEntryIds,
|
||||||
|
attachments,
|
||||||
notes,
|
notes,
|
||||||
tags: initial?.tags ?? [],
|
tags: initial?.tags ?? [],
|
||||||
visibility: initial?.visibility ?? 'all',
|
visibility: initial?.visibility ?? 'all',
|
||||||
@@ -43,15 +119,26 @@ export function RegistryEntryForm({ initial, onSubmit, onCancel }: RegistryEntry
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
{/* Row 1: Direction + Document type + Date */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>Tip document</Label>
|
<Label>Direcție</Label>
|
||||||
<Select value={type} onValueChange={(v) => setType(v as RegistryEntryType)}>
|
<Select value={direction} onValueChange={(v) => setDirection(v as RegistryDirection)}>
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="incoming">Intrare</SelectItem>
|
<SelectItem value="intrat">Intrat</SelectItem>
|
||||||
<SelectItem value="outgoing">Ieșire</SelectItem>
|
<SelectItem value="iesit">Ieșit</SelectItem>
|
||||||
<SelectItem value="internal">Intern</SelectItem>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Tip document</Label>
|
||||||
|
<Select value={documentType} onValueChange={(v) => setDocumentType(v as DocumentType)}>
|
||||||
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.entries(DOC_TYPE_LABELS) as [DocumentType, string][]).map(([key, label]) => (
|
||||||
|
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,23 +148,78 @@ export function RegistryEntryForm({ initial, onSubmit, onCancel }: RegistryEntry
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Subject */}
|
||||||
<div>
|
<div>
|
||||||
<Label>Subiect</Label>
|
<Label>Subiect *</Label>
|
||||||
<Input value={subject} onChange={(e) => setSubject(e.target.value)} className="mt-1" required />
|
<Input value={subject} onChange={(e) => setSubject(e.target.value)} className="mt-1" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sender / Recipient with autocomplete */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div className="relative">
|
||||||
<Label>Expeditor</Label>
|
<Label>Expeditor</Label>
|
||||||
<Input value={sender} onChange={(e) => setSender(e.target.value)} className="mt-1" />
|
<Input
|
||||||
|
value={sender}
|
||||||
|
onChange={(e) => { setSender(e.target.value); setSenderContactId(''); }}
|
||||||
|
onFocus={() => setSenderFocused(true)}
|
||||||
|
onBlur={() => setTimeout(() => setSenderFocused(false), 200)}
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="Nume sau companie..."
|
||||||
|
/>
|
||||||
|
{senderFocused && senderSuggestions.length > 0 && (
|
||||||
|
<div className="absolute z-10 mt-1 w-full rounded-md border bg-popover p-1 shadow-md">
|
||||||
|
{senderSuggestions.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
|
||||||
|
onMouseDown={() => {
|
||||||
|
setSender(c.company ? `${c.name} (${c.company})` : c.name);
|
||||||
|
setSenderContactId(c.id);
|
||||||
|
setSenderFocused(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{c.name}</span>
|
||||||
|
{c.company && <span className="ml-1 text-muted-foreground text-xs">{c.company}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="relative">
|
||||||
<Label>Destinatar</Label>
|
<Label>Destinatar</Label>
|
||||||
<Input value={recipient} onChange={(e) => setRecipient(e.target.value)} className="mt-1" />
|
<Input
|
||||||
|
value={recipient}
|
||||||
|
onChange={(e) => { setRecipient(e.target.value); setRecipientContactId(''); }}
|
||||||
|
onFocus={() => setRecipientFocused(true)}
|
||||||
|
onBlur={() => setTimeout(() => setRecipientFocused(false), 200)}
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="Nume sau companie..."
|
||||||
|
/>
|
||||||
|
{recipientFocused && recipientSuggestions.length > 0 && (
|
||||||
|
<div className="absolute z-10 mt-1 w-full rounded-md border bg-popover p-1 shadow-md">
|
||||||
|
{recipientSuggestions.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
|
||||||
|
onMouseDown={() => {
|
||||||
|
setRecipient(c.company ? `${c.name} (${c.company})` : c.name);
|
||||||
|
setRecipientContactId(c.id);
|
||||||
|
setRecipientFocused(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{c.name}</span>
|
||||||
|
{c.company && <span className="ml-1 text-muted-foreground text-xs">{c.company}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
{/* Company + Status + Deadline */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>Companie</Label>
|
<Label>Companie</Label>
|
||||||
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
|
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
|
||||||
@@ -92,18 +234,85 @@ export function RegistryEntryForm({ initial, onSubmit, onCancel }: RegistryEntry
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Status</Label>
|
<Label>Status</Label>
|
||||||
<Select value={status} onValueChange={(v) => setStatus(v as RegistryEntryStatus)}>
|
<Select value={status} onValueChange={(v) => setStatus(v as RegistryStatus)}>
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="registered">Înregistrat</SelectItem>
|
<SelectItem value="deschis">Deschis</SelectItem>
|
||||||
<SelectItem value="in-progress">În lucru</SelectItem>
|
<SelectItem value="inchis">Închis</SelectItem>
|
||||||
<SelectItem value="completed">Finalizat</SelectItem>
|
|
||||||
<SelectItem value="archived">Arhivat</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Termen limită</Label>
|
||||||
|
<Input type="date" value={deadline} onChange={(e) => setDeadline(e.target.value)} className="mt-1" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Linked entries */}
|
||||||
|
{allEntries && allEntries.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label>Înregistrări legate</Label>
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||||
|
{allEntries
|
||||||
|
.filter((e) => e.id !== initial?.id)
|
||||||
|
.slice(0, 20)
|
||||||
|
.map((e) => (
|
||||||
|
<button
|
||||||
|
key={e.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setLinkedEntryIds((prev) =>
|
||||||
|
prev.includes(e.id) ? prev.filter((id) => id !== e.id) : [...prev, e.id]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className={`rounded border px-2 py-0.5 text-xs transition-colors ${
|
||||||
|
linkedEntryIds.includes(e.id)
|
||||||
|
? 'border-primary bg-primary/10 text-primary'
|
||||||
|
: 'border-muted-foreground/30 text-muted-foreground hover:border-primary/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{e.number}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attachments */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Atașamente</Label>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||||
|
<Paperclip className="mr-1 h-3.5 w-3.5" /> Adaugă fișier
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.xls,.xlsx"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{attachments.map((att) => (
|
||||||
|
<div key={att.id} className="flex items-center gap-2 rounded border px-2 py-1 text-sm">
|
||||||
|
<Paperclip className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="flex-1 truncate">{att.name}</span>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{(att.size / 1024).toFixed(0)} KB
|
||||||
|
</Badge>
|
||||||
|
<button type="button" onClick={() => removeAttachment(att.id)} className="text-destructive">
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
<div>
|
<div>
|
||||||
<Label>Note</Label>
|
<Label>Note</Label>
|
||||||
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={3} className="mt-1" />
|
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={3} className="mt-1" />
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ interface RegistryFiltersProps {
|
|||||||
onUpdate: <K extends keyof Filters>(key: K, value: Filters[K]) => void;
|
onUpdate: <K extends keyof Filters>(key: K, value: Filters[K]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||||
|
contract: 'Contract',
|
||||||
|
oferta: 'Ofertă',
|
||||||
|
factura: 'Factură',
|
||||||
|
scrisoare: 'Scrisoare',
|
||||||
|
aviz: 'Aviz',
|
||||||
|
'nota-de-comanda': 'Notă de comandă',
|
||||||
|
raport: 'Raport',
|
||||||
|
cerere: 'Cerere',
|
||||||
|
altele: 'Altele',
|
||||||
|
};
|
||||||
|
|
||||||
export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) {
|
export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
@@ -23,28 +35,37 @@ export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select value={filters.type} onValueChange={(v) => onUpdate('type', v as Filters['type'])}>
|
<Select value={filters.direction} onValueChange={(v) => onUpdate('direction', v as Filters['direction'])}>
|
||||||
<SelectTrigger className="w-[150px]">
|
<SelectTrigger className="w-[130px]">
|
||||||
<SelectValue placeholder="Tip" />
|
<SelectValue placeholder="Direcție" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Toate</SelectItem>
|
||||||
|
<SelectItem value="intrat">Intrat</SelectItem>
|
||||||
|
<SelectItem value="iesit">Ieșit</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={filters.documentType} onValueChange={(v) => onUpdate('documentType', v as Filters['documentType'])}>
|
||||||
|
<SelectTrigger className="w-[160px]">
|
||||||
|
<SelectValue placeholder="Tip document" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Toate tipurile</SelectItem>
|
<SelectItem value="all">Toate tipurile</SelectItem>
|
||||||
<SelectItem value="incoming">Intrare</SelectItem>
|
{Object.entries(DOC_TYPE_LABELS).map(([key, label]) => (
|
||||||
<SelectItem value="outgoing">Ieșire</SelectItem>
|
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||||
<SelectItem value="internal">Intern</SelectItem>
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={filters.status} onValueChange={(v) => onUpdate('status', v as Filters['status'])}>
|
<Select value={filters.status} onValueChange={(v) => onUpdate('status', v as Filters['status'])}>
|
||||||
<SelectTrigger className="w-[150px]">
|
<SelectTrigger className="w-[130px]">
|
||||||
<SelectValue placeholder="Status" />
|
<SelectValue placeholder="Status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Toate</SelectItem>
|
<SelectItem value="all">Toate</SelectItem>
|
||||||
<SelectItem value="registered">Înregistrat</SelectItem>
|
<SelectItem value="deschis">Deschis</SelectItem>
|
||||||
<SelectItem value="in-progress">În lucru</SelectItem>
|
<SelectItem value="inchis">Închis</SelectItem>
|
||||||
<SelectItem value="completed">Finalizat</SelectItem>
|
|
||||||
<SelectItem value="archived">Arhivat</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Pencil, Trash2 } from 'lucide-react';
|
import { Pencil, Trash2, CheckCircle2, Link2 } from 'lucide-react';
|
||||||
import { Button } from '@/shared/components/ui/button';
|
import { Button } from '@/shared/components/ui/button';
|
||||||
import { Badge } from '@/shared/components/ui/badge';
|
import { Badge } from '@/shared/components/ui/badge';
|
||||||
import type { RegistryEntry } from '../types';
|
import type { RegistryEntry, DocumentType } from '../types';
|
||||||
|
import { getOverdueDays } from '../services/registry-service';
|
||||||
import { cn } from '@/shared/lib/utils';
|
import { cn } from '@/shared/lib/utils';
|
||||||
|
|
||||||
interface RegistryTableProps {
|
interface RegistryTableProps {
|
||||||
@@ -11,29 +12,32 @@ interface RegistryTableProps {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
onEdit: (entry: RegistryEntry) => void;
|
onEdit: (entry: RegistryEntry) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
|
onClose: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_LABELS: Record<string, string> = {
|
const DIRECTION_LABELS: Record<string, string> = {
|
||||||
incoming: 'Intrare',
|
intrat: 'Intrat',
|
||||||
outgoing: 'Ieșire',
|
iesit: 'Ieșit',
|
||||||
internal: 'Intern',
|
};
|
||||||
|
|
||||||
|
const DOC_TYPE_LABELS: Record<DocumentType, string> = {
|
||||||
|
contract: 'Contract',
|
||||||
|
oferta: 'Ofertă',
|
||||||
|
factura: 'Factură',
|
||||||
|
scrisoare: 'Scrisoare',
|
||||||
|
aviz: 'Aviz',
|
||||||
|
'nota-de-comanda': 'Notă comandă',
|
||||||
|
raport: 'Raport',
|
||||||
|
cerere: 'Cerere',
|
||||||
|
altele: 'Altele',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
registered: 'Înregistrat',
|
deschis: 'Deschis',
|
||||||
'in-progress': 'În lucru',
|
inchis: 'Închis',
|
||||||
completed: 'Finalizat',
|
|
||||||
archived: 'Arhivat',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'outline' | 'destructive'> = {
|
export function RegistryTable({ entries, loading, onEdit, onDelete, onClose }: RegistryTableProps) {
|
||||||
registered: 'default',
|
|
||||||
'in-progress': 'secondary',
|
|
||||||
completed: 'outline',
|
|
||||||
archived: 'outline',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function RegistryTable({ entries, loading, onEdit, onDelete }: RegistryTableProps) {
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>;
|
return <p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>;
|
||||||
}
|
}
|
||||||
@@ -53,40 +57,90 @@ export function RegistryTable({ entries, loading, onEdit, onDelete }: RegistryTa
|
|||||||
<tr className="border-b bg-muted/40">
|
<tr className="border-b bg-muted/40">
|
||||||
<th className="px-3 py-2 text-left font-medium">Nr.</th>
|
<th className="px-3 py-2 text-left font-medium">Nr.</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Data</th>
|
<th className="px-3 py-2 text-left font-medium">Data</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Dir.</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Tip</th>
|
<th className="px-3 py-2 text-left font-medium">Tip</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Subiect</th>
|
<th className="px-3 py-2 text-left font-medium">Subiect</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Expeditor</th>
|
<th className="px-3 py-2 text-left font-medium">Expeditor</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Destinatar</th>
|
<th className="px-3 py-2 text-left font-medium">Destinatar</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Termen</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Status</th>
|
<th className="px-3 py-2 text-left font-medium">Status</th>
|
||||||
<th className="px-3 py-2 text-right font-medium">Acțiuni</th>
|
<th className="px-3 py-2 text-right font-medium">Acțiuni</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => {
|
||||||
<tr key={entry.id} className={cn('border-b hover:bg-muted/20 transition-colors')}>
|
const overdueDays = entry.status === 'deschis' ? getOverdueDays(entry.deadline) : null;
|
||||||
<td className="px-3 py-2 font-mono text-xs">{entry.number}</td>
|
const isOverdue = overdueDays !== null && overdueDays > 0;
|
||||||
<td className="px-3 py-2 text-xs whitespace-nowrap">{formatDate(entry.date)}</td>
|
return (
|
||||||
<td className="px-3 py-2">
|
<tr
|
||||||
<Badge variant="outline" className="text-xs">{TYPE_LABELS[entry.type]}</Badge>
|
key={entry.id}
|
||||||
</td>
|
className={cn(
|
||||||
<td className="px-3 py-2 max-w-[250px] truncate">{entry.subject}</td>
|
'border-b transition-colors hover:bg-muted/20',
|
||||||
<td className="px-3 py-2 max-w-[150px] truncate">{entry.sender}</td>
|
isOverdue && 'bg-destructive/5'
|
||||||
<td className="px-3 py-2 max-w-[150px] truncate">{entry.recipient}</td>
|
)}
|
||||||
<td className="px-3 py-2">
|
>
|
||||||
<Badge variant={STATUS_VARIANT[entry.status]}>{STATUS_LABELS[entry.status]}</Badge>
|
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">{entry.number}</td>
|
||||||
</td>
|
<td className="px-3 py-2 text-xs whitespace-nowrap">{formatDate(entry.date)}</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2">
|
||||||
<div className="flex justify-end gap-1">
|
<Badge
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onEdit(entry)}>
|
variant={entry.direction === 'intrat' ? 'default' : 'secondary'}
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
className="text-xs"
|
||||||
</Button>
|
>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => onDelete(entry.id)}>
|
{DIRECTION_LABELS[entry.direction]}
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
</Badge>
|
||||||
</Button>
|
</td>
|
||||||
</div>
|
<td className="px-3 py-2 text-xs">{DOC_TYPE_LABELS[entry.documentType]}</td>
|
||||||
</td>
|
<td className="px-3 py-2 max-w-[200px] truncate">
|
||||||
</tr>
|
{entry.subject}
|
||||||
))}
|
{entry.linkedEntryIds.length > 0 && (
|
||||||
|
<Link2 className="ml-1 inline h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{entry.attachments.length > 0 && (
|
||||||
|
<Badge variant="outline" className="ml-1 text-[10px] px-1">
|
||||||
|
{entry.attachments.length} fișiere
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 max-w-[130px] truncate">{entry.sender}</td>
|
||||||
|
<td className="px-3 py-2 max-w-[130px] truncate">{entry.recipient}</td>
|
||||||
|
<td className="px-3 py-2 text-xs whitespace-nowrap">
|
||||||
|
{entry.deadline ? (
|
||||||
|
<span className={cn(isOverdue && 'font-medium text-destructive')}>
|
||||||
|
{formatDate(entry.deadline)}
|
||||||
|
{overdueDays !== null && overdueDays > 0 && (
|
||||||
|
<span className="ml-1 text-[10px]">({overdueDays}z depășit)</span>
|
||||||
|
)}
|
||||||
|
{overdueDays !== null && overdueDays < 0 && (
|
||||||
|
<span className="ml-1 text-[10px] text-muted-foreground">({Math.abs(overdueDays)}z)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Badge variant={entry.status === 'deschis' ? 'default' : 'outline'}>
|
||||||
|
{STATUS_LABELS[entry.status]}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
{entry.status === 'deschis' && (
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-green-600" onClick={() => onClose(entry.id)} title="Închide">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onEdit(entry)}>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => onDelete(entry.id)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useStorage } from '@/core/storage';
|
import { useStorage } from '@/core/storage';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import type { RegistryEntry, RegistryEntryType, RegistryEntryStatus } from '../types';
|
import type { RegistryEntry, RegistryDirection, RegistryStatus, DocumentType } from '../types';
|
||||||
import { getAllEntries, saveEntry, deleteEntry, generateRegistryNumber } from '../services/registry-service';
|
import { getAllEntries, saveEntry, deleteEntry, generateRegistryNumber } from '../services/registry-service';
|
||||||
|
|
||||||
export interface RegistryFilters {
|
export interface RegistryFilters {
|
||||||
search: string;
|
search: string;
|
||||||
type: RegistryEntryType | 'all';
|
direction: RegistryDirection | 'all';
|
||||||
status: RegistryEntryStatus | 'all';
|
status: RegistryStatus | 'all';
|
||||||
|
documentType: DocumentType | 'all';
|
||||||
company: string;
|
company: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,8 +20,9 @@ export function useRegistry() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filters, setFilters] = useState<RegistryFilters>({
|
const [filters, setFilters] = useState<RegistryFilters>({
|
||||||
search: '',
|
search: '',
|
||||||
type: 'all',
|
direction: 'all',
|
||||||
status: 'all',
|
status: 'all',
|
||||||
|
documentType: 'all',
|
||||||
company: 'all',
|
company: 'all',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,18 +38,18 @@ export function useRegistry() {
|
|||||||
|
|
||||||
const addEntry = useCallback(async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => {
|
const addEntry = useCallback(async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const nextIndex = entries.length + 1;
|
const number = generateRegistryNumber(data.company, data.date, entries);
|
||||||
const entry: RegistryEntry = {
|
const entry: RegistryEntry = {
|
||||||
...data,
|
...data,
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
number: generateRegistryNumber(data.date, nextIndex),
|
number,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
await saveEntry(storage, entry);
|
await saveEntry(storage, entry);
|
||||||
await refresh();
|
await refresh();
|
||||||
return entry;
|
return entry;
|
||||||
}, [storage, refresh, entries.length]);
|
}, [storage, refresh, entries]);
|
||||||
|
|
||||||
const updateEntry = useCallback(async (id: string, updates: Partial<RegistryEntry>) => {
|
const updateEntry = useCallback(async (id: string, updates: Partial<RegistryEntry>) => {
|
||||||
const existing = entries.find((e) => e.id === id);
|
const existing = entries.find((e) => e.id === id);
|
||||||
@@ -69,13 +71,35 @@ export function useRegistry() {
|
|||||||
await refresh();
|
await refresh();
|
||||||
}, [storage, refresh]);
|
}, [storage, refresh]);
|
||||||
|
|
||||||
|
/** Close an entry and optionally its linked entries */
|
||||||
|
const closeEntry = useCallback(async (id: string, closeLinked: boolean) => {
|
||||||
|
const entry = entries.find((e) => e.id === id);
|
||||||
|
if (!entry) return;
|
||||||
|
await updateEntry(id, { status: 'inchis' });
|
||||||
|
if (closeLinked && entry.linkedEntryIds.length > 0) {
|
||||||
|
for (const linkedId of entry.linkedEntryIds) {
|
||||||
|
const linked = entries.find((e) => e.id === linkedId);
|
||||||
|
if (linked && linked.status !== 'inchis') {
|
||||||
|
const updatedLinked: RegistryEntry = {
|
||||||
|
...linked,
|
||||||
|
status: 'inchis',
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await saveEntry(storage, updatedLinked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
}, [entries, updateEntry, storage, refresh]);
|
||||||
|
|
||||||
const updateFilter = useCallback(<K extends keyof RegistryFilters>(key: K, value: RegistryFilters[K]) => {
|
const updateFilter = useCallback(<K extends keyof RegistryFilters>(key: K, value: RegistryFilters[K]) => {
|
||||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filteredEntries = entries.filter((entry) => {
|
const filteredEntries = entries.filter((entry) => {
|
||||||
if (filters.type !== 'all' && entry.type !== filters.type) return false;
|
if (filters.direction !== 'all' && entry.direction !== filters.direction) return false;
|
||||||
if (filters.status !== 'all' && entry.status !== filters.status) return false;
|
if (filters.status !== 'all' && entry.status !== filters.status) return false;
|
||||||
|
if (filters.documentType !== 'all' && entry.documentType !== filters.documentType) return false;
|
||||||
if (filters.company !== 'all' && entry.company !== filters.company) return false;
|
if (filters.company !== 'all' && entry.company !== filters.company) return false;
|
||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
const q = filters.search.toLowerCase();
|
const q = filters.search.toLowerCase();
|
||||||
@@ -83,7 +107,7 @@ export function useRegistry() {
|
|||||||
entry.subject.toLowerCase().includes(q) ||
|
entry.subject.toLowerCase().includes(q) ||
|
||||||
entry.sender.toLowerCase().includes(q) ||
|
entry.sender.toLowerCase().includes(q) ||
|
||||||
entry.recipient.toLowerCase().includes(q) ||
|
entry.recipient.toLowerCase().includes(q) ||
|
||||||
entry.number.includes(q)
|
entry.number.toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -98,6 +122,7 @@ export function useRegistry() {
|
|||||||
addEntry,
|
addEntry,
|
||||||
updateEntry,
|
updateEntry,
|
||||||
removeEntry,
|
removeEntry,
|
||||||
|
closeEntry,
|
||||||
refresh,
|
refresh,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export { registraturaConfig } from './config';
|
export { registraturaConfig } from './config';
|
||||||
export { RegistraturaModule } from './components/registratura-module';
|
export { RegistraturaModule } from './components/registratura-module';
|
||||||
export type { RegistryEntry, RegistryEntryType, RegistryEntryStatus } from './types';
|
export type { RegistryEntry, RegistryDirection, RegistryStatus, DocumentType } from './types';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { CompanyId } from '@/core/auth/types';
|
||||||
import type { RegistryEntry } from '../types';
|
import type { RegistryEntry } from '../types';
|
||||||
|
|
||||||
const STORAGE_PREFIX = 'entry:';
|
const STORAGE_PREFIX = 'entry:';
|
||||||
@@ -30,9 +31,44 @@ export async function deleteEntry(storage: RegistryStorage, id: string): Promise
|
|||||||
await storage.delete(`${STORAGE_PREFIX}${id}`);
|
await storage.delete(`${STORAGE_PREFIX}${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateRegistryNumber(date: string, index: number): string {
|
const COMPANY_PREFIXES: Record<CompanyId, string> = {
|
||||||
|
beletage: 'B',
|
||||||
|
'urban-switch': 'US',
|
||||||
|
'studii-de-teren': 'SDT',
|
||||||
|
group: 'G',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate company-specific registry number: B-0001/2026
|
||||||
|
* Uses the next sequential number for that company in that year.
|
||||||
|
*/
|
||||||
|
export function generateRegistryNumber(
|
||||||
|
company: CompanyId,
|
||||||
|
date: string,
|
||||||
|
existingEntries: RegistryEntry[]
|
||||||
|
): string {
|
||||||
const d = new Date(date);
|
const d = new Date(date);
|
||||||
const year = d.getFullYear();
|
const year = d.getFullYear();
|
||||||
const padded = String(index).padStart(4, '0');
|
const prefix = COMPANY_PREFIXES[company];
|
||||||
return `${padded}/${year}`;
|
|
||||||
|
// Count existing entries for this company in this year
|
||||||
|
const sameCompanyYear = existingEntries.filter((e) => {
|
||||||
|
const entryYear = new Date(e.date).getFullYear();
|
||||||
|
return e.company === company && entryYear === year;
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextIndex = sameCompanyYear.length + 1;
|
||||||
|
const padded = String(nextIndex).padStart(4, '0');
|
||||||
|
return `${prefix}-${padded}/${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calculate days overdue (negative = days remaining, positive = overdue) */
|
||||||
|
export function getOverdueDays(deadline: string | undefined): number | null {
|
||||||
|
if (!deadline) return null;
|
||||||
|
const now = new Date();
|
||||||
|
now.setHours(0, 0, 0, 0);
|
||||||
|
const dl = new Date(deadline);
|
||||||
|
dl.setHours(0, 0, 0, 0);
|
||||||
|
const diff = now.getTime() - dl.getTime();
|
||||||
|
return Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,57 @@
|
|||||||
import type { Visibility } from '@/core/module-registry/types';
|
import type { Visibility } from '@/core/module-registry/types';
|
||||||
import type { CompanyId } from '@/core/auth/types';
|
import type { CompanyId } from '@/core/auth/types';
|
||||||
|
|
||||||
export type RegistryEntryType = 'incoming' | 'outgoing' | 'internal';
|
/** Document direction — simplified from the old 3-way type */
|
||||||
|
export type RegistryDirection = 'intrat' | 'iesit';
|
||||||
|
|
||||||
export type RegistryEntryStatus =
|
/** Document type categories */
|
||||||
| 'registered'
|
export type DocumentType =
|
||||||
| 'in-progress'
|
| 'contract'
|
||||||
| 'completed'
|
| 'oferta'
|
||||||
| 'archived';
|
| 'factura'
|
||||||
|
| 'scrisoare'
|
||||||
|
| 'aviz'
|
||||||
|
| 'nota-de-comanda'
|
||||||
|
| 'raport'
|
||||||
|
| 'cerere'
|
||||||
|
| 'altele';
|
||||||
|
|
||||||
|
/** Status — simplified to open/closed */
|
||||||
|
export type RegistryStatus = 'deschis' | 'inchis';
|
||||||
|
|
||||||
|
/** File attachment */
|
||||||
|
export interface RegistryAttachment {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** base64-encoded content or URL */
|
||||||
|
data: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
addedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegistryEntry {
|
export interface RegistryEntry {
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Company-specific number: B-0001/2026, US-0001/2026, SDT-0001/2026 */
|
||||||
number: string;
|
number: string;
|
||||||
date: string;
|
date: string;
|
||||||
type: RegistryEntryType;
|
direction: RegistryDirection;
|
||||||
|
documentType: DocumentType;
|
||||||
subject: string;
|
subject: string;
|
||||||
|
/** Expeditor — free text or linked contact ID */
|
||||||
sender: string;
|
sender: string;
|
||||||
|
senderContactId?: string;
|
||||||
|
/** Destinatar — free text or linked contact ID */
|
||||||
recipient: string;
|
recipient: string;
|
||||||
|
recipientContactId?: string;
|
||||||
company: CompanyId;
|
company: CompanyId;
|
||||||
status: RegistryEntryStatus;
|
status: RegistryStatus;
|
||||||
|
/** Deadline date (YYYY-MM-DD) */
|
||||||
|
deadline?: string;
|
||||||
|
/** Linked entry IDs (for closing/archiving related entries) */
|
||||||
|
linkedEntryIds: string[];
|
||||||
|
/** File attachments */
|
||||||
|
attachments: RegistryAttachment[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
notes: string;
|
notes: string;
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { Plus, Trash2, Tag as TagIcon } from 'lucide-react';
|
import {
|
||||||
|
Plus, Trash2, Pencil, Check, X, Download, ChevronDown, ChevronRight,
|
||||||
|
Tag as TagIcon, Search, FolderTree,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Button } from '@/shared/components/ui/button';
|
import { Button } from '@/shared/components/ui/button';
|
||||||
import { Input } from '@/shared/components/ui/input';
|
import { Input } from '@/shared/components/ui/input';
|
||||||
import { Label } from '@/shared/components/ui/label';
|
import { Label } from '@/shared/components/ui/label';
|
||||||
import { Badge } from '@/shared/components/ui/badge';
|
import { Badge } from '@/shared/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from '@/shared/components/ui/select';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||||
|
} from '@/shared/components/ui/dialog';
|
||||||
import { useTags } from '@/core/tagging';
|
import { useTags } from '@/core/tagging';
|
||||||
import type { TagCategory, TagScope } from '@/core/tagging/types';
|
import type { Tag, TagCategory, TagScope } from '@/core/tagging/types';
|
||||||
|
import { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from '@/core/tagging/types';
|
||||||
|
import type { CompanyId } from '@/core/auth/types';
|
||||||
import { cn } from '@/shared/lib/utils';
|
import { cn } from '@/shared/lib/utils';
|
||||||
|
import { getManicTimeSeedTags } from '../services/seed-data';
|
||||||
const CATEGORY_LABELS: Record<TagCategory, string> = {
|
|
||||||
project: 'Proiect',
|
|
||||||
phase: 'Fază',
|
|
||||||
activity: 'Activitate',
|
|
||||||
'document-type': 'Tip document',
|
|
||||||
company: 'Companie',
|
|
||||||
priority: 'Prioritate',
|
|
||||||
status: 'Status',
|
|
||||||
custom: 'Personalizat',
|
|
||||||
};
|
|
||||||
|
|
||||||
const SCOPE_LABELS: Record<TagScope, string> = {
|
const SCOPE_LABELS: Record<TagScope, string> = {
|
||||||
global: 'Global',
|
global: 'Global',
|
||||||
@@ -29,20 +29,102 @@ const SCOPE_LABELS: Record<TagScope, string> = {
|
|||||||
company: 'Companie',
|
company: 'Companie',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const COMPANY_LABELS: Record<CompanyId, string> = {
|
||||||
|
beletage: 'Beletage',
|
||||||
|
'urban-switch': 'Urban Switch',
|
||||||
|
'studii-de-teren': 'Studii de Teren',
|
||||||
|
group: 'Grup',
|
||||||
|
};
|
||||||
|
|
||||||
const TAG_COLORS = [
|
const TAG_COLORS = [
|
||||||
'#ef4444', '#f97316', '#f59e0b', '#84cc16',
|
'#ef4444', '#f97316', '#f59e0b', '#84cc16',
|
||||||
'#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6',
|
'#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6',
|
||||||
'#ec4899', '#64748b',
|
'#ec4899', '#64748b', '#22B5AB', '#6366f1',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function TagManagerModule() {
|
export function TagManagerModule() {
|
||||||
const { tags, loading, createTag, deleteTag } = useTags();
|
const { tags, loading, createTag, updateTag, deleteTag, importTags } = useTags();
|
||||||
|
|
||||||
|
// ── Create form state ──
|
||||||
const [newLabel, setNewLabel] = useState('');
|
const [newLabel, setNewLabel] = useState('');
|
||||||
const [newCategory, setNewCategory] = useState<TagCategory>('custom');
|
const [newCategory, setNewCategory] = useState<TagCategory>('custom');
|
||||||
const [newScope, setNewScope] = useState<TagScope>('global');
|
const [newScope, setNewScope] = useState<TagScope>('global');
|
||||||
const [newColor, setNewColor] = useState(TAG_COLORS[5]);
|
const [newColor, setNewColor] = useState('#3b82f6');
|
||||||
const [filterCategory, setFilterCategory] = useState<TagCategory | 'all'>('all');
|
const [newCompanyId, setNewCompanyId] = useState<CompanyId>('beletage');
|
||||||
|
const [newProjectCode, setNewProjectCode] = useState('');
|
||||||
|
const [newParentId, setNewParentId] = useState('');
|
||||||
|
|
||||||
|
// ── Filter / search state ──
|
||||||
|
const [filterCategory, setFilterCategory] = useState<TagCategory | 'all'>('all');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||||
|
() => new Set(TAG_CATEGORY_ORDER)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Edit state ──
|
||||||
|
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
||||||
|
const [editLabel, setEditLabel] = useState('');
|
||||||
|
const [editColor, setEditColor] = useState('');
|
||||||
|
const [editProjectCode, setEditProjectCode] = useState('');
|
||||||
|
const [editScope, setEditScope] = useState<TagScope>('global');
|
||||||
|
const [editCompanyId, setEditCompanyId] = useState<CompanyId>('beletage');
|
||||||
|
|
||||||
|
// ── Seed import state ──
|
||||||
|
const [showSeedDialog, setShowSeedDialog] = useState(false);
|
||||||
|
const [seedImporting, setSeedImporting] = useState(false);
|
||||||
|
const [seedResult, setSeedResult] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ── Computed ──
|
||||||
|
const filteredTags = useMemo(() => {
|
||||||
|
let result = tags;
|
||||||
|
if (filterCategory !== 'all') {
|
||||||
|
result = result.filter((t) => t.category === filterCategory);
|
||||||
|
}
|
||||||
|
if (searchQuery) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
result = result.filter(
|
||||||
|
(t) =>
|
||||||
|
t.label.toLowerCase().includes(q) ||
|
||||||
|
(t.projectCode?.toLowerCase().includes(q) ?? false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [tags, filterCategory, searchQuery]);
|
||||||
|
|
||||||
|
const groupedByCategory = useMemo(() => {
|
||||||
|
const groups: Record<string, Tag[]> = {};
|
||||||
|
for (const cat of TAG_CATEGORY_ORDER) {
|
||||||
|
const catTags = filteredTags.filter((t) => t.category === cat);
|
||||||
|
if (catTags.length > 0) {
|
||||||
|
groups[cat] = catTags;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, [filteredTags]);
|
||||||
|
|
||||||
|
/** Build a parent→children map for hierarchy display */
|
||||||
|
const childrenMap = useMemo(() => {
|
||||||
|
const map: Record<string, Tag[]> = {};
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (tag.parentId) {
|
||||||
|
const existing = map[tag.parentId];
|
||||||
|
if (existing) {
|
||||||
|
existing.push(tag);
|
||||||
|
} else {
|
||||||
|
map[tag.parentId] = [tag];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [tags]);
|
||||||
|
|
||||||
|
const parentCandidates = useMemo(() => {
|
||||||
|
return tags.filter(
|
||||||
|
(t) => t.category === newCategory && !t.parentId
|
||||||
|
);
|
||||||
|
}, [tags, newCategory]);
|
||||||
|
|
||||||
|
// ── Handlers ──
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!newLabel.trim()) return;
|
if (!newLabel.trim()) return;
|
||||||
await createTag({
|
await createTag({
|
||||||
@@ -50,158 +132,475 @@ export function TagManagerModule() {
|
|||||||
category: newCategory,
|
category: newCategory,
|
||||||
scope: newScope,
|
scope: newScope,
|
||||||
color: newColor,
|
color: newColor,
|
||||||
|
companyId: newScope === 'company' ? newCompanyId : undefined,
|
||||||
|
projectCode: newCategory === 'project' && newProjectCode ? newProjectCode : undefined,
|
||||||
|
parentId: newParentId || undefined,
|
||||||
});
|
});
|
||||||
setNewLabel('');
|
setNewLabel('');
|
||||||
|
setNewProjectCode('');
|
||||||
|
setNewParentId('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredTags = filterCategory === 'all'
|
const startEdit = (tag: Tag) => {
|
||||||
? tags
|
setEditingTag(tag);
|
||||||
: tags.filter((t) => t.category === filterCategory);
|
setEditLabel(tag.label);
|
||||||
|
setEditColor(tag.color ?? '#3b82f6');
|
||||||
|
setEditProjectCode(tag.projectCode ?? '');
|
||||||
|
setEditScope(tag.scope);
|
||||||
|
setEditCompanyId(tag.companyId ?? 'beletage');
|
||||||
|
};
|
||||||
|
|
||||||
const groupedByCategory = filteredTags.reduce<Record<string, typeof tags>>((acc, tag) => {
|
const saveEdit = async () => {
|
||||||
const key = tag.category;
|
if (!editingTag || !editLabel.trim()) return;
|
||||||
if (!acc[key]) acc[key] = [];
|
await updateTag(editingTag.id, {
|
||||||
acc[key].push(tag);
|
label: editLabel.trim(),
|
||||||
return acc;
|
color: editColor,
|
||||||
}, {});
|
projectCode: editingTag.category === 'project' && editProjectCode ? editProjectCode : undefined,
|
||||||
|
scope: editScope,
|
||||||
|
companyId: editScope === 'company' ? editCompanyId : undefined,
|
||||||
|
});
|
||||||
|
setEditingTag(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => setEditingTag(null);
|
||||||
|
|
||||||
|
const handleSeedImport = async () => {
|
||||||
|
setSeedImporting(true);
|
||||||
|
setSeedResult(null);
|
||||||
|
const seedTags = getManicTimeSeedTags();
|
||||||
|
const count = await importTags(seedTags);
|
||||||
|
setSeedResult(`${count} etichete importate din ${seedTags.length} disponibile.`);
|
||||||
|
setSeedImporting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCategory = (cat: string) => {
|
||||||
|
setExpandedCategories((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(cat)) next.delete(cat);
|
||||||
|
else next.add(cat);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Stats ──
|
||||||
|
const projectCount = tags.filter((t) => t.category === 'project').length;
|
||||||
|
const phaseCount = tags.filter((t) => t.category === 'phase').length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||||
<Card><CardContent className="p-4">
|
<Card><CardContent className="p-4">
|
||||||
<p className="text-xs text-muted-foreground">Total etichete</p>
|
<p className="text-xs text-muted-foreground">Total etichete</p>
|
||||||
<p className="text-2xl font-bold">{tags.length}</p>
|
<p className="text-2xl font-bold">{tags.length}</p>
|
||||||
</CardContent></Card>
|
</CardContent></Card>
|
||||||
<Card><CardContent className="p-4">
|
{TAG_CATEGORY_ORDER.map((cat) => (
|
||||||
<p className="text-xs text-muted-foreground">Categorii folosite</p>
|
<Card key={cat}><CardContent className="p-4">
|
||||||
<p className="text-2xl font-bold">{new Set(tags.map((t) => t.category)).size}</p>
|
<p className="text-xs text-muted-foreground">{TAG_CATEGORY_LABELS[cat]}</p>
|
||||||
</CardContent></Card>
|
<p className="text-2xl font-bold">
|
||||||
<Card><CardContent className="p-4">
|
{tags.filter((t) => t.category === cat).length}
|
||||||
<p className="text-xs text-muted-foreground">Globale</p>
|
</p>
|
||||||
<p className="text-2xl font-bold">{tags.filter((t) => t.scope === 'global').length}</p>
|
</CardContent></Card>
|
||||||
</CardContent></Card>
|
))}
|
||||||
<Card><CardContent className="p-4">
|
|
||||||
<p className="text-xs text-muted-foreground">Personalizate</p>
|
|
||||||
<p className="text-2xl font-bold">{tags.filter((t) => t.category === 'custom').length}</p>
|
|
||||||
</CardContent></Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Seed import banner */}
|
||||||
|
{tags.length === 0 && !loading && (
|
||||||
|
<Card className="border-dashed border-2">
|
||||||
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Nicio etichetă găsită</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Importă datele din ManicTime pentru a popula proiectele, fazele și activitățile.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowSeedDialog(true)}>
|
||||||
|
<Download className="mr-1.5 h-4 w-4" /> Importă date inițiale
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Create new tag */}
|
{/* Create new tag */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle className="text-base">Etichetă nouă</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-base">Etichetă nouă</CardTitle></CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="space-y-3">
|
||||||
<div className="min-w-[200px] flex-1">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<Label>Nume</Label>
|
<div className="min-w-[200px] flex-1">
|
||||||
<Input
|
<Label>Nume</Label>
|
||||||
value={newLabel}
|
<Input
|
||||||
onChange={(e) => setNewLabel(e.target.value)}
|
value={newLabel}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
onChange={(e) => setNewLabel(e.target.value)}
|
||||||
placeholder="Numele etichetei..."
|
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||||
className="mt-1"
|
placeholder="Numele etichetei..."
|
||||||
/>
|
className="mt-1"
|
||||||
</div>
|
/>
|
||||||
<div className="w-[160px]">
|
|
||||||
<Label>Categorie</Label>
|
|
||||||
<Select value={newCategory} onValueChange={(v) => setNewCategory(v as TagCategory)}>
|
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(Object.keys(CATEGORY_LABELS) as TagCategory[]).map((cat) => (
|
|
||||||
<SelectItem key={cat} value={cat}>{CATEGORY_LABELS[cat]}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="w-[130px]">
|
|
||||||
<Label>Vizibilitate</Label>
|
|
||||||
<Select value={newScope} onValueChange={(v) => setNewScope(v as TagScope)}>
|
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(Object.keys(SCOPE_LABELS) as TagScope[]).map((s) => (
|
|
||||||
<SelectItem key={s} value={s}>{SCOPE_LABELS[s]}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="mb-1.5 block">Culoare</Label>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{TAG_COLORS.map((color) => (
|
|
||||||
<button
|
|
||||||
key={color}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setNewColor(color)}
|
|
||||||
className={cn(
|
|
||||||
'h-7 w-7 rounded-full border-2 transition-all',
|
|
||||||
newColor === color ? 'border-primary scale-110' : 'border-transparent hover:scale-105'
|
|
||||||
)}
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-[160px]">
|
||||||
|
<Label>Categorie</Label>
|
||||||
|
<Select value={newCategory} onValueChange={(v) => setNewCategory(v as TagCategory)}>
|
||||||
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TAG_CATEGORY_ORDER.map((cat) => (
|
||||||
|
<SelectItem key={cat} value={cat}>{TAG_CATEGORY_LABELS[cat]}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="w-[140px]">
|
||||||
|
<Label>Vizibilitate</Label>
|
||||||
|
<Select value={newScope} onValueChange={(v) => setNewScope(v as TagScope)}>
|
||||||
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.keys(SCOPE_LABELS) as TagScope[]).map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>{SCOPE_LABELS[s]}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{newScope === 'company' && (
|
||||||
|
<div className="w-[150px]">
|
||||||
|
<Label>Companie</Label>
|
||||||
|
<Select value={newCompanyId} onValueChange={(v) => setNewCompanyId(v as CompanyId)}>
|
||||||
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.keys(COMPANY_LABELS) as CompanyId[]).map((c) => (
|
||||||
|
<SelectItem key={c} value={c}>{COMPANY_LABELS[c]}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
{newCategory === 'project' && (
|
||||||
|
<div className="w-[140px]">
|
||||||
|
<Label>Cod proiect</Label>
|
||||||
|
<Input
|
||||||
|
value={newProjectCode}
|
||||||
|
onChange={(e) => setNewProjectCode(e.target.value)}
|
||||||
|
placeholder="B-001"
|
||||||
|
className="mt-1 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{parentCandidates.length > 0 && (
|
||||||
|
<div className="w-[200px]">
|
||||||
|
<Label>Tag părinte (opțional)</Label>
|
||||||
|
<Select value={newParentId || '__none__'} onValueChange={(v) => setNewParentId(v === '__none__' ? '' : v)}>
|
||||||
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">— Niciun părinte —</SelectItem>
|
||||||
|
{parentCandidates.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.projectCode ? `${p.projectCode} ` : ''}{p.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block">Culoare</Label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{TAG_COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setNewColor(color)}
|
||||||
|
className={cn(
|
||||||
|
'h-7 w-7 rounded-full border-2 transition-all',
|
||||||
|
newColor === color ? 'border-primary scale-110' : 'border-transparent hover:scale-105'
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleCreate} disabled={!newLabel.trim()}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" /> Adaugă
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleCreate} disabled={!newLabel.trim()}>
|
|
||||||
<Plus className="mr-1 h-4 w-4" /> Adaugă
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Filter */}
|
{/* Search + Filter bar */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Label>Filtrează:</Label>
|
<div className="relative min-w-[200px] flex-1">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Caută etichete..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Select value={filterCategory} onValueChange={(v) => setFilterCategory(v as TagCategory | 'all')}>
|
<Select value={filterCategory} onValueChange={(v) => setFilterCategory(v as TagCategory | 'all')}>
|
||||||
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Toate categoriile</SelectItem>
|
<SelectItem value="all">Toate categoriile</SelectItem>
|
||||||
{(Object.keys(CATEGORY_LABELS) as TagCategory[]).map((cat) => (
|
{TAG_CATEGORY_ORDER.map((cat) => (
|
||||||
<SelectItem key={cat} value={cat}>{CATEGORY_LABELS[cat]}</SelectItem>
|
<SelectItem key={cat} value={cat}>{TAG_CATEGORY_LABELS[cat]}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setShowSeedDialog(true)}>
|
||||||
|
<Download className="mr-1 h-3.5 w-3.5" /> Importă ManicTime
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tag list by category */}
|
{/* Tag list by category with hierarchy */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
|
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
|
||||||
) : Object.keys(groupedByCategory).length === 0 ? (
|
) : Object.keys(groupedByCategory).length === 0 ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">Nicio etichetă găsită. Creează prima etichetă.</p>
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Nicio etichetă găsită. Creează prima etichetă sau importă datele inițiale.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
{Object.entries(groupedByCategory).map(([category, catTags]) => (
|
{Object.entries(groupedByCategory).map(([category, catTags]) => {
|
||||||
<Card key={category}>
|
const isExpanded = expandedCategories.has(category);
|
||||||
<CardHeader className="pb-3">
|
const rootTags = catTags.filter((t) => !t.parentId);
|
||||||
<CardTitle className="flex items-center gap-2 text-sm">
|
return (
|
||||||
<TagIcon className="h-4 w-4" />
|
<Card key={category}>
|
||||||
{CATEGORY_LABELS[category as TagCategory] ?? category}
|
<CardHeader
|
||||||
<Badge variant="secondary" className="ml-1">{catTags.length}</Badge>
|
className="cursor-pointer pb-3"
|
||||||
</CardTitle>
|
onClick={() => toggleCategory(category)}
|
||||||
</CardHeader>
|
>
|
||||||
<CardContent>
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
<div className="flex flex-wrap gap-2">
|
{isExpanded
|
||||||
{catTags.map((tag) => (
|
? <ChevronDown className="h-4 w-4" />
|
||||||
<div
|
: <ChevronRight className="h-4 w-4" />}
|
||||||
key={tag.id}
|
<TagIcon className="h-4 w-4" />
|
||||||
className="group flex items-center gap-1.5 rounded-full border py-1 pl-3 pr-1.5 text-sm"
|
{TAG_CATEGORY_LABELS[category as TagCategory] ?? category}
|
||||||
>
|
<Badge variant="secondary" className="ml-1">{catTags.length}</Badge>
|
||||||
{tag.color && (
|
{(category === 'project' || category === 'phase') && (
|
||||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: tag.color }} />
|
<Badge variant="default" className="ml-1 text-[10px]">obligatoriu</Badge>
|
||||||
)}
|
)}
|
||||||
<span>{tag.label}</span>
|
</CardTitle>
|
||||||
<Badge variant="outline" className="text-[10px] px-1">{SCOPE_LABELS[tag.scope]}</Badge>
|
</CardHeader>
|
||||||
<button
|
{isExpanded && (
|
||||||
type="button"
|
<CardContent>
|
||||||
onClick={() => deleteTag(tag.id)}
|
<div className="space-y-1">
|
||||||
className="ml-0.5 rounded-full p-0.5 opacity-0 transition-opacity hover:bg-destructive/10 group-hover:opacity-100"
|
{rootTags.map((tag) => (
|
||||||
>
|
<TagRow
|
||||||
<Trash2 className="h-3 w-3 text-destructive" />
|
key={tag.id}
|
||||||
</button>
|
tag={tag}
|
||||||
|
children={childrenMap[tag.id]}
|
||||||
|
editingTag={editingTag}
|
||||||
|
editLabel={editLabel}
|
||||||
|
editColor={editColor}
|
||||||
|
editProjectCode={editProjectCode}
|
||||||
|
editScope={editScope}
|
||||||
|
editCompanyId={editCompanyId}
|
||||||
|
onStartEdit={startEdit}
|
||||||
|
onSaveEdit={saveEdit}
|
||||||
|
onCancelEdit={cancelEdit}
|
||||||
|
onDelete={deleteTag}
|
||||||
|
setEditLabel={setEditLabel}
|
||||||
|
setEditColor={setEditColor}
|
||||||
|
setEditProjectCode={setEditProjectCode}
|
||||||
|
setEditScope={setEditScope}
|
||||||
|
setEditCompanyId={setEditCompanyId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</CardContent>
|
||||||
</div>
|
)}
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Seed Import Dialog */}
|
||||||
|
<Dialog open={showSeedDialog} onOpenChange={setShowSeedDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Importă date inițiale ManicTime</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Aceasta va importa proiectele Beletage, fazele, activitățile și tipurile de documente
|
||||||
|
din lista ManicTime. Etichetele existente nu vor fi duplicate.
|
||||||
|
</p>
|
||||||
|
{seedResult && (
|
||||||
|
<p className="rounded bg-muted p-2 text-sm font-medium">{seedResult}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowSeedDialog(false)}>Închide</Button>
|
||||||
|
<Button onClick={handleSeedImport} disabled={seedImporting}>
|
||||||
|
{seedImporting ? 'Se importă...' : 'Importă'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tag Row with inline editing ──
|
||||||
|
|
||||||
|
interface TagRowProps {
|
||||||
|
tag: Tag;
|
||||||
|
children?: Tag[];
|
||||||
|
editingTag: Tag | null;
|
||||||
|
editLabel: string;
|
||||||
|
editColor: string;
|
||||||
|
editProjectCode: string;
|
||||||
|
editScope: TagScope;
|
||||||
|
editCompanyId: CompanyId;
|
||||||
|
onStartEdit: (tag: Tag) => void;
|
||||||
|
onSaveEdit: () => void;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
setEditLabel: (v: string) => void;
|
||||||
|
setEditColor: (v: string) => void;
|
||||||
|
setEditProjectCode: (v: string) => void;
|
||||||
|
setEditScope: (v: TagScope) => void;
|
||||||
|
setEditCompanyId: (v: CompanyId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagRow({
|
||||||
|
tag, children, editingTag, editLabel, editColor, editProjectCode,
|
||||||
|
editScope, editCompanyId,
|
||||||
|
onStartEdit, onSaveEdit, onCancelEdit, onDelete,
|
||||||
|
setEditLabel, setEditColor, setEditProjectCode, setEditScope, setEditCompanyId,
|
||||||
|
}: TagRowProps) {
|
||||||
|
const isEditing = editingTag?.id === tag.id;
|
||||||
|
const [showChildren, setShowChildren] = useState(false);
|
||||||
|
const hasChildren = children && children.length > 0;
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-2">
|
||||||
|
{tag.category === 'project' && (
|
||||||
|
<Input
|
||||||
|
value={editProjectCode}
|
||||||
|
onChange={(e) => setEditProjectCode(e.target.value)}
|
||||||
|
className="w-[100px] font-mono text-xs"
|
||||||
|
placeholder="B-001"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
value={editLabel}
|
||||||
|
onChange={(e) => setEditLabel(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') onSaveEdit(); if (e.key === 'Escape') onCancelEdit(); }}
|
||||||
|
className="min-w-[200px] flex-1"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Select value={editScope} onValueChange={(v) => setEditScope(v as TagScope)}>
|
||||||
|
<SelectTrigger className="w-[120px]"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="global">Global</SelectItem>
|
||||||
|
<SelectItem value="module">Modul</SelectItem>
|
||||||
|
<SelectItem value="company">Companie</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{editScope === 'company' && (
|
||||||
|
<Select value={editCompanyId} onValueChange={(v) => setEditCompanyId(v as CompanyId)}>
|
||||||
|
<SelectTrigger className="w-[140px]"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.keys(COMPANY_LABELS) as CompanyId[]).map((c) => (
|
||||||
|
<SelectItem key={c} value={c}>{COMPANY_LABELS[c]}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{TAG_COLORS.slice(0, 6).map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditColor(c)}
|
||||||
|
className={cn(
|
||||||
|
'h-5 w-5 rounded-full border-2 transition-all',
|
||||||
|
editColor === c ? 'border-primary scale-110' : 'border-transparent'
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onSaveEdit}>
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
</Button>
|
||||||
|
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onCancelEdit}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/30">
|
||||||
|
{hasChildren && (
|
||||||
|
<button type="button" onClick={() => setShowChildren(!showChildren)} className="p-0.5">
|
||||||
|
{showChildren
|
||||||
|
? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
: <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!hasChildren && <span className="w-5" />}
|
||||||
|
{tag.color && (
|
||||||
|
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: tag.color }} />
|
||||||
|
)}
|
||||||
|
{tag.projectCode && (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{tag.projectCode}</span>
|
||||||
|
)}
|
||||||
|
<span className="flex-1 text-sm">{tag.label}</span>
|
||||||
|
{tag.companyId && (
|
||||||
|
<Badge variant="outline" className="text-[10px] px-1.5">
|
||||||
|
{COMPANY_LABELS[tag.companyId]}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline" className="text-[10px] px-1">
|
||||||
|
{SCOPE_LABELS[tag.scope]}
|
||||||
|
</Badge>
|
||||||
|
<div className="flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStartEdit(tag)}
|
||||||
|
className="rounded p-1 hover:bg-muted"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(tag.id)}
|
||||||
|
className="rounded p-1 hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3 text-destructive" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasChildren && showChildren && (
|
||||||
|
<div className="ml-6 border-l pl-2">
|
||||||
|
{children.map((child) => (
|
||||||
|
<div key={child.id} className="group flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/30">
|
||||||
|
<FolderTree className="h-3 w-3 text-muted-foreground" />
|
||||||
|
{child.color && (
|
||||||
|
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: child.color }} />
|
||||||
|
)}
|
||||||
|
{child.projectCode && (
|
||||||
|
<span className="font-mono text-[11px] text-muted-foreground">{child.projectCode}</span>
|
||||||
|
)}
|
||||||
|
<span className="flex-1 text-sm">{child.label}</span>
|
||||||
|
<div className="flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<button type="button" onClick={() => onStartEdit(child)} className="rounded p-1 hover:bg-muted">
|
||||||
|
<Pencil className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onDelete(child.id)} className="rounded p-1 hover:bg-destructive/10">
|
||||||
|
<Trash2 className="h-3 w-3 text-destructive" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { tagManagerConfig } from './config';
|
export { tagManagerConfig } from './config';
|
||||||
export { TagManagerModule } from './components/tag-manager-module';
|
export { TagManagerModule } from './components/tag-manager-module';
|
||||||
export type { Tag, TagCategory, TagScope } from './types';
|
export type { Tag, TagCategory, TagScope } from './types';
|
||||||
|
export { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from './types';
|
||||||
|
|||||||
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 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';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Plus, Pencil, Trash2, Search, FileText, ExternalLink } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Search, FileText, ExternalLink, Copy } from 'lucide-react';
|
||||||
import { Button } from '@/shared/components/ui/button';
|
import { Button } from '@/shared/components/ui/button';
|
||||||
import { Input } from '@/shared/components/ui/input';
|
import { Input } from '@/shared/components/ui/input';
|
||||||
import { Label } from '@/shared/components/ui/label';
|
import { Label } from '@/shared/components/ui/label';
|
||||||
@@ -9,22 +9,31 @@ import { Textarea } from '@/shared/components/ui/textarea';
|
|||||||
import { Badge } from '@/shared/components/ui/badge';
|
import { Badge } from '@/shared/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog';
|
||||||
import type { CompanyId } from '@/core/auth/types';
|
import type { CompanyId } from '@/core/auth/types';
|
||||||
import type { WordTemplate } from '../types';
|
import type { WordTemplate, TemplateCategory } from '../types';
|
||||||
import { useTemplates } from '../hooks/use-templates';
|
import { useTemplates } from '../hooks/use-templates';
|
||||||
|
|
||||||
const TEMPLATE_CATEGORIES = [
|
const CATEGORY_LABELS: Record<TemplateCategory, string> = {
|
||||||
'Contract', 'Memoriu tehnic', 'Ofertă', 'Factură', 'Raport', 'Deviz', 'Proces-verbal', 'Altele',
|
contract: 'Contract',
|
||||||
];
|
memoriu: 'Memoriu tehnic',
|
||||||
|
oferta: 'Ofertă',
|
||||||
|
raport: 'Raport',
|
||||||
|
cerere: 'Cerere',
|
||||||
|
aviz: 'Aviz',
|
||||||
|
scrisoare: 'Scrisoare',
|
||||||
|
altele: 'Altele',
|
||||||
|
};
|
||||||
|
|
||||||
type ViewMode = 'list' | 'add' | 'edit';
|
type ViewMode = 'list' | 'add' | 'edit';
|
||||||
|
|
||||||
export function WordTemplatesModule() {
|
export function WordTemplatesModule() {
|
||||||
const { templates, allTemplates, allCategories, loading, filters, updateFilter, addTemplate, updateTemplate, removeTemplate } = useTemplates();
|
const { templates, allTemplates, loading, filters, updateFilter, addTemplate, updateTemplate, cloneTemplate, removeTemplate } = useTemplates();
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||||
const [editingTemplate, setEditingTemplate] = useState<WordTemplate | null>(null);
|
const [editingTemplate, setEditingTemplate] = useState<WordTemplate | null>(null);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleSubmit = async (data: Omit<WordTemplate, 'id' | 'createdAt'>) => {
|
const handleSubmit = async (data: Omit<WordTemplate, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||||
if (viewMode === 'edit' && editingTemplate) {
|
if (viewMode === 'edit' && editingTemplate) {
|
||||||
await updateTemplate(editingTemplate.id, data);
|
await updateTemplate(editingTemplate.id, data);
|
||||||
} else {
|
} else {
|
||||||
@@ -34,16 +43,21 @@ export function WordTemplatesModule() {
|
|||||||
setEditingTemplate(null);
|
setEditingTemplate(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const filterCategories = allCategories.length > 0 ? allCategories : TEMPLATE_CATEGORIES;
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (deletingId) {
|
||||||
|
await removeTemplate(deletingId);
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total șabloane</p><p className="text-2xl font-bold">{allTemplates.length}</p></CardContent></Card>
|
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total șabloane</p><p className="text-2xl font-bold">{allTemplates.length}</p></CardContent></Card>
|
||||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Categorii</p><p className="text-2xl font-bold">{allCategories.length}</p></CardContent></Card>
|
|
||||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Beletage</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'beletage').length}</p></CardContent></Card>
|
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Beletage</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'beletage').length}</p></CardContent></Card>
|
||||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Urban Switch</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'urban-switch').length}</p></CardContent></Card>
|
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Urban Switch</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'urban-switch').length}</p></CardContent></Card>
|
||||||
|
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Studii de Teren</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'studii-de-teren').length}</p></CardContent></Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewMode === 'list' && (
|
{viewMode === 'list' && (
|
||||||
@@ -53,15 +67,25 @@ export function WordTemplatesModule() {
|
|||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input placeholder="Caută șablon..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
|
<Input placeholder="Caută șablon..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
|
||||||
</div>
|
</div>
|
||||||
<Select value={filters.category} onValueChange={(v) => updateFilter('category', v)}>
|
<Select value={filters.category} onValueChange={(v) => updateFilter('category', v as TemplateCategory | 'all')}>
|
||||||
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Toate</SelectItem>
|
<SelectItem value="all">Toate categoriile</SelectItem>
|
||||||
{filterCategories.map((c) => (
|
{(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map((c) => (
|
||||||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<Select value={filters.company} onValueChange={(v) => updateFilter('company', v)}>
|
||||||
|
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Toate companiile</SelectItem>
|
||||||
|
<SelectItem value="beletage">Beletage</SelectItem>
|
||||||
|
<SelectItem value="urban-switch">Urban Switch</SelectItem>
|
||||||
|
<SelectItem value="studii-de-teren">Studii de Teren</SelectItem>
|
||||||
|
<SelectItem value="group">Grup</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<Button onClick={() => setViewMode('add')} className="shrink-0">
|
<Button onClick={() => setViewMode('add')} className="shrink-0">
|
||||||
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
||||||
</Button>
|
</Button>
|
||||||
@@ -70,19 +94,20 @@ export function WordTemplatesModule() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
|
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
|
||||||
) : templates.length === 0 ? (
|
) : templates.length === 0 ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
<p className="py-8 text-center text-sm text-muted-foreground">Niciun șablon găsit. Adaugă primul șablon Word.</p>
|
||||||
Niciun șablon găsit. Adaugă primul șablon Word.
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{templates.map((tpl) => (
|
{templates.map((tpl) => (
|
||||||
<Card key={tpl.id} className="group relative">
|
<Card key={tpl.id} className="group relative">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" title="Clonează" onClick={() => cloneTemplate(tpl.id)}>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingTemplate(tpl); setViewMode('edit'); }}>
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingTemplate(tpl); setViewMode('edit'); }}>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeTemplate(tpl.id)}>
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => setDeletingId(tpl.id)}>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,9 +119,18 @@ export function WordTemplatesModule() {
|
|||||||
<p className="font-medium">{tpl.name}</p>
|
<p className="font-medium">{tpl.name}</p>
|
||||||
{tpl.description && <p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">{tpl.description}</p>}
|
{tpl.description && <p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">{tpl.description}</p>}
|
||||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
{tpl.category && <Badge variant="outline" className="text-[10px]">{tpl.category}</Badge>}
|
<Badge variant="outline" className="text-[10px]">{CATEGORY_LABELS[tpl.category]}</Badge>
|
||||||
<Badge variant="secondary" className="text-[10px]">v{tpl.version}</Badge>
|
<Badge variant="secondary" className="text-[10px]">v{tpl.version}</Badge>
|
||||||
|
{tpl.clonedFrom && <Badge variant="secondary" className="text-[10px]">Clonă</Badge>}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Placeholders display */}
|
||||||
|
{tpl.placeholders.length > 0 && (
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
|
{tpl.placeholders.map((p) => (
|
||||||
|
<span key={p} className="rounded bg-muted px-1 py-0.5 font-mono text-[10px] text-muted-foreground">{`{{${p}}}`}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{tpl.fileUrl && (
|
{tpl.fileUrl && (
|
||||||
<a href={tpl.fileUrl} target="_blank" rel="noopener noreferrer" className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline">
|
<a href={tpl.fileUrl} target="_blank" rel="noopener noreferrer" className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline">
|
||||||
<ExternalLink className="h-3 w-3" /> Deschide fișier
|
<ExternalLink className="h-3 w-3" /> Deschide fișier
|
||||||
@@ -120,30 +154,58 @@ export function WordTemplatesModule() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>Confirmare ștergere</DialogTitle></DialogHeader>
|
||||||
|
<p className="text-sm">Ești sigur 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TemplateForm({ initial, onSubmit, onCancel }: {
|
function TemplateForm({ initial, onSubmit, onCancel }: {
|
||||||
initial?: WordTemplate;
|
initial?: WordTemplate;
|
||||||
onSubmit: (data: Omit<WordTemplate, 'id' | 'createdAt'>) => void;
|
onSubmit: (data: Omit<WordTemplate, 'id' | 'createdAt' | 'updatedAt'>) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState(initial?.name ?? '');
|
const [name, setName] = useState(initial?.name ?? '');
|
||||||
const [description, setDescription] = useState(initial?.description ?? '');
|
const [description, setDescription] = useState(initial?.description ?? '');
|
||||||
const [category, setCategory] = useState(initial?.category ?? 'Contract');
|
const [category, setCategory] = useState<TemplateCategory>(initial?.category ?? 'contract');
|
||||||
const [fileUrl, setFileUrl] = useState(initial?.fileUrl ?? '');
|
const [fileUrl, setFileUrl] = useState(initial?.fileUrl ?? '');
|
||||||
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
|
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
|
||||||
const [version, setVersion] = useState(initial?.version ?? '1.0.0');
|
const [version, setVersion] = useState(initial?.version ?? '1.0.0');
|
||||||
|
const [placeholdersText, setPlaceholdersText] = useState(initial?.placeholders.join(', ') ?? '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ name, description, category, fileUrl, company, version, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all' }); }} className="space-y-4">
|
<form onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const placeholders = placeholdersText
|
||||||
|
.split(',')
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter((p) => p.length > 0);
|
||||||
|
onSubmit({
|
||||||
|
name, description, category, fileUrl, company, version, placeholders,
|
||||||
|
clonedFrom: initial?.clonedFrom,
|
||||||
|
tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all',
|
||||||
|
});
|
||||||
|
}} className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div><Label>Nume șablon</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div>
|
<div><Label>Nume șablon *</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div>
|
||||||
<div><Label>Categorie</Label>
|
<div><Label>Categorie</Label>
|
||||||
<Select value={category} onValueChange={setCategory}>
|
<Select value={category} onValueChange={(v) => setCategory(v as TemplateCategory)}>
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{TEMPLATE_CATEGORIES.map((c) => (<SelectItem key={c} value={c}>{c}</SelectItem>))}</SelectContent>
|
<SelectContent>
|
||||||
|
{(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map((c) => (
|
||||||
|
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,6 +225,11 @@ function TemplateForm({ initial, onSubmit, onCancel }: {
|
|||||||
<div><Label>Versiune</Label><Input value={version} onChange={(e) => setVersion(e.target.value)} className="mt-1" /></div>
|
<div><Label>Versiune</Label><Input value={version} onChange={(e) => setVersion(e.target.value)} className="mt-1" /></div>
|
||||||
<div><Label>URL fișier</Label><Input value={fileUrl} onChange={(e) => setFileUrl(e.target.value)} className="mt-1" placeholder="https://..." /></div>
|
<div><Label>URL fișier</Label><Input value={fileUrl} onChange={(e) => setFileUrl(e.target.value)} className="mt-1" placeholder="https://..." /></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Placeholder-e (separate prin virgulă)</Label>
|
||||||
|
<Input value={placeholdersText} onChange={(e) => setPlaceholdersText(e.target.value)} className="mt-1" placeholder="NUME_BENEFICIAR, DATA_CONTRACT, NR_PROIECT..." />
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Variabilele din șablon, de forma {'{{VARIABILA}}'}, separate prin virgulă.</p>
|
||||||
|
</div>
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
||||||
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
|
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
|
||||||
|
|||||||
@@ -3,20 +3,21 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useStorage } from '@/core/storage';
|
import { useStorage } from '@/core/storage';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import type { WordTemplate } from '../types';
|
import type { WordTemplate, TemplateCategory } from '../types';
|
||||||
|
|
||||||
const PREFIX = 'tpl:';
|
const PREFIX = 'tpl:';
|
||||||
|
|
||||||
export interface TemplateFilters {
|
export interface TemplateFilters {
|
||||||
search: string;
|
search: string;
|
||||||
category: string;
|
category: TemplateCategory | 'all';
|
||||||
|
company: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTemplates() {
|
export function useTemplates() {
|
||||||
const storage = useStorage('word-templates');
|
const storage = useStorage('word-templates');
|
||||||
const [templates, setTemplates] = useState<WordTemplate[]>([]);
|
const [templates, setTemplates] = useState<WordTemplate[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filters, setFilters] = useState<TemplateFilters>({ search: '', category: 'all' });
|
const [filters, setFilters] = useState<TemplateFilters>({ search: '', category: 'all', company: 'all' });
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -36,8 +37,9 @@ export function useTemplates() {
|
|||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
useEffect(() => { refresh(); }, [refresh]);
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
const addTemplate = useCallback(async (data: Omit<WordTemplate, 'id' | 'createdAt'>) => {
|
const addTemplate = useCallback(async (data: Omit<WordTemplate, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||||
const template: WordTemplate = { ...data, id: uuid(), createdAt: new Date().toISOString() };
|
const now = new Date().toISOString();
|
||||||
|
const template: WordTemplate = { ...data, id: uuid(), createdAt: now, updatedAt: now };
|
||||||
await storage.set(`${PREFIX}${template.id}`, template);
|
await storage.set(`${PREFIX}${template.id}`, template);
|
||||||
await refresh();
|
await refresh();
|
||||||
return template;
|
return template;
|
||||||
@@ -46,11 +48,32 @@ export function useTemplates() {
|
|||||||
const updateTemplate = useCallback(async (id: string, updates: Partial<WordTemplate>) => {
|
const updateTemplate = useCallback(async (id: string, updates: Partial<WordTemplate>) => {
|
||||||
const existing = templates.find((t) => t.id === id);
|
const existing = templates.find((t) => t.id === id);
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
const updated = { ...existing, ...updates, id: existing.id, createdAt: existing.createdAt };
|
const updated: WordTemplate = {
|
||||||
|
...existing, ...updates,
|
||||||
|
id: existing.id, createdAt: existing.createdAt,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
await storage.set(`${PREFIX}${id}`, updated);
|
await storage.set(`${PREFIX}${id}`, updated);
|
||||||
await refresh();
|
await refresh();
|
||||||
}, [storage, refresh, templates]);
|
}, [storage, refresh, templates]);
|
||||||
|
|
||||||
|
const cloneTemplate = useCallback(async (id: string) => {
|
||||||
|
const existing = templates.find((t) => t.id === id);
|
||||||
|
if (!existing) return;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const cloned: WordTemplate = {
|
||||||
|
...existing,
|
||||||
|
id: uuid(),
|
||||||
|
name: `${existing.name} (copie)`,
|
||||||
|
clonedFrom: existing.id,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
await storage.set(`${PREFIX}${cloned.id}`, cloned);
|
||||||
|
await refresh();
|
||||||
|
return cloned;
|
||||||
|
}, [storage, refresh, templates]);
|
||||||
|
|
||||||
const removeTemplate = useCallback(async (id: string) => {
|
const removeTemplate = useCallback(async (id: string) => {
|
||||||
await storage.delete(`${PREFIX}${id}`);
|
await storage.delete(`${PREFIX}${id}`);
|
||||||
await refresh();
|
await refresh();
|
||||||
@@ -60,10 +83,9 @@ export function useTemplates() {
|
|||||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const allCategories = [...new Set(templates.map((t) => t.category).filter(Boolean))];
|
|
||||||
|
|
||||||
const filteredTemplates = templates.filter((t) => {
|
const filteredTemplates = templates.filter((t) => {
|
||||||
if (filters.category !== 'all' && t.category !== filters.category) return false;
|
if (filters.category !== 'all' && t.category !== filters.category) return false;
|
||||||
|
if (filters.company !== 'all' && t.company !== filters.company) return false;
|
||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
const q = filters.search.toLowerCase();
|
const q = filters.search.toLowerCase();
|
||||||
return t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q);
|
return t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q);
|
||||||
@@ -71,5 +93,5 @@ export function useTemplates() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { templates: filteredTemplates, allTemplates: templates, allCategories, loading, filters, updateFilter, addTemplate, updateTemplate, removeTemplate, refresh };
|
return { templates: filteredTemplates, allTemplates: templates, loading, filters, updateFilter, addTemplate, updateTemplate, cloneTemplate, removeTemplate, refresh };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export { wordTemplatesConfig } from './config';
|
export { wordTemplatesConfig } from './config';
|
||||||
export { WordTemplatesModule } from './components/word-templates-module';
|
export { WordTemplatesModule } from './components/word-templates-module';
|
||||||
export type { WordTemplate } from './types';
|
export type { WordTemplate, TemplateCategory } from './types';
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
import type { Visibility } from '@/core/module-registry/types';
|
import type { Visibility } from '@/core/module-registry/types';
|
||||||
import type { CompanyId } from '@/core/auth/types';
|
import type { CompanyId } from '@/core/auth/types';
|
||||||
|
|
||||||
|
export type TemplateCategory =
|
||||||
|
| 'contract'
|
||||||
|
| 'memoriu'
|
||||||
|
| 'oferta'
|
||||||
|
| 'raport'
|
||||||
|
| 'cerere'
|
||||||
|
| 'aviz'
|
||||||
|
| 'scrisoare'
|
||||||
|
| 'altele';
|
||||||
|
|
||||||
export interface WordTemplate {
|
export interface WordTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
category: string;
|
category: TemplateCategory;
|
||||||
fileUrl: string;
|
fileUrl: string;
|
||||||
company: CompanyId;
|
company: CompanyId;
|
||||||
|
/** Detected placeholders in template */
|
||||||
|
placeholders: string[];
|
||||||
|
/** Cloned from template ID */
|
||||||
|
clonedFrom?: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
version: string;
|
version: string;
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user