feat: 3.01 header logos+nav, 3.09 dynamic contact types, 3.10 hot-desk window, 3.11 vault email+link
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -46,7 +46,7 @@ import { useTags } from "@/core/tagging";
|
|||||||
import { downloadVCard } from "../services/vcard-export";
|
import { downloadVCard } from "../services/vcard-export";
|
||||||
import { useRegistry } from "@/modules/registratura/hooks/use-registry";
|
import { useRegistry } from "@/modules/registratura/hooks/use-registry";
|
||||||
|
|
||||||
const TYPE_LABELS: Record<ContactType, string> = {
|
const DEFAULT_TYPE_LABELS: Record<string, string> = {
|
||||||
client: "Client",
|
client: "Client",
|
||||||
supplier: "Furnizor",
|
supplier: "Furnizor",
|
||||||
institution: "Instituție",
|
institution: "Instituție",
|
||||||
@@ -54,6 +54,11 @@ const TYPE_LABELS: Record<ContactType, string> = {
|
|||||||
internal: "Intern",
|
internal: "Intern",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Get a label for any contact type, including custom ones */
|
||||||
|
function getTypeLabel(type: string): string {
|
||||||
|
return DEFAULT_TYPE_LABELS[type] ?? type;
|
||||||
|
}
|
||||||
|
|
||||||
type ViewMode = "list" | "add" | "edit";
|
type ViewMode = "list" | "add" | "edit";
|
||||||
|
|
||||||
export function AddressBookModule() {
|
export function AddressBookModule() {
|
||||||
@@ -75,6 +80,17 @@ export function AddressBookModule() {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Collect all contact types (defaults + custom ones from existing contacts)
|
||||||
|
const allTypes = useMemo(() => {
|
||||||
|
const types = { ...DEFAULT_TYPE_LABELS };
|
||||||
|
for (const c of allContacts) {
|
||||||
|
if (c.type && !types[c.type]) {
|
||||||
|
types[c.type] = c.type; // custom type — label is the type itself
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return types;
|
||||||
|
}, [allContacts]);
|
||||||
|
|
||||||
const handleSubmit = async (
|
const handleSubmit = async (
|
||||||
data: Omit<AddressContact, "id" | "createdAt" | "updatedAt">,
|
data: Omit<AddressContact, "id" | "createdAt" | "updatedAt">,
|
||||||
) => {
|
) => {
|
||||||
@@ -97,18 +113,20 @@ export function AddressBookModule() {
|
|||||||
<p className="text-2xl font-bold">{allContacts.length}</p>
|
<p className="text-2xl font-bold">{allContacts.length}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{(Object.keys(TYPE_LABELS) as ContactType[]).slice(0, 4).map((type) => (
|
{Object.keys(DEFAULT_TYPE_LABELS)
|
||||||
<Card key={type}>
|
.slice(0, 4)
|
||||||
<CardContent className="p-4">
|
.map((type) => (
|
||||||
<p className="text-xs text-muted-foreground">
|
<Card key={type}>
|
||||||
{TYPE_LABELS[type]}
|
<CardContent className="p-4">
|
||||||
</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
<p className="text-2xl font-bold">
|
{getTypeLabel(type)}
|
||||||
{allContacts.filter((c) => c.type === type).length}
|
</p>
|
||||||
</p>
|
<p className="text-2xl font-bold">
|
||||||
</CardContent>
|
{allContacts.filter((c) => c.type === type).length}
|
||||||
</Card>
|
</p>
|
||||||
))}
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewMode === "list" && (
|
{viewMode === "list" && (
|
||||||
@@ -134,9 +152,9 @@ export function AddressBookModule() {
|
|||||||
</SelectTrigger>
|
</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(allTypes).map((t) => (
|
||||||
<SelectItem key={t} value={t}>
|
<SelectItem key={t} value={t}>
|
||||||
{TYPE_LABELS[t]}
|
{allTypes[t]}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -269,7 +287,7 @@ function ContactCard({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Badge variant="outline" className="text-[10px]">
|
<Badge variant="outline" className="text-[10px]">
|
||||||
{TYPE_LABELS[contact.type]}
|
{getTypeLabel(contact.type)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{contact.department && (
|
{contact.department && (
|
||||||
<Badge variant="secondary" className="text-[10px]">
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
@@ -380,7 +398,7 @@ function ContactDetailDialog({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-3">
|
<DialogTitle className="flex items-center gap-3">
|
||||||
<span>{contact.name}</span>
|
<span>{contact.name}</span>
|
||||||
<Badge variant="outline">{TYPE_LABELS[contact.type]}</Badge>
|
<Badge variant="outline">{getTypeLabel(contact.type)}</Badge>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -565,6 +583,99 @@ function ContactDetailDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Creatable Type Select ──
|
||||||
|
|
||||||
|
function CreatableTypeSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [customValue, setCustomValue] = useState("");
|
||||||
|
|
||||||
|
if (isCreating) {
|
||||||
|
return (
|
||||||
|
<div className="mt-1 flex gap-1.5">
|
||||||
|
<Input
|
||||||
|
value={customValue}
|
||||||
|
onChange={(e) => setCustomValue(e.target.value)}
|
||||||
|
placeholder="Tip nou (ex: Colaborator Extern)"
|
||||||
|
className="flex-1"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && customValue.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
onChange(customValue.trim());
|
||||||
|
setIsCreating(false);
|
||||||
|
setCustomValue("");
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setIsCreating(false);
|
||||||
|
setCustomValue("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (customValue.trim()) {
|
||||||
|
onChange(customValue.trim());
|
||||||
|
}
|
||||||
|
setIsCreating(false);
|
||||||
|
setCustomValue("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIsCreating(false);
|
||||||
|
setCustomValue("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-1 flex gap-1.5">
|
||||||
|
<Select value={value} onValueChange={onChange}>
|
||||||
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(DEFAULT_TYPE_LABELS).map(([k, label]) => (
|
||||||
|
<SelectItem key={k} value={k}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{/* Show current custom value if not in defaults */}
|
||||||
|
{value && !DEFAULT_TYPE_LABELS[value] && (
|
||||||
|
<SelectItem value={value}>{value}</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsCreating(true)}
|
||||||
|
title="Adaugă tip nou"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Contact Form ──
|
// ── Contact Form ──
|
||||||
|
|
||||||
function ContactForm({
|
function ContactForm({
|
||||||
@@ -675,18 +786,7 @@ function ContactForm({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Tip</Label>
|
<Label>Tip</Label>
|
||||||
<Select value={type} onValueChange={(v) => setType(v as ContactType)}>
|
<CreatableTypeSelect value={type} onChange={(v) => setType(v)} />
|
||||||
<SelectTrigger className="mt-1">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(Object.keys(TYPE_LABELS) as ContactType[]).map((t) => (
|
|
||||||
<SelectItem key={t} value={t}>
|
|
||||||
{TYPE_LABELS[t]}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import type { Visibility } from '@/core/module-registry/types';
|
import type { Visibility } from "@/core/module-registry/types";
|
||||||
|
|
||||||
export type ContactType = 'client' | 'supplier' | 'institution' | 'collaborator' | 'internal';
|
export type ContactType =
|
||||||
|
| "client"
|
||||||
|
| "supplier"
|
||||||
|
| "institution"
|
||||||
|
| "collaborator"
|
||||||
|
| "internal"
|
||||||
|
| string;
|
||||||
|
|
||||||
/** A contact person within an organization/entity */
|
/** A contact person within an organization/entity */
|
||||||
export interface ContactPerson {
|
export interface ContactPerson {
|
||||||
|
|||||||
@@ -20,16 +20,26 @@ export function DeskRoomLayout({
|
|||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
{/* Room container — styled like a top-down floor plan */}
|
{/* 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">
|
<div className="relative w-full max-w-[340px] rounded-xl border border-border/60 bg-muted/20 p-5">
|
||||||
{/* Window indicator — top edge */}
|
{/* Window indicator — LEFT wall (landmark for orientation) */}
|
||||||
<div className="absolute top-0 left-4 right-4 h-1.5 rounded-b-sm bg-muted-foreground/15" />
|
<div className="absolute top-4 bottom-4 left-0 w-1.5 rounded-r-sm bg-sky-300/30 dark:bg-sky-500/20" />
|
||||||
<div className="absolute top-0 left-6 right-6 flex justify-between">
|
<div className="absolute top-6 bottom-6 left-0 flex flex-col justify-between">
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="mt-0.5 h-0.5 w-3 rounded-full bg-muted-foreground/10"
|
className="ml-0.5 h-3 w-0.5 rounded-full bg-sky-400/25 dark:bg-sky-400/15"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Window label */}
|
||||||
|
<div className="absolute left-1.5 top-1/2 -translate-y-1/2 -rotate-90 text-[9px] font-medium text-muted-foreground/40 tracking-widest uppercase select-none">
|
||||||
|
Fereastră
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Door indicator — RIGHT wall */}
|
||||||
|
<div className="absolute top-[60%] right-0 h-8 w-1.5 rounded-l-sm bg-amber-400/25 dark:bg-amber-500/15" />
|
||||||
|
<div className="absolute top-[60%] right-1.5 translate-y-1 text-[8px] text-muted-foreground/30 select-none">
|
||||||
|
Ușă
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Central table */}
|
{/* Central table */}
|
||||||
<div className="mx-auto mt-4 mb-4 flex flex-col items-center">
|
<div className="mx-auto mt-4 mb-4 flex flex-col items-center">
|
||||||
|
|||||||
@@ -267,6 +267,11 @@ export function PasswordVaultModule() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{entry.username}
|
{entry.username}
|
||||||
|
{entry.email && (
|
||||||
|
<span className="ml-2 text-muted-foreground/70">
|
||||||
|
({entry.email})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="text-xs">
|
<code className="text-xs">
|
||||||
@@ -301,9 +306,19 @@ export function PasswordVaultModule() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{entry.url && (
|
{entry.url && (
|
||||||
<p className="flex items-center gap-1 text-xs text-muted-foreground">
|
<a
|
||||||
|
href={
|
||||||
|
entry.url.startsWith("http")
|
||||||
|
? entry.url
|
||||||
|
: `https://${entry.url}`
|
||||||
|
}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 text-xs text-primary hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<ExternalLink className="h-3 w-3" /> {entry.url}
|
<ExternalLink className="h-3 w-3" /> {entry.url}
|
||||||
</p>
|
</a>
|
||||||
)}
|
)}
|
||||||
{entry.customFields && entry.customFields.length > 0 && (
|
{entry.customFields && entry.customFields.length > 0 && (
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
@@ -408,6 +423,7 @@ function VaultForm({
|
|||||||
}) {
|
}) {
|
||||||
const [label, setLabel] = useState(initial?.label ?? "");
|
const [label, setLabel] = useState(initial?.label ?? "");
|
||||||
const [username, setUsername] = useState(initial?.username ?? "");
|
const [username, setUsername] = useState(initial?.username ?? "");
|
||||||
|
const [email, setEmail] = useState(initial?.email ?? "");
|
||||||
const [password, setPassword] = useState(initial?.password ?? "");
|
const [password, setPassword] = useState(initial?.password ?? "");
|
||||||
const [url, setUrl] = useState(initial?.url ?? "");
|
const [url, setUrl] = useState(initial?.url ?? "");
|
||||||
const [category, setCategory] = useState<VaultEntryCategory>(
|
const [category, setCategory] = useState<VaultEntryCategory>(
|
||||||
@@ -468,6 +484,7 @@ function VaultForm({
|
|||||||
onSubmit({
|
onSubmit({
|
||||||
label,
|
label,
|
||||||
username,
|
username,
|
||||||
|
email,
|
||||||
password,
|
password,
|
||||||
url,
|
url,
|
||||||
category,
|
category,
|
||||||
@@ -539,6 +556,16 @@ function VaultForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Email</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="utilizator@exemplu.ro"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Parolă</Label>
|
<Label>Parolă</Label>
|
||||||
<div className="mt-1 flex gap-1.5">
|
<div className="mt-1 flex gap-1.5">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface VaultEntry {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
url: string;
|
url: string;
|
||||||
category: VaultEntryCategory;
|
category: VaultEntryCategory;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState, useCallback, useEffect } from "react";
|
||||||
import * as Icons from "lucide-react";
|
import * as Icons from "lucide-react";
|
||||||
import { buildNavigation } from "@/config/navigation";
|
import { buildNavigation } from "@/config/navigation";
|
||||||
import { COMPANIES } from "@/config/companies";
|
import { COMPANIES } from "@/config/companies";
|
||||||
@@ -64,24 +64,61 @@ function NavItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarLogo() {
|
const LOGO_COMPANIES = ["studii-de-teren", "urban-switch"] as const;
|
||||||
const sdt = COMPANIES["studii-de-teren"];
|
|
||||||
|
|
||||||
const logoSrc = sdt.logo?.light ?? null;
|
function SidebarLogos() {
|
||||||
|
const [clickCount, setClickCount] = useState(0);
|
||||||
|
const [shuffled, setShuffled] = useState(false);
|
||||||
|
|
||||||
if (!logoSrc) {
|
const handleLogoClick = useCallback(() => {
|
||||||
return <Icons.LayoutDashboard className="h-5 w-5 text-primary" />;
|
setClickCount((prev) => {
|
||||||
}
|
const next = prev + 1;
|
||||||
|
if (next >= 3) {
|
||||||
|
setShuffled((s) => !s);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset click count after 2 seconds of inactivity
|
||||||
|
useEffect(() => {
|
||||||
|
if (clickCount === 0) return;
|
||||||
|
const timer = setTimeout(() => setClickCount(0), 2000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [clickCount]);
|
||||||
|
|
||||||
|
const logos = shuffled ? [...LOGO_COMPANIES].reverse() : [...LOGO_COMPANIES];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Image
|
<div className="flex items-center gap-1.5">
|
||||||
src={logoSrc}
|
{logos.map((companyId) => {
|
||||||
alt={sdt.shortName}
|
const company = COMPANIES[companyId];
|
||||||
width={28}
|
const logoSrc = company?.logo?.light;
|
||||||
height={28}
|
if (!logoSrc) return null;
|
||||||
className="h-7 w-7 shrink-0"
|
return (
|
||||||
suppressHydrationWarning
|
<button
|
||||||
/>
|
key={companyId}
|
||||||
|
type="button"
|
||||||
|
onClick={handleLogoClick}
|
||||||
|
className={cn(
|
||||||
|
"relative shrink-0 rounded-md p-0.5 transition-all duration-300 hover:scale-110 focus:outline-none",
|
||||||
|
shuffled && "animate-pulse",
|
||||||
|
)}
|
||||||
|
title={company.shortName}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={logoSrc}
|
||||||
|
alt={company.shortName}
|
||||||
|
width={36}
|
||||||
|
height={36}
|
||||||
|
className="h-9 w-9 object-contain"
|
||||||
|
suppressHydrationWarning
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,10 +128,14 @@ export function Sidebar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-full w-64 shrink-0 flex-col border-r bg-card">
|
<aside className="flex h-full w-64 shrink-0 flex-col border-r bg-card">
|
||||||
<div className="flex h-14 items-center gap-2 border-b px-4">
|
<Link
|
||||||
<SidebarLogo />
|
href="/"
|
||||||
|
className="flex h-14 items-center gap-2.5 border-b px-4 transition-colors hover:bg-accent/30"
|
||||||
|
title="Panou principal"
|
||||||
|
>
|
||||||
|
<SidebarLogos />
|
||||||
<span className="text-lg font-semibold">ArchiTools</span>
|
<span className="text-lg font-semibold">ArchiTools</span>
|
||||||
</div>
|
</Link>
|
||||||
|
|
||||||
<ScrollArea className="flex-1 px-3 py-3">
|
<ScrollArea className="flex-1 px-3 py-3">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
Reference in New Issue
Block a user