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:
31
src/app/(modules)/hot-desk/page.tsx
Normal file
31
src/app/(modules)/hot-desk/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,36 +1,38 @@
|
|||||||
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.
|
||||||
* Dashboard-ul nu este inclus deoarece este pagina principală, nu un modul standard.
|
* Dashboard-ul nu este inclus deoarece este pagina principală, nu un modul standard.
|
||||||
*/
|
*/
|
||||||
export const MODULE_CONFIGS: ModuleConfig[] = [
|
export const MODULE_CONFIGS: ModuleConfig[] = [
|
||||||
registraturaConfig, // navOrder: 10 | operations
|
registraturaConfig, // navOrder: 10 | operations
|
||||||
passwordVaultConfig, // navOrder: 11 | operations
|
passwordVaultConfig, // navOrder: 11 | operations
|
||||||
emailSignatureConfig, // navOrder: 20 | generators
|
emailSignatureConfig, // navOrder: 20 | generators
|
||||||
wordXmlConfig, // navOrder: 21 | generators
|
wordXmlConfig, // navOrder: 21 | generators
|
||||||
wordTemplatesConfig, // navOrder: 22 | generators
|
wordTemplatesConfig, // navOrder: 22 | generators
|
||||||
digitalSignaturesConfig, // navOrder: 30 | management
|
digitalSignaturesConfig, // navOrder: 30 | management
|
||||||
itInventoryConfig, // navOrder: 31 | management
|
itInventoryConfig, // navOrder: 31 | management
|
||||||
addressBookConfig, // navOrder: 32 | management
|
addressBookConfig, // navOrder: 32 | management
|
||||||
tagManagerConfig, // navOrder: 40 | tools
|
hotDeskConfig, // navOrder: 33 | management
|
||||||
miniUtilitiesConfig, // navOrder: 41 | tools
|
tagManagerConfig, // navOrder: 40 | tools
|
||||||
promptGeneratorConfig, // navOrder: 50 | ai
|
miniUtilitiesConfig, // navOrder: 41 | tools
|
||||||
aiChatConfig, // navOrder: 51 | ai
|
promptGeneratorConfig, // navOrder: 50 | ai
|
||||||
|
aiChatConfig, // navOrder: 51 | ai
|
||||||
];
|
];
|
||||||
|
|
||||||
// Înregistrare automată a tuturor modulelor în registru
|
// Înregistrare automată a tuturor modulelor în registru
|
||||||
|
|||||||
@@ -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ă",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
161
src/modules/hot-desk/components/desk-calendar.tsx
Normal file
161
src/modules/hot-desk/components/desk-calendar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
src/modules/hot-desk/components/desk-room-layout.tsx
Normal file
137
src/modules/hot-desk/components/desk-room-layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
305
src/modules/hot-desk/components/hot-desk-module.tsx
Normal file
305
src/modules/hot-desk/components/hot-desk-module.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
src/modules/hot-desk/components/reservation-dialog.tsx
Normal file
161
src/modules/hot-desk/components/reservation-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/modules/hot-desk/config.ts
Normal file
17
src/modules/hot-desk/config.ts
Normal 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"],
|
||||||
|
};
|
||||||
77
src/modules/hot-desk/hooks/use-reservations.ts
Normal file
77
src/modules/hot-desk/hooks/use-reservations.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
3
src/modules/hot-desk/index.ts
Normal file
3
src/modules/hot-desk/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { hotDeskConfig } from "./config";
|
||||||
|
export { HotDeskModule } from "./components/hot-desk-module";
|
||||||
|
export type { DeskReservation, DeskId } from "./types";
|
||||||
206
src/modules/hot-desk/services/reservation-service.ts
Normal file
206
src/modules/hot-desk/services/reservation-service.ts
Normal 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}`;
|
||||||
|
}
|
||||||
38
src/modules/hot-desk/types.ts
Normal file
38
src/modules/hot-desk/types.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user