Initial commit: ArchiTools modular dashboard platform

Complete Next.js 16 application with 13 fully implemented modules:
Email Signature, Word XML Generator, Registratura, Dashboard,
Tag Manager, IT Inventory, Address Book, Password Vault,
Mini Utilities, Prompt Generator, Digital Signatures,
Word Templates, and AI Chat.

Includes core platform systems (module registry, feature flags,
storage abstraction, i18n, theming, auth stub, tagging),
16 technical documentation files, Docker deployment config,
and legacy HTML tool reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Marius Tarau
2026-02-17 12:50:25 +02:00
commit 4c46e8bcdd
189 changed files with 33780 additions and 0 deletions

View File

@@ -0,0 +1,168 @@
'use client';
import { useState } from 'react';
import { Plus, Pencil, Trash2, Search, Mail, Phone, MapPin } from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label';
import { Textarea } from '@/shared/components/ui/textarea';
import { Badge } from '@/shared/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
import type { AddressContact, ContactType } from '../types';
import { useContacts } from '../hooks/use-contacts';
const TYPE_LABELS: Record<ContactType, string> = {
client: 'Client', supplier: 'Furnizor', institution: 'Instituție', collaborator: 'Colaborator',
};
type ViewMode = 'list' | 'add' | 'edit';
export function AddressBookModule() {
const { contacts, allContacts, loading, filters, updateFilter, addContact, updateContact, removeContact } = useContacts();
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [editingContact, setEditingContact] = useState<AddressContact | null>(null);
const handleSubmit = async (data: Omit<AddressContact, 'id' | 'createdAt'>) => {
if (viewMode === 'edit' && editingContact) {
await updateContact(editingContact.id, data);
} else {
await addContact(data);
}
setViewMode('list');
setEditingContact(null);
};
return (
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total</p><p className="text-2xl font-bold">{allContacts.length}</p></CardContent></Card>
{(Object.keys(TYPE_LABELS) as ContactType[]).slice(0, 3).map((type) => (
<Card key={type}><CardContent className="p-4">
<p className="text-xs text-muted-foreground">{TYPE_LABELS[type]}</p>
<p className="text-2xl font-bold">{allContacts.filter((c) => c.type === type).length}</p>
</CardContent></Card>
))}
</div>
{viewMode === 'list' && (
<>
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Caută contact..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
</div>
<Select value={filters.type} onValueChange={(v) => updateFilter('type', v as ContactType | 'all')}>
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate tipurile</SelectItem>
{(Object.keys(TYPE_LABELS) as ContactType[]).map((t) => (
<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => setViewMode('add')} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
) : contacts.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Niciun contact găsit.</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{contacts.map((contact) => (
<Card key={contact.id} className="group relative">
<CardContent className="p-4">
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingContact(contact); setViewMode('edit'); }}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeContact(contact.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-2">
<div>
<p className="font-medium">{contact.name}</p>
<div className="flex items-center gap-2">
{contact.company && <p className="text-xs text-muted-foreground">{contact.company}</p>}
<Badge variant="outline" className="text-[10px]">{TYPE_LABELS[contact.type]}</Badge>
</div>
</div>
{contact.email && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Mail className="h-3 w-3" /><span>{contact.email}</span>
</div>
)}
{contact.phone && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Phone className="h-3 w-3" /><span>{contact.phone}</span>
</div>
)}
{contact.address && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPin className="h-3 w-3" /><span className="truncate">{contact.address}</span>
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
</>
)}
{(viewMode === 'add' || viewMode === 'edit') && (
<Card>
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare contact' : 'Contact nou'}</CardTitle></CardHeader>
<CardContent>
<ContactForm initial={editingContact ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingContact(null); }} />
</CardContent>
</Card>
)}
</div>
);
}
function ContactForm({ initial, onSubmit, onCancel }: {
initial?: AddressContact;
onSubmit: (data: Omit<AddressContact, 'id' | 'createdAt'>) => void;
onCancel: () => void;
}) {
const [name, setName] = useState(initial?.name ?? '');
const [company, setCompany] = useState(initial?.company ?? '');
const [type, setType] = useState<ContactType>(initial?.type ?? 'client');
const [email, setEmail] = useState(initial?.email ?? '');
const [phone, setPhone] = useState(initial?.phone ?? '');
const [address, setAddress] = useState(initial?.address ?? '');
const [notes, setNotes] = useState(initial?.notes ?? '');
return (
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ name, company, type, email, phone, address, notes, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all' }); }} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div><Label>Nume</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div>
<div><Label>Companie/Organizație</Label><Input value={company} onChange={(e) => setCompany(e.target.value)} className="mt-1" /></div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div><Label>Tip</Label>
<Select value={type} onValueChange={(v) => setType(v as ContactType)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>{(Object.keys(TYPE_LABELS) as ContactType[]).map((t) => (<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem>))}</SelectContent>
</Select>
</div>
<div><Label>Email</Label><Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="mt-1" /></div>
<div><Label>Telefon</Label><Input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} className="mt-1" /></div>
</div>
<div><Label>Adresă</Label><Input value={address} onChange={(e) => setAddress(e.target.value)} 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">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,17 @@
import type { ModuleConfig } from '@/core/module-registry/types';
export const addressBookConfig: ModuleConfig = {
id: 'address-book',
name: 'Contacte',
description: 'Agendă de contacte organizată pe tipuri: clienți, furnizori, instituții, colaboratori',
icon: 'users',
route: '/address-book',
category: 'management',
featureFlag: 'module.address-book',
visibility: 'all',
version: '0.1.0',
dependencies: [],
storageNamespace: 'address-book',
navOrder: 32,
tags: ['contacte', 'agendă', 'clienți'],
};

View File

@@ -0,0 +1,73 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useStorage } from '@/core/storage';
import { v4 as uuid } from 'uuid';
import type { AddressContact, ContactType } from '../types';
const PREFIX = 'contact:';
export interface ContactFilters {
search: string;
type: ContactType | 'all';
}
export function useContacts() {
const storage = useStorage('address-book');
const [contacts, setContacts] = useState<AddressContact[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<ContactFilters>({ search: '', type: 'all' });
const refresh = useCallback(async () => {
setLoading(true);
const keys = await storage.list();
const results: AddressContact[] = [];
for (const key of keys) {
if (key.startsWith(PREFIX)) {
const item = await storage.get<AddressContact>(key);
if (item) results.push(item);
}
}
results.sort((a, b) => a.name.localeCompare(b.name));
setContacts(results);
setLoading(false);
}, [storage]);
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]);
const addContact = useCallback(async (data: Omit<AddressContact, 'id' | 'createdAt'>) => {
const contact: AddressContact = { ...data, id: uuid(), createdAt: new Date().toISOString() };
await storage.set(`${PREFIX}${contact.id}`, contact);
await refresh();
return contact;
}, [storage, refresh]);
const updateContact = useCallback(async (id: string, updates: Partial<AddressContact>) => {
const existing = contacts.find((c) => c.id === id);
if (!existing) return;
const updated = { ...existing, ...updates, id: existing.id, createdAt: existing.createdAt };
await storage.set(`${PREFIX}${id}`, updated);
await refresh();
}, [storage, refresh, contacts]);
const removeContact = useCallback(async (id: string) => {
await storage.delete(`${PREFIX}${id}`);
await refresh();
}, [storage, refresh]);
const updateFilter = useCallback(<K extends keyof ContactFilters>(key: K, value: ContactFilters[K]) => {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
const filteredContacts = contacts.filter((c) => {
if (filters.type !== 'all' && c.type !== filters.type) return false;
if (filters.search) {
const q = filters.search.toLowerCase();
return c.name.toLowerCase().includes(q) || c.company.toLowerCase().includes(q) || c.email.toLowerCase().includes(q) || c.phone.includes(q);
}
return true;
});
return { contacts: filteredContacts, allContacts: contacts, loading, filters, updateFilter, addContact, updateContact, removeContact, refresh };
}

View File

@@ -0,0 +1,3 @@
export { addressBookConfig } from './config';
export { AddressBookModule } from './components/address-book-module';
export type { AddressContact, ContactType } from './types';

View File

@@ -0,0 +1,17 @@
import type { Visibility } from '@/core/module-registry/types';
export type ContactType = 'client' | 'supplier' | 'institution' | 'collaborator';
export interface AddressContact {
id: string;
name: string;
company: string;
type: ContactType;
email: string;
phone: string;
address: string;
tags: string[];
notes: string;
visibility: Visibility;
createdAt: string;
}

View File

@@ -0,0 +1,164 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { Plus, Send, Trash2, MessageSquare, Settings } from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import { Input } from '@/shared/components/ui/input';
import { Card, CardContent } from '@/shared/components/ui/card';
import { Badge } from '@/shared/components/ui/badge';
import { cn } from '@/shared/lib/utils';
import { useChat } from '../hooks/use-chat';
export function AiChatModule() {
const {
sessions, activeSession, activeSessionId,
createSession, addMessage, deleteSession, selectSession,
} = useChat();
const [input, setInput] = useState('');
const [showConfig, setShowConfig] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [activeSession?.messages.length]);
const handleSend = async () => {
if (!input.trim()) return;
const text = input.trim();
setInput('');
if (!activeSessionId) {
await createSession();
}
await addMessage(text, 'user');
// Simulate AI response (no real API connected)
setTimeout(async () => {
await addMessage(
'Acest modul necesită configurarea unei conexiuni API către un model AI (ex: Claude, GPT). ' +
'Momentan funcționează în mod demonstrativ — mesajele sunt salvate local, dar răspunsurile AI nu sunt generate.\n\n' +
'Pentru a activa răspunsurile AI, configurați cheia API în setările modulului.',
'assistant'
);
}, 500);
};
return (
<div className="flex h-[calc(100vh-12rem)] gap-4">
{/* Sidebar - sessions */}
<div className="hidden w-64 shrink-0 flex-col gap-2 md:flex">
<Button onClick={() => createSession()} size="sm" className="w-full">
<Plus className="mr-1.5 h-3.5 w-3.5" /> Conversație nouă
</Button>
<div className="flex-1 space-y-1 overflow-y-auto">
{sessions.map((session) => (
<div
key={session.id}
className={cn(
'group flex cursor-pointer items-center justify-between rounded-md px-2 py-1.5 text-sm transition-colors',
session.id === activeSessionId ? 'bg-accent' : 'hover:bg-accent/50'
)}
onClick={() => selectSession(session.id)}
>
<div className="flex min-w-0 items-center gap-1.5">
<MessageSquare className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{session.title}</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100"
onClick={(e) => { e.stopPropagation(); deleteSession(session.id); }}
>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
))}
</div>
</div>
{/* Main chat area */}
<div className="flex flex-1 flex-col rounded-lg border">
{/* Header */}
<div className="flex items-center justify-between border-b px-4 py-2">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium">
{activeSession?.title ?? 'Chat AI'}
</h3>
<Badge variant="outline" className="text-[10px]">Demo</Badge>
</div>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setShowConfig(!showConfig)}>
<Settings className="h-3.5 w-3.5" />
</Button>
</div>
{/* Config banner */}
{showConfig && (
<div className="border-b bg-muted/30 px-4 py-3 text-xs text-muted-foreground">
<p className="font-medium">Configurare API (viitor)</p>
<p className="mt-1">
Modulul va suporta conectarea la API-uri AI (Anthropic Claude, OpenAI, modele locale via Ollama).
Cheia API și endpoint-ul se vor configura din setările aplicației sau variabile de mediu.
</p>
<p className="mt-1">
Momentan, conversațiile sunt salvate local, dar fără generare de răspunsuri AI reale.
</p>
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4">
{!activeSession || activeSession.messages.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<MessageSquare className="mb-3 h-10 w-10 opacity-30" />
<p className="text-sm">Începe o conversație nouă</p>
<p className="mt-1 text-xs">Scrie un mesaj sau creează o sesiune nouă din bara laterală.</p>
</div>
) : (
<div className="space-y-4">
{activeSession.messages.map((msg) => (
<div
key={msg.id}
className={cn(
'max-w-[80%] rounded-lg px-3 py-2 text-sm',
msg.role === 'user'
? 'ml-auto bg-primary text-primary-foreground'
: 'bg-muted'
)}
>
<p className="whitespace-pre-wrap">{msg.content}</p>
<p className={cn(
'mt-1 text-[10px]',
msg.role === 'user' ? 'text-primary-foreground/60' : 'text-muted-foreground'
)}>
{new Date(msg.timestamp).toLocaleTimeString('ro-RO', { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Input */}
<div className="border-t p-3">
<div className="flex gap-2">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
placeholder="Scrie un mesaj..."
className="flex-1"
/>
<Button onClick={handleSend} disabled={!input.trim()}>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import type { ModuleConfig } from '@/core/module-registry/types';
export const aiChatConfig: ModuleConfig = {
id: 'ai-chat',
name: 'Chat AI',
description: 'Interfață de conversație cu modele AI pentru asistență profesională',
icon: 'message-square',
route: '/ai-chat',
category: 'ai',
featureFlag: 'module.ai-chat',
visibility: 'all',
version: '0.1.0',
dependencies: [],
storageNamespace: 'ai-chat',
navOrder: 51,
tags: ['chat', 'ai', 'conversație'],
};

View File

@@ -0,0 +1,93 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useStorage } from '@/core/storage';
import { v4 as uuid } from 'uuid';
import type { ChatMessage, ChatSession } from '../types';
const SESSION_PREFIX = 'session:';
export function useChat() {
const storage = useStorage('ai-chat');
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const activeSession = sessions.find((s) => s.id === activeSessionId) ?? null;
const refresh = useCallback(async () => {
setLoading(true);
const keys = await storage.list();
const results: ChatSession[] = [];
for (const key of keys) {
if (key.startsWith(SESSION_PREFIX)) {
const session = await storage.get<ChatSession>(key);
if (session) results.push(session);
}
}
results.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
setSessions(results);
setLoading(false);
}, [storage]);
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]);
const createSession = useCallback(async (title?: string) => {
const session: ChatSession = {
id: uuid(),
title: title || `Conversație ${new Date().toLocaleDateString('ro-RO')}`,
messages: [],
createdAt: new Date().toISOString(),
};
await storage.set(`${SESSION_PREFIX}${session.id}`, session);
setSessions((prev) => [session, ...prev]);
setActiveSessionId(session.id);
return session;
}, [storage]);
const addMessage = useCallback(async (content: string, role: ChatMessage['role']) => {
if (!activeSessionId) return;
const message: ChatMessage = {
id: uuid(),
role,
content,
timestamp: new Date().toISOString(),
};
setSessions((prev) => prev.map((s) => {
if (s.id !== activeSessionId) return s;
return { ...s, messages: [...s.messages, message] };
}));
// Persist
const current = sessions.find((s) => s.id === activeSessionId);
if (current) {
const updated = { ...current, messages: [...current.messages, message] };
await storage.set(`${SESSION_PREFIX}${activeSessionId}`, updated);
}
return message;
}, [storage, activeSessionId, sessions]);
const deleteSession = useCallback(async (id: string) => {
await storage.delete(`${SESSION_PREFIX}${id}`);
setSessions((prev) => prev.filter((s) => s.id !== id));
if (activeSessionId === id) {
setActiveSessionId(null);
}
}, [storage, activeSessionId]);
const selectSession = useCallback((id: string) => {
setActiveSessionId(id);
}, []);
return {
sessions,
activeSession,
activeSessionId,
loading,
createSession,
addMessage,
deleteSession,
selectSession,
refresh,
};
}

View File

@@ -0,0 +1,3 @@
export { aiChatConfig } from './config';
export { AiChatModule } from './components/ai-chat-module';
export type { ChatMessage, ChatRole, ChatSession } from './types';

View File

@@ -0,0 +1,15 @@
export type ChatRole = 'user' | 'assistant';
export interface ChatMessage {
id: string;
role: ChatRole;
content: string;
timestamp: string;
}
export interface ChatSession {
id: string;
title: string;
messages: ChatMessage[];
createdAt: string;
}

View File

@@ -0,0 +1 @@
export type { DashboardWidget, DashboardWidgetType } from './types';

View File

@@ -0,0 +1,9 @@
export type DashboardWidgetType = 'stat' | 'recent' | 'quick-action' | 'external-link';
export interface DashboardWidget {
id: string;
title: string;
type: DashboardWidgetType;
moduleId?: string;
data?: Record<string, unknown>;
}

View File

@@ -0,0 +1,182 @@
'use client';
import { useState } from 'react';
import { Plus, Pencil, Trash2, Search, PenTool, Stamp, Type } from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label';
import { Badge } from '@/shared/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
import type { CompanyId } from '@/core/auth/types';
import type { SignatureAsset, SignatureAssetType } from '../types';
import { useSignatures } from '../hooks/use-signatures';
const TYPE_LABELS: Record<SignatureAssetType, string> = {
signature: 'Semnătură', stamp: 'Ștampilă', initials: 'Inițiale',
};
const TYPE_ICONS: Record<SignatureAssetType, typeof PenTool> = {
signature: PenTool, stamp: Stamp, initials: Type,
};
type ViewMode = 'list' | 'add' | 'edit';
export function DigitalSignaturesModule() {
const { assets, allAssets, loading, filters, updateFilter, addAsset, updateAsset, removeAsset } = useSignatures();
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [editingAsset, setEditingAsset] = useState<SignatureAsset | null>(null);
const handleSubmit = async (data: Omit<SignatureAsset, 'id' | 'createdAt'>) => {
if (viewMode === 'edit' && editingAsset) {
await updateAsset(editingAsset.id, data);
} else {
await addAsset(data);
}
setViewMode('list');
setEditingAsset(null);
};
return (
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total</p><p className="text-2xl font-bold">{allAssets.length}</p></CardContent></Card>
{(Object.keys(TYPE_LABELS) as SignatureAssetType[]).map((type) => (
<Card key={type}><CardContent className="p-4">
<p className="text-xs text-muted-foreground">{TYPE_LABELS[type]}</p>
<p className="text-2xl font-bold">{allAssets.filter((a) => a.type === type).length}</p>
</CardContent></Card>
))}
</div>
{viewMode === 'list' && (
<>
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Caută..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
</div>
<Select value={filters.type} onValueChange={(v) => updateFilter('type', v as SignatureAssetType | 'all')}>
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate tipurile</SelectItem>
{(Object.keys(TYPE_LABELS) as SignatureAssetType[]).map((t) => (
<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => setViewMode('add')} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
) : assets.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Niciun element găsit. Adaugă o semnătură, ștampilă sau inițiale.</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{assets.map((asset) => {
const Icon = TYPE_ICONS[asset.type];
return (
<Card key={asset.id} className="group relative">
<CardContent className="p-4">
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingAsset(asset); setViewMode('edit'); }}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeAsset(asset.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-lg border bg-muted/30">
{asset.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={asset.imageUrl} alt={asset.label} className="max-h-10 max-w-10 object-contain" />
) : (
<Icon className="h-6 w-6 text-muted-foreground" />
)}
</div>
<div>
<p className="font-medium">{asset.label}</p>
<div className="flex items-center gap-1.5">
<Badge variant="outline" className="text-[10px]">{TYPE_LABELS[asset.type]}</Badge>
<span className="text-xs text-muted-foreground">{asset.owner}</span>
</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</>
)}
{(viewMode === 'add' || viewMode === 'edit') && (
<Card>
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare' : 'Element nou'}</CardTitle></CardHeader>
<CardContent>
<AssetForm initial={editingAsset ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingAsset(null); }} />
</CardContent>
</Card>
)}
</div>
);
}
function AssetForm({ initial, onSubmit, onCancel }: {
initial?: SignatureAsset;
onSubmit: (data: Omit<SignatureAsset, 'id' | 'createdAt'>) => void;
onCancel: () => void;
}) {
const [label, setLabel] = useState(initial?.label ?? '');
const [type, setType] = useState<SignatureAssetType>(initial?.type ?? 'signature');
const [imageUrl, setImageUrl] = useState(initial?.imageUrl ?? '');
const [owner, setOwner] = useState(initial?.owner ?? '');
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
return (
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ label, type, imageUrl, owner, company, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all' }); }} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div><Label>Denumire</Label><Input value={label} onChange={(e) => setLabel(e.target.value)} className="mt-1" required /></div>
<div><Label>Tip</Label>
<Select value={type} onValueChange={(v) => setType(v as SignatureAssetType)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="signature">Semnătură</SelectItem>
<SelectItem value="stamp">Ștampilă</SelectItem>
<SelectItem value="initials">Inițiale</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div><Label>Proprietar</Label><Input value={owner} onChange={(e) => setOwner(e.target.value)} className="mt-1" /></div>
<div><Label>Companie</Label>
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<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>
</div>
</div>
<div>
<Label>URL imagine</Label>
<Input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} className="mt-1" placeholder="https://... sau data:image/png;base64,..." />
<p className="mt-1 text-xs text-muted-foreground">URL către imaginea semnăturii/ștampilei. Suportă URL-uri externe sau base64.</p>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,17 @@
import type { ModuleConfig } from '@/core/module-registry/types';
export const digitalSignaturesConfig: ModuleConfig = {
id: 'digital-signatures',
name: 'Semnături și Ștampile',
description: 'Gestionare semnături digitale, ștampile și inițiale pentru documente',
icon: 'pen-tool',
route: '/digital-signatures',
category: 'management',
featureFlag: 'module.digital-signatures',
visibility: 'all',
version: '0.1.0',
dependencies: [],
storageNamespace: 'digital-signatures',
navOrder: 30,
tags: ['semnături', 'ștampile', 'documente'],
};

View File

@@ -0,0 +1,73 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useStorage } from '@/core/storage';
import { v4 as uuid } from 'uuid';
import type { SignatureAsset, SignatureAssetType } from '../types';
const PREFIX = 'sig:';
export interface SignatureFilters {
search: string;
type: SignatureAssetType | 'all';
}
export function useSignatures() {
const storage = useStorage('digital-signatures');
const [assets, setAssets] = useState<SignatureAsset[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<SignatureFilters>({ search: '', type: 'all' });
const refresh = useCallback(async () => {
setLoading(true);
const keys = await storage.list();
const results: SignatureAsset[] = [];
for (const key of keys) {
if (key.startsWith(PREFIX)) {
const item = await storage.get<SignatureAsset>(key);
if (item) results.push(item);
}
}
results.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
setAssets(results);
setLoading(false);
}, [storage]);
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]);
const addAsset = useCallback(async (data: Omit<SignatureAsset, 'id' | 'createdAt'>) => {
const asset: SignatureAsset = { ...data, id: uuid(), createdAt: new Date().toISOString() };
await storage.set(`${PREFIX}${asset.id}`, asset);
await refresh();
return asset;
}, [storage, refresh]);
const updateAsset = useCallback(async (id: string, updates: Partial<SignatureAsset>) => {
const existing = assets.find((a) => a.id === id);
if (!existing) return;
const updated = { ...existing, ...updates, id: existing.id, createdAt: existing.createdAt };
await storage.set(`${PREFIX}${id}`, updated);
await refresh();
}, [storage, refresh, assets]);
const removeAsset = useCallback(async (id: string) => {
await storage.delete(`${PREFIX}${id}`);
await refresh();
}, [storage, refresh]);
const updateFilter = useCallback(<K extends keyof SignatureFilters>(key: K, value: SignatureFilters[K]) => {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
const filteredAssets = assets.filter((a) => {
if (filters.type !== 'all' && a.type !== filters.type) return false;
if (filters.search) {
const q = filters.search.toLowerCase();
return a.label.toLowerCase().includes(q) || a.owner.toLowerCase().includes(q);
}
return true;
});
return { assets: filteredAssets, allAssets: assets, loading, filters, updateFilter, addAsset, updateAsset, removeAsset, refresh };
}

View File

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

View File

@@ -0,0 +1,16 @@
import type { Visibility } from '@/core/module-registry/types';
import type { CompanyId } from '@/core/auth/types';
export type SignatureAssetType = 'signature' | 'stamp' | 'initials';
export interface SignatureAsset {
id: string;
label: string;
type: SignatureAssetType;
imageUrl: string;
owner: string;
company: CompanyId;
tags: string[];
visibility: Visibility;
createdAt: string;
}

View File

@@ -0,0 +1,58 @@
'use client';
import { useSignatureConfig } from '../hooks/use-signature-config';
import { useSavedSignatures } from '../hooks/use-saved-signatures';
import { SignatureConfigurator } from './signature-configurator';
import { SignaturePreview } from './signature-preview';
import { SavedSignaturesPanel } from './saved-signatures-panel';
import { Separator } from '@/shared/components/ui/separator';
import { Button } from '@/shared/components/ui/button';
import { RotateCcw } from 'lucide-react';
export function EmailSignatureModule() {
const {
config, updateField, updateColor, updateLayout,
setVariant, setCompany, resetToDefaults, loadConfig,
} = useSignatureConfig();
const { saved, loading, save, remove } = useSavedSignatures();
return (
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
{/* Left panel — configurator */}
<div className="space-y-6 overflow-y-auto lg:max-h-[calc(100vh-10rem)] lg:pr-2">
<SignatureConfigurator
config={config}
onUpdateField={updateField}
onUpdateColor={updateColor}
onUpdateLayout={updateLayout}
onSetVariant={setVariant}
onSetCompany={setCompany}
/>
<Separator />
<SavedSignaturesPanel
saved={saved}
loading={loading}
onSave={async (label, cfg) => { await save(label, cfg); }}
onLoad={loadConfig}
onRemove={remove}
currentConfig={config}
/>
<Separator />
<Button variant="outline" size="sm" onClick={resetToDefaults} className="w-full">
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Resetare la valorile implicite
</Button>
</div>
{/* Right panel — preview */}
<div>
<SignaturePreview config={config} />
</div>
</div>
);
}

View File

@@ -0,0 +1,78 @@
'use client';
import { useState } from 'react';
import { Trash2, Upload, Save } from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import { Input } from '@/shared/components/ui/input';
import type { SavedSignature, SignatureConfig } from '../types';
interface SavedSignaturesPanelProps {
saved: SavedSignature[];
loading: boolean;
onSave: (label: string, config: SignatureConfig) => Promise<void>;
onLoad: (config: SignatureConfig) => void;
onRemove: (id: string) => Promise<void>;
currentConfig: SignatureConfig;
}
export function SavedSignaturesPanel({
saved, loading, onSave, onLoad, onRemove, currentConfig,
}: SavedSignaturesPanelProps) {
const [label, setLabel] = useState('');
const [saving, setSaving] = useState(false);
const handleSave = async () => {
if (!label.trim()) return;
setSaving(true);
await onSave(label.trim(), currentConfig);
setLabel('');
setSaving(false);
};
return (
<div className="space-y-3">
<h3 className="text-sm font-semibold">Semnături salvate</h3>
<div className="flex gap-2">
<Input
placeholder="Nume semnătură..."
value={label}
onChange={(e) => setLabel(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
className="h-8 text-sm"
/>
<Button size="sm" onClick={handleSave} disabled={saving || !label.trim()}>
<Save className="mr-1 h-3.5 w-3.5" />
Salvează
</Button>
</div>
{loading ? (
<p className="text-xs text-muted-foreground">Se încarcă...</p>
) : saved.length === 0 ? (
<p className="text-xs text-muted-foreground">Nicio semnătură salvată.</p>
) : (
<ul className="space-y-1.5">
{saved.map((s) => (
<li key={s.id} className="flex items-center justify-between rounded-md border px-3 py-2">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium">{s.label}</span>
<span className="ml-2 text-xs text-muted-foreground">
{s.config.company} &middot; {s.config.name || 'fără nume'}
</span>
</div>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onLoad(s.config)}>
<Upload className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => onRemove(s.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,166 @@
'use client';
import type { CompanyId } from '@/core/auth/types';
import type { SignatureConfig, SignatureColors, SignatureLayout, SignatureVariant } from '../types';
import { COMPANY_BRANDING } from '../services/company-branding';
import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label';
import { Switch } from '@/shared/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
import { Separator } from '@/shared/components/ui/separator';
import { cn } from '@/shared/lib/utils';
interface SignatureConfiguratorProps {
config: SignatureConfig;
onUpdateField: <K extends keyof SignatureConfig>(key: K, value: SignatureConfig[K]) => void;
onUpdateColor: (key: keyof SignatureColors, value: string) => void;
onUpdateLayout: (key: keyof SignatureLayout, value: number) => void;
onSetVariant: (variant: SignatureVariant) => void;
onSetCompany: (company: CompanyId) => void;
}
const COLOR_PALETTE: Record<string, string> = {
verde: '#22B5AB',
griInchis: '#54504F',
griDeschis: '#A7A9AA',
negru: '#323232',
};
const COLOR_LABELS: Record<keyof SignatureColors, string> = {
prefix: 'Titulatură',
name: 'Nume',
title: 'Funcție',
address: 'Adresă',
phone: 'Telefon',
website: 'Website',
motto: 'Motto',
};
const LAYOUT_CONTROLS: { key: keyof SignatureLayout; label: string; min: number; max: number }[] = [
{ key: 'greenLineWidth', label: 'Lungime linie accent', min: 50, max: 300 },
{ key: 'sectionSpacing', label: 'Spațiere secțiuni', min: 0, max: 30 },
{ key: 'logoSpacing', label: 'Spațiere logo', min: 0, max: 30 },
{ key: 'titleSpacing', label: 'Spațiere funcție', min: 0, max: 20 },
{ key: 'gutterWidth', label: 'Aliniere contact', min: 0, max: 150 },
{ key: 'iconTextSpacing', label: 'Spațiu icon-text', min: -10, max: 30 },
{ key: 'iconVerticalOffset', label: 'Aliniere verticală iconițe', min: -10, max: 10 },
{ key: 'mottoSpacing', label: 'Spațiere motto', min: 0, max: 20 },
];
export function SignatureConfigurator({
config, onUpdateField, onUpdateColor, onUpdateLayout, onSetVariant, onSetCompany,
}: SignatureConfiguratorProps) {
return (
<div className="space-y-6">
{/* Company selector */}
<div>
<Label>Companie</Label>
<Select value={config.company} onValueChange={(v) => onSetCompany(v as CompanyId)}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(COMPANY_BRANDING).map((b) => (
<SelectItem key={b.id} value={b.id}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Separator />
{/* Personal data */}
<div className="space-y-3">
<h3 className="text-sm font-semibold">Date personale</h3>
<div>
<Label htmlFor="sig-prefix">Titulatură (prefix)</Label>
<Input id="sig-prefix" value={config.prefix} onChange={(e) => onUpdateField('prefix', e.target.value)} className="mt-1" />
</div>
<div>
<Label htmlFor="sig-name">Nume și Prenume</Label>
<Input id="sig-name" value={config.name} onChange={(e) => onUpdateField('name', e.target.value)} className="mt-1" />
</div>
<div>
<Label htmlFor="sig-title">Funcția</Label>
<Input id="sig-title" value={config.title} onChange={(e) => onUpdateField('title', e.target.value)} className="mt-1" />
</div>
<div>
<Label htmlFor="sig-phone">Telefon (format 07xxxxxxxx)</Label>
<Input id="sig-phone" type="tel" value={config.phone} onChange={(e) => onUpdateField('phone', e.target.value)} className="mt-1" />
</div>
</div>
<Separator />
{/* Variant */}
<div className="space-y-3">
<h3 className="text-sm font-semibold">Variantă</h3>
<Select value={config.variant} onValueChange={(v) => onSetVariant(v as SignatureVariant)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="full">Completă (logo + adresă + motto)</SelectItem>
<SelectItem value="reply">Simplă (fără logo/adresă)</SelectItem>
<SelectItem value="minimal">Super-simplă (doar nume/telefon)</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Switch checked={config.useSvg} onCheckedChange={(v) => onUpdateField('useSvg', v)} id="svg-toggle" />
<Label htmlFor="svg-toggle" className="cursor-pointer text-sm">Imagini SVG (calitate maximă)</Label>
</div>
</div>
<Separator />
{/* Colors */}
<div className="space-y-3">
<h3 className="text-sm font-semibold">Culori text</h3>
{(Object.keys(COLOR_LABELS) as (keyof SignatureColors)[]).map((colorKey) => (
<div key={colorKey} className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{COLOR_LABELS[colorKey]}</span>
<div className="flex gap-1.5">
{Object.values(COLOR_PALETTE).map((color) => (
<button
key={color}
type="button"
onClick={() => onUpdateColor(colorKey, color)}
className={cn(
'h-6 w-6 rounded-full border-2 transition-all',
config.colors[colorKey] === color
? 'border-primary scale-110 ring-2 ring-primary/30'
: 'border-transparent hover:scale-105'
)}
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
))}
</div>
<Separator />
{/* Layout sliders */}
<div className="space-y-3">
<h3 className="text-sm font-semibold">Stil & Aranjare</h3>
{LAYOUT_CONTROLS.map(({ key, label, min, max }) => (
<div key={key}>
<div className="flex justify-between text-sm">
<Label>{label}</Label>
<span className="text-muted-foreground">{config.layout[key]}px</span>
</div>
<input
type="range"
min={min}
max={max}
value={config.layout[key]}
onChange={(e) => onUpdateLayout(key, parseInt(e.target.value, 10))}
className="mt-1 w-full accent-primary"
/>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
'use client';
import { useMemo, useRef, useState } from 'react';
import { Download, ZoomIn, ZoomOut, Copy } from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import type { SignatureConfig } from '../types';
import { generateSignatureHtml, downloadSignatureHtml } from '../services/signature-builder';
interface SignaturePreviewProps {
config: SignatureConfig;
}
export function SignaturePreview({ config }: SignaturePreviewProps) {
const [zoom, setZoom] = useState(1);
const [copied, setCopied] = useState(false);
const previewRef = useRef<HTMLDivElement>(null);
const html = useMemo(() => generateSignatureHtml(config), [config]);
const handleDownload = () => {
const filename = `semnatura-${config.company}-${config.name.toLowerCase().replace(/\s+/g, '-') || 'email'}.html`;
downloadSignatureHtml(html, filename);
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(html);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// fallback
}
};
const toggleZoom = () => setZoom((z) => (z === 1 ? 2 : 1));
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Previzualizare</h2>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={toggleZoom}>
{zoom === 1 ? <ZoomIn className="mr-1 h-4 w-4" /> : <ZoomOut className="mr-1 h-4 w-4" />}
{zoom === 1 ? '200%' : '100%'}
</Button>
<Button variant="outline" size="sm" onClick={handleCopy}>
<Copy className="mr-1 h-4 w-4" />
{copied ? 'Copiat!' : 'Copiază HTML'}
</Button>
<Button size="sm" onClick={handleDownload}>
<Download className="mr-1 h-4 w-4" />
Descarcă HTML
</Button>
</div>
</div>
<div className="overflow-auto rounded-lg border bg-white p-6">
<div
ref={previewRef}
style={{ transform: `scale(${zoom})`, transformOrigin: 'top left' }}
className="transition-transform"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import type { ModuleConfig } from '@/core/module-registry/types';
export const emailSignatureConfig: ModuleConfig = {
id: 'email-signature',
name: 'Generator Semnătură Email',
description: 'Generator de semnături email profesionale cu suport multi-firmă și variante de layout',
icon: 'mail',
route: '/email-signature',
category: 'generators',
featureFlag: 'module.email-signature',
visibility: 'all',
version: '0.1.0',
dependencies: [],
storageNamespace: 'email-signature',
navOrder: 20,
tags: ['email', 'semnătură', 'generator'],
};

View File

@@ -0,0 +1,51 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useStorage } from '@/core/storage';
import { v4 as uuid } from 'uuid';
import type { SignatureConfig, SavedSignature } from '../types';
export function useSavedSignatures() {
const storage = useStorage('email-signature');
const [saved, setSaved] = useState<SavedSignature[]>([]);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
setLoading(true);
const keys = await storage.list();
const items: SavedSignature[] = [];
for (const key of keys) {
if (key.startsWith('sig:')) {
const item = await storage.get<SavedSignature>(key);
if (item) items.push(item);
}
}
items.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
setSaved(items);
setLoading(false);
}, [storage]);
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]);
const save = useCallback(async (label: string, config: SignatureConfig) => {
const now = new Date().toISOString();
const entry: SavedSignature = {
id: uuid(),
label,
config,
createdAt: now,
updatedAt: now,
};
await storage.set(`sig:${entry.id}`, entry);
await refresh();
return entry;
}, [storage, refresh]);
const remove = useCallback(async (id: string) => {
await storage.delete(`sig:${id}`);
await refresh();
}, [storage, refresh]);
return { saved, loading, save, remove, refresh };
}

View File

@@ -0,0 +1,89 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import type { CompanyId } from '@/core/auth/types';
import type { SignatureConfig, SignatureVariant, SignatureColors, SignatureLayout } from '../types';
import { getBranding } from '../services/company-branding';
const DEFAULT_LAYOUT: SignatureLayout = {
greenLineWidth: 97,
gutterWidth: 13,
iconTextSpacing: 5,
iconVerticalOffset: 1,
mottoSpacing: 3,
sectionSpacing: 10,
titleSpacing: 2,
logoSpacing: 10,
};
function createDefaultConfig(company: CompanyId = 'beletage'): SignatureConfig {
const branding = getBranding(company);
return {
prefix: 'arh.',
name: '',
title: '',
phone: '',
company,
colors: { ...branding.defaultColors },
layout: { ...DEFAULT_LAYOUT },
variant: 'full',
useSvg: false,
};
}
export function useSignatureConfig(initialCompany: CompanyId = 'beletage') {
const [config, setConfig] = useState<SignatureConfig>(() => createDefaultConfig(initialCompany));
const updateField = useCallback(<K extends keyof SignatureConfig>(
key: K,
value: SignatureConfig[K]
) => {
setConfig((prev) => ({ ...prev, [key]: value }));
}, []);
const updateColor = useCallback((key: keyof SignatureColors, value: string) => {
setConfig((prev) => ({
...prev,
colors: { ...prev.colors, [key]: value },
}));
}, []);
const updateLayout = useCallback((key: keyof SignatureLayout, value: number) => {
setConfig((prev) => ({
...prev,
layout: { ...prev.layout, [key]: value },
}));
}, []);
const setVariant = useCallback((variant: SignatureVariant) => {
setConfig((prev) => ({ ...prev, variant }));
}, []);
const setCompany = useCallback((company: CompanyId) => {
const branding = getBranding(company);
setConfig((prev) => ({
...prev,
company,
colors: { ...branding.defaultColors },
}));
}, []);
const resetToDefaults = useCallback(() => {
setConfig(createDefaultConfig(config.company));
}, [config.company]);
const loadConfig = useCallback((loaded: SignatureConfig) => {
setConfig(loaded);
}, []);
return useMemo(() => ({
config,
updateField,
updateColor,
updateLayout,
setVariant,
setCompany,
resetToDefaults,
loadConfig,
}), [config, updateField, updateColor, updateLayout, setVariant, setCompany, resetToDefaults, loadConfig]);
}

View File

@@ -0,0 +1,3 @@
export { emailSignatureConfig } from './config';
export { EmailSignatureModule } from './components/email-signature-module';
export type { SignatureConfig, SignatureVariant, SignatureColors, SignatureLayout, SavedSignature } from './types';

View File

@@ -0,0 +1,114 @@
import type { CompanyId } from '@/core/auth/types';
import type { CompanyBranding, SignatureColors } from '../types';
const BELETAGE_COLORS: SignatureColors = {
prefix: '#54504F',
name: '#54504F',
title: '#A7A9AA',
address: '#A7A9AA',
phone: '#54504F',
website: '#54504F',
motto: '#22B5AB',
};
const URBAN_SWITCH_COLORS: SignatureColors = {
prefix: '#3B3B3B',
name: '#3B3B3B',
title: '#8B8B8B',
address: '#8B8B8B',
phone: '#3B3B3B',
website: '#3B3B3B',
motto: '#6366f1',
};
const STUDII_COLORS: SignatureColors = {
prefix: '#3B3B3B',
name: '#3B3B3B',
title: '#8B8B8B',
address: '#8B8B8B',
phone: '#3B3B3B',
website: '#3B3B3B',
motto: '#f59e0b',
};
export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
beletage: {
id: 'beletage',
name: 'Beletage SRL',
accent: '#22B5AB',
logo: {
png: 'https://beletage.ro/img/Semnatura-Logo.png',
svg: 'https://beletage.ro/img/Logo-Beletage.svg',
},
slashGrey: {
png: 'https://beletage.ro/img/Grey-slash.png',
svg: 'https://beletage.ro/img/Grey-slash.svg',
},
slashAccent: {
png: 'https://beletage.ro/img/Green-slash.png',
svg: 'https://beletage.ro/img/Green-slash.svg',
},
address: ['str. Unirii, nr. 3, ap. 26', 'Cluj-Napoca, Cluj 400417', 'România'],
website: 'www.beletage.ro',
motto: 'we make complex simple',
defaultColors: BELETAGE_COLORS,
},
'urban-switch': {
id: 'urban-switch',
name: 'Urban Switch SRL',
accent: '#6366f1',
logo: {
png: '',
svg: '',
},
slashGrey: {
png: 'https://beletage.ro/img/Grey-slash.png',
svg: 'https://beletage.ro/img/Grey-slash.svg',
},
slashAccent: {
png: '',
svg: '',
},
address: ['Cluj-Napoca', 'România'],
website: '',
motto: '',
defaultColors: URBAN_SWITCH_COLORS,
},
'studii-de-teren': {
id: 'studii-de-teren',
name: 'Studii de Teren SRL',
accent: '#f59e0b',
logo: {
png: '',
svg: '',
},
slashGrey: {
png: 'https://beletage.ro/img/Grey-slash.png',
svg: 'https://beletage.ro/img/Grey-slash.svg',
},
slashAccent: {
png: '',
svg: '',
},
address: ['Cluj-Napoca', 'România'],
website: '',
motto: '',
defaultColors: STUDII_COLORS,
},
group: {
id: 'group',
name: 'Grup Companii',
accent: '#64748b',
logo: { png: '', svg: '' },
slashGrey: { png: '', svg: '' },
slashAccent: { png: '', svg: '' },
address: ['Cluj-Napoca', 'România'],
website: '',
motto: '',
defaultColors: BELETAGE_COLORS,
},
};
export function getBranding(company: CompanyId): CompanyBranding {
return COMPANY_BRANDING[company];
}

View File

@@ -0,0 +1,124 @@
import type { SignatureConfig, CompanyBranding } from '../types';
import { getBranding } from './company-branding';
export function formatPhone(raw: string): { display: string; link: string } {
const clean = raw.replace(/\s/g, '');
if (clean.length === 10 && clean.startsWith('07')) {
return {
display: `+40 ${clean.substring(1, 4)} ${clean.substring(4, 7)} ${clean.substring(7, 10)}`,
link: `tel:+40${clean.substring(1)}`,
};
}
return { display: raw, link: `tel:${clean}` };
}
export function generateSignatureHtml(config: SignatureConfig): string {
const branding = getBranding(config.company);
const { display: phone, link: phoneLink } = formatPhone(config.phone);
const images = config.useSvg
? { logo: branding.logo.svg, greySlash: branding.slashGrey.svg, accentSlash: branding.slashAccent.svg }
: { logo: branding.logo.png, greySlash: branding.slashGrey.png, accentSlash: branding.slashAccent.png };
const {
greenLineWidth, gutterWidth, iconTextSpacing, iconVerticalOffset,
mottoSpacing, sectionSpacing, titleSpacing, logoSpacing,
} = config.layout;
const colors = config.colors;
const isReply = config.variant === 'reply' || config.variant === 'minimal';
const isMinimal = config.variant === 'minimal';
const hide = 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;';
const hideTitle = isReply ? hide : '';
const hideLogo = isReply ? hide : '';
const hideBottom = isMinimal ? hide : '';
const hidePhoneIcon = isMinimal ? hide : '';
const spacerWidth = Math.max(0, iconTextSpacing);
const textPaddingLeft = Math.max(0, -iconTextSpacing);
const prefixHtml = config.prefix
? `<span style="font-size:13px; color:${colors.prefix};">${esc(config.prefix)} </span>`
: '';
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540" style="font-family: Arial, Helvetica, sans-serif; color:#333333; font-size:14px; line-height:18px;">
<tbody>
<tr><td style="padding:0 0 ${titleSpacing}px 0;">${prefixHtml}<span style="font-size:15px; color:${colors.name}; font-weight:700;">${esc(config.name)}</span></td></tr>
<tr style="${hideTitle}"><td style="padding:0 0 8px 0;"><span style="font-size:12px; color:${colors.title};">${esc(config.title)}</span></td></tr>
<tr style="${hideBottom}">
<td style="padding:0; font-size:0; line-height:0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540">
<tr>
<td width="${greenLineWidth}" height="2" bgcolor="${branding.accent}" style="font-size:0; line-height:0; height:2px;"></td>
<td width="${540 - greenLineWidth}" height="2" style="font-size:0; line-height:0; height:2px;"></td>
</tr>
</table>
</td>
</tr>
<tr style="${hideLogo}"><td style="padding:${logoSpacing}px 0 ${logoSpacing + 2}px 0;">
${images.logo ? `<a href="https://${branding.website}" style="text-decoration:none; border:0;">
<img src="${images.logo}" alt="${esc(branding.name)}" style="display:block; border:0; height:24px; width:162px;" height="24" width="162">
</a>` : ''}
</td></tr>
<tr>
<td style="padding-top:${hideLogo ? '0' : sectionSpacing}px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540" style="font-size:13px; line-height:18px;">
<tbody>
<tr style="${hideLogo}">
<td width="${gutterWidth}" style="width:${gutterWidth}px; font-size:0; line-height:0;"></td>
<td width="11" style="width:11px; vertical-align:top; padding-top:${4 + iconVerticalOffset}px;">
${images.greySlash ? `<img src="${images.greySlash}" alt="" width="11" height="11" style="display:block; border: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;">
<span style="color:${colors.address}; text-decoration:none;">${branding.address.join('<br>')}</span>
</td>
</tr>
<tr>
<td width="${gutterWidth}" style="width:${gutterWidth}px; font-size:0; line-height:0;"></td>
<td width="11" style="width:11px; vertical-align:top; padding-top:${12 + iconVerticalOffset}px; ${hidePhoneIcon}">
${images.accentSlash ? `<img src="${images.accentSlash}" alt="" width="11" height="7" style="display:block; border:0;">` : ''}
</td>
<td width="${isMinimal ? 0 : spacerWidth}" style="width:${isMinimal ? 0 : spacerWidth}px; font-size:0; line-height:0;"></td>
<td style="vertical-align:top; padding:8px 0 0 ${isMinimal ? 0 : textPaddingLeft}px;">
<a href="${phoneLink}" style="color:${colors.phone}; text-decoration:none;"><span style="color:${colors.phone}; text-decoration:none;">${phone}</span></a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
${branding.website ? `<tr style="${hideBottom}"><td style="padding:${sectionSpacing}px 0 ${mottoSpacing}px 0;"><a href="https://${branding.website}" style="color:${colors.website}; text-decoration:none;"><span style="color:${colors.website}; text-decoration:none;">${branding.website}</span></a></td></tr>` : ''}
<tr style="${hideBottom}">
<td style="padding:0; font-size:0; line-height:0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540">
<tr>
<td width="${greenLineWidth}" height="1" bgcolor="${branding.accent}" style="font-size:0; line-height:0; height:1px;"></td>
<td width="${540 - greenLineWidth}" height="1" style="font-size:0; line-height:0; height:1px;"></td>
</tr>
</table>
</td>
</tr>
${branding.motto ? `<tr style="${hideBottom}"><td style="padding:${mottoSpacing}px 0 0 0;"><span style="font-size:12px; color:${colors.motto}; font-style:italic;">${esc(branding.motto)}</span></td></tr>` : ''}
</tbody>
</table>`;
}
function esc(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function downloadSignatureHtml(html: string, filename: string): void {
const blob = new Blob([html], { type: 'text/html' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
}

View File

@@ -0,0 +1,59 @@
import type { CompanyId } from '@/core/auth/types';
export type SignatureVariant = 'full' | 'reply' | 'minimal';
export interface SignatureColors {
prefix: string;
name: string;
title: string;
address: string;
phone: string;
website: string;
motto: string;
}
export interface SignatureLayout {
greenLineWidth: number;
gutterWidth: number;
iconTextSpacing: number;
iconVerticalOffset: number;
mottoSpacing: number;
sectionSpacing: number;
titleSpacing: number;
logoSpacing: number;
}
export interface CompanyBranding {
id: CompanyId;
name: string;
accent: string;
logo: { png: string; svg: string };
slashGrey: { png: string; svg: string };
slashAccent: { png: string; svg: string };
address: string[];
website: string;
motto: string;
defaultColors: SignatureColors;
}
export interface SignatureConfig {
id?: string;
label?: string;
prefix: string;
name: string;
title: string;
phone: string;
company: CompanyId;
colors: SignatureColors;
layout: SignatureLayout;
variant: SignatureVariant;
useSvg: boolean;
}
export interface SavedSignature {
id: string;
label: string;
config: SignatureConfig;
createdAt: string;
updatedAt: string;
}

View File

@@ -0,0 +1,204 @@
'use client';
import { useState } from 'react';
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label';
import { Textarea } from '@/shared/components/ui/textarea';
import { Badge } from '@/shared/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
import type { CompanyId } from '@/core/auth/types';
import type { InventoryItem, InventoryItemType, InventoryItemStatus } from '../types';
import { useInventory } from '../hooks/use-inventory';
const TYPE_LABELS: Record<InventoryItemType, string> = {
laptop: 'Laptop', desktop: 'Desktop', monitor: 'Monitor', printer: 'Imprimantă',
phone: 'Telefon', tablet: 'Tabletă', network: 'Rețea', peripheral: 'Periferic', other: 'Altele',
};
const STATUS_LABELS: Record<InventoryItemStatus, string> = {
active: 'Activ', 'in-repair': 'În reparație', storage: 'Depozitat', decommissioned: 'Dezafectat',
};
type ViewMode = 'list' | 'add' | 'edit';
export function ItInventoryModule() {
const { items, allItems, loading, filters, updateFilter, addItem, updateItem, removeItem } = useInventory();
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
const handleSubmit = async (data: Omit<InventoryItem, 'id' | 'createdAt'>) => {
if (viewMode === 'edit' && editingItem) {
await updateItem(editingItem.id, data);
} else {
await addItem(data);
}
setViewMode('list');
setEditingItem(null);
};
return (
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total</p><p className="text-2xl font-bold">{allItems.length}</p></CardContent></Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Active</p><p className="text-2xl font-bold">{allItems.filter((i) => i.status === 'active').length}</p></CardContent></Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">În reparație</p><p className="text-2xl font-bold">{allItems.filter((i) => i.status === 'in-repair').length}</p></CardContent></Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Dezafectate</p><p className="text-2xl font-bold">{allItems.filter((i) => i.status === 'decommissioned').length}</p></CardContent></Card>
</div>
{viewMode === 'list' && (
<>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Caută..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
</div>
<Select value={filters.type} onValueChange={(v) => updateFilter('type', v as InventoryItemType | 'all')}>
<SelectTrigger className="w-[140px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate tipurile</SelectItem>
{(Object.keys(TYPE_LABELS) as InventoryItemType[]).map((t) => (
<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filters.status} onValueChange={(v) => updateFilter('status', v as InventoryItemStatus | 'all')}>
<SelectTrigger className="w-[140px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate</SelectItem>
{(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map((s) => (
<SelectItem key={s} value={s}>{STATUS_LABELS[s]}</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => setViewMode('add')} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button>
</div>
{/* Table */}
{loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
) : items.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Niciun echipament găsit.</p>
) : (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead><tr className="border-b bg-muted/40">
<th className="px-3 py-2 text-left font-medium">Nume</th>
<th className="px-3 py-2 text-left font-medium">Tip</th>
<th className="px-3 py-2 text-left font-medium">S/N</th>
<th className="px-3 py-2 text-left font-medium">Atribuit</th>
<th className="px-3 py-2 text-left font-medium">Locație</th>
<th className="px-3 py-2 text-left font-medium">Status</th>
<th className="px-3 py-2 text-right font-medium">Acțiuni</th>
</tr></thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-b hover:bg-muted/20 transition-colors">
<td className="px-3 py-2 font-medium">{item.name}</td>
<td className="px-3 py-2"><Badge variant="outline">{TYPE_LABELS[item.type]}</Badge></td>
<td className="px-3 py-2 font-mono text-xs">{item.serialNumber}</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"><Badge variant="secondary">{STATUS_LABELS[item.status]}</Badge></td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingItem(item); setViewMode('edit'); }}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeItem(item.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
{(viewMode === 'add' || viewMode === 'edit') && (
<Card>
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare echipament' : 'Echipament nou'}</CardTitle></CardHeader>
<CardContent>
<InventoryForm
initial={editingItem ?? undefined}
onSubmit={handleSubmit}
onCancel={() => { setViewMode('list'); setEditingItem(null); }}
/>
</CardContent>
</Card>
)}
</div>
);
}
function InventoryForm({ initial, onSubmit, onCancel }: {
initial?: InventoryItem;
onSubmit: (data: Omit<InventoryItem, 'id' | 'createdAt'>) => void;
onCancel: () => void;
}) {
const [name, setName] = useState(initial?.name ?? '');
const [type, setType] = useState<InventoryItemType>(initial?.type ?? 'laptop');
const [serialNumber, setSerialNumber] = useState(initial?.serialNumber ?? '');
const [assignedTo, setAssignedTo] = useState(initial?.assignedTo ?? '');
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
const [location, setLocation] = useState(initial?.location ?? '');
const [purchaseDate, setPurchaseDate] = useState(initial?.purchaseDate ?? '');
const [status, setStatus] = useState<InventoryItemStatus>(initial?.status ?? 'active');
const [notes, setNotes] = useState(initial?.notes ?? '');
return (
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ name, type, serialNumber, assignedTo, company, location, purchaseDate, status, notes, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all' }); }} className="space-y-4">
<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>Tip</Label>
<Select value={type} onValueChange={(v) => setType(v as InventoryItemType)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>{(Object.keys(TYPE_LABELS) as InventoryItemType[]).map((t) => (<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem>))}</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div><Label>Număr serie</Label><Input value={serialNumber} onChange={(e) => setSerialNumber(e.target.value)} className="mt-1" /></div>
<div><Label>Atribuit</Label><Input value={assignedTo} onChange={(e) => setAssignedTo(e.target.value)} className="mt-1" /></div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div><Label>Companie</Label>
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<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>
</div>
<div><Label>Locație</Label><Input value={location} onChange={(e) => setLocation(e.target.value)} className="mt-1" /></div>
<div><Label>Data achiziție</Label><Input type="date" value={purchaseDate} onChange={(e) => setPurchaseDate(e.target.value)} className="mt-1" /></div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div><Label>Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v as InventoryItemStatus)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>{(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map((s) => (<SelectItem key={s} value={s}>{STATUS_LABELS[s]}</SelectItem>))}</SelectContent>
</Select>
</div>
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,17 @@
import type { ModuleConfig } from '@/core/module-registry/types';
export const itInventoryConfig: ModuleConfig = {
id: 'it-inventory',
name: 'Inventar IT',
description: 'Evidență echipamente IT cu urmărire atribuiri și locații',
icon: 'monitor',
route: '/it-inventory',
category: 'management',
featureFlag: 'module.it-inventory',
visibility: 'all',
version: '0.1.0',
dependencies: [],
storageNamespace: 'it-inventory',
navOrder: 31,
tags: ['inventar', 'echipamente', 'IT'],
};

View File

@@ -0,0 +1,79 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useStorage } from '@/core/storage';
import { v4 as uuid } from 'uuid';
import type { InventoryItem, InventoryItemType, InventoryItemStatus } from '../types';
const PREFIX = 'item:';
export interface InventoryFilters {
search: string;
type: InventoryItemType | 'all';
status: InventoryItemStatus | 'all';
company: string;
}
export function useInventory() {
const storage = useStorage('it-inventory');
const [items, setItems] = useState<InventoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<InventoryFilters>({
search: '', type: 'all', status: 'all', company: 'all',
});
const refresh = useCallback(async () => {
setLoading(true);
const keys = await storage.list();
const results: InventoryItem[] = [];
for (const key of keys) {
if (key.startsWith(PREFIX)) {
const item = await storage.get<InventoryItem>(key);
if (item) results.push(item);
}
}
results.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
setItems(results);
setLoading(false);
}, [storage]);
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]);
const addItem = useCallback(async (data: Omit<InventoryItem, 'id' | 'createdAt'>) => {
const item: InventoryItem = { ...data, id: uuid(), createdAt: new Date().toISOString() };
await storage.set(`${PREFIX}${item.id}`, item);
await refresh();
return item;
}, [storage, refresh]);
const updateItem = useCallback(async (id: string, updates: Partial<InventoryItem>) => {
const existing = items.find((i) => i.id === id);
if (!existing) return;
const updated = { ...existing, ...updates, id: existing.id, createdAt: existing.createdAt };
await storage.set(`${PREFIX}${id}`, updated);
await refresh();
}, [storage, refresh, items]);
const removeItem = useCallback(async (id: string) => {
await storage.delete(`${PREFIX}${id}`);
await refresh();
}, [storage, refresh]);
const updateFilter = useCallback(<K extends keyof InventoryFilters>(key: K, value: InventoryFilters[K]) => {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
const filteredItems = items.filter((item) => {
if (filters.type !== 'all' && item.type !== filters.type) return false;
if (filters.status !== 'all' && item.status !== filters.status) return false;
if (filters.company !== 'all' && item.company !== filters.company) return false;
if (filters.search) {
const q = filters.search.toLowerCase();
return item.name.toLowerCase().includes(q) || item.serialNumber.toLowerCase().includes(q) || item.assignedTo.toLowerCase().includes(q);
}
return true;
});
return { items: filteredItems, allItems: items, loading, filters, updateFilter, addItem, updateItem, removeItem, refresh };
}

View File

@@ -0,0 +1,3 @@
export { itInventoryConfig } from './config';
export { ItInventoryModule } from './components/it-inventory-module';
export type { InventoryItem, InventoryItemType, InventoryItemStatus } from './types';

View File

@@ -0,0 +1,35 @@
import type { Visibility } from '@/core/module-registry/types';
import type { CompanyId } from '@/core/auth/types';
export type InventoryItemType =
| 'laptop'
| 'desktop'
| 'monitor'
| 'printer'
| 'phone'
| 'tablet'
| 'network'
| 'peripheral'
| 'other';
export type InventoryItemStatus =
| 'active'
| 'in-repair'
| 'storage'
| 'decommissioned';
export interface InventoryItem {
id: string;
name: string;
type: InventoryItemType;
serialNumber: string;
assignedTo: string;
company: CompanyId;
location: string;
purchaseDate: string;
status: InventoryItemStatus;
tags: string[];
notes: string;
visibility: Visibility;
createdAt: string;
}

View File

@@ -0,0 +1,159 @@
'use client';
import { useState } from 'react';
import { Copy, Check, Hash, Type, Percent, Ruler } from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label';
import { Textarea } from '@/shared/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/components/ui/tabs';
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch { /* silent */ }
};
return (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopy} disabled={!text}>
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
);
}
function TextCaseConverter() {
const [input, setInput] = useState('');
const upper = input.toUpperCase();
const lower = input.toLowerCase();
const title = input.replace(/\b\w/g, (c) => c.toUpperCase());
const sentence = input.charAt(0).toUpperCase() + input.slice(1).toLowerCase();
return (
<div className="space-y-3">
<div><Label>Text sursă</Label><Textarea value={input} onChange={(e) => setInput(e.target.value)} rows={3} className="mt-1" placeholder="Introdu text..." /></div>
{[
{ label: 'UPPERCASE', value: upper },
{ label: 'lowercase', value: lower },
{ label: 'Title Case', value: title },
{ label: 'Sentence case', value: sentence },
].map(({ label, value }) => (
<div key={label} className="flex items-center gap-2">
<code className="flex-1 truncate rounded border bg-muted/30 px-2 py-1 text-xs">{value || '—'}</code>
<span className="w-24 text-xs text-muted-foreground">{label}</span>
<CopyButton text={value} />
</div>
))}
</div>
);
}
function CharacterCounter() {
const [input, setInput] = useState('');
const chars = input.length;
const charsNoSpaces = input.replace(/\s/g, '').length;
const words = input.trim() ? input.trim().split(/\s+/).length : 0;
const lines = input ? input.split('\n').length : 0;
return (
<div className="space-y-3">
<div><Label>Text</Label><Textarea value={input} onChange={(e) => setInput(e.target.value)} rows={5} className="mt-1" placeholder="Introdu text..." /></div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card><CardContent className="p-3"><p className="text-xs text-muted-foreground">Caractere</p><p className="text-xl font-bold">{chars}</p></CardContent></Card>
<Card><CardContent className="p-3"><p className="text-xs text-muted-foreground">Fără spații</p><p className="text-xl font-bold">{charsNoSpaces}</p></CardContent></Card>
<Card><CardContent className="p-3"><p className="text-xs text-muted-foreground">Cuvinte</p><p className="text-xl font-bold">{words}</p></CardContent></Card>
<Card><CardContent className="p-3"><p className="text-xs text-muted-foreground">Linii</p><p className="text-xl font-bold">{lines}</p></CardContent></Card>
</div>
</div>
);
}
function PercentageCalculator() {
const [value, setValue] = useState('');
const [total, setTotal] = useState('');
const [percent, setPercent] = useState('');
const v = parseFloat(value);
const t = parseFloat(total);
const p = parseFloat(percent);
const pctOfTotal = !isNaN(v) && !isNaN(t) && t !== 0 ? ((v / t) * 100).toFixed(2) : '—';
const valFromPct = !isNaN(p) && !isNaN(t) ? ((p / 100) * t).toFixed(2) : '—';
return (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<div><Label>Valoare</Label><Input type="number" value={value} onChange={(e) => setValue(e.target.value)} className="mt-1" /></div>
<div><Label>Total</Label><Input type="number" value={total} onChange={(e) => setTotal(e.target.value)} className="mt-1" /></div>
<div><Label>Procent</Label><Input type="number" value={percent} onChange={(e) => setPercent(e.target.value)} className="mt-1" /></div>
</div>
<div className="space-y-2 rounded-md border bg-muted/30 p-3 text-sm">
<p><strong>{value || '?'}</strong> din <strong>{total || '?'}</strong> = <strong>{pctOfTotal}%</strong></p>
<p><strong>{percent || '?'}%</strong> din <strong>{total || '?'}</strong> = <strong>{valFromPct}</strong></p>
</div>
</div>
);
}
function AreaConverter() {
const [mp, setMp] = useState('');
const v = parseFloat(mp);
const conversions = !isNaN(v) ? [
{ label: 'mp (m²)', value: v.toFixed(2) },
{ label: 'ari (100 m²)', value: (v / 100).toFixed(4) },
{ label: 'hectare (10.000 m²)', value: (v / 10000).toFixed(6) },
{ label: 'km²', value: (v / 1000000).toFixed(8) },
{ label: 'sq ft', value: (v * 10.7639).toFixed(2) },
] : [];
return (
<div className="space-y-3">
<div><Label>Suprafață (m²)</Label><Input type="number" value={mp} onChange={(e) => setMp(e.target.value)} className="mt-1" placeholder="Introdu suprafața..." /></div>
{conversions.length > 0 && (
<div className="space-y-1.5">
{conversions.map(({ label, value: val }) => (
<div key={label} className="flex items-center gap-2">
<code className="flex-1 rounded border bg-muted/30 px-2 py-1 text-xs">{val}</code>
<span className="w-36 text-xs text-muted-foreground">{label}</span>
<CopyButton text={val} />
</div>
))}
</div>
)}
</div>
);
}
export function MiniUtilitiesModule() {
return (
<Tabs defaultValue="text-case" className="space-y-4">
<TabsList className="flex-wrap">
<TabsTrigger value="text-case"><Type className="mr-1 h-3.5 w-3.5" /> Transformare text</TabsTrigger>
<TabsTrigger value="char-count"><Hash className="mr-1 h-3.5 w-3.5" /> Numărare caractere</TabsTrigger>
<TabsTrigger value="percentage"><Percent className="mr-1 h-3.5 w-3.5" /> Procente</TabsTrigger>
<TabsTrigger value="area"><Ruler className="mr-1 h-3.5 w-3.5" /> Convertor suprafețe</TabsTrigger>
</TabsList>
<TabsContent value="text-case">
<Card><CardHeader><CardTitle className="text-base">Transformare text</CardTitle></CardHeader>
<CardContent><TextCaseConverter /></CardContent></Card>
</TabsContent>
<TabsContent value="char-count">
<Card><CardHeader><CardTitle className="text-base">Numărare caractere</CardTitle></CardHeader>
<CardContent><CharacterCounter /></CardContent></Card>
</TabsContent>
<TabsContent value="percentage">
<Card><CardHeader><CardTitle className="text-base">Calculator procente</CardTitle></CardHeader>
<CardContent><PercentageCalculator /></CardContent></Card>
</TabsContent>
<TabsContent value="area">
<Card><CardHeader><CardTitle className="text-base">Convertor suprafețe</CardTitle></CardHeader>
<CardContent><AreaConverter /></CardContent></Card>
</TabsContent>
</Tabs>
);
}

View File

@@ -0,0 +1,17 @@
import type { ModuleConfig } from '@/core/module-registry/types';
export const miniUtilitiesConfig: ModuleConfig = {
id: 'mini-utilities',
name: 'Utilitare',
description: 'Colecție de instrumente utilitare rapide: calculatoare, convertoare, formatare',
icon: 'calculator',
route: '/mini-utilities',
category: 'tools',
featureFlag: 'module.mini-utilities',
visibility: 'all',
version: '0.1.0',
dependencies: [],
storageNamespace: 'mini-utilities',
navOrder: 41,
tags: ['utilitare', 'calculatoare', 'instrumente'],
};

View File

@@ -0,0 +1,3 @@
export { miniUtilitiesConfig } from './config';
export { MiniUtilitiesModule } from './components/mini-utilities-module';
export type { UtilityTool } from './types';

View File

@@ -0,0 +1,6 @@
export interface UtilityTool {
id: string;
name: string;
description: string;
component: string;
}

View File

@@ -0,0 +1,185 @@
'use client';
import { useState } from 'react';
import { Plus, Pencil, Trash2, Search, Eye, EyeOff, Copy, ExternalLink } from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label';
import { Textarea } from '@/shared/components/ui/textarea';
import { Badge } from '@/shared/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
import type { VaultEntry, VaultEntryCategory } from '../types';
import { useVault } from '../hooks/use-vault';
const CATEGORY_LABELS: Record<VaultEntryCategory, string> = {
web: 'Web', email: 'Email', server: 'Server', database: 'Bază de date', api: 'API', other: 'Altele',
};
type ViewMode = 'list' | 'add' | 'edit';
export function PasswordVaultModule() {
const { entries, allEntries, loading, filters, updateFilter, addEntry, updateEntry, removeEntry } = useVault();
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [editingEntry, setEditingEntry] = useState<VaultEntry | null>(null);
const [visiblePasswords, setVisiblePasswords] = useState<Set<string>>(new Set());
const [copiedId, setCopiedId] = useState<string | null>(null);
const togglePassword = (id: string) => {
setVisiblePasswords((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const handleCopy = async (text: string, id: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
} catch { /* silent */ }
};
const handleSubmit = async (data: Omit<VaultEntry, 'id' | 'createdAt' | 'updatedAt'>) => {
if (viewMode === 'edit' && editingEntry) {
await updateEntry(editingEntry.id, data);
} else {
await addEntry(data);
}
setViewMode('list');
setEditingEntry(null);
};
return (
<div className="space-y-6">
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-2 text-xs text-amber-700 dark:text-amber-400">
Atenție: Parolele sunt stocate local (localStorage). Nu sunt criptate. Folosiți un manager de parole dedicat pentru date sensibile.
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total</p><p className="text-2xl font-bold">{allEntries.length}</p></CardContent></Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Web</p><p className="text-2xl font-bold">{allEntries.filter((e) => e.category === 'web').length}</p></CardContent></Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Server</p><p className="text-2xl font-bold">{allEntries.filter((e) => e.category === 'server').length}</p></CardContent></Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">API</p><p className="text-2xl font-bold">{allEntries.filter((e) => e.category === 'api').length}</p></CardContent></Card>
</div>
{viewMode === 'list' && (
<>
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Caută..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
</div>
<Select value={filters.category} onValueChange={(v) => updateFilter('category', v as VaultEntryCategory | 'all')}>
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate</SelectItem>
{(Object.keys(CATEGORY_LABELS) as VaultEntryCategory[]).map((c) => (
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => setViewMode('add')} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
) : entries.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Nicio intrare găsită.</p>
) : (
<div className="space-y-2">
{entries.map((entry) => (
<Card key={entry.id} className="group">
<CardContent className="flex items-center gap-4 p-4">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2">
<p className="font-medium">{entry.label}</p>
<Badge variant="outline" className="text-[10px]">{CATEGORY_LABELS[entry.category]}</Badge>
</div>
<p className="text-xs text-muted-foreground">{entry.username}</p>
<div className="flex items-center gap-2">
<code className="text-xs">
{visiblePasswords.has(entry.id) ? entry.encryptedPassword : '••••••••••'}
</code>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => togglePassword(entry.id)}>
{visiblePasswords.has(entry.id) ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</Button>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => handleCopy(entry.encryptedPassword, entry.id)}>
<Copy className="h-3 w-3" />
</Button>
{copiedId === entry.id && <span className="text-[10px] text-green-500">Copiat!</span>}
</div>
{entry.url && (
<p className="flex items-center gap-1 text-xs text-muted-foreground">
<ExternalLink className="h-3 w-3" /> {entry.url}
</p>
)}
</div>
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingEntry(entry); setViewMode('edit'); }}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeEntry(entry.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</>
)}
{(viewMode === 'add' || viewMode === 'edit') && (
<Card>
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare' : 'Intrare nouă'}</CardTitle></CardHeader>
<CardContent>
<VaultForm initial={editingEntry ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingEntry(null); }} />
</CardContent>
</Card>
)}
</div>
);
}
function VaultForm({ initial, onSubmit, onCancel }: {
initial?: VaultEntry;
onSubmit: (data: Omit<VaultEntry, 'id' | 'createdAt' | 'updatedAt'>) => void;
onCancel: () => void;
}) {
const [label, setLabel] = useState(initial?.label ?? '');
const [username, setUsername] = useState(initial?.username ?? '');
const [password, setPassword] = useState(initial?.encryptedPassword ?? '');
const [url, setUrl] = useState(initial?.url ?? '');
const [category, setCategory] = useState<VaultEntryCategory>(initial?.category ?? 'web');
const [notes, setNotes] = useState(initial?.notes ?? '');
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">
<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>Categorie</Label>
<Select value={category} onValueChange={(v) => setCategory(v as VaultEntryCategory)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>{(Object.keys(CATEGORY_LABELS) as VaultEntryCategory[]).map((c) => (<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>))}</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div><Label>Utilizator</Label><Input value={username} onChange={(e) => setUsername(e.target.value)} className="mt-1" /></div>
<div><Label>Parolă</Label><Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="mt-1" /></div>
</div>
<div><Label>URL</Label><Input value={url} onChange={(e) => setUrl(e.target.value)} className="mt-1" placeholder="https://..." /></div>
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,17 @@
import type { ModuleConfig } from '@/core/module-registry/types';
export const passwordVaultConfig: ModuleConfig = {
id: 'password-vault',
name: 'Seif Parole',
description: 'Manager securizat de parole și credențiale cu criptare locală',
icon: 'lock',
route: '/password-vault',
category: 'operations',
featureFlag: 'module.password-vault',
visibility: 'admin',
version: '0.1.0',
dependencies: [],
storageNamespace: 'password-vault',
navOrder: 11,
tags: ['parole', 'securitate', 'credențiale'],
};

View File

@@ -0,0 +1,74 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useStorage } from '@/core/storage';
import { v4 as uuid } from 'uuid';
import type { VaultEntry, VaultEntryCategory } from '../types';
const PREFIX = 'vault:';
export interface VaultFilters {
search: string;
category: VaultEntryCategory | 'all';
}
export function useVault() {
const storage = useStorage('password-vault');
const [entries, setEntries] = useState<VaultEntry[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<VaultFilters>({ search: '', category: 'all' });
const refresh = useCallback(async () => {
setLoading(true);
const keys = await storage.list();
const results: VaultEntry[] = [];
for (const key of keys) {
if (key.startsWith(PREFIX)) {
const item = await storage.get<VaultEntry>(key);
if (item) results.push(item);
}
}
results.sort((a, b) => a.label.localeCompare(b.label));
setEntries(results);
setLoading(false);
}, [storage]);
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]);
const addEntry = useCallback(async (data: Omit<VaultEntry, 'id' | 'createdAt' | 'updatedAt'>) => {
const now = new Date().toISOString();
const entry: VaultEntry = { ...data, id: uuid(), createdAt: now, updatedAt: now };
await storage.set(`${PREFIX}${entry.id}`, entry);
await refresh();
return entry;
}, [storage, refresh]);
const updateEntry = useCallback(async (id: string, updates: Partial<VaultEntry>) => {
const existing = entries.find((e) => e.id === id);
if (!existing) return;
const updated = { ...existing, ...updates, id: existing.id, createdAt: existing.createdAt, updatedAt: new Date().toISOString() };
await storage.set(`${PREFIX}${id}`, updated);
await refresh();
}, [storage, refresh, entries]);
const removeEntry = useCallback(async (id: string) => {
await storage.delete(`${PREFIX}${id}`);
await refresh();
}, [storage, refresh]);
const updateFilter = useCallback(<K extends keyof VaultFilters>(key: K, value: VaultFilters[K]) => {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
const filteredEntries = entries.filter((e) => {
if (filters.category !== 'all' && e.category !== filters.category) return false;
if (filters.search) {
const q = filters.search.toLowerCase();
return e.label.toLowerCase().includes(q) || e.username.toLowerCase().includes(q) || e.url.toLowerCase().includes(q);
}
return true;
});
return { entries: filteredEntries, allEntries: entries, loading, filters, updateFilter, addEntry, updateEntry, removeEntry, refresh };
}

View File

@@ -0,0 +1,3 @@
export { passwordVaultConfig } from './config';
export { PasswordVaultModule } from './components/password-vault-module';
export type { VaultEntry, VaultEntryCategory } from './types';

View File

@@ -0,0 +1,23 @@
import type { Visibility } from '@/core/module-registry/types';
export type VaultEntryCategory =
| 'web'
| 'email'
| 'server'
| 'database'
| 'api'
| 'other';
export interface VaultEntry {
id: string;
label: string;
username: string;
encryptedPassword: string;
url: string;
category: VaultEntryCategory;
notes: string;
tags: string[];
visibility: Visibility;
createdAt: string;
updatedAt: string;
}

View File

@@ -0,0 +1,327 @@
'use client';
import { useState } from 'react';
import { ArrowLeft, Copy, Check, Save, Trash2, History, Sparkles } from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label';
import { Textarea } from '@/shared/components/ui/textarea';
import { Badge } from '@/shared/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/shared/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
import { Separator } from '@/shared/components/ui/separator';
import type { PromptTemplate, PromptVariable } from '../types';
import { usePromptGenerator } from '../hooks/use-prompt-generator';
import { cn } from '@/shared/lib/utils';
const CATEGORY_LABELS: Record<string, string> = {
architecture: 'Arhitectură',
legal: 'Legal',
technical: 'Tehnic',
administrative: 'Administrativ',
gis: 'GIS',
bim: 'BIM',
rendering: 'Vizualizare',
procurement: 'Achiziții',
general: 'General',
};
const TARGET_LABELS: Record<string, string> = {
text: 'Text', image: 'Imagine', code: 'Cod', review: 'Review', rewrite: 'Rescriere',
};
type ViewMode = 'templates' | 'compose' | 'history';
export function PromptGeneratorModule() {
const {
allTemplates, selectedTemplate, values, composedPrompt,
history, selectTemplate, updateValue, saveToHistory,
deleteHistoryEntry, clearSelection,
} = usePromptGenerator();
const [viewMode, setViewMode] = useState<ViewMode>('templates');
const [copied, setCopied] = useState(false);
const [saved, setSaved] = useState(false);
const [filterCategory, setFilterCategory] = useState<string>('all');
const handleSelectTemplate = (template: PromptTemplate) => {
selectTemplate(template);
setViewMode('compose');
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(composedPrompt);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch { /* silent */ }
};
const handleSave = async () => {
await saveToHistory();
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
const handleBack = () => {
clearSelection();
setViewMode('templates');
};
const filteredTemplates = filterCategory === 'all'
? allTemplates
: allTemplates.filter((t) => t.category === filterCategory);
const usedCategories = [...new Set(allTemplates.map((t) => t.category))];
return (
<div className="space-y-6">
{/* Navigation */}
<div className="flex items-center gap-2">
<Button
variant={viewMode === 'templates' ? 'default' : 'outline'}
size="sm"
onClick={() => { clearSelection(); setViewMode('templates'); }}
>
<Sparkles className="mr-1 h-3.5 w-3.5" /> Șabloane
</Button>
<Button
variant={viewMode === 'history' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('history')}
>
<History className="mr-1 h-3.5 w-3.5" /> Istoric ({history.length})
</Button>
</div>
{/* Template browser */}
{viewMode === 'templates' && (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Label>Categorie:</Label>
<Select value={filterCategory} onValueChange={setFilterCategory}>
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate</SelectItem>
{usedCategories.map((cat) => (
<SelectItem key={cat} value={cat}>{CATEGORY_LABELS[cat] ?? cat}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{filteredTemplates.map((template) => (
<Card
key={template.id}
className="cursor-pointer transition-colors hover:border-primary/50 hover:bg-accent/30"
onClick={() => handleSelectTemplate(template)}
>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<CardTitle className="text-sm">{template.name}</CardTitle>
<Badge variant="outline" className="text-[10px]">{TARGET_LABELS[template.targetAiType]}</Badge>
</div>
<CardDescription className="text-xs">{template.description}</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-wrap gap-1">
<Badge variant="secondary" className="text-[10px]">{CATEGORY_LABELS[template.category] ?? template.category}</Badge>
<Badge variant="secondary" className="text-[10px]">{template.variables.length} variabile</Badge>
<Badge variant="secondary" className="text-[10px]">{template.blocks.length} blocuri</Badge>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)}
{/* Composition view */}
{viewMode === 'compose' && selectedTemplate && (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={handleBack}>
<ArrowLeft className="mr-1 h-3.5 w-3.5" /> Înapoi
</Button>
<div>
<h3 className="font-semibold">{selectedTemplate.name}</h3>
<p className="text-xs text-muted-foreground">{selectedTemplate.description}</p>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{/* Variable form */}
<Card>
<CardHeader><CardTitle className="text-base">Variabile</CardTitle></CardHeader>
<CardContent className="space-y-4">
{selectedTemplate.variables.map((variable) => (
<VariableField
key={variable.id}
variable={variable}
value={values[variable.id]}
onChange={(val) => updateValue(variable.id, val)}
/>
))}
</CardContent>
</Card>
{/* Output */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Prompt compus</h3>
<div className="flex gap-1.5">
<Button variant="outline" size="sm" onClick={handleSave} disabled={!composedPrompt}>
<Save className="mr-1 h-3.5 w-3.5" />
{saved ? 'Salvat!' : 'Salvează'}
</Button>
<Button size="sm" onClick={handleCopy} disabled={!composedPrompt}>
{copied ? <Check className="mr-1 h-3.5 w-3.5" /> : <Copy className="mr-1 h-3.5 w-3.5" />}
{copied ? 'Copiat!' : 'Copiază'}
</Button>
</div>
</div>
<div className="min-h-[300px] whitespace-pre-wrap rounded-lg border bg-muted/30 p-4 text-sm">
{composedPrompt || <span className="text-muted-foreground italic">Completează variabilele pentru a genera promptul...</span>}
</div>
<div className="flex flex-wrap gap-1.5">
<Badge variant="outline" className="text-[10px]">Output: {selectedTemplate.outputMode}</Badge>
<Badge variant="outline" className="text-[10px]">Blocuri: {selectedTemplate.blocks.length}</Badge>
<Badge variant="outline" className="text-[10px]">Caractere: {composedPrompt.length}</Badge>
</div>
</div>
</div>
</div>
)}
{/* History view */}
{viewMode === 'history' && (
<div className="space-y-3">
{history.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Niciun prompt salvat în istoric.</p>
) : (
history.map((entry) => (
<Card key={entry.id} className="group">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{entry.templateName}</p>
<Badge variant="outline" className="text-[10px]">{entry.outputMode}</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{new Date(entry.createdAt).toLocaleString('ro-RO')} {entry.composedPrompt.length} caractere
</p>
<pre className="mt-2 max-h-24 overflow-hidden truncate text-xs text-muted-foreground">
{entry.composedPrompt.slice(0, 200)}...
</pre>
</div>
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<CopyHistoryButton text={entry.composedPrompt} />
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => deleteHistoryEntry(entry.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
)}
</div>
);
}
function VariableField({ variable, value, onChange }: {
variable: PromptVariable;
value: unknown;
onChange: (val: unknown) => void;
}) {
const strVal = value !== undefined && value !== null ? String(value) : '';
return (
<div>
<Label className={cn(variable.required && 'after:content-["*"] after:ml-0.5 after:text-destructive')}>
{variable.label}
</Label>
{variable.helperText && (
<p className="text-[11px] text-muted-foreground">{variable.helperText}</p>
)}
{(variable.type === 'text' || variable.type === 'number') && (
<Input
type={variable.type === 'number' ? 'number' : 'text'}
value={strVal}
onChange={(e) => onChange(variable.type === 'number' ? Number(e.target.value) : e.target.value)}
placeholder={variable.placeholder}
className="mt-1"
/>
)}
{(variable.type === 'select' || variable.type === 'tone-selector' || variable.type === 'company-selector') && variable.options && (
<Select value={strVal} onValueChange={onChange}>
<SelectTrigger className="mt-1"><SelectValue placeholder="Selectează..." /></SelectTrigger>
<SelectContent>
{variable.options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
{variable.type === 'boolean' && (
<div className="mt-1 flex items-center gap-2">
<input
type="checkbox"
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 rounded accent-primary"
/>
<span className="text-sm text-muted-foreground">{variable.placeholder ?? 'Da/Nu'}</span>
</div>
)}
{variable.type === 'multi-select' && variable.options && (
<div className="mt-1 flex flex-wrap gap-1.5">
{variable.options.map((opt) => {
const selected = Array.isArray(value) && (value as string[]).includes(opt.value);
return (
<button
key={opt.value}
type="button"
onClick={() => {
const arr = Array.isArray(value) ? [...(value as string[])] : [];
if (selected) onChange(arr.filter((v) => v !== opt.value));
else onChange([...arr, opt.value]);
}}
className={cn(
'rounded-full border px-2.5 py-0.5 text-xs transition-colors',
selected ? 'border-primary bg-primary text-primary-foreground' : 'hover:bg-accent'
)}
>
{opt.label}
</button>
);
})}
</div>
)}
</div>
);
}
function CopyHistoryButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch { /* silent */ }
};
return (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopy}>
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
);
}

View File

@@ -0,0 +1,17 @@
import type { ModuleConfig } from '@/core/module-registry/types';
export const promptGeneratorConfig: ModuleConfig = {
id: 'prompt-generator',
name: 'Generator Prompturi',
description: 'Generator structurat de prompturi pe bază de șabloane parametrizate, organizate pe domenii profesionale',
icon: 'sparkles',
route: '/prompt-generator',
category: 'ai',
featureFlag: 'module.prompt-generator',
visibility: 'all',
version: '0.1.0',
dependencies: [],
storageNamespace: 'prompt-generator',
navOrder: 50,
tags: ['prompt', 'ai', 'generator', 'șabloane'],
};

View File

@@ -0,0 +1,110 @@
'use client';
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useStorage } from '@/core/storage';
import { v4 as uuid } from 'uuid';
import type { PromptTemplate, PromptHistoryEntry, OutputMode } from '../types';
import { BUILTIN_TEMPLATES } from '../services/builtin-templates';
import { composePrompt } from '../services/prompt-composer';
const HISTORY_PREFIX = 'history:';
const TEMPLATE_PREFIX = 'template:';
export function usePromptGenerator() {
const storage = useStorage('prompt-generator');
const [selectedTemplate, setSelectedTemplate] = useState<PromptTemplate | null>(null);
const [values, setValues] = useState<Record<string, unknown>>({});
const [customTemplates, setCustomTemplates] = useState<PromptTemplate[]>([]);
const [history, setHistory] = useState<PromptHistoryEntry[]>([]);
const [loading, setLoading] = useState(true);
const allTemplates = useMemo(() => [...BUILTIN_TEMPLATES, ...customTemplates], [customTemplates]);
// Load custom templates and history
useEffect(() => {
async function load() {
setLoading(true);
const keys = await storage.list();
const templates: PromptTemplate[] = [];
const entries: PromptHistoryEntry[] = [];
for (const key of keys) {
if (key.startsWith(TEMPLATE_PREFIX)) {
const t = await storage.get<PromptTemplate>(key);
if (t) templates.push(t);
} else if (key.startsWith(HISTORY_PREFIX)) {
const h = await storage.get<PromptHistoryEntry>(key);
if (h) entries.push(h);
}
}
entries.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
setCustomTemplates(templates);
setHistory(entries);
setLoading(false);
}
load();
}, [storage]);
const selectTemplate = useCallback((template: PromptTemplate) => {
setSelectedTemplate(template);
// Initialize values with defaults
const defaults: Record<string, unknown> = {};
for (const v of template.variables) {
if (v.defaultValue !== undefined) defaults[v.id] = v.defaultValue;
}
setValues(defaults);
}, []);
const updateValue = useCallback((variableId: string, value: unknown) => {
setValues((prev) => ({ ...prev, [variableId]: value }));
}, []);
const composedPrompt = useMemo(() => {
if (!selectedTemplate) return '';
return composePrompt(selectedTemplate, values);
}, [selectedTemplate, values]);
const saveToHistory = useCallback(async () => {
if (!selectedTemplate || !composedPrompt) return;
const entry: PromptHistoryEntry = {
id: uuid(),
templateId: selectedTemplate.id,
templateName: selectedTemplate.name,
templateVersion: selectedTemplate.version,
values: { ...values },
composedPrompt,
outputMode: selectedTemplate.outputMode,
providerProfile: selectedTemplate.providerProfile ?? null,
safetyBlocks: selectedTemplate.safetyBlocks?.filter((s) => s.enabled).map((s) => s.id) ?? [],
tags: selectedTemplate.tags,
isFavorite: false,
createdAt: new Date().toISOString(),
};
await storage.set(`${HISTORY_PREFIX}${entry.id}`, entry);
setHistory((prev) => [entry, ...prev]);
return entry;
}, [storage, selectedTemplate, values, composedPrompt]);
const deleteHistoryEntry = useCallback(async (id: string) => {
await storage.delete(`${HISTORY_PREFIX}${id}`);
setHistory((prev) => prev.filter((h) => h.id !== id));
}, [storage]);
const clearSelection = useCallback(() => {
setSelectedTemplate(null);
setValues({});
}, []);
return {
allTemplates,
selectedTemplate,
values,
composedPrompt,
history,
loading,
selectTemplate,
updateValue,
saveToHistory,
deleteHistoryEntry,
clearSelection,
};
}

View File

@@ -0,0 +1,16 @@
export { promptGeneratorConfig } from './config';
export { PromptGeneratorModule } from './components/prompt-generator-module';
export type {
BlockType,
OutputMode,
PromptBlock,
PromptCategory,
PromptDomain,
PromptHistoryEntry,
PromptTemplate,
PromptVariable,
ProviderProfile,
SafetyBlock,
SelectOption,
VariableType,
} from './types';

View File

@@ -0,0 +1,146 @@
import type { PromptTemplate } from '../types';
export const BUILTIN_TEMPLATES: PromptTemplate[] = [
{
id: 'arch-description',
name: 'Descriere Proiect Arhitectură',
category: 'architecture',
domain: 'architecture-visualization',
description: 'Generează o descriere narativă a unui proiect de arhitectură pentru documentație sau prezentare.',
targetAiType: 'text',
blocks: [
{ id: 'b1', type: 'role', label: 'Rol', content: 'Ești un arhitect experimentat, specialist în prezentări de proiecte și memorii tehnice.', order: 1, required: true },
{ id: 'b2', type: 'context', label: 'Context', content: 'Proiectul se numește "{{projectName}}" și este situat în {{location}}. Tipul proiectului: {{projectType}}. Suprafața terenului: {{landArea}} mp. Beneficiar: {{clientName}}.', order: 2, required: true },
{ id: 'b3', type: 'task', label: 'Sarcină', content: 'Scrie o descriere narativă a proiectului pentru {{targetDocument}}. Tonul trebuie să fie {{tone}}.', order: 3, required: true },
{ id: 'b4', type: 'constraints', label: 'Constrângeri', content: 'Lungimea textului: {{textLength}}. Limba: română. Include referințe la specificul local și la contextul urbanistic.', order: 4, required: true },
],
variables: [
{ id: 'projectName', label: 'Nume proiect', type: 'text', required: true, placeholder: 'ex: Locuință unifamilială P+1' },
{ id: 'location', label: 'Locație', type: 'text', required: true, placeholder: 'ex: Cluj-Napoca, str. Unirii nr. 3' },
{ id: 'projectType', label: 'Tip proiect', type: 'select', required: true, options: [
{ value: 'residential', label: 'Rezidențial' }, { value: 'commercial', label: 'Comercial' },
{ value: 'mixed', label: 'Mixt' }, { value: 'industrial', label: 'Industrial' },
{ value: 'public', label: 'Public' },
]},
{ id: 'landArea', label: 'Suprafață teren (mp)', type: 'number', required: false, placeholder: '500' },
{ id: 'clientName', label: 'Beneficiar', type: 'text', required: false, placeholder: 'Nume client' },
{ id: 'targetDocument', label: 'Document țintă', type: 'select', required: true, options: [
{ value: 'memoriu tehnic', label: 'Memoriu tehnic' }, { value: 'prezentare client', label: 'Prezentare client' },
{ value: 'documentație urbanism', label: 'Documentație urbanism' },
]},
{ id: 'tone', label: 'Ton', type: 'select', required: true, options: [
{ value: 'tehnic și formal', label: 'Tehnic/Formal' }, { value: 'narativ și inspirațional', label: 'Narativ' },
{ value: 'concis și factual', label: 'Concis' },
]},
{ id: 'textLength', label: 'Lungime', type: 'select', required: true, options: [
{ value: '200-300 cuvinte', label: 'Scurt (200-300 cuvinte)' }, { value: '500-700 cuvinte', label: 'Mediu (500-700)' },
{ value: '1000+ cuvinte', label: 'Lung (1000+)' },
]},
],
outputMode: 'expanded-expert',
tags: ['arhitectură', 'memoriu', 'descriere'],
version: '1.0.0',
author: 'ArchiTools',
visibility: 'all',
},
{
id: 'legal-review',
name: 'Verificare Conformitate Legislativă',
category: 'legal',
domain: 'legal-review',
description: 'Generează un prompt pentru verificarea conformității unui document cu legislația aplicabilă.',
targetAiType: 'review',
blocks: [
{ id: 'b1', type: 'role', label: 'Rol', content: 'Ești un consultant juridic specializat în legislația construcțiilor și urbanismului din România.', order: 1, required: true },
{ id: 'b2', type: 'context', label: 'Context', content: 'Documentul de verificat: {{documentType}}. Faza de proiectare: {{projectPhase}}.', order: 2, required: true },
{ id: 'b3', type: 'task', label: 'Sarcină', content: 'Verifică conformitatea cu: {{regulations}}. Identifică potențiale neconformități și recomandă acțiuni corective.', order: 3, required: true },
],
variables: [
{ id: 'documentType', label: 'Tip document', type: 'select', required: true, options: [
{ value: 'CU', label: 'Certificat Urbanism' }, { value: 'DTAC', label: 'DTAC' },
{ value: 'PT', label: 'Proiect Tehnic' }, { value: 'PUZ', label: 'PUZ' }, { value: 'PUD', label: 'PUD' },
]},
{ id: 'projectPhase', label: 'Fază proiect', type: 'select', required: true, options: [
{ value: 'studiu fezabilitate', label: 'Studiu fezabilitate' }, { value: 'proiectare', label: 'Proiectare' },
{ value: 'autorizare', label: 'Autorizare' }, { value: 'execuție', label: 'Execuție' },
]},
{ id: 'regulations', label: 'Legislație de referință', type: 'text', required: true, placeholder: 'ex: Legea 50/1991, Normativ P118' },
],
outputMode: 'checklist',
tags: ['legal', 'conformitate', 'verificare'],
version: '1.0.0',
author: 'ArchiTools',
visibility: 'all',
},
{
id: 'tech-spec',
name: 'Specificație Tehnică',
category: 'technical',
domain: 'technical-documentation',
description: 'Generează o specificație tehnică pentru un element de proiect.',
targetAiType: 'text',
blocks: [
{ id: 'b1', type: 'role', label: 'Rol', content: 'Ești un inginer de specialitate cu experiență în redactarea specificațiilor tehnice pentru proiecte de construcții.', order: 1, required: true },
{ id: 'b2', type: 'task', label: 'Sarcină', content: 'Redactează o specificație tehnică pentru: {{element}}. Nivelul de detaliu: {{detailLevel}}.', order: 2, required: true },
{ id: 'b3', type: 'format', label: 'Format', content: 'Structurează specificația cu: descriere generală, materiale, dimensiuni, cerințe de performanță, standarde aplicabile.', order: 3, required: true },
],
variables: [
{ id: 'element', label: 'Element de proiect', type: 'text', required: true, placeholder: 'ex: Structură metalică hală industrială' },
{ id: 'detailLevel', label: 'Nivel detaliu', type: 'select', required: true, options: [
{ value: 'orientativ', label: 'Orientativ' }, { value: 'detaliat', label: 'Detaliat' }, { value: 'execuție', label: 'Nivel execuție' },
]},
],
outputMode: 'step-by-step',
tags: ['tehnic', 'specificație', 'documentație'],
version: '1.0.0',
author: 'ArchiTools',
visibility: 'all',
},
{
id: 'image-prompt',
name: 'Prompt Vizualizare Arhitecturală',
category: 'rendering',
domain: 'architecture-visualization',
description: 'Generează un prompt optimizat pentru AI de generare imagini (Midjourney, DALL-E, Stable Diffusion).',
targetAiType: 'image',
blocks: [
{ id: 'b1', type: 'task', label: 'Descriere', content: '{{buildingType}}, {{style}}, situated in {{setting}}.', order: 1, required: true },
{ id: 'b2', type: 'context', label: 'Atmosferă', content: '{{atmosphere}}. {{timeOfDay}} lighting. {{season}}.', order: 2, required: true },
{ id: 'b3', type: 'format', label: 'Parametri tehnici', content: '{{cameraAngle}}, {{renderStyle}}, high quality, detailed, 8k resolution.', order: 3, required: true },
],
variables: [
{ id: 'buildingType', label: 'Tip clădire', type: 'text', required: true, placeholder: 'ex: Modern minimalist villa with flat roof' },
{ id: 'style', label: 'Stil arhitectural', type: 'select', required: true, options: [
{ value: 'modern minimalist', label: 'Modern minimalist' }, { value: 'contemporary', label: 'Contemporan' },
{ value: 'traditional Romanian', label: 'Tradițional românesc' }, { value: 'brutalist', label: 'Brutalist' },
{ value: 'art deco', label: 'Art Deco' },
]},
{ id: 'setting', label: 'Amplasament', type: 'text', required: true, placeholder: 'ex: hillside with forest background' },
{ id: 'atmosphere', label: 'Atmosferă', type: 'select', required: true, options: [
{ value: 'Warm and inviting', label: 'Cald/Primitor' }, { value: 'Dramatic and bold', label: 'Dramatic' },
{ value: 'Serene and peaceful', label: 'Calm/Liniștit' }, { value: 'Urban and dynamic', label: 'Urban/Dinamic' },
]},
{ id: 'timeOfDay', label: 'Moment al zilei', type: 'select', required: true, options: [
{ value: 'golden hour', label: 'Golden hour' }, { value: 'midday', label: 'Amiază' },
{ value: 'blue hour / dusk', label: 'Blue hour' }, { value: 'night with interior lights', label: 'Noapte' },
]},
{ id: 'season', label: 'Anotimp', type: 'select', required: false, options: [
{ value: 'Spring with green vegetation', label: 'Primăvară' }, { value: 'Summer', label: 'Vară' },
{ value: 'Autumn with warm colors', label: 'Toamnă' }, { value: 'Winter with snow', label: 'Iarnă' },
]},
{ id: 'cameraAngle', label: 'Unghi cameră', type: 'select', required: true, options: [
{ value: 'eye-level perspective', label: 'Nivel ochi' }, { value: 'aerial drone view', label: 'Vedere aeriană' },
{ value: 'low angle dramatic', label: 'Unghi jos' }, { value: 'birds-eye plan view', label: 'Vedere de sus' },
]},
{ id: 'renderStyle', label: 'Stil render', type: 'select', required: true, options: [
{ value: 'photorealistic', label: 'Fotorealistic' }, { value: 'architectural illustration', label: 'Ilustrație' },
{ value: 'watercolor sketch', label: 'Acuarelă' }, { value: 'technical section render', label: 'Secțiune' },
]},
],
outputMode: 'short',
tags: ['render', 'imagine', 'vizualizare', 'midjourney'],
version: '1.0.0',
author: 'ArchiTools',
visibility: 'all',
},
];

View File

@@ -0,0 +1,75 @@
import type { PromptTemplate, PromptBlock, OutputMode } from '../types';
const OUTPUT_MODE_INSTRUCTIONS: Record<OutputMode, string> = {
short: 'Răspunde concis, maximum 2-3 paragrafe.',
'expanded-expert': 'Răspunde detaliat, ca expert în domeniu. Folosește terminologie profesională.',
'step-by-step': 'Răspunde pas cu pas, cu numerotare clară.',
'chain-of-thought': 'Gândește cu voce tare. Arată raționamentul pas cu pas înainte de a da răspunsul final.',
'structured-json': 'Răspunde în format JSON structurat.',
checklist: 'Răspunde sub formă de checklist cu casete de bifat (- [ ] item).',
};
function interpolateVariables(content: string, values: Record<string, unknown>): string {
return content.replace(/\{\{(\w+)\}\}/g, (match, key: string) => {
const val = values[key];
if (val === undefined || val === null || val === '') return match;
if (Array.isArray(val)) return val.join(', ');
return String(val);
});
}
function shouldIncludeBlock(block: PromptBlock, values: Record<string, unknown>): boolean {
if (!block.conditional) return true;
const { variableId, operator, value } = block.conditional;
const actual = values[variableId];
switch (operator) {
case 'truthy': return !!actual;
case 'falsy': return !actual;
case 'equals': return String(actual) === value;
case 'notEquals': return String(actual) !== value;
default: return true;
}
}
export function composePrompt(
template: PromptTemplate,
values: Record<string, unknown>,
): string {
const blocks = [...template.blocks]
.sort((a, b) => a.order - b.order)
.filter((block) => shouldIncludeBlock(block, values));
const parts: string[] = [];
for (const block of blocks) {
const content = interpolateVariables(block.content, values).trim();
if (!content) continue;
parts.push(content);
}
// Add output mode instruction
const modeInstruction = OUTPUT_MODE_INSTRUCTIONS[template.outputMode];
if (modeInstruction) {
parts.push(modeInstruction);
}
// Add safety blocks if enabled
if (template.safetyBlocks) {
const enabledSafety = template.safetyBlocks.filter((s) => s.enabled);
if (enabledSafety.length > 0) {
parts.push(enabledSafety.map((s) => s.content).join('\n'));
}
}
return parts.join('\n\n');
}
export function getUnfilledVariables(
template: PromptTemplate,
values: Record<string, unknown>,
): string[] {
return template.variables
.filter((v) => v.required && (values[v.id] === undefined || values[v.id] === '' || values[v.id] === null))
.map((v) => v.label);
}

View File

@@ -0,0 +1,165 @@
import type { Visibility } from '@/core/module-registry/types';
// ---------------------------------------------------------------------------
// Block system
// ---------------------------------------------------------------------------
export type BlockType =
| 'role'
| 'context'
| 'task'
| 'constraints'
| 'format'
| 'checklist'
| 'validation'
| 'output-schema'
| 'custom';
export interface PromptBlock {
id: string;
type: BlockType;
label: string;
content: string;
order: number;
required: boolean;
conditional?: {
variableId: string;
operator: 'equals' | 'notEquals' | 'truthy' | 'falsy';
value?: string;
};
}
// ---------------------------------------------------------------------------
// Variable system
// ---------------------------------------------------------------------------
export type VariableType =
| 'text'
| 'number'
| 'select'
| 'multi-select'
| 'boolean'
| 'tag-selector'
| 'project-selector'
| 'company-selector'
| 'tone-selector'
| 'regulation-set-selector';
export interface SelectOption {
value: string;
label: string;
}
export interface PromptVariable {
id: string;
label: string;
type: VariableType;
required: boolean;
defaultValue?: unknown;
placeholder?: string;
helperText?: string;
options?: SelectOption[];
validation?: {
min?: number;
max?: number;
pattern?: string;
};
}
// ---------------------------------------------------------------------------
// Output & provider
// ---------------------------------------------------------------------------
export type OutputMode =
| 'short'
| 'expanded-expert'
| 'step-by-step'
| 'chain-of-thought'
| 'structured-json'
| 'checklist';
export type PromptCategory =
| 'architecture'
| 'legal'
| 'technical'
| 'administrative'
| 'gis'
| 'bim'
| 'rendering'
| 'procurement'
| 'general';
export type PromptDomain =
| 'architecture-visualization'
| 'technical-documentation'
| 'legal-review'
| 'urbanism-gis'
| 'bim-coordination'
| 'procurement-bidding'
| 'administrative-writing'
| 'general';
export interface ProviderProfile {
id: string;
name: string;
maxLength?: number;
formattingPreference: 'markdown' | 'plain' | 'structured';
instructionStyle: 'direct' | 'conversational' | 'system-prompt';
verbosityLevel: 'concise' | 'standard' | 'detailed';
}
// ---------------------------------------------------------------------------
// Safety blocks
// ---------------------------------------------------------------------------
export interface SafetyBlock {
id: string;
label: string;
content: string;
enabled: boolean;
category: 'legal' | 'tone' | 'citation' | 'disclaimer';
}
// ---------------------------------------------------------------------------
// Template
// ---------------------------------------------------------------------------
export interface PromptTemplate {
id: string;
name: string;
category: PromptCategory;
domain: PromptDomain;
description: string;
targetAiType: 'text' | 'image' | 'code' | 'review' | 'rewrite';
blocks: PromptBlock[];
variables: PromptVariable[];
outputMode: OutputMode;
providerProfile?: string;
safetyBlocks?: SafetyBlock[];
tags: string[];
version: string;
author: string;
visibility: Visibility;
exampleOutputs?: string[];
metadata?: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// History
// ---------------------------------------------------------------------------
export interface PromptHistoryEntry {
id: string;
templateId: string;
templateName: string;
templateVersion: string;
values: Record<string, unknown>;
composedPrompt: string;
outputMode: OutputMode;
providerProfile: string | null;
safetyBlocks: string[];
tags: string[];
isFavorite: boolean;
createdAt: string;
metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,128 @@
'use client';
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
import { Badge } from '@/shared/components/ui/badge';
import { useRegistry } from '../hooks/use-registry';
import { RegistryFilters } from './registry-filters';
import { RegistryTable } from './registry-table';
import { RegistryEntryForm } from './registry-entry-form';
import type { RegistryEntry } from '../types';
type ViewMode = 'list' | 'add' | 'edit';
export function RegistraturaModule() {
const {
entries, allEntries, loading, filters, updateFilter,
addEntry, updateEntry, removeEntry,
} = useRegistry();
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [editingEntry, setEditingEntry] = useState<RegistryEntry | null>(null);
const handleAdd = async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => {
await addEntry(data);
setViewMode('list');
};
const handleEdit = (entry: RegistryEntry) => {
setEditingEntry(entry);
setViewMode('edit');
};
const handleUpdate = async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => {
if (!editingEntry) return;
await updateEntry(editingEntry.id, data);
setEditingEntry(null);
setViewMode('list');
};
const handleDelete = async (id: string) => {
await removeEntry(id);
};
const handleCancel = () => {
setViewMode('list');
setEditingEntry(null);
};
// Stats
const total = allEntries.length;
const incoming = allEntries.filter((e) => e.type === 'incoming').length;
const outgoing = allEntries.filter((e) => e.type === 'outgoing').length;
const inProgress = allEntries.filter((e) => e.status === 'in-progress').length;
return (
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Total" value={total} />
<StatCard label="Intrare" value={incoming} />
<StatCard label="Ieșire" value={outgoing} />
<StatCard label="În lucru" value={inProgress} />
</div>
{viewMode === 'list' && (
<>
<div className="flex items-center justify-between gap-4">
<RegistryFilters filters={filters} onUpdate={updateFilter} />
<Button onClick={() => setViewMode('add')} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button>
</div>
<RegistryTable
entries={entries}
loading={loading}
onEdit={handleEdit}
onDelete={handleDelete}
/>
{!loading && (
<p className="text-xs text-muted-foreground">
{entries.length} din {total} înregistrări afișate
</p>
)}
</>
)}
{viewMode === 'add' && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
Înregistrare nouă
<Badge variant="outline" className="text-xs">Nr. auto</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<RegistryEntryForm onSubmit={handleAdd} onCancel={handleCancel} />
</CardContent>
</Card>
)}
{viewMode === 'edit' && editingEntry && (
<Card>
<CardHeader>
<CardTitle>Editare {editingEntry.number}</CardTitle>
</CardHeader>
<CardContent>
<RegistryEntryForm initial={editingEntry} onSubmit={handleUpdate} onCancel={handleCancel} />
</CardContent>
</Card>
)}
</div>
);
}
function StatCard({ label, value }: { label: string; value: number }) {
return (
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-2xl font-bold">{value}</p>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import { useState } from 'react';
import type { CompanyId } from '@/core/auth/types';
import type { RegistryEntry, RegistryEntryType, RegistryEntryStatus } from '../types';
import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label';
import { Textarea } from '@/shared/components/ui/textarea';
import { Button } from '@/shared/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
interface RegistryEntryFormProps {
initial?: RegistryEntry;
onSubmit: (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => void;
onCancel: () => void;
}
export function RegistryEntryForm({ initial, onSubmit, onCancel }: RegistryEntryFormProps) {
const [type, setType] = useState<RegistryEntryType>(initial?.type ?? 'incoming');
const [subject, setSubject] = useState(initial?.subject ?? '');
const [date, setDate] = useState(initial?.date ?? new Date().toISOString().slice(0, 10));
const [sender, setSender] = useState(initial?.sender ?? '');
const [recipient, setRecipient] = useState(initial?.recipient ?? '');
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
const [status, setStatus] = useState<RegistryEntryStatus>(initial?.status ?? 'registered');
const [notes, setNotes] = useState(initial?.notes ?? '');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({
type,
subject,
date,
sender,
recipient,
company,
status,
notes,
tags: initial?.tags ?? [],
visibility: initial?.visibility ?? 'all',
});
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label>Tip document</Label>
<Select value={type} onValueChange={(v) => setType(v as RegistryEntryType)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="incoming">Intrare</SelectItem>
<SelectItem value="outgoing">Ieșire</SelectItem>
<SelectItem value="internal">Intern</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Data</Label>
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} className="mt-1" />
</div>
</div>
<div>
<Label>Subiect</Label>
<Input value={subject} onChange={(e) => setSubject(e.target.value)} className="mt-1" required />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label>Expeditor</Label>
<Input value={sender} onChange={(e) => setSender(e.target.value)} className="mt-1" />
</div>
<div>
<Label>Destinatar</Label>
<Input value={recipient} onChange={(e) => setRecipient(e.target.value)} className="mt-1" />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label>Companie</Label>
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<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>
</div>
<div>
<Label>Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v as RegistryEntryStatus)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="registered">Înregistrat</SelectItem>
<SelectItem value="in-progress">În lucru</SelectItem>
<SelectItem value="completed">Finalizat</SelectItem>
<SelectItem value="archived">Arhivat</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label>Note</Label>
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={3} className="mt-1" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,65 @@
'use client';
import { Search } from 'lucide-react';
import { Input } from '@/shared/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
import type { RegistryFilters as Filters } from '../hooks/use-registry';
interface RegistryFiltersProps {
filters: Filters;
onUpdate: <K extends keyof Filters>(key: K, value: Filters[K]) => void;
}
export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) {
return (
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Caută după subiect, expeditor, număr..."
value={filters.search}
onChange={(e) => onUpdate('search', e.target.value)}
className="pl-9"
/>
</div>
<Select value={filters.type} onValueChange={(v) => onUpdate('type', v as Filters['type'])}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Tip" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate tipurile</SelectItem>
<SelectItem value="incoming">Intrare</SelectItem>
<SelectItem value="outgoing">Ieșire</SelectItem>
<SelectItem value="internal">Intern</SelectItem>
</SelectContent>
</Select>
<Select value={filters.status} onValueChange={(v) => onUpdate('status', v as Filters['status'])}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate</SelectItem>
<SelectItem value="registered">Înregistrat</SelectItem>
<SelectItem value="in-progress">În lucru</SelectItem>
<SelectItem value="completed">Finalizat</SelectItem>
<SelectItem value="archived">Arhivat</SelectItem>
</SelectContent>
</Select>
<Select value={filters.company} onValueChange={(v) => onUpdate('company', v)}>
<SelectTrigger className="w-[170px]">
<SelectValue placeholder="Companie" />
</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>
</div>
);
}

View File

@@ -0,0 +1,102 @@
'use client';
import { Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import { Badge } from '@/shared/components/ui/badge';
import type { RegistryEntry } from '../types';
import { cn } from '@/shared/lib/utils';
interface RegistryTableProps {
entries: RegistryEntry[];
loading: boolean;
onEdit: (entry: RegistryEntry) => void;
onDelete: (id: string) => void;
}
const TYPE_LABELS: Record<string, string> = {
incoming: 'Intrare',
outgoing: 'Ieșire',
internal: 'Intern',
};
const STATUS_LABELS: Record<string, string> = {
registered: 'Înregistrat',
'in-progress': 'În lucru',
completed: 'Finalizat',
archived: 'Arhivat',
};
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'outline' | 'destructive'> = {
registered: 'default',
'in-progress': 'secondary',
completed: 'outline',
archived: 'outline',
};
export function RegistryTable({ entries, loading, onEdit, onDelete }: RegistryTableProps) {
if (loading) {
return <p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>;
}
if (entries.length === 0) {
return (
<p className="py-8 text-center text-sm text-muted-foreground">
Nicio înregistrare găsită. Adaugă prima înregistrare.
</p>
);
}
return (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40">
<th className="px-3 py-2 text-left font-medium">Nr.</th>
<th className="px-3 py-2 text-left font-medium">Data</th>
<th className="px-3 py-2 text-left font-medium">Tip</th>
<th className="px-3 py-2 text-left font-medium">Subiect</th>
<th className="px-3 py-2 text-left font-medium">Expeditor</th>
<th className="px-3 py-2 text-left font-medium">Destinatar</th>
<th className="px-3 py-2 text-left font-medium">Status</th>
<th className="px-3 py-2 text-right font-medium">Acțiuni</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.id} className={cn('border-b hover:bg-muted/20 transition-colors')}>
<td className="px-3 py-2 font-mono text-xs">{entry.number}</td>
<td className="px-3 py-2 text-xs whitespace-nowrap">{formatDate(entry.date)}</td>
<td className="px-3 py-2">
<Badge variant="outline" className="text-xs">{TYPE_LABELS[entry.type]}</Badge>
</td>
<td className="px-3 py-2 max-w-[250px] truncate">{entry.subject}</td>
<td className="px-3 py-2 max-w-[150px] truncate">{entry.sender}</td>
<td className="px-3 py-2 max-w-[150px] truncate">{entry.recipient}</td>
<td className="px-3 py-2">
<Badge variant={STATUS_VARIANT[entry.status]}>{STATUS_LABELS[entry.status]}</Badge>
</td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onEdit(entry)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => onDelete(entry.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch {
return iso;
}
}

View File

@@ -0,0 +1,17 @@
import type { ModuleConfig } from '@/core/module-registry/types';
export const registraturaConfig: ModuleConfig = {
id: 'registratura',
name: 'Registratură',
description: 'Registru de corespondență multi-firmă cu urmărire documente',
icon: 'book-open',
route: '/registratura',
category: 'operations',
featureFlag: 'module.registratura',
visibility: 'all',
version: '0.1.0',
dependencies: [],
storageNamespace: 'registratura',
navOrder: 10,
tags: ['registru', 'corespondență', 'documente'],
};

View File

@@ -0,0 +1,103 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useStorage } from '@/core/storage';
import { v4 as uuid } from 'uuid';
import type { RegistryEntry, RegistryEntryType, RegistryEntryStatus } from '../types';
import { getAllEntries, saveEntry, deleteEntry, generateRegistryNumber } from '../services/registry-service';
export interface RegistryFilters {
search: string;
type: RegistryEntryType | 'all';
status: RegistryEntryStatus | 'all';
company: string;
}
export function useRegistry() {
const storage = useStorage('registratura');
const [entries, setEntries] = useState<RegistryEntry[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<RegistryFilters>({
search: '',
type: 'all',
status: 'all',
company: 'all',
});
const refresh = useCallback(async () => {
setLoading(true);
const items = await getAllEntries(storage);
setEntries(items);
setLoading(false);
}, [storage]);
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]);
const addEntry = useCallback(async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => {
const now = new Date().toISOString();
const nextIndex = entries.length + 1;
const entry: RegistryEntry = {
...data,
id: uuid(),
number: generateRegistryNumber(data.date, nextIndex),
createdAt: now,
updatedAt: now,
};
await saveEntry(storage, entry);
await refresh();
return entry;
}, [storage, refresh, entries.length]);
const updateEntry = useCallback(async (id: string, updates: Partial<RegistryEntry>) => {
const existing = entries.find((e) => e.id === id);
if (!existing) return;
const updated: RegistryEntry = {
...existing,
...updates,
id: existing.id,
number: existing.number,
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
await saveEntry(storage, updated);
await refresh();
}, [storage, refresh, entries]);
const removeEntry = useCallback(async (id: string) => {
await deleteEntry(storage, id);
await refresh();
}, [storage, refresh]);
const updateFilter = useCallback(<K extends keyof RegistryFilters>(key: K, value: RegistryFilters[K]) => {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
const filteredEntries = entries.filter((entry) => {
if (filters.type !== 'all' && entry.type !== filters.type) return false;
if (filters.status !== 'all' && entry.status !== filters.status) return false;
if (filters.company !== 'all' && entry.company !== filters.company) return false;
if (filters.search) {
const q = filters.search.toLowerCase();
return (
entry.subject.toLowerCase().includes(q) ||
entry.sender.toLowerCase().includes(q) ||
entry.recipient.toLowerCase().includes(q) ||
entry.number.includes(q)
);
}
return true;
});
return {
entries: filteredEntries,
allEntries: entries,
loading,
filters,
updateFilter,
addEntry,
updateEntry,
removeEntry,
refresh,
};
}

View File

@@ -0,0 +1,3 @@
export { registraturaConfig } from './config';
export { RegistraturaModule } from './components/registratura-module';
export type { RegistryEntry, RegistryEntryType, RegistryEntryStatus } from './types';

View File

@@ -0,0 +1,38 @@
import type { RegistryEntry } from '../types';
const STORAGE_PREFIX = 'entry:';
export interface RegistryStorage {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T): Promise<void>;
delete(key: string): Promise<void>;
list(): Promise<string[]>;
}
export async function getAllEntries(storage: RegistryStorage): Promise<RegistryEntry[]> {
const keys = await storage.list();
const entries: RegistryEntry[] = [];
for (const key of keys) {
if (key.startsWith(STORAGE_PREFIX)) {
const entry = await storage.get<RegistryEntry>(key);
if (entry) entries.push(entry);
}
}
entries.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
return entries;
}
export async function saveEntry(storage: RegistryStorage, entry: RegistryEntry): Promise<void> {
await storage.set(`${STORAGE_PREFIX}${entry.id}`, entry);
}
export async function deleteEntry(storage: RegistryStorage, id: string): Promise<void> {
await storage.delete(`${STORAGE_PREFIX}${id}`);
}
export function generateRegistryNumber(date: string, index: number): string {
const d = new Date(date);
const year = d.getFullYear();
const padded = String(index).padStart(4, '0');
return `${padded}/${year}`;
}

View File

@@ -0,0 +1,27 @@
import type { Visibility } from '@/core/module-registry/types';
import type { CompanyId } from '@/core/auth/types';
export type RegistryEntryType = 'incoming' | 'outgoing' | 'internal';
export type RegistryEntryStatus =
| 'registered'
| 'in-progress'
| 'completed'
| 'archived';
export interface RegistryEntry {
id: string;
number: string;
date: string;
type: RegistryEntryType;
subject: string;
sender: string;
recipient: string;
company: CompanyId;
status: RegistryEntryStatus;
tags: string[];
notes: string;
visibility: Visibility;
createdAt: string;
updatedAt: string;
}

View File

@@ -0,0 +1,210 @@
'use client';
import { useState } from 'react';
import { Plus, Trash2, Tag as TagIcon } from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label';
import { Badge } from '@/shared/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
import { useTags } from '@/core/tagging';
import type { TagCategory, TagScope } from '@/core/tagging/types';
import { cn } from '@/shared/lib/utils';
const CATEGORY_LABELS: Record<TagCategory, string> = {
project: 'Proiect',
phase: 'Fază',
activity: 'Activitate',
'document-type': 'Tip document',
company: 'Companie',
priority: 'Prioritate',
status: 'Status',
custom: 'Personalizat',
};
const SCOPE_LABELS: Record<TagScope, string> = {
global: 'Global',
module: 'Modul',
company: 'Companie',
};
const TAG_COLORS = [
'#ef4444', '#f97316', '#f59e0b', '#84cc16',
'#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6',
'#ec4899', '#64748b',
];
export function TagManagerModule() {
const { tags, loading, createTag, deleteTag } = useTags();
const [newLabel, setNewLabel] = useState('');
const [newCategory, setNewCategory] = useState<TagCategory>('custom');
const [newScope, setNewScope] = useState<TagScope>('global');
const [newColor, setNewColor] = useState(TAG_COLORS[5]);
const [filterCategory, setFilterCategory] = useState<TagCategory | 'all'>('all');
const handleCreate = async () => {
if (!newLabel.trim()) return;
await createTag({
label: newLabel.trim(),
category: newCategory,
scope: newScope,
color: newColor,
});
setNewLabel('');
};
const filteredTags = filterCategory === 'all'
? tags
: tags.filter((t) => t.category === filterCategory);
const groupedByCategory = filteredTags.reduce<Record<string, typeof tags>>((acc, tag) => {
const key = tag.category;
if (!acc[key]) acc[key] = [];
acc[key].push(tag);
return acc;
}, {});
return (
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card><CardContent className="p-4">
<p className="text-xs text-muted-foreground">Total etichete</p>
<p className="text-2xl font-bold">{tags.length}</p>
</CardContent></Card>
<Card><CardContent className="p-4">
<p className="text-xs text-muted-foreground">Categorii folosite</p>
<p className="text-2xl font-bold">{new Set(tags.map((t) => t.category)).size}</p>
</CardContent></Card>
<Card><CardContent className="p-4">
<p className="text-xs text-muted-foreground">Globale</p>
<p className="text-2xl font-bold">{tags.filter((t) => t.scope === 'global').length}</p>
</CardContent></Card>
<Card><CardContent className="p-4">
<p className="text-xs text-muted-foreground">Personalizate</p>
<p className="text-2xl font-bold">{tags.filter((t) => t.category === 'custom').length}</p>
</CardContent></Card>
</div>
{/* Create new tag */}
<Card>
<CardHeader><CardTitle className="text-base">Etichetă nouă</CardTitle></CardHeader>
<CardContent>
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-[200px] flex-1">
<Label>Nume</Label>
<Input
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
placeholder="Numele etichetei..."
className="mt-1"
/>
</div>
<div className="w-[160px]">
<Label>Categorie</Label>
<Select value={newCategory} onValueChange={(v) => setNewCategory(v as TagCategory)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{(Object.keys(CATEGORY_LABELS) as TagCategory[]).map((cat) => (
<SelectItem key={cat} value={cat}>{CATEGORY_LABELS[cat]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-[130px]">
<Label>Vizibilitate</Label>
<Select value={newScope} onValueChange={(v) => setNewScope(v as TagScope)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{(Object.keys(SCOPE_LABELS) as TagScope[]).map((s) => (
<SelectItem key={s} value={s}>{SCOPE_LABELS[s]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block">Culoare</Label>
<div className="flex gap-1">
{TAG_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setNewColor(color)}
className={cn(
'h-7 w-7 rounded-full border-2 transition-all',
newColor === color ? 'border-primary scale-110' : 'border-transparent hover:scale-105'
)}
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
<Button onClick={handleCreate} disabled={!newLabel.trim()}>
<Plus className="mr-1 h-4 w-4" /> Adaugă
</Button>
</div>
</CardContent>
</Card>
{/* Filter */}
<div className="flex items-center gap-3">
<Label>Filtrează:</Label>
<Select value={filterCategory} onValueChange={(v) => setFilterCategory(v as TagCategory | 'all')}>
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate categoriile</SelectItem>
{(Object.keys(CATEGORY_LABELS) as TagCategory[]).map((cat) => (
<SelectItem key={cat} value={cat}>{CATEGORY_LABELS[cat]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Tag list by category */}
{loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
) : Object.keys(groupedByCategory).length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Nicio etichetă găsită. Creează prima etichetă.</p>
) : (
<div className="space-y-4">
{Object.entries(groupedByCategory).map(([category, catTags]) => (
<Card key={category}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm">
<TagIcon className="h-4 w-4" />
{CATEGORY_LABELS[category as TagCategory] ?? category}
<Badge variant="secondary" className="ml-1">{catTags.length}</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{catTags.map((tag) => (
<div
key={tag.id}
className="group flex items-center gap-1.5 rounded-full border py-1 pl-3 pr-1.5 text-sm"
>
{tag.color && (
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: tag.color }} />
)}
<span>{tag.label}</span>
<Badge variant="outline" className="text-[10px] px-1">{SCOPE_LABELS[tag.scope]}</Badge>
<button
type="button"
onClick={() => deleteTag(tag.id)}
className="ml-0.5 rounded-full p-0.5 opacity-0 transition-opacity hover:bg-destructive/10 group-hover:opacity-100"
>
<Trash2 className="h-3 w-3 text-destructive" />
</button>
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,17 @@
import type { ModuleConfig } from '@/core/module-registry/types';
export const tagManagerConfig: ModuleConfig = {
id: 'tag-manager',
name: 'Manager Etichete',
description: 'Administrare centralizată a etichetelor și categoriilor din platformă',
icon: 'tags',
route: '/tag-manager',
category: 'tools',
featureFlag: 'module.tag-manager',
visibility: 'all',
version: '0.1.0',
dependencies: [],
storageNamespace: 'tag-manager',
navOrder: 40,
tags: ['etichete', 'categorii', 'organizare'],
};

View File

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

View File

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

View File

@@ -0,0 +1,172 @@
'use client';
import { useState } from 'react';
import { Plus, Pencil, Trash2, Search, FileText, ExternalLink } from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label';
import { Textarea } from '@/shared/components/ui/textarea';
import { Badge } from '@/shared/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
import type { CompanyId } from '@/core/auth/types';
import type { WordTemplate } from '../types';
import { useTemplates } from '../hooks/use-templates';
const TEMPLATE_CATEGORIES = [
'Contract', 'Memoriu tehnic', 'Ofertă', 'Factură', 'Raport', 'Deviz', 'Proces-verbal', 'Altele',
];
type ViewMode = 'list' | 'add' | 'edit';
export function WordTemplatesModule() {
const { templates, allTemplates, allCategories, loading, filters, updateFilter, addTemplate, updateTemplate, removeTemplate } = useTemplates();
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [editingTemplate, setEditingTemplate] = useState<WordTemplate | null>(null);
const handleSubmit = async (data: Omit<WordTemplate, 'id' | 'createdAt'>) => {
if (viewMode === 'edit' && editingTemplate) {
await updateTemplate(editingTemplate.id, data);
} else {
await addTemplate(data);
}
setViewMode('list');
setEditingTemplate(null);
};
const filterCategories = allCategories.length > 0 ? allCategories : TEMPLATE_CATEGORIES;
return (
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total șabloane</p><p className="text-2xl font-bold">{allTemplates.length}</p></CardContent></Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Categorii</p><p className="text-2xl font-bold">{allCategories.length}</p></CardContent></Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Beletage</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'beletage').length}</p></CardContent></Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Urban Switch</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'urban-switch').length}</p></CardContent></Card>
</div>
{viewMode === 'list' && (
<>
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Caută șablon..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
</div>
<Select value={filters.category} onValueChange={(v) => updateFilter('category', v)}>
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate</SelectItem>
{filterCategories.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => setViewMode('add')} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
) : templates.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Niciun șablon găsit. Adaugă primul șablon Word.
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{templates.map((tpl) => (
<Card key={tpl.id} className="group relative">
<CardContent className="p-4">
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingTemplate(tpl); setViewMode('edit'); }}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeTemplate(tpl.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border bg-muted/30">
<FileText className="h-5 w-5 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="font-medium">{tpl.name}</p>
{tpl.description && <p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">{tpl.description}</p>}
<div className="mt-1.5 flex flex-wrap gap-1">
{tpl.category && <Badge variant="outline" className="text-[10px]">{tpl.category}</Badge>}
<Badge variant="secondary" className="text-[10px]">v{tpl.version}</Badge>
</div>
{tpl.fileUrl && (
<a href={tpl.fileUrl} target="_blank" rel="noopener noreferrer" className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline">
<ExternalLink className="h-3 w-3" /> Deschide fișier
</a>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</>
)}
{(viewMode === 'add' || viewMode === 'edit') && (
<Card>
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare șablon' : 'Șablon nou'}</CardTitle></CardHeader>
<CardContent>
<TemplateForm initial={editingTemplate ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingTemplate(null); }} />
</CardContent>
</Card>
)}
</div>
);
}
function TemplateForm({ initial, onSubmit, onCancel }: {
initial?: WordTemplate;
onSubmit: (data: Omit<WordTemplate, 'id' | 'createdAt'>) => void;
onCancel: () => void;
}) {
const [name, setName] = useState(initial?.name ?? '');
const [description, setDescription] = useState(initial?.description ?? '');
const [category, setCategory] = useState(initial?.category ?? 'Contract');
const [fileUrl, setFileUrl] = useState(initial?.fileUrl ?? '');
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
const [version, setVersion] = useState(initial?.version ?? '1.0.0');
return (
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ name, description, category, fileUrl, company, version, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all' }); }} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div><Label>Nume șablon</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div>
<div><Label>Categorie</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>{TEMPLATE_CATEGORIES.map((c) => (<SelectItem key={c} value={c}>{c}</SelectItem>))}</SelectContent>
</Select>
</div>
</div>
<div><Label>Descriere</Label><Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} className="mt-1" /></div>
<div className="grid gap-4 sm:grid-cols-3">
<div><Label>Companie</Label>
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<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>
</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>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,17 @@
import type { ModuleConfig } from '@/core/module-registry/types';
export const wordTemplatesConfig: ModuleConfig = {
id: 'word-templates',
name: 'Șabloane Word',
description: 'Bibliotecă de șabloane Word organizate pe categorii cu suport versionare',
icon: 'file-text',
route: '/word-templates',
category: 'generators',
featureFlag: 'module.word-templates',
visibility: 'all',
version: '0.1.0',
dependencies: [],
storageNamespace: 'word-templates',
navOrder: 22,
tags: ['word', 'șabloane', 'documente'],
};

View File

@@ -0,0 +1,75 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useStorage } from '@/core/storage';
import { v4 as uuid } from 'uuid';
import type { WordTemplate } from '../types';
const PREFIX = 'tpl:';
export interface TemplateFilters {
search: string;
category: string;
}
export function useTemplates() {
const storage = useStorage('word-templates');
const [templates, setTemplates] = useState<WordTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<TemplateFilters>({ search: '', category: 'all' });
const refresh = useCallback(async () => {
setLoading(true);
const keys = await storage.list();
const results: WordTemplate[] = [];
for (const key of keys) {
if (key.startsWith(PREFIX)) {
const item = await storage.get<WordTemplate>(key);
if (item) results.push(item);
}
}
results.sort((a, b) => a.name.localeCompare(b.name));
setTemplates(results);
setLoading(false);
}, [storage]);
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]);
const addTemplate = useCallback(async (data: Omit<WordTemplate, 'id' | 'createdAt'>) => {
const template: WordTemplate = { ...data, id: uuid(), createdAt: new Date().toISOString() };
await storage.set(`${PREFIX}${template.id}`, template);
await refresh();
return template;
}, [storage, refresh]);
const updateTemplate = useCallback(async (id: string, updates: Partial<WordTemplate>) => {
const existing = templates.find((t) => t.id === id);
if (!existing) return;
const updated = { ...existing, ...updates, id: existing.id, createdAt: existing.createdAt };
await storage.set(`${PREFIX}${id}`, updated);
await refresh();
}, [storage, refresh, templates]);
const removeTemplate = useCallback(async (id: string) => {
await storage.delete(`${PREFIX}${id}`);
await refresh();
}, [storage, refresh]);
const updateFilter = useCallback(<K extends keyof TemplateFilters>(key: K, value: TemplateFilters[K]) => {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
const allCategories = [...new Set(templates.map((t) => t.category).filter(Boolean))];
const filteredTemplates = templates.filter((t) => {
if (filters.category !== 'all' && t.category !== filters.category) return false;
if (filters.search) {
const q = filters.search.toLowerCase();
return t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q);
}
return true;
});
return { templates: filteredTemplates, allTemplates: templates, allCategories, loading, filters, updateFilter, addTemplate, updateTemplate, removeTemplate, refresh };
}

View File

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

View File

@@ -0,0 +1,15 @@
import type { Visibility } from '@/core/module-registry/types';
import type { CompanyId } from '@/core/auth/types';
export interface WordTemplate {
id: string;
name: string;
description: string;
category: string;
fileUrl: string;
company: CompanyId;
tags: string[];
version: string;
visibility: Visibility;
createdAt: string;
}

View File

@@ -0,0 +1,143 @@
'use client';
import { useState } from 'react';
import { Plus, RotateCcw, Trash2, X } from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import { Textarea } from '@/shared/components/ui/textarea';
import { Label } from '@/shared/components/ui/label';
import { Input } from '@/shared/components/ui/input';
import { cn } from '@/shared/lib/utils';
import { isPresetCategory } from '../services/category-presets';
interface CategoryManagerProps {
categories: Record<string, { name: string; fieldsText: string }>;
currentCategory: string;
onSelectCategory: (name: string) => void;
onUpdateFields: (name: string, fieldsText: string) => void;
onAddCategory: (name: string) => void;
onRemoveCategory: (name: string) => void;
onResetToPreset: (name: string) => void;
onClearFields: (name: string) => void;
baseNamespace: string;
}
function getCategoryNamespace(baseNs: string, category: string): string {
const safe = category.replace(/\s+/g, '_').replace(/[^A-Za-z0-9_.-]/g, '');
return baseNs.replace(/\/+$/, '') + '/' + safe;
}
function getCategoryRoot(category: string): string {
const safe = category.replace(/\s+/g, '_').replace(/[^A-Za-z0-9_.-]/g, '');
return safe + 'Data';
}
export function CategoryManager({
categories, currentCategory, onSelectCategory, onUpdateFields,
onAddCategory, onRemoveCategory, onResetToPreset, onClearFields,
baseNamespace,
}: CategoryManagerProps) {
const [newCatName, setNewCatName] = useState('');
const [showNewCat, setShowNewCat] = useState(false);
const catNames = Object.keys(categories);
const currentFields = categories[currentCategory]?.fieldsText ?? '';
const ns = getCategoryNamespace(baseNamespace, currentCategory);
const root = getCategoryRoot(currentCategory);
const handleAddCategory = () => {
const name = newCatName.trim();
if (!name || categories[name]) return;
onAddCategory(name);
setNewCatName('');
setShowNewCat(false);
};
return (
<div className="space-y-4">
{/* Category pills */}
<div>
<Label className="mb-1.5 block">Categorii de date</Label>
<div className="flex flex-wrap gap-1.5">
{catNames.map((cat) => (
<button
key={cat}
type="button"
onClick={() => onSelectCategory(cat)}
className={cn(
'group inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium transition-colors',
cat === currentCategory
? 'border-primary bg-primary text-primary-foreground'
: 'border-border hover:bg-accent'
)}
>
{cat}
{!isPresetCategory(cat) && (
<X
className="h-3 w-3 opacity-50 group-hover:opacity-100"
onClick={(e) => { e.stopPropagation(); onRemoveCategory(cat); }}
/>
)}
</button>
))}
{showNewCat ? (
<div className="flex items-center gap-1">
<Input
value={newCatName}
onChange={(e) => setNewCatName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddCategory()}
placeholder="Nume categorie..."
className="h-7 w-36 text-xs"
autoFocus
/>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={handleAddCategory}>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => { setShowNewCat(false); setNewCatName(''); }}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<button
type="button"
onClick={() => setShowNewCat(true)}
className="inline-flex items-center gap-1 rounded-full border border-dashed px-3 py-1 text-xs text-muted-foreground hover:bg-accent"
>
<Plus className="h-3 w-3" /> Adaugă
</button>
)}
</div>
</div>
{/* Fields editor */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<Label>Câmpuri {currentCategory}</Label>
<div className="flex gap-1">
{isPresetCategory(currentCategory) && (
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => onResetToPreset(currentCategory)}>
<RotateCcw className="mr-1 h-3 w-3" /> Reset preset
</Button>
)}
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => onClearFields(currentCategory)}>
<Trash2 className="mr-1 h-3 w-3" /> Curăță
</Button>
</div>
</div>
<Textarea
value={currentFields}
onChange={(e) => onUpdateFields(currentCategory, e.target.value)}
rows={8}
className="font-mono text-xs"
placeholder="Un câmp pe linie (ex: NumeClient)"
/>
</div>
{/* Namespace info */}
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<p><strong>Namespace:</strong> <code>{ns}</code></p>
<p><strong>Root element:</strong> <code>&lt;{root}&gt;</code></p>
</div>
</div>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import { useXmlConfig } from '../hooks/use-xml-config';
import { XmlSettings } from './xml-settings';
import { CategoryManager } from './category-manager';
import { XmlPreview } from './xml-preview';
import { Separator } from '@/shared/components/ui/separator';
import { Button } from '@/shared/components/ui/button';
import { RotateCcw } from 'lucide-react';
export function WordXmlModule() {
const {
config, setMode, setBaseNamespace, setComputeMetrics,
setCurrentCategory, updateCategoryFields, addCategory,
removeCategory, resetCategoryToPreset, clearCategoryFields, resetAll,
} = useXmlConfig();
return (
<div className="space-y-6">
{/* Settings */}
<XmlSettings
baseNamespace={config.baseNamespace}
mode={config.mode}
computeMetrics={config.computeMetrics}
onSetBaseNamespace={setBaseNamespace}
onSetMode={setMode}
onSetComputeMetrics={setComputeMetrics}
/>
<Separator />
{/* Category manager + field editor */}
<CategoryManager
categories={config.categories}
currentCategory={config.currentCategory}
onSelectCategory={setCurrentCategory}
onUpdateFields={updateCategoryFields}
onAddCategory={addCategory}
onRemoveCategory={removeCategory}
onResetToPreset={resetCategoryToPreset}
onClearFields={clearCategoryFields}
baseNamespace={config.baseNamespace}
/>
<Separator />
{/* Preview & export */}
<XmlPreview config={config} />
<Separator />
<Button variant="outline" size="sm" onClick={resetAll}>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" /> Reset complet
</Button>
</div>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import { useMemo, useState } from 'react';
import { Copy, Download, FileArchive } from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import type { XmlGeneratorConfig } from '../types';
import { generateAllCategories, downloadXmlFile, downloadZipAll } from '../services/xml-generator';
interface XmlPreviewProps {
config: XmlGeneratorConfig;
}
export function XmlPreview({ config }: XmlPreviewProps) {
const [copied, setCopied] = useState<'xml' | 'xpath' | null>(null);
const allOutputs = useMemo(
() => generateAllCategories(config.categories, config.baseNamespace, config.mode, config.computeMetrics),
[config.categories, config.baseNamespace, config.mode, config.computeMetrics],
);
const current = allOutputs[config.currentCategory];
const xml = current?.xml || '';
const xpaths = current?.xpaths || '';
const handleCopy = async (text: string, type: 'xml' | 'xpath') => {
try {
await navigator.clipboard.writeText(text);
setCopied(type);
setTimeout(() => setCopied(null), 2000);
} catch {
// silent
}
};
const safeCatName = (config.currentCategory || 'unknown')
.replace(/\s+/g, '_')
.replace(/[^A-Za-z0-9_.-]/g, '');
const handleDownloadCurrent = () => {
if (!xml) return;
downloadXmlFile(xml, `${safeCatName}Data.xml`);
};
const handleDownloadZip = async () => {
await downloadZipAll(config.categories, config.baseNamespace, config.mode, config.computeMetrics);
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold">Preview & Export</h2>
<div className="ml-auto flex gap-2">
<Button variant="outline" size="sm" onClick={handleDownloadCurrent} disabled={!xml}>
<Download className="mr-1 h-4 w-4" /> XML curent
</Button>
<Button size="sm" onClick={handleDownloadZip}>
<FileArchive className="mr-1 h-4 w-4" /> ZIP toate
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{/* XML preview */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">XML {config.currentCategory}</span>
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => handleCopy(xml, 'xml')} disabled={!xml}>
<Copy className="mr-1 h-3 w-3" />
{copied === 'xml' ? 'Copiat!' : 'Copiază'}
</Button>
</div>
<pre className="max-h-80 overflow-auto rounded-lg border bg-muted/30 p-3 text-xs">
{xml || '<!-- Niciun XML generat. Adaugă câmpuri în categoria curentă. -->'}
</pre>
</div>
{/* XPath preview */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">XPaths {config.currentCategory}</span>
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => handleCopy(xpaths, 'xpath')} disabled={!xpaths}>
<Copy className="mr-1 h-3 w-3" />
{copied === 'xpath' ? 'Copiat!' : 'Copiază'}
</Button>
</div>
<pre className="max-h-80 overflow-auto rounded-lg border bg-muted/30 p-3 text-xs">
{xpaths || ''}
</pre>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import type { XmlGeneratorMode } from '../types';
import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label';
import { Switch } from '@/shared/components/ui/switch';
import { cn } from '@/shared/lib/utils';
interface XmlSettingsProps {
baseNamespace: string;
mode: XmlGeneratorMode;
computeMetrics: boolean;
onSetBaseNamespace: (ns: string) => void;
onSetMode: (mode: XmlGeneratorMode) => void;
onSetComputeMetrics: (v: boolean) => void;
}
export function XmlSettings({
baseNamespace, mode, computeMetrics,
onSetBaseNamespace, onSetMode, onSetComputeMetrics,
}: XmlSettingsProps) {
return (
<div className="space-y-4">
<div>
<Label htmlFor="xml-ns">Bază Namespace</Label>
<Input
id="xml-ns"
value={baseNamespace}
onChange={(e) => onSetBaseNamespace(e.target.value)}
className="mt-1 font-mono text-xs"
/>
<p className="mt-1 text-xs text-muted-foreground">
Se completează automat cu <code>/Categorie</code>
</p>
</div>
<div className="flex items-center gap-4">
<div>
<Label className="mb-1.5 block">Mod generare</Label>
<div className="flex gap-1.5">
{(['simple', 'advanced'] as XmlGeneratorMode[]).map((m) => (
<button
key={m}
type="button"
onClick={() => onSetMode(m)}
className={cn(
'rounded-full border px-3 py-1 text-xs font-medium transition-colors',
mode === m
? 'border-primary bg-primary text-primary-foreground'
: 'border-border hover:bg-accent'
)}
>
{m === 'simple' ? 'Simple' : 'Advanced'}
</button>
))}
</div>
<p className="mt-1 text-xs text-muted-foreground">
{mode === 'simple' ? 'Doar câmpurile definite.' : '+ Short / Upper / Lower / Initials / First.'}
</p>
</div>
<div className="ml-auto flex items-center gap-2">
<Switch checked={computeMetrics} onCheckedChange={onSetComputeMetrics} id="xml-metrics" />
<Label htmlFor="xml-metrics" className="cursor-pointer text-sm">POT / CUT automat</Label>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import type { ModuleConfig } from '@/core/module-registry/types';
export const wordXmlConfig: ModuleConfig = {
id: 'word-xml',
name: 'Generator XML Word',
description: 'Generator de structuri XML compatibile Word pentru documente și formulare',
icon: 'file-code-2',
route: '/word-xml',
category: 'generators',
featureFlag: 'module.word-xml',
visibility: 'all',
version: '0.1.0',
dependencies: [],
storageNamespace: 'word-xml',
navOrder: 21,
tags: ['xml', 'word', 'generator'],
};

View File

@@ -0,0 +1,128 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import type { XmlGeneratorConfig, XmlGeneratorMode } from '../types';
import { DEFAULT_PRESETS } from '../services/category-presets';
function createDefaultConfig(): XmlGeneratorConfig {
const categories: Record<string, { name: string; fieldsText: string }> = {};
for (const [name, fields] of Object.entries(DEFAULT_PRESETS)) {
categories[name] = { name, fieldsText: fields.join('\n') };
}
return {
baseNamespace: 'http://schemas.beletage.ro/contract',
mode: 'advanced',
computeMetrics: true,
categories,
currentCategory: 'Beneficiar',
};
}
export function useXmlConfig() {
const [config, setConfig] = useState<XmlGeneratorConfig>(createDefaultConfig);
const setMode = useCallback((mode: XmlGeneratorMode) => {
setConfig((prev) => ({ ...prev, mode }));
}, []);
const setBaseNamespace = useCallback((baseNamespace: string) => {
setConfig((prev) => ({ ...prev, baseNamespace }));
}, []);
const setComputeMetrics = useCallback((computeMetrics: boolean) => {
setConfig((prev) => ({ ...prev, computeMetrics }));
}, []);
const setCurrentCategory = useCallback((name: string) => {
setConfig((prev) => ({ ...prev, currentCategory: name }));
}, []);
const updateCategoryFields = useCallback((categoryName: string, fieldsText: string) => {
setConfig((prev) => {
const existing = prev.categories[categoryName];
if (!existing) return prev;
return {
...prev,
categories: {
...prev.categories,
[categoryName]: { name: existing.name, fieldsText },
},
};
});
}, []);
const addCategory = useCallback((name: string) => {
setConfig((prev) => {
if (prev.categories[name]) return prev;
return {
...prev,
categories: { ...prev.categories, [name]: { name, fieldsText: '' } },
currentCategory: name,
};
});
}, []);
const removeCategory = useCallback((name: string) => {
setConfig((prev) => {
const next = { ...prev.categories };
delete next[name];
const keys = Object.keys(next);
return {
...prev,
categories: next,
currentCategory: keys.includes(prev.currentCategory) ? prev.currentCategory : keys[0] || '',
};
});
}, []);
const resetCategoryToPreset = useCallback((name: string) => {
const preset = DEFAULT_PRESETS[name];
if (!preset) return;
setConfig((prev) => ({
...prev,
categories: {
...prev.categories,
[name]: { name, fieldsText: preset.join('\n') },
},
}));
}, []);
const clearCategoryFields = useCallback((name: string) => {
setConfig((prev) => {
const existing = prev.categories[name];
if (!existing) return prev;
return {
...prev,
categories: {
...prev.categories,
[name]: { name: existing.name, fieldsText: '' },
},
};
});
}, []);
const resetAll = useCallback(() => {
setConfig(createDefaultConfig());
}, []);
const loadConfig = useCallback((loaded: XmlGeneratorConfig) => {
setConfig(loaded);
}, []);
return useMemo(() => ({
config,
setMode,
setBaseNamespace,
setComputeMetrics,
setCurrentCategory,
updateCategoryFields,
addCategory,
removeCategory,
resetCategoryToPreset,
clearCategoryFields,
resetAll,
loadConfig,
}), [config, setMode, setBaseNamespace, setComputeMetrics, setCurrentCategory,
updateCategoryFields, addCategory, removeCategory, resetCategoryToPreset,
clearCategoryFields, resetAll, loadConfig]);
}

View File

@@ -0,0 +1,3 @@
export { wordXmlConfig } from './config';
export { WordXmlModule } from './components/word-xml-module';
export type { XmlGeneratorConfig, XmlGeneratorMode, XmlCategory, GeneratedOutput } from './types';

View File

@@ -0,0 +1,36 @@
export const DEFAULT_PRESETS: Record<string, string[]> = {
Beneficiar: [
'NumeClient',
'Adresa',
'CUI',
'CNP',
'Reprezentant',
'Email',
'Telefon',
],
Proiect: [
'TitluProiect',
'AdresaImobil',
'NrCadastral',
'NrCF',
'Localitate',
'Judet',
],
Suprafete: [
'SuprafataTeren',
'SuprafataConstruitaLaSol',
'SuprafataDesfasurata',
'SuprafataUtila',
],
Meta: [
'NrContract',
'DataContract',
'Responsabil',
'VersiuneDocument',
'DataGenerarii',
],
};
export function isPresetCategory(name: string): boolean {
return name in DEFAULT_PRESETS;
}

View File

@@ -0,0 +1,186 @@
import type { XmlGeneratorMode, XmlCategory, GeneratedOutput } from '../types';
function sanitizeName(name: string): string | null {
const trimmed = name.trim();
if (!trimmed) return null;
let n = trimmed.replace(/\s+/g, '_').replace(/[^A-Za-z0-9_.-]/g, '');
if (!/^[A-Za-z_]/.test(n)) n = '_' + n;
return n || null;
}
function getCategoryNamespace(baseNs: string, category: string): string {
const safeCat = sanitizeName(category) || category;
return baseNs.replace(/\/+$/, '') + '/' + safeCat;
}
function getCategoryRoot(category: string): string {
const safeCat = sanitizeName(category) || category;
return safeCat + 'Data';
}
interface FieldEntry {
label: string;
baseName: string;
variants: string[];
}
export function generateCategoryXml(
category: string,
catData: XmlCategory,
baseNamespace: string,
mode: XmlGeneratorMode,
computeMetrics: boolean,
): GeneratedOutput {
const raw = catData.fieldsText
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.length > 0);
if (raw.length === 0) return { xml: '', xpaths: '' };
const ns = getCategoryNamespace(baseNamespace, category);
const root = getCategoryRoot(category);
const usedNames = new Set<string>();
const fields: FieldEntry[] = [];
for (const label of raw) {
const base = sanitizeName(label);
if (!base) continue;
let baseName = base;
let idx = 2;
while (usedNames.has(baseName)) {
baseName = base + '_' + idx;
idx++;
}
usedNames.add(baseName);
const variants = [baseName];
if (mode === 'advanced') {
const suffixes = ['Short', 'Upper', 'Lower', 'Initials', 'First'];
for (const suffix of suffixes) {
let vn = baseName + suffix;
let k = 2;
while (usedNames.has(vn)) {
vn = baseName + suffix + '_' + k;
k++;
}
usedNames.add(vn);
variants.push(vn);
}
}
fields.push({ label, baseName, variants });
}
// Auto-add POT/CUT for Suprafete category
const extraMetricFields: FieldEntry[] = [];
if (computeMetrics && category.toLowerCase().includes('suprafete')) {
const hasTeren = fields.some((f) => f.baseName.toLowerCase().includes('suprafatateren'));
const hasLaSol = fields.some((f) => f.baseName.toLowerCase().includes('suprafataconstruitalasol'));
const hasDesf = fields.some((f) => f.baseName.toLowerCase().includes('suprafatadesfasurata'));
if (hasTeren && hasLaSol && !usedNames.has('POT')) {
usedNames.add('POT');
extraMetricFields.push({ label: 'Procent Ocupare Teren', baseName: 'POT', variants: ['POT'] });
}
if (hasTeren && hasDesf && !usedNames.has('CUT')) {
usedNames.add('CUT');
extraMetricFields.push({ label: 'Coeficient Utilizare Teren', baseName: 'CUT', variants: ['CUT'] });
}
}
const allFields = fields.concat(extraMetricFields);
// Build XML
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += `<${root} xmlns="${ns}">\n`;
for (const f of allFields) {
for (const v of f.variants) {
xml += ` <${v}></${v}>\n`;
}
}
xml += `</${root}>\n`;
// Build XPaths
let xp = `Categorie: ${category}\nNamespace: ${ns}\nRoot: /${root}\n\n`;
for (const f of fields) {
xp += `# ${f.label}\n`;
for (const v of f.variants) {
xp += `/${root}/${v}\n`;
}
xp += '\n';
}
if (extraMetricFields.length > 0) {
xp += '# Metrici auto (POT / CUT)\n';
for (const f of extraMetricFields) {
for (const v of f.variants) {
xp += `/${root}/${v}\n`;
}
}
xp += '\n';
}
return { xml, xpaths: xp };
}
export function generateAllCategories(
categories: Record<string, XmlCategory>,
baseNamespace: string,
mode: XmlGeneratorMode,
computeMetrics: boolean,
): Record<string, GeneratedOutput> {
const results: Record<string, GeneratedOutput> = {};
for (const cat of Object.keys(categories)) {
const catData = categories[cat];
if (!catData) continue;
results[cat] = generateCategoryXml(cat, catData, baseNamespace, mode, computeMetrics);
}
return results;
}
export function downloadXmlFile(xml: string, filename: string): void {
const blob = new Blob([xml], { type: 'application/xml' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
}
export async function downloadZipAll(
categories: Record<string, XmlCategory>,
baseNamespace: string,
mode: XmlGeneratorMode,
computeMetrics: boolean,
): Promise<void> {
const JSZip = (await import('jszip')).default;
const results = generateAllCategories(categories, baseNamespace, mode, computeMetrics);
const zip = new JSZip();
const folder = zip.folder('customXmlParts')!;
let hasAny = false;
for (const cat of Object.keys(results)) {
const output = results[cat];
if (!output?.xml) continue;
const { xml } = output;
hasAny = true;
const safeCat = sanitizeName(cat) || cat;
folder.file(`${safeCat}Data.xml`, xml);
}
if (!hasAny) return;
const content = await zip.generateAsync({ type: 'blob' });
const a = document.createElement('a');
a.href = URL.createObjectURL(content);
a.download = 'beletage_custom_xml_parts.zip';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
}

View File

@@ -0,0 +1,27 @@
export type XmlGeneratorMode = 'simple' | 'advanced';
export interface XmlCategory {
name: string;
fieldsText: string;
}
export interface XmlGeneratorConfig {
baseNamespace: string;
mode: XmlGeneratorMode;
computeMetrics: boolean;
categories: Record<string, XmlCategory>;
currentCategory: string;
}
export interface GeneratedOutput {
xml: string;
xpaths: string;
}
export interface SavedXmlConfig {
id: string;
label: string;
config: XmlGeneratorConfig;
createdAt: string;
updatedAt: string;
}