feat: add Hot Desk module (Phase 2) 4-desk booking with 2-week window, room layout, calendar, subtle unbooked-day alerts

This commit is contained in:
AI Assistant
2026-02-19 08:10:50 +02:00
parent 6cb655a79f
commit 3b1ba589f0
15 changed files with 1806 additions and 273 deletions

View File

@@ -0,0 +1,31 @@
"use client";
import { FeatureGate } from "@/core/feature-flags";
import { useI18n } from "@/core/i18n";
import { HotDeskModule } from "@/modules/hot-desk";
export default function HotDeskPage() {
const { t } = useI18n();
return (
<FeatureGate flag="module.hot-desk" fallback={<ModuleDisabled />}>
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("hot-desk.title")}
</h1>
<p className="text-muted-foreground">{t("hot-desk.description")}</p>
</div>
<HotDeskModule />
</div>
</FeatureGate>
);
}
function ModuleDisabled() {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-muted-foreground">Modul dezactivat</p>
</div>
);
}

View File

@@ -98,6 +98,14 @@ export const DEFAULT_FLAGS: FeatureFlag[] = [
category: "module", category: "module",
overridable: true, overridable: true,
}, },
{
key: "module.hot-desk",
enabled: true,
label: "Birouri Partajate",
description: "Rezervare birouri în camera partajată",
category: "module",
overridable: true,
},
// System flags // System flags
{ {

View File

@@ -1,18 +1,19 @@
import type { ModuleConfig } from '@/core/module-registry/types'; import type { ModuleConfig } from "@/core/module-registry/types";
import { registerModules } from '@/core/module-registry'; import { registerModules } from "@/core/module-registry";
import { registraturaConfig } from '@/modules/registratura/config'; import { registraturaConfig } from "@/modules/registratura/config";
import { emailSignatureConfig } from '@/modules/email-signature/config'; import { emailSignatureConfig } from "@/modules/email-signature/config";
import { wordXmlConfig } from '@/modules/word-xml/config'; import { wordXmlConfig } from "@/modules/word-xml/config";
import { promptGeneratorConfig } from '@/modules/prompt-generator/config'; import { promptGeneratorConfig } from "@/modules/prompt-generator/config";
import { digitalSignaturesConfig } from '@/modules/digital-signatures/config'; import { digitalSignaturesConfig } from "@/modules/digital-signatures/config";
import { passwordVaultConfig } from '@/modules/password-vault/config'; import { passwordVaultConfig } from "@/modules/password-vault/config";
import { itInventoryConfig } from '@/modules/it-inventory/config'; import { itInventoryConfig } from "@/modules/it-inventory/config";
import { addressBookConfig } from '@/modules/address-book/config'; import { addressBookConfig } from "@/modules/address-book/config";
import { wordTemplatesConfig } from '@/modules/word-templates/config'; import { wordTemplatesConfig } from "@/modules/word-templates/config";
import { tagManagerConfig } from '@/modules/tag-manager/config'; import { tagManagerConfig } from "@/modules/tag-manager/config";
import { miniUtilitiesConfig } from '@/modules/mini-utilities/config'; import { miniUtilitiesConfig } from "@/modules/mini-utilities/config";
import { aiChatConfig } from '@/modules/ai-chat/config'; import { aiChatConfig } from "@/modules/ai-chat/config";
import { hotDeskConfig } from "@/modules/hot-desk/config";
/** /**
* Toate configurările modulelor ArchiTools, ordonate după navOrder. * Toate configurările modulelor ArchiTools, ordonate după navOrder.
@@ -27,6 +28,7 @@ export const MODULE_CONFIGS: ModuleConfig[] = [
digitalSignaturesConfig, // navOrder: 30 | management digitalSignaturesConfig, // navOrder: 30 | management
itInventoryConfig, // navOrder: 31 | management itInventoryConfig, // navOrder: 31 | management
addressBookConfig, // navOrder: 32 | management addressBookConfig, // navOrder: 32 | management
hotDeskConfig, // navOrder: 33 | management
tagManagerConfig, // navOrder: 40 | tools tagManagerConfig, // navOrder: 40 | tools
miniUtilitiesConfig, // navOrder: 41 | tools miniUtilitiesConfig, // navOrder: 41 | tools
promptGeneratorConfig, // navOrder: 50 | ai promptGeneratorConfig, // navOrder: 50 | ai

View File

@@ -1,109 +1,113 @@
import type { Labels } from '../types'; import type { Labels } from "../types";
export const ro: Labels = { export const ro: Labels = {
common: { common: {
save: 'Salvează', save: "Salvează",
cancel: 'Anulează', cancel: "Anulează",
delete: 'Șterge', delete: "Șterge",
edit: 'Editează', edit: "Editează",
create: 'Creează', create: "Creează",
search: 'Caută', search: "Caută",
filter: 'Filtrează', filter: "Filtrează",
export: 'Exportă', export: "Exportă",
import: 'Importă', import: "Importă",
copy: 'Copiază', copy: "Copiază",
close: 'Închide', close: "Închide",
confirm: 'Confirmă', confirm: "Confirmă",
back: 'Înapoi', back: "Înapoi",
next: 'Următorul', next: "Următorul",
loading: 'Se încarcă...', loading: "Se încarcă...",
noResults: 'Niciun rezultat', noResults: "Niciun rezultat",
error: 'Eroare', error: "Eroare",
success: 'Succes', success: "Succes",
actions: 'Acțiuni', actions: "Acțiuni",
settings: 'Setări', settings: "Setări",
all: 'Toate', all: "Toate",
yes: 'Da', yes: "Da",
no: 'Nu', no: "Nu",
}, },
nav: { nav: {
dashboard: 'Panou principal', dashboard: "Panou principal",
operations: 'Operațiuni', operations: "Operațiuni",
generators: 'Generatoare', generators: "Generatoare",
management: 'Management', management: "Management",
tools: 'Instrumente', tools: "Instrumente",
ai: 'AI & Automatizări', ai: "AI & Automatizări",
externalTools: 'Instrumente externe', externalTools: "Instrumente externe",
}, },
dashboard: { dashboard: {
title: 'Panou principal', title: "Panou principal",
welcome: 'Bine ai venit în ArchiTools', welcome: "Bine ai venit în ArchiTools",
subtitle: 'Platforma internă de instrumente pentru birou', subtitle: "Platforma internă de instrumente pentru birou",
quickActions: 'Acțiuni rapide', quickActions: "Acțiuni rapide",
recentActivity: 'Activitate recentă', recentActivity: "Activitate recentă",
modules: 'Module', modules: "Module",
infrastructure: 'Infrastructură', infrastructure: "Infrastructură",
}, },
registratura: { registratura: {
title: 'Registratură', title: "Registratură",
description: 'Registru de corespondență multi-firmă', description: "Registru de corespondență multi-firmă",
newEntry: 'Înregistrare nouă', newEntry: "Înregistrare nouă",
entries: 'Înregistrări', entries: "Înregistrări",
incoming: 'Intrare', incoming: "Intrare",
outgoing: 'Ieșire', outgoing: "Ieșire",
internal: 'Intern', internal: "Intern",
}, },
'email-signature': { "email-signature": {
title: 'Generator Semnătură Email', title: "Generator Semnătură Email",
description: 'Configurator semnătură email pentru companii', description: "Configurator semnătură email pentru companii",
preview: 'Previzualizare', preview: "Previzualizare",
downloadHtml: 'Descarcă HTML', downloadHtml: "Descarcă HTML",
}, },
'word-xml': { "word-xml": {
title: 'Generator XML Word', title: "Generator XML Word",
description: 'Generator Custom XML Parts pentru Word', description: "Generator Custom XML Parts pentru Word",
generate: 'Generează XML', generate: "Generează XML",
downloadXml: 'Descarcă XML', downloadXml: "Descarcă XML",
downloadZip: 'Descarcă ZIP', downloadZip: "Descarcă ZIP",
}, },
'prompt-generator': { "prompt-generator": {
title: 'Generator Prompturi', title: "Generator Prompturi",
description: 'Constructor de prompturi structurate pentru AI', description: "Constructor de prompturi structurate pentru AI",
templates: 'Șabloane', templates: "Șabloane",
compose: 'Compune', compose: "Compune",
history: 'Istoric', history: "Istoric",
preview: 'Previzualizare', preview: "Previzualizare",
}, },
'digital-signatures': { "digital-signatures": {
title: 'Semnături și Ștampile', title: "Semnături și Ștampile",
description: 'Bibliotecă semnături digitale și ștampile scanate', description: "Bibliotecă semnături digitale și ștampile scanate",
}, },
'password-vault': { "password-vault": {
title: 'Seif Parole', title: "Seif Parole",
description: 'Depozit intern de credențiale partajate', description: "Depozit intern de credențiale partajate",
}, },
'it-inventory': { "it-inventory": {
title: 'Inventar IT', title: "Inventar IT",
description: 'Evidența echipamentelor și dispozitivelor', description: "Evidența echipamentelor și dispozitivelor",
}, },
'address-book': { "address-book": {
title: 'Contacte', title: "Contacte",
description: 'Clienți, furnizori, instituții', description: "Clienți, furnizori, instituții",
}, },
'word-templates': { "word-templates": {
title: 'Șabloane Word', title: "Șabloane Word",
description: 'Bibliotecă contracte, oferte, rapoarte', description: "Bibliotecă contracte, oferte, rapoarte",
}, },
'tag-manager': { "tag-manager": {
title: 'Manager Etichete', title: "Manager Etichete",
description: 'Administrare etichete proiecte și categorii', description: "Administrare etichete proiecte și categorii",
}, },
'mini-utilities': { "mini-utilities": {
title: 'Utilitare', title: "Utilitare",
description: 'Calculatoare tehnice și instrumente text', description: "Calculatoare tehnice și instrumente text",
}, },
'ai-chat': { "ai-chat": {
title: 'Chat AI', title: "Chat AI",
description: 'Interfață asistent AI', description: "Interfață asistent AI",
},
"hot-desk": {
title: "Birouri Partajate",
description: "Rezervare birouri în camera partajată",
}, },
}; };

View File

@@ -1,51 +1,89 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import { import {
Plus, Pencil, Trash2, Search, Mail, Phone, MapPin, Plus,
Globe, Building2, UserPlus, X, Download, FileText, Pencil,
} from 'lucide-react'; Trash2,
import { Button } from '@/shared/components/ui/button'; Search,
import { Input } from '@/shared/components/ui/input'; Mail,
import { Label } from '@/shared/components/ui/label'; Phone,
import { Textarea } from '@/shared/components/ui/textarea'; MapPin,
import { Badge } from '@/shared/components/ui/badge'; Globe,
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card'; Building2,
UserPlus,
X,
Download,
FileText,
} 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 { import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Card,
} from '@/shared/components/ui/select'; CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, Select,
} from '@/shared/components/ui/dialog'; SelectContent,
import type { AddressContact, ContactType, ContactPerson } from '../types'; SelectItem,
import { useContacts } from '../hooks/use-contacts'; SelectTrigger,
import { useTags } from '@/core/tagging'; SelectValue,
import { downloadVCard } from '../services/vcard-export'; } from "@/shared/components/ui/select";
import { useRegistry } from '@/modules/registratura/hooks/use-registry'; import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import type { AddressContact, ContactType, ContactPerson } from "../types";
import { useContacts } from "../hooks/use-contacts";
import { useTags } from "@/core/tagging";
import { downloadVCard } from "../services/vcard-export";
import { useRegistry } from "@/modules/registratura/hooks/use-registry";
const TYPE_LABELS: Record<ContactType, string> = { const TYPE_LABELS: Record<ContactType, string> = {
client: 'Client', client: "Client",
supplier: 'Furnizor', supplier: "Furnizor",
institution: 'Instituție', institution: "Instituție",
collaborator: 'Colaborator', collaborator: "Colaborator",
internal: 'Intern', internal: "Intern",
}; };
type ViewMode = 'list' | 'add' | 'edit'; type ViewMode = "list" | "add" | "edit";
export function AddressBookModule() { export function AddressBookModule() {
const { contacts, allContacts, loading, filters, updateFilter, addContact, updateContact, removeContact } = useContacts(); const {
const [viewMode, setViewMode] = useState<ViewMode>('list'); contacts,
const [editingContact, setEditingContact] = useState<AddressContact | null>(null); allContacts,
const [viewingContact, setViewingContact] = useState<AddressContact | null>(null); loading,
filters,
updateFilter,
addContact,
updateContact,
removeContact,
} = useContacts();
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [editingContact, setEditingContact] = useState<AddressContact | null>(
null,
);
const [viewingContact, setViewingContact] = useState<AddressContact | null>(
null,
);
const handleSubmit = async (data: Omit<AddressContact, 'id' | 'createdAt' | 'updatedAt'>) => { const handleSubmit = async (
if (viewMode === 'edit' && editingContact) { data: Omit<AddressContact, "id" | "createdAt" | "updatedAt">,
) => {
if (viewMode === "edit" && editingContact) {
await updateContact(editingContact.id, data); await updateContact(editingContact.id, data);
} else { } else {
await addContact(data); await addContact(data);
} }
setViewMode('list'); setViewMode("list");
setEditingContact(null); setEditingContact(null);
}; };
@@ -53,47 +91,79 @@ export function AddressBookModule() {
<div className="space-y-6"> <div className="space-y-6">
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total</p><p className="text-2xl font-bold">{allContacts.length}</p></CardContent></Card> <Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Total</p>
<p className="text-2xl font-bold">{allContacts.length}</p>
</CardContent>
</Card>
{(Object.keys(TYPE_LABELS) as ContactType[]).slice(0, 4).map((type) => ( {(Object.keys(TYPE_LABELS) as ContactType[]).slice(0, 4).map((type) => (
<Card key={type}><CardContent className="p-4"> <Card key={type}>
<p className="text-xs text-muted-foreground">{TYPE_LABELS[type]}</p> <CardContent className="p-4">
<p className="text-2xl font-bold">{allContacts.filter((c) => c.type === type).length}</p> <p className="text-xs text-muted-foreground">
</CardContent></Card> {TYPE_LABELS[type]}
</p>
<p className="text-2xl font-bold">
{allContacts.filter((c) => c.type === type).length}
</p>
</CardContent>
</Card>
))} ))}
</div> </div>
{viewMode === 'list' && ( {viewMode === "list" && (
<> <>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1"> <div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Caută contact..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" /> <Input
placeholder="Caută contact..."
value={filters.search}
onChange={(e) => updateFilter("search", e.target.value)}
className="pl-9"
/>
</div> </div>
<Select value={filters.type} onValueChange={(v) => updateFilter('type', v as ContactType | 'all')}> <Select
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger> value={filters.type}
onValueChange={(v) =>
updateFilter("type", v as ContactType | "all")
}
>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Toate tipurile</SelectItem> <SelectItem value="all">Toate tipurile</SelectItem>
{(Object.keys(TYPE_LABELS) as ContactType[]).map((t) => ( {(Object.keys(TYPE_LABELS) as ContactType[]).map((t) => (
<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem> <SelectItem key={t} value={t}>
{TYPE_LABELS[t]}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Button onClick={() => setViewMode('add')} className="shrink-0"> <Button onClick={() => setViewMode("add")} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă <Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button> </Button>
</div> </div>
{loading ? ( {loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p> <p className="py-8 text-center text-sm text-muted-foreground">
Se încarcă...
</p>
) : contacts.length === 0 ? ( ) : contacts.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Niciun contact găsit.</p> <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"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{contacts.map((contact) => ( {contacts.map((contact) => (
<ContactCard <ContactCard
key={contact.id} key={contact.id}
contact={contact} contact={contact}
onEdit={() => { setEditingContact(contact); setViewMode('edit'); }} onEdit={() => {
setEditingContact(contact);
setViewMode("edit");
}}
onDelete={() => removeContact(contact.id)} onDelete={() => removeContact(contact.id)}
onViewDetail={() => setViewingContact(contact)} onViewDetail={() => setViewingContact(contact)}
/> />
@@ -103,14 +173,21 @@ export function AddressBookModule() {
</> </>
)} )}
{(viewMode === 'add' || viewMode === 'edit') && ( {(viewMode === "add" || viewMode === "edit") && (
<Card> <Card>
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare contact' : 'Contact nou'}</CardTitle></CardHeader> <CardHeader>
<CardTitle>
{viewMode === "edit" ? "Editare contact" : "Contact nou"}
</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<ContactForm <ContactForm
initial={editingContact ?? undefined} initial={editingContact ?? undefined}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onCancel={() => { setViewMode('list'); setEditingContact(null); }} onCancel={() => {
setViewMode("list");
setEditingContact(null);
}}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -120,7 +197,11 @@ export function AddressBookModule() {
<ContactDetailDialog <ContactDetailDialog
contact={viewingContact} contact={viewingContact}
onClose={() => setViewingContact(null)} onClose={() => setViewingContact(null)}
onEdit={(c) => { setViewingContact(null); setEditingContact(c); setViewMode('edit'); }} onEdit={(c) => {
setViewingContact(null);
setEditingContact(c);
setViewMode("edit");
}}
/> />
</div> </div>
); );
@@ -128,7 +209,12 @@ export function AddressBookModule() {
// ── Contact Card ── // ── Contact Card ──
function ContactCard({ contact, onEdit, onDelete, onViewDetail }: { function ContactCard({
contact,
onEdit,
onDelete,
onViewDetail,
}: {
contact: AddressContact; contact: AddressContact;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
@@ -138,16 +224,38 @@ function ContactCard({ contact, onEdit, onDelete, onViewDetail }: {
<Card className="group relative"> <Card className="group relative">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" className="h-7 w-7" title="Detalii" onClick={onViewDetail}> <Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="Detalii"
onClick={onViewDetail}
>
<FileText className="h-3.5 w-3.5" /> <FileText className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7" title="Descarcă vCard" onClick={() => downloadVCard(contact)}> <Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="Descarcă vCard"
onClick={() => downloadVCard(contact)}
>
<Download className="h-3.5 w-3.5" /> <Download className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onEdit}> <Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onEdit}
>
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={onDelete}> <Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={onDelete}
>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -155,44 +263,60 @@ function ContactCard({ contact, onEdit, onDelete, onViewDetail }: {
<div> <div>
<p className="font-medium">{contact.name}</p> <p className="font-medium">{contact.name}</p>
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
{contact.company && <p className="text-xs text-muted-foreground">{contact.company}</p>} {contact.company && (
<Badge variant="outline" className="text-[10px]">{TYPE_LABELS[contact.type]}</Badge> <p className="text-xs text-muted-foreground">
{contact.company}
</p>
)}
<Badge variant="outline" className="text-[10px]">
{TYPE_LABELS[contact.type]}
</Badge>
{contact.department && ( {contact.department && (
<Badge variant="secondary" className="text-[10px]">{contact.department}</Badge> <Badge variant="secondary" className="text-[10px]">
{contact.department}
</Badge>
)} )}
</div> </div>
{contact.role && ( {contact.role && (
<p className="text-xs text-muted-foreground italic">{contact.role}</p> <p className="text-xs text-muted-foreground italic">
{contact.role}
</p>
)} )}
</div> </div>
{contact.email && ( {contact.email && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Mail className="h-3 w-3 shrink-0" /><span className="truncate">{contact.email}</span> <Mail className="h-3 w-3 shrink-0" />
<span className="truncate">{contact.email}</span>
</div> </div>
)} )}
{contact.email2 && ( {contact.email2 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Mail className="h-3 w-3 shrink-0" /><span className="truncate">{contact.email2}</span> <Mail className="h-3 w-3 shrink-0" />
<span className="truncate">{contact.email2}</span>
</div> </div>
)} )}
{contact.phone && ( {contact.phone && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Phone className="h-3 w-3 shrink-0" /><span>{contact.phone}</span> <Phone className="h-3 w-3 shrink-0" />
<span>{contact.phone}</span>
</div> </div>
)} )}
{contact.phone2 && ( {contact.phone2 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Phone className="h-3 w-3 shrink-0" /><span>{contact.phone2}</span> <Phone className="h-3 w-3 shrink-0" />
<span>{contact.phone2}</span>
</div> </div>
)} )}
{contact.address && ( {contact.address && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPin className="h-3 w-3 shrink-0" /><span className="truncate">{contact.address}</span> <MapPin className="h-3 w-3 shrink-0" />
<span className="truncate">{contact.address}</span>
</div> </div>
)} )}
{contact.website && ( {contact.website && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Globe className="h-3 w-3 shrink-0" /><span className="truncate">{contact.website}</span> <Globe className="h-3 w-3 shrink-0" />
<span className="truncate">{contact.website}</span>
</div> </div>
)} )}
{(contact.contactPersons ?? []).length > 0 && ( {(contact.contactPersons ?? []).length > 0 && (
@@ -202,7 +326,8 @@ function ContactCard({ contact, onEdit, onDelete, onViewDetail }: {
</p> </p>
{contact.contactPersons.slice(0, 2).map((cp, i) => ( {contact.contactPersons.slice(0, 2).map((cp, i) => (
<p key={i} className="text-xs text-muted-foreground"> <p key={i} className="text-xs text-muted-foreground">
{cp.name}{cp.role ? `${cp.role}` : ''} {cp.name}
{cp.role ? `${cp.role}` : ""}
</p> </p>
))} ))}
{contact.contactPersons.length > 2 && ( {contact.contactPersons.length > 2 && (
@@ -221,11 +346,15 @@ function ContactCard({ contact, onEdit, onDelete, onViewDetail }: {
// ── Contact Detail Dialog (with Registratura reverse lookup) ── // ── Contact Detail Dialog (with Registratura reverse lookup) ──
const DIRECTION_LABELS: Record<string, string> = { const DIRECTION_LABELS: Record<string, string> = {
intrat: 'Intrat', intrat: "Intrat",
iesit: 'Ieșit', iesit: "Ieșit",
}; };
function ContactDetailDialog({ contact, onClose, onEdit }: { function ContactDetailDialog({
contact,
onClose,
onEdit,
}: {
contact: AddressContact | null; contact: AddressContact | null;
onClose: () => void; onClose: () => void;
onEdit: (c: AddressContact) => void; onEdit: (c: AddressContact) => void;
@@ -236,11 +365,17 @@ function ContactDetailDialog({ contact, onClose, onEdit }: {
// Find registratura entries linked to this contact (search all, ignoring active filters) // Find registratura entries linked to this contact (search all, ignoring active filters)
const linkedEntries = allEntries.filter( const linkedEntries = allEntries.filter(
(e) => e.senderContactId === contact.id || e.recipientContactId === contact.id (e) =>
e.senderContactId === contact.id || e.recipientContactId === contact.id,
); );
return ( return (
<Dialog open={contact !== null} onOpenChange={(open) => { if (!open) onClose(); }}> <Dialog
open={contact !== null}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto"> <DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-3"> <DialogTitle className="flex items-center gap-3">
@@ -255,41 +390,68 @@ function ContactDetailDialog({ contact, onClose, onEdit }: {
{contact.company && ( {contact.company && (
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<Building2 className="h-4 w-4 shrink-0" /> <Building2 className="h-4 w-4 shrink-0" />
<span>{contact.company}{contact.department ? `${contact.department}` : ''}</span> <span>
{contact.company}
{contact.department ? `${contact.department}` : ""}
</span>
</div> </div>
)} )}
{contact.role && <p className="text-xs italic text-muted-foreground pl-6">{contact.role}</p>} {contact.role && (
<p className="text-xs italic text-muted-foreground pl-6">
{contact.role}
</p>
)}
{contact.email && ( {contact.email && (
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<Mail className="h-4 w-4 shrink-0" /> <Mail className="h-4 w-4 shrink-0" />
<a href={`mailto:${contact.email}`} className="hover:text-foreground">{contact.email}</a> <a
href={`mailto:${contact.email}`}
className="hover:text-foreground"
>
{contact.email}
</a>
</div> </div>
)} )}
{contact.email2 && ( {contact.email2 && (
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<Mail className="h-4 w-4 shrink-0" /> <Mail className="h-4 w-4 shrink-0" />
<a href={`mailto:${contact.email2}`} className="hover:text-foreground">{contact.email2}</a> <a
href={`mailto:${contact.email2}`}
className="hover:text-foreground"
>
{contact.email2}
</a>
</div> </div>
)} )}
{contact.phone && ( {contact.phone && (
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<Phone className="h-4 w-4 shrink-0" /><span>{contact.phone}</span> <Phone className="h-4 w-4 shrink-0" />
<span>{contact.phone}</span>
</div> </div>
)} )}
{contact.phone2 && ( {contact.phone2 && (
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<Phone className="h-4 w-4 shrink-0" /><span>{contact.phone2}</span> <Phone className="h-4 w-4 shrink-0" />
<span>{contact.phone2}</span>
</div> </div>
)} )}
{contact.address && ( {contact.address && (
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<MapPin className="h-4 w-4 shrink-0" /><span>{contact.address}</span> <MapPin className="h-4 w-4 shrink-0" />
<span>{contact.address}</span>
</div> </div>
)} )}
{contact.website && ( {contact.website && (
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<Globe className="h-4 w-4 shrink-0" /> <Globe className="h-4 w-4 shrink-0" />
<a href={contact.website} target="_blank" rel="noopener noreferrer" className="hover:text-foreground truncate">{contact.website}</a> <a
href={contact.website}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground truncate"
>
{contact.website}
</a>
</div> </div>
)} )}
</div> </div>
@@ -297,14 +459,34 @@ function ContactDetailDialog({ contact, onClose, onEdit }: {
{/* Contact persons */} {/* Contact persons */}
{contact.contactPersons && contact.contactPersons.length > 0 && ( {contact.contactPersons && contact.contactPersons.length > 0 && (
<div> <div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1.5">Persoane de contact</p> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
Persoane de contact
</p>
<div className="space-y-1"> <div className="space-y-1">
{contact.contactPersons.map((cp, i) => ( {contact.contactPersons.map((cp, i) => (
<div key={i} className="flex flex-wrap items-center gap-2 text-sm"> <div
key={i}
className="flex flex-wrap items-center gap-2 text-sm"
>
<span className="font-medium">{cp.name}</span> <span className="font-medium">{cp.name}</span>
{cp.role && <span className="text-muted-foreground text-xs">{cp.role}</span>} {cp.role && (
{cp.email && <a href={`mailto:${cp.email}`} className="text-xs text-muted-foreground hover:text-foreground">{cp.email}</a>} <span className="text-muted-foreground text-xs">
{cp.phone && <span className="text-xs text-muted-foreground">{cp.phone}</span>} {cp.role}
</span>
)}
{cp.email && (
<a
href={`mailto:${cp.email}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{cp.email}
</a>
)}
{cp.phone && (
<span className="text-xs text-muted-foreground">
{cp.phone}
</span>
)}
</div> </div>
))} ))}
</div> </div>
@@ -313,8 +495,12 @@ function ContactDetailDialog({ contact, onClose, onEdit }: {
{contact.notes && ( {contact.notes && (
<div> <div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">Note</p> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{contact.notes}</p> Note
</p>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{contact.notes}
</p>
</div> </div>
)} )}
@@ -324,22 +510,35 @@ function ContactDetailDialog({ contact, onClose, onEdit }: {
Registratură ({linkedEntries.length}) Registratură ({linkedEntries.length})
</p> </p>
{linkedEntries.length === 0 ? ( {linkedEntries.length === 0 ? (
<p className="text-xs text-muted-foreground">Nicio înregistrare în registratură pentru acest contact.</p> <p className="text-xs text-muted-foreground">
Nicio înregistrare în registratură pentru acest contact.
</p>
) : ( ) : (
<div className="space-y-1.5 max-h-52 overflow-y-auto pr-1"> <div className="space-y-1.5 max-h-52 overflow-y-auto pr-1">
{linkedEntries.map((entry) => ( {linkedEntries.map((entry) => (
<div key={entry.id} className="flex items-center gap-3 rounded border p-2 text-xs"> <div
key={entry.id}
className="flex items-center gap-3 rounded border p-2 text-xs"
>
<Badge variant="outline" className="shrink-0 text-[10px]"> <Badge variant="outline" className="shrink-0 text-[10px]">
{DIRECTION_LABELS[entry.direction] ?? entry.direction} {DIRECTION_LABELS[entry.direction] ?? entry.direction}
</Badge> </Badge>
<span className="font-mono shrink-0 text-muted-foreground">{entry.number}</span> <span className="font-mono shrink-0 text-muted-foreground">
<span className="flex-1 truncate font-medium">{entry.subject}</span> {entry.number}
<span className="shrink-0 text-muted-foreground">{entry.date}</span> </span>
<span className="flex-1 truncate font-medium">
{entry.subject}
</span>
<span className="shrink-0 text-muted-foreground">
{entry.date}
</span>
<Badge <Badge
variant={entry.status === 'deschis' ? 'default' : 'secondary'} variant={
entry.status === "deschis" ? "default" : "secondary"
}
className="shrink-0 text-[10px]" className="shrink-0 text-[10px]"
> >
{entry.status === 'deschis' ? 'Deschis' : 'Închis'} {entry.status === "deschis" ? "Deschis" : "Închis"}
</Badge> </Badge>
</div> </div>
))} ))}
@@ -349,7 +548,11 @@ function ContactDetailDialog({ contact, onClose, onEdit }: {
{/* Actions */} {/* Actions */}
<div className="flex justify-end gap-2 pt-2 border-t"> <div className="flex justify-end gap-2 pt-2 border-t">
<Button variant="outline" size="sm" onClick={() => downloadVCard(contact)}> <Button
variant="outline"
size="sm"
onClick={() => downloadVCard(contact)}
>
<Download className="mr-1.5 h-3.5 w-3.5" /> Descarcă vCard <Download className="mr-1.5 h-3.5 w-3.5" /> Descarcă vCard
</Button> </Button>
<Button variant="outline" size="sm" onClick={() => onEdit(contact)}> <Button variant="outline" size="sm" onClick={() => onEdit(contact)}>
@@ -364,37 +567,54 @@ function ContactDetailDialog({ contact, onClose, onEdit }: {
// ── Contact Form ── // ── Contact Form ──
function ContactForm({ initial, onSubmit, onCancel }: { function ContactForm({
initial,
onSubmit,
onCancel,
}: {
initial?: AddressContact; initial?: AddressContact;
onSubmit: (data: Omit<AddressContact, 'id' | 'createdAt' | 'updatedAt'>) => void; onSubmit: (
data: Omit<AddressContact, "id" | "createdAt" | "updatedAt">,
) => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const { tags: projectTags } = useTags('project'); const { tags: projectTags } = useTags("project");
const [name, setName] = useState(initial?.name ?? ''); const [name, setName] = useState(initial?.name ?? "");
const [company, setCompany] = useState(initial?.company ?? ''); const [company, setCompany] = useState(initial?.company ?? "");
const [type, setType] = useState<ContactType>(initial?.type ?? 'client'); const [type, setType] = useState<ContactType>(initial?.type ?? "client");
const [email, setEmail] = useState(initial?.email ?? ''); const [email, setEmail] = useState(initial?.email ?? "");
const [email2, setEmail2] = useState(initial?.email2 ?? ''); const [email2, setEmail2] = useState(initial?.email2 ?? "");
const [phone, setPhone] = useState(initial?.phone ?? ''); const [phone, setPhone] = useState(initial?.phone ?? "");
const [phone2, setPhone2] = useState(initial?.phone2 ?? ''); const [phone2, setPhone2] = useState(initial?.phone2 ?? "");
const [address, setAddress] = useState(initial?.address ?? ''); const [address, setAddress] = useState(initial?.address ?? "");
const [department, setDepartment] = useState(initial?.department ?? ''); const [department, setDepartment] = useState(initial?.department ?? "");
const [role, setRole] = useState(initial?.role ?? ''); const [role, setRole] = useState(initial?.role ?? "");
const [website, setWebsite] = useState(initial?.website ?? ''); const [website, setWebsite] = useState(initial?.website ?? "");
const [notes, setNotes] = useState(initial?.notes ?? ''); const [notes, setNotes] = useState(initial?.notes ?? "");
const [projectIds, setProjectIds] = useState<string[]>(initial?.projectIds ?? []); const [projectIds, setProjectIds] = useState<string[]>(
initial?.projectIds ?? [],
);
const [contactPersons, setContactPersons] = useState<ContactPerson[]>( const [contactPersons, setContactPersons] = useState<ContactPerson[]>(
initial?.contactPersons ?? [] initial?.contactPersons ?? [],
); );
const addContactPerson = () => { const addContactPerson = () => {
setContactPersons([...contactPersons, { name: '', role: '', email: '', phone: '' }]); setContactPersons([
...contactPersons,
{ name: "", role: "", email: "", phone: "" },
]);
}; };
const updateContactPerson = (index: number, field: keyof ContactPerson, value: string) => { const updateContactPerson = (
setContactPersons(contactPersons.map((cp, i) => index: number,
i === index ? { ...cp, [field]: value } : cp field: keyof ContactPerson,
)); value: string,
) => {
setContactPersons(
contactPersons.map((cp, i) =>
i === index ? { ...cp, [field]: value } : cp,
),
);
}; };
const removeContactPerson = (index: number) => { const removeContactPerson = (index: number) => {
@@ -403,7 +623,9 @@ function ContactForm({ initial, onSubmit, onCancel }: {
const toggleProject = (projectId: string) => { const toggleProject = (projectId: string) => {
setProjectIds((prev) => setProjectIds((prev) =>
prev.includes(projectId) ? prev.filter((id) => id !== projectId) : [...prev, projectId] prev.includes(projectId)
? prev.filter((id) => id !== projectId)
: [...prev, projectId],
); );
}; };
@@ -412,26 +634,56 @@ function ContactForm({ initial, onSubmit, onCancel }: {
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
onSubmit({ onSubmit({
name, company, type, email, email2, phone, phone2, name,
address, department, role, website, notes, company,
type,
email,
email2,
phone,
phone2,
address,
department,
role,
website,
notes,
projectIds, projectIds,
contactPersons: contactPersons.filter((cp) => cp.name.trim()), contactPersons: contactPersons.filter((cp) => cp.name.trim()),
tags: initial?.tags ?? [], tags: initial?.tags ?? [],
visibility: initial?.visibility ?? 'all', visibility: initial?.visibility ?? "all",
}); });
}} }}
className="space-y-4" className="space-y-4"
> >
{/* Row 1: Name + Company + Type */} {/* Row 1: Name + Company + Type */}
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div><Label>Nume *</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div> <div>
<div><Label>Companie/Organizație</Label><Input value={company} onChange={(e) => setCompany(e.target.value)} className="mt-1" /></div> <Label>Nume *</Label>
<div><Label>Tip</Label> <Input
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1"
required
/>
</div>
<div>
<Label>Companie/Organizație</Label>
<Input
value={company}
onChange={(e) => setCompany(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Tip</Label>
<Select value={type} onValueChange={(v) => setType(v as ContactType)}> <Select value={type} onValueChange={(v) => setType(v as ContactType)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> <SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
{(Object.keys(TYPE_LABELS) as ContactType[]).map((t) => ( {(Object.keys(TYPE_LABELS) as ContactType[]).map((t) => (
<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem> <SelectItem key={t} value={t}>
{TYPE_LABELS[t]}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -440,25 +692,87 @@ function ContactForm({ initial, onSubmit, onCancel }: {
{/* Row 2: Department + Role + Website */} {/* Row 2: Department + Role + Website */}
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div><Label>Departament</Label><Input value={department} onChange={(e) => setDepartment(e.target.value)} className="mt-1" /></div> <div>
<div><Label>Funcție/Rol</Label><Input value={role} onChange={(e) => setRole(e.target.value)} className="mt-1" /></div> <Label>Departament</Label>
<div><Label>Website</Label><Input type="url" value={website} onChange={(e) => setWebsite(e.target.value)} className="mt-1" placeholder="https://" /></div> <Input
value={department}
onChange={(e) => setDepartment(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Funcție/Rol</Label>
<Input
value={role}
onChange={(e) => setRole(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Website</Label>
<Input
type="url"
value={website}
onChange={(e) => setWebsite(e.target.value)}
className="mt-1"
placeholder="https://"
/>
</div>
</div> </div>
{/* Row 3: Emails + Phones */} {/* Row 3: Emails + Phones */}
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
<div><Label>Email principal</Label><Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="mt-1" /></div> <div>
<div><Label>Email secundar</Label><Input type="email" value={email2} onChange={(e) => setEmail2(e.target.value)} className="mt-1" /></div> <Label>Email principal</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Email secundar</Label>
<Input
type="email"
value={email2}
onChange={(e) => setEmail2(e.target.value)}
className="mt-1"
/>
</div>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<div><Label>Telefon principal</Label><Input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} className="mt-1" /></div> <div>
<div><Label>Telefon secundar</Label><Input type="tel" value={phone2} onChange={(e) => setPhone2(e.target.value)} className="mt-1" /></div> <Label>Telefon principal</Label>
<Input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Telefon secundar</Label>
<Input
type="tel"
value={phone2}
onChange={(e) => setPhone2(e.target.value)}
className="mt-1"
/>
</div>
</div> </div>
</div> </div>
{/* Address */} {/* Address */}
<div><Label>Adresă</Label><Input value={address} onChange={(e) => setAddress(e.target.value)} className="mt-1" /></div> <div>
<Label>Adresă</Label>
<Input
value={address}
onChange={(e) => setAddress(e.target.value)}
className="mt-1"
/>
</div>
{/* Project links */} {/* Project links */}
{projectTags.length > 0 && ( {projectTags.length > 0 && (
@@ -472,11 +786,12 @@ function ContactForm({ initial, onSubmit, onCancel }: {
onClick={() => toggleProject(pt.id)} onClick={() => toggleProject(pt.id)}
className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${ className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${
projectIds.includes(pt.id) projectIds.includes(pt.id)
? 'border-primary bg-primary/10 text-primary' ? "border-primary bg-primary/10 text-primary"
: 'border-muted-foreground/30 text-muted-foreground hover:border-primary/50' : "border-muted-foreground/30 text-muted-foreground hover:border-primary/50"
}`} }`}
> >
{pt.projectCode ? `${pt.projectCode} ` : ''}{pt.label} {pt.projectCode ? `${pt.projectCode} ` : ""}
{pt.label}
</button> </button>
))} ))}
</div> </div>
@@ -487,19 +802,61 @@ function ContactForm({ initial, onSubmit, onCancel }: {
<div> <div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label>Persoane de contact</Label> <Label>Persoane de contact</Label>
<Button type="button" variant="outline" size="sm" onClick={addContactPerson}> <Button
type="button"
variant="outline"
size="sm"
onClick={addContactPerson}
>
<UserPlus className="mr-1 h-3.5 w-3.5" /> Adaugă persoană <UserPlus className="mr-1 h-3.5 w-3.5" /> Adaugă persoană
</Button> </Button>
</div> </div>
{contactPersons.length > 0 && ( {contactPersons.length > 0 && (
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
{contactPersons.map((cp, i) => ( {contactPersons.map((cp, i) => (
<div key={i} className="flex flex-wrap items-start gap-2 rounded border p-2"> <div
<Input placeholder="Nume" value={cp.name} onChange={(e) => updateContactPerson(i, 'name', e.target.value)} className="min-w-[150px] flex-1 text-sm" /> key={i}
<Input placeholder="Funcție" value={cp.role} onChange={(e) => updateContactPerson(i, 'role', e.target.value)} className="w-[140px] text-sm" /> className="flex flex-wrap items-start gap-2 rounded border p-2"
<Input placeholder="Email" value={cp.email} onChange={(e) => updateContactPerson(i, 'email', e.target.value)} className="w-[180px] text-sm" /> >
<Input placeholder="Telefon" value={cp.phone} onChange={(e) => updateContactPerson(i, 'phone', e.target.value)} className="w-[140px] text-sm" /> <Input
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0 text-destructive" onClick={() => removeContactPerson(i)}> placeholder="Nume"
value={cp.name}
onChange={(e) =>
updateContactPerson(i, "name", e.target.value)
}
className="min-w-[150px] flex-1 text-sm"
/>
<Input
placeholder="Funcție"
value={cp.role}
onChange={(e) =>
updateContactPerson(i, "role", e.target.value)
}
className="w-[140px] text-sm"
/>
<Input
placeholder="Email"
value={cp.email}
onChange={(e) =>
updateContactPerson(i, "email", e.target.value)
}
className="w-[180px] text-sm"
/>
<Input
placeholder="Telefon"
value={cp.phone}
onChange={(e) =>
updateContactPerson(i, "phone", e.target.value)
}
className="w-[140px] text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-destructive"
onClick={() => removeContactPerson(i)}
>
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -509,11 +866,21 @@ function ContactForm({ initial, onSubmit, onCancel }: {
</div> </div>
{/* Notes */} {/* Notes */}
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div> <div>
<Label>Note</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="mt-1"
/>
</div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button> <Button type="button" variant="outline" onClick={onCancel}>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button> Anulează
</Button>
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
</div> </div>
</form> </form>
); );

View File

@@ -1,22 +1,28 @@
import type { AddressContact } from '../types'; import type { AddressContact } from "../types";
/** /**
* Generates a vCard 3.0 string for a contact and triggers a file download. * Generates a vCard 3.0 string for a contact and triggers a file download.
*/ */
export function downloadVCard(contact: AddressContact): void { export function downloadVCard(contact: AddressContact): void {
const lines: string[] = ['BEGIN:VCARD', 'VERSION:3.0']; const lines: string[] = ["BEGIN:VCARD", "VERSION:3.0"];
// Full name // Full name
lines.push(`FN:${esc(contact.name)}`); lines.push(`FN:${esc(contact.name)}`);
// Structured name — try to split first/last (best-effort) // Structured name — try to split first/last (best-effort)
const nameParts = contact.name.trim().split(/\s+/); const nameParts = contact.name.trim().split(/\s+/);
const last = nameParts.length > 1 ? (nameParts[nameParts.length - 1] ?? '') : ''; const last =
const first = nameParts.length > 1 ? nameParts.slice(0, -1).join(' ') : (nameParts[0] ?? ''); nameParts.length > 1 ? (nameParts[nameParts.length - 1] ?? "") : "";
const first =
nameParts.length > 1
? nameParts.slice(0, -1).join(" ")
: (nameParts[0] ?? "");
lines.push(`N:${esc(last)};${esc(first)};;;`); lines.push(`N:${esc(last)};${esc(first)};;;`);
if (contact.company) { if (contact.company) {
lines.push(`ORG:${esc(contact.company)}${contact.department ? `;${esc(contact.department)}` : ''}`); lines.push(
`ORG:${esc(contact.company)}${contact.department ? `;${esc(contact.department)}` : ""}`,
);
} }
if (contact.role) { if (contact.role) {
@@ -56,20 +62,26 @@ export function downloadVCard(contact: AddressContact): void {
// Contact persons as additional notes // Contact persons as additional notes
if (contact.contactPersons && contact.contactPersons.length > 0) { if (contact.contactPersons && contact.contactPersons.length > 0) {
const cpNote = contact.contactPersons const cpNote = contact.contactPersons
.map((cp) => `${cp.name}${cp.role ? ` (${cp.role})` : ''}${cp.email ? ` <${cp.email}>` : ''}${cp.phone ? ` ${cp.phone}` : ''}`) .map(
.join('; '); (cp) =>
`${cp.name}${cp.role ? ` (${cp.role})` : ""}${cp.email ? ` <${cp.email}>` : ""}${cp.phone ? ` ${cp.phone}` : ""}`,
)
.join("; ");
lines.push(`NOTE:Persoane contact: ${esc(cpNote)}`); lines.push(`NOTE:Persoane contact: ${esc(cpNote)}`);
} }
lines.push(`REV:${new Date().toISOString()}`); lines.push(`REV:${new Date().toISOString()}`);
lines.push('END:VCARD'); lines.push("END:VCARD");
const vcfContent = lines.join('\r\n'); const vcfContent = lines.join("\r\n");
const blob = new Blob([vcfContent], { type: 'text/vcard;charset=utf-8' }); const blob = new Blob([vcfContent], { type: "text/vcard;charset=utf-8" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = `${contact.name.replace(/[^a-zA-Z0-9\s-]/g, '').trim().replace(/\s+/g, '_')}.vcf`; a.download = `${contact.name
.replace(/[^a-zA-Z0-9\s-]/g, "")
.trim()
.replace(/\s+/g, "_")}.vcf`;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
@@ -78,5 +90,9 @@ export function downloadVCard(contact: AddressContact): void {
/** Escape special characters for vCard values */ /** Escape special characters for vCard values */
function esc(value: string): string { function esc(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/,/g, '\\,').replace(/;/g, '\\;').replace(/\n/g, '\\n'); return value
.replace(/\\/g, "\\\\")
.replace(/,/g, "\\,")
.replace(/;/g, "\\;")
.replace(/\n/g, "\\n");
} }

View File

@@ -0,0 +1,161 @@
"use client";
import { useMemo } from "react";
import { cn } from "@/shared/lib/utils";
import type { DeskReservation } from "../types";
import { DESKS } from "../types";
import {
getMonday,
toDateKey,
formatDateShort,
getReservationsForDate,
isDateBookable,
} from "../services/reservation-service";
interface DeskCalendarProps {
/** The week anchor date */
weekStart: Date;
selectedDate: string;
reservations: DeskReservation[];
onSelectDate: (dateKey: string) => void;
onPrevWeek: () => void;
onNextWeek: () => void;
canGoPrev: boolean;
canGoNext: boolean;
}
export function DeskCalendar({
weekStart,
selectedDate,
reservations,
onSelectDate,
onPrevWeek,
onNextWeek,
canGoPrev,
canGoNext,
}: DeskCalendarProps) {
const monday = useMemo(() => getMonday(weekStart), [weekStart]);
const weekDays = useMemo(() => {
const days: string[] = [];
for (let i = 0; i < 5; i++) {
const d = new Date(monday);
d.setDate(monday.getDate() + i);
days.push(toDateKey(d));
}
return days;
}, [monday]);
const todayKey = toDateKey(new Date());
return (
<div className="flex flex-col gap-2">
{/* Week navigation */}
<div className="flex items-center justify-between">
<button
type="button"
onClick={onPrevWeek}
disabled={!canGoPrev}
className={cn(
"rounded-md px-2 py-1 text-xs font-medium transition-colors",
canGoPrev
? "text-muted-foreground hover:text-foreground hover:bg-muted/60"
: "text-muted-foreground/30 cursor-not-allowed",
)}
>
Săpt. anterioară
</button>
<button
type="button"
onClick={onNextWeek}
disabled={!canGoNext}
className={cn(
"rounded-md px-2 py-1 text-xs font-medium transition-colors",
canGoNext
? "text-muted-foreground hover:text-foreground hover:bg-muted/60"
: "text-muted-foreground/30 cursor-not-allowed",
)}
>
Săpt. următoare
</button>
</div>
{/* Day cells */}
<div className="grid grid-cols-5 gap-1.5">
{weekDays.map((dateKey) => {
const dayReservations = getReservationsForDate(dateKey, reservations);
const bookedCount = dayReservations.length;
const totalDesks = DESKS.length;
const isSelected = dateKey === selectedDate;
const isToday = dateKey === todayKey;
const bookable = isDateBookable(dateKey);
const isPast = dateKey < todayKey;
const hasNoBookings = bookedCount === 0 && !isPast;
return (
<button
key={dateKey}
type="button"
onClick={() => onSelectDate(dateKey)}
disabled={!bookable && !isPast}
className={cn(
"relative flex flex-col items-center gap-0.5 rounded-lg border px-2 py-2 text-center transition-all",
isSelected
? "border-primary/50 bg-primary/8 ring-1 ring-primary/20"
: "border-border/40 hover:border-border/70 hover:bg-muted/30",
isPast && !isSelected && "opacity-50",
!bookable && !isPast && "opacity-40 cursor-not-allowed",
)}
>
{/* Today dot */}
{isToday && (
<div className="absolute top-1 right-1 h-1.5 w-1.5 rounded-full bg-primary" />
)}
{/* Day name + date */}
<span
className={cn(
"text-[11px] font-medium leading-tight",
isSelected ? "text-primary" : "text-muted-foreground",
)}
>
{formatDateShort(dateKey)}
</span>
{/* Occupancy indicator */}
<div className="flex gap-0.5 mt-0.5">
{Array.from({ length: totalDesks }).map((_, i) => (
<div
key={i}
className={cn(
"h-1 w-3 rounded-full transition-colors",
i < bookedCount
? "bg-primary/60"
: hasNoBookings
? "bg-amber-400/40"
: "bg-muted-foreground/15",
)}
/>
))}
</div>
{/* Count label */}
<span
className={cn(
"text-[10px] leading-tight",
bookedCount === totalDesks
? "text-primary/70"
: hasNoBookings
? "text-amber-500/70"
: "text-muted-foreground/60",
)}
>
{bookedCount}/{totalDesks}
</span>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,137 @@
"use client";
import { DESKS, getDeskLabel } from "../types";
import type { DeskId, DeskReservation } from "../types";
import { getReservationForDesk } from "../services/reservation-service";
import { cn } from "@/shared/lib/utils";
interface DeskRoomLayoutProps {
selectedDate: string;
reservations: DeskReservation[];
onDeskClick: (deskId: DeskId) => void;
}
export function DeskRoomLayout({
selectedDate,
reservations,
onDeskClick,
}: DeskRoomLayoutProps) {
return (
<div className="flex flex-col items-center gap-3">
{/* Room container — styled like a top-down floor plan */}
<div className="relative w-full max-w-[340px] rounded-xl border border-border/60 bg-muted/20 p-5">
{/* Window indicator — top edge */}
<div className="absolute top-0 left-4 right-4 h-1.5 rounded-b-sm bg-muted-foreground/15" />
<div className="absolute top-0 left-6 right-6 flex justify-between">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="mt-0.5 h-0.5 w-3 rounded-full bg-muted-foreground/10"
/>
))}
</div>
{/* Central table */}
<div className="mx-auto mt-4 mb-4 flex flex-col items-center">
{/* Top row desks */}
<div className="flex gap-3 mb-2">
{DESKS.filter((d) => d.position.startsWith("top")).map((desk) => {
const reservation = getReservationForDesk(
desk.id,
selectedDate,
reservations,
);
return (
<DeskSlot
key={desk.id}
deskId={desk.id}
label={getDeskLabel(desk.id)}
reservation={reservation}
side="top"
onClick={() => onDeskClick(desk.id)}
/>
);
})}
</div>
{/* The table surface */}
<div className="h-12 w-full max-w-[280px] rounded-md border border-border/50 bg-muted/40" />
{/* Bottom row desks */}
<div className="flex gap-3 mt-2">
{DESKS.filter((d) => d.position.startsWith("bottom")).map(
(desk) => {
const reservation = getReservationForDesk(
desk.id,
selectedDate,
reservations,
);
return (
<DeskSlot
key={desk.id}
deskId={desk.id}
label={getDeskLabel(desk.id)}
reservation={reservation}
side="bottom"
onClick={() => onDeskClick(desk.id)}
/>
);
},
)}
</div>
</div>
</div>
</div>
);
}
interface DeskSlotProps {
deskId: DeskId;
label: string;
reservation: DeskReservation | undefined;
side: "top" | "bottom";
onClick: () => void;
}
function DeskSlot({ label, reservation, side, onClick }: DeskSlotProps) {
const isBooked = !!reservation;
return (
<button
type="button"
onClick={onClick}
className={cn(
"group relative flex w-[125px] cursor-pointer flex-col items-center rounded-lg border p-3 transition-all",
side === "top" ? "rounded-b-sm" : "rounded-t-sm",
isBooked
? "border-primary/30 bg-primary/8 hover:border-primary/50 hover:bg-primary/12"
: "border-dashed border-border/60 bg-background/60 hover:border-primary/40 hover:bg-muted/50",
)}
>
{/* Chair indicator */}
<div
className={cn(
"absolute left-1/2 -translate-x-1/2 h-1.5 w-8 rounded-full transition-colors",
side === "top" ? "-top-2.5" : "-bottom-2.5",
isBooked
? "bg-primary/40"
: "bg-muted-foreground/15 group-hover:bg-muted-foreground/25",
)}
/>
{/* Desk label */}
<span className="text-[11px] font-medium text-muted-foreground">
{label}
</span>
{/* Status */}
{isBooked ? (
<span className="mt-1 text-xs font-medium text-primary truncate max-w-full">
{reservation.userName}
</span>
) : (
<span className="mt-1 text-[11px] text-muted-foreground/50">Liber</span>
)}
</button>
);
}

View File

@@ -0,0 +1,305 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import { Card, CardContent } from "@/shared/components/ui/card";
import { useReservations } from "../hooks/use-reservations";
import { DeskRoomLayout } from "./desk-room-layout";
import { DeskCalendar } from "./desk-calendar";
import { ReservationDialog } from "./reservation-dialog";
import type { DeskId } from "../types";
import { DESKS } from "../types";
import {
toDateKey,
getMonday,
getReservationsForDate,
getReservationForDesk,
getUnbookedCurrentWeekDays,
formatDateRo,
formatDateShort,
MAX_ADVANCE_DAYS,
} from "../services/reservation-service";
import { cn } from "@/shared/lib/utils";
export function HotDeskModule() {
const { reservations, loading, addReservation, cancelReservation } =
useReservations();
const today = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
const todayKey = useMemo(() => toDateKey(today), [today]);
const [selectedDate, setSelectedDate] = useState(todayKey);
const [weekStartDate, setWeekStartDate] = useState(() => getMonday(today));
// Dialog state
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogDeskId, setDialogDeskId] = useState<DeskId>("desk-1");
// --- Week navigation ---
const currentMonday = useMemo(() => getMonday(today), [today]);
const maxDate = useMemo(() => {
const d = new Date(today);
d.setDate(d.getDate() + MAX_ADVANCE_DAYS);
return d;
}, [today]);
const canGoPrev = useMemo(() => {
return weekStartDate > currentMonday;
}, [weekStartDate, currentMonday]);
const canGoNext = useMemo(() => {
const nextMonday = new Date(weekStartDate);
nextMonday.setDate(nextMonday.getDate() + 7);
return nextMonday <= maxDate;
}, [weekStartDate, maxDate]);
const handlePrevWeek = useCallback(() => {
setWeekStartDate((prev) => {
const d = new Date(prev);
d.setDate(d.getDate() - 7);
if (d < currentMonday) return currentMonday;
return d;
});
}, [currentMonday]);
const handleNextWeek = useCallback(() => {
setWeekStartDate((prev) => {
const d = new Date(prev);
d.setDate(d.getDate() + 7);
return d;
});
}, []);
// --- Stats ---
const todayReservations = useMemo(
() => getReservationsForDate(todayKey, reservations),
[todayKey, reservations],
);
const selectedDayReservations = useMemo(
() => getReservationsForDate(selectedDate, reservations),
[selectedDate, reservations],
);
const unbookedDays = useMemo(
() => getUnbookedCurrentWeekDays(reservations),
[reservations],
);
// --- Desk click ---
const handleDeskClick = useCallback((deskId: DeskId) => {
setDialogDeskId(deskId);
setDialogOpen(true);
}, []);
const handleBook = useCallback(
async (userName: string, notes: string) => {
await addReservation(dialogDeskId, selectedDate, userName, notes);
},
[addReservation, dialogDeskId, selectedDate],
);
const handleCancelReservation = useCallback(
async (reservationId: string) => {
await cancelReservation(reservationId);
},
[cancelReservation],
);
const existingReservation = useMemo(
() => getReservationForDesk(dialogDeskId, selectedDate, reservations),
[dialogDeskId, selectedDate, reservations],
);
if (loading) {
return (
<div className="flex min-h-[30vh] items-center justify-center">
<p className="text-sm text-muted-foreground">Se încarcă...</p>
</div>
);
}
return (
<div className="space-y-5">
{/* Subtle alert for unbooked work days */}
{unbookedDays.length > 0 && (
<div className="flex items-start gap-2.5 rounded-lg border border-amber-500/20 bg-amber-500/5 px-3.5 py-2.5">
<div className="mt-0.5 h-1.5 w-1.5 shrink-0 rounded-full bg-amber-500/60" />
<p className="text-xs text-amber-600/80 dark:text-amber-400/70">
{unbookedDays.length === 1
? `${formatDateShort(unbookedDays[0] ?? "")} nu are nicio rezervare.`
: `${unbookedDays.length} zile din această săptămână nu au rezervări: ${unbookedDays.map((d) => formatDateShort(d)).join(", ")}.`}
</p>
</div>
)}
{/* Stats row */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Birouri" value={DESKS.length} sub="total cameră" />
<StatCard
label="Azi ocupate"
value={todayReservations.length}
sub={`din ${DESKS.length}`}
highlight={todayReservations.length === DESKS.length}
/>
<StatCard
label="Azi libere"
value={DESKS.length - todayReservations.length}
sub="disponibile"
/>
<StatCard
label="Săpt. curentă"
value={unbookedDays.length === 0 ? "✓" : unbookedDays.length}
sub={unbookedDays.length === 0 ? "acoperit" : "zile neacoperite"}
warn={unbookedDays.length > 0}
/>
</div>
{/* Main content: calendar + room layout */}
<div className="grid gap-5 lg:grid-cols-[1fr_380px]">
{/* Left: Calendar */}
<Card className="border-border/50">
<CardContent className="p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Calendar</h3>
<span className="text-xs text-muted-foreground">
{formatDateRo(selectedDate)}
</span>
</div>
<DeskCalendar
weekStart={weekStartDate}
selectedDate={selectedDate}
reservations={reservations}
onSelectDate={setSelectedDate}
onPrevWeek={handlePrevWeek}
onNextWeek={handleNextWeek}
canGoPrev={canGoPrev}
canGoNext={canGoNext}
/>
{/* Day detail table */}
{selectedDayReservations.length > 0 ? (
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground">
Rezervări {formatDateRo(selectedDate)}
</h4>
<div className="rounded-lg border border-border/40 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/30 bg-muted/30">
<th className="px-3 py-1.5 text-left text-xs font-medium text-muted-foreground">
Birou
</th>
<th className="px-3 py-1.5 text-left text-xs font-medium text-muted-foreground">
Persoana
</th>
<th className="px-3 py-1.5 text-left text-xs font-medium text-muted-foreground">
Note
</th>
</tr>
</thead>
<tbody>
{selectedDayReservations.map((r) => (
<tr
key={r.id}
className="border-b border-border/20 last:border-0 hover:bg-muted/20 cursor-pointer transition-colors"
onClick={() => handleDeskClick(r.deskId)}
>
<td className="px-3 py-1.5 text-xs font-medium">
{DESKS.find((d) => d.id === r.deskId)?.label ??
r.deskId}
</td>
<td className="px-3 py-1.5 text-xs">{r.userName}</td>
<td className="px-3 py-1.5 text-xs text-muted-foreground truncate max-w-[120px]">
{r.notes || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground/60 text-center py-2">
Nicio rezervare în {formatDateRo(selectedDate)}.
</p>
)}
</CardContent>
</Card>
{/* Right: Room layout */}
<Card className="border-border/50">
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Cameră</h3>
<span className="text-xs text-muted-foreground">
{selectedDayReservations.length}/{DESKS.length} ocupate
</span>
</div>
<DeskRoomLayout
selectedDate={selectedDate}
reservations={reservations}
onDeskClick={handleDeskClick}
/>
<p className="text-[11px] text-muted-foreground/50 text-center">
Click pe un birou pentru a rezerva sau anula
</p>
</CardContent>
</Card>
</div>
{/* Reservation dialog */}
<ReservationDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
deskId={dialogDeskId}
dateKey={selectedDate}
existingReservation={existingReservation}
onBook={handleBook}
onCancel={handleCancelReservation}
/>
</div>
);
}
// --- Stat Card ---
interface StatCardProps {
label: string;
value: string | number;
sub: string;
highlight?: boolean;
warn?: boolean;
}
function StatCard({ label, value, sub, highlight, warn }: StatCardProps) {
return (
<Card
className={cn(
"border-border/40",
highlight && "border-primary/30 bg-primary/5",
warn && "border-amber-500/20 bg-amber-500/5",
)}
>
<CardContent className="p-3">
<p className="text-[11px] font-medium text-muted-foreground">{label}</p>
<p
className={cn(
"text-xl font-bold",
highlight && "text-primary",
warn && "text-amber-600 dark:text-amber-400",
)}
>
{value}
</p>
<p className="text-[10px] text-muted-foreground/60">{sub}</p>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,161 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/shared/components/ui/dialog";
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 type { DeskId, DeskReservation } from "../types";
import { getDeskLabel } from "../types";
import { formatDateRo, isDateBookable } from "../services/reservation-service";
interface ReservationDialogProps {
open: boolean;
onClose: () => void;
deskId: DeskId;
dateKey: string;
existingReservation: DeskReservation | undefined;
onBook: (userName: string, notes: string) => Promise<void>;
onCancel: (reservationId: string) => Promise<void>;
}
export function ReservationDialog({
open,
onClose,
deskId,
dateKey,
existingReservation,
onBook,
onCancel,
}: ReservationDialogProps) {
const [userName, setUserName] = useState("");
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const bookable = isDateBookable(dateKey);
const isBooked = !!existingReservation;
const handleBook = async () => {
if (!userName.trim()) return;
setSubmitting(true);
try {
await onBook(userName.trim(), notes.trim());
setUserName("");
setNotes("");
onClose();
} finally {
setSubmitting(false);
}
};
const handleCancel = async () => {
if (!existingReservation) return;
setSubmitting(true);
try {
await onCancel(existingReservation.id);
onClose();
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>
{getDeskLabel(deskId)} {formatDateRo(dateKey)}
</DialogTitle>
</DialogHeader>
{isBooked ? (
<div className="space-y-3 py-2">
<div className="rounded-lg border border-border/50 bg-muted/30 p-3 space-y-1.5">
<div className="text-sm">
<span className="text-muted-foreground">Rezervat de: </span>
<span className="font-medium">
{existingReservation.userName}
</span>
</div>
{existingReservation.notes && (
<div className="text-sm">
<span className="text-muted-foreground">Note: </span>
<span>{existingReservation.notes}</span>
</div>
)}
</div>
</div>
) : bookable ? (
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="userName">Nume</Label>
<Input
id="userName"
placeholder="Numele tău"
value={userName}
onChange={(e) => setUserName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleBook()}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Note (opțional)</Label>
<Textarea
id="notes"
placeholder="Ex: lucrez la proiect X, am nevoie de monitor extern..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="resize-none"
/>
</div>
</div>
) : (
<div className="py-4 text-center text-sm text-muted-foreground">
Această dată nu mai este disponibilă pentru rezervări.
</div>
)}
<DialogFooter>
{isBooked ? (
<>
<Button variant="outline" onClick={onClose} disabled={submitting}>
Închide
</Button>
<Button
variant="destructive"
onClick={handleCancel}
disabled={submitting}
>
{submitting ? "Se anulează..." : "Anulează rezervarea"}
</Button>
</>
) : bookable ? (
<>
<Button variant="outline" onClick={onClose} disabled={submitting}>
Renunță
</Button>
<Button
onClick={handleBook}
disabled={submitting || !userName.trim()}
>
{submitting ? "Se rezervă..." : "Rezervă"}
</Button>
</>
) : (
<Button variant="outline" onClick={onClose}>
Închide
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,17 @@
import type { ModuleConfig } from "@/core/module-registry/types";
export const hotDeskConfig: ModuleConfig = {
id: "hot-desk",
name: "Birouri Partajate",
description: "Rezervare birouri în camera partajată",
icon: "armchair",
route: "/hot-desk",
category: "management",
featureFlag: "module.hot-desk",
visibility: "all",
version: "0.1.0",
dependencies: [],
storageNamespace: "hot-desk",
navOrder: 33,
tags: ["birouri", "rezervare", "hot-desk"],
};

View File

@@ -0,0 +1,77 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useStorage } from "@/core/storage";
import { v4 as uuid } from "uuid";
import type { DeskId, DeskReservation } from "../types";
const PREFIX = "res:";
export function useReservations() {
const storage = useStorage("hot-desk");
const [reservations, setReservations] = useState<DeskReservation[]>([]);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
setLoading(true);
const keys = await storage.list();
const results: DeskReservation[] = [];
for (const key of keys) {
if (key.startsWith(PREFIX)) {
const item = await storage.get<DeskReservation>(key);
if (item) results.push(item);
}
}
results.sort((a, b) => a.date.localeCompare(b.date));
setReservations(results);
setLoading(false);
}, [storage]);
useEffect(() => {
refresh();
}, [refresh]);
const addReservation = useCallback(
async (deskId: DeskId, date: string, userName: string, notes: string) => {
// Check for conflict
const existing = reservations.find(
(r) => r.deskId === deskId && r.date === date,
);
if (existing) {
throw new Error(`Biroul este deja rezervat pe ${date}`);
}
const now = new Date().toISOString();
const reservation: DeskReservation = {
id: uuid(),
deskId,
date,
userName,
notes,
visibility: "all",
createdAt: now,
updatedAt: now,
};
await storage.set(`${PREFIX}${reservation.id}`, reservation);
await refresh();
return reservation;
},
[storage, refresh, reservations],
);
const cancelReservation = useCallback(
async (id: string) => {
await storage.delete(`${PREFIX}${id}`);
await refresh();
},
[storage, refresh],
);
return {
reservations,
loading,
addReservation,
cancelReservation,
refresh,
};
}

View File

@@ -0,0 +1,3 @@
export { hotDeskConfig } from "./config";
export { HotDeskModule } from "./components/hot-desk-module";
export type { DeskReservation, DeskId } from "./types";

View File

@@ -0,0 +1,206 @@
import type { DeskId, DeskReservation } from "../types";
import { DESKS } from "../types";
/** Maximum number of days in advance a reservation can be made */
export const MAX_ADVANCE_DAYS = 14;
/**
* Check if a date string falls on a weekday (Mon-Fri).
*/
export function isWeekday(dateStr: string): boolean {
const day = new Date(dateStr).getDay();
return day >= 1 && day <= 5;
}
/**
* Format a date as YYYY-MM-DD.
*/
export function toDateKey(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
/**
* Get the Monday of the week containing `date`.
*/
export function getMonday(date: Date): Date {
const d = new Date(date);
const day = d.getDay();
const diff = day === 0 ? -6 : 1 - day;
d.setDate(d.getDate() + diff);
d.setHours(0, 0, 0, 0);
return d;
}
/**
* Build an array of weekday date keys for the week containing `date`.
*/
export function getWeekDays(date: Date): string[] {
const monday = getMonday(date);
const days: string[] = [];
for (let i = 0; i < 5; i++) {
const d = new Date(monday);
d.setDate(monday.getDate() + i);
days.push(toDateKey(d));
}
return days;
}
/**
* Build all bookable date keys: today + up to MAX_ADVANCE_DAYS, weekdays only.
*/
export function getBookableDates(): string[] {
const today = new Date();
today.setHours(0, 0, 0, 0);
const dates: string[] = [];
for (let i = 0; i <= MAX_ADVANCE_DAYS; i++) {
const d = new Date(today);
d.setDate(today.getDate() + i);
const key = toDateKey(d);
if (isWeekday(key)) {
dates.push(key);
}
}
return dates;
}
/**
* Check if a desk is available on a specific date.
*/
export function isDeskAvailable(
deskId: DeskId,
dateKey: string,
reservations: DeskReservation[],
): boolean {
return !reservations.some((r) => r.deskId === deskId && r.date === dateKey);
}
/**
* Check if a specific date can be booked (not in the past, within 2-week window, is a weekday).
*/
export function isDateBookable(dateKey: string): boolean {
const today = new Date();
today.setHours(0, 0, 0, 0);
const target = new Date(dateKey);
target.setHours(0, 0, 0, 0);
if (target < today) return false;
const diffMs = target.getTime() - today.getTime();
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
if (diffDays > MAX_ADVANCE_DAYS) return false;
return isWeekday(dateKey);
}
/**
* Get reservations for a specific date.
*/
export function getReservationsForDate(
dateKey: string,
reservations: DeskReservation[],
): DeskReservation[] {
return reservations.filter((r) => r.date === dateKey);
}
/**
* Get reservations for a specific desk on a date.
*/
export function getReservationForDesk(
deskId: DeskId,
dateKey: string,
reservations: DeskReservation[],
): DeskReservation | undefined {
return reservations.find((r) => r.deskId === deskId && r.date === dateKey);
}
/**
* Detect workdays in the current week that have zero reservations.
* Returns date keys that need attention.
*/
export function getUnbookedCurrentWeekDays(
reservations: DeskReservation[],
): string[] {
const today = new Date();
today.setHours(0, 0, 0, 0);
const weekDays = getWeekDays(today);
const todayKey = toDateKey(today);
return weekDays.filter((dateKey) => {
// Only check today and future days in the current week
if (dateKey < todayKey) return false;
const dayReservations = getReservationsForDate(dateKey, reservations);
return dayReservations.length === 0;
});
}
/**
* Count available desks for a date.
*/
export function countAvailableDesks(
dateKey: string,
reservations: DeskReservation[],
): number {
const booked = reservations.filter((r) => r.date === dateKey).length;
return DESKS.length - booked;
}
/**
* Format a date key to a human-friendly Romanian string.
*/
export function formatDateRo(dateKey: string): string {
const date = new Date(dateKey);
const days = [
"Duminică",
"Luni",
"Marți",
"Miercuri",
"Joi",
"Vineri",
"Sâmbătă",
];
const months = [
"ianuarie",
"februarie",
"martie",
"aprilie",
"mai",
"iunie",
"iulie",
"august",
"septembrie",
"octombrie",
"noiembrie",
"decembrie",
];
const dayName = days[date.getDay()] ?? "";
const monthName = months[date.getMonth()] ?? "";
return `${dayName}, ${date.getDate()} ${monthName}`;
}
/**
* Short format: "Lun 19 feb"
*/
export function formatDateShort(dateKey: string): string {
const date = new Date(dateKey);
const days = ["Dum", "Lun", "Mar", "Mie", "Joi", "Vin", "Sâm"];
const months = [
"ian",
"feb",
"mar",
"apr",
"mai",
"iun",
"iul",
"aug",
"sep",
"oct",
"noi",
"dec",
];
const dayName = days[date.getDay()] ?? "";
const monthName = months[date.getMonth()] ?? "";
return `${dayName} ${date.getDate()} ${monthName}`;
}

View File

@@ -0,0 +1,38 @@
import type { Visibility } from "@/core/module-registry/types";
export type DeskId = "desk-1" | "desk-2" | "desk-3" | "desk-4";
export type DeskPosition =
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right";
export interface DeskDefinition {
id: DeskId;
label: string;
position: DeskPosition;
}
export interface DeskReservation {
id: string;
deskId: DeskId;
date: string; // YYYY-MM-DD
userName: string;
notes: string;
visibility: Visibility;
createdAt: string;
updatedAt: string;
}
export const DESKS: DeskDefinition[] = [
{ id: "desk-1", label: "Birou 1", position: "top-left" },
{ id: "desk-2", label: "Birou 2", position: "top-right" },
{ id: "desk-3", label: "Birou 3", position: "bottom-left" },
{ id: "desk-4", label: "Birou 4", position: "bottom-right" },
];
export function getDeskLabel(deskId: DeskId): string {
const desk = DESKS.find((d) => d.id === deskId);
return desk?.label ?? deskId;
}