feat(utilities+vault): TVA configurable rate, scale/rooms calculators, multi-user vault

Mini Utilities:
- TVA calculator: rate now configurable (5%/9%/19%/21% presets + custom input)
  replaces hardcoded 19% constant; displays effective rate in results
- New tab: Calculator scară desen — real↔drawing conversion with 6 scale presets
  (1:50..1:5000) + custom, shows cm equivalent for drawing→real
- New tab: Calculator suprafețe camere — multi-room area accumulator
  (name + W×L×H), live m²/m³ per room, running total, copy-all

Password Vault:
- New VaultUser type: { username, password, email?, notes? }
- VaultEntry.additionalUsers: VaultUser[] — backward compat (defaults to [])
- VaultForm: collapsible "Utilizatori suplimentari" section, add/remove rows
- Card list: badge showing count of additional users when present
- useVault: normalizes legacy entries (additionalUsers ?? [])

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-12 20:51:34 +02:00
parent b0f27053ae
commit 06b3a820de
4 changed files with 531 additions and 7 deletions
@@ -22,6 +22,10 @@ import {
ArrowDown,
Unlock,
PenTool,
Maximize2,
LayoutGrid,
Plus,
X,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
@@ -212,13 +216,18 @@ function PercentageCalculator() {
);
}
const TVA_PRESETS = [5, 9, 19, 21];
function TvaCalculator() {
const TVA_RATE = 19; // Romania standard VAT
const [tvaRate, setTvaRate] = useState(19);
const [customRate, setCustomRate] = useState("");
const [amount, setAmount] = useState("");
const [mode, setMode] = useState<"add" | "extract">("add");
const effectiveRate =
customRate !== "" ? parseFloat(customRate) || tvaRate : tvaRate;
const val = parseFloat(amount);
const tvaMultiplier = TVA_RATE / 100;
const tvaMultiplier = effectiveRate / 100;
const cuTva = !isNaN(val) && mode === "add" ? val * (1 + tvaMultiplier) : NaN;
const faraTva =
@@ -239,6 +248,38 @@ function TvaCalculator() {
return (
<div className="space-y-4">
{/* Rate selector */}
<div>
<Label className="text-xs text-muted-foreground">Cotă TVA</Label>
<div className="mt-1 flex flex-wrap items-center gap-2">
{TVA_PRESETS.map((r) => (
<Button
key={r}
type="button"
variant={tvaRate === r && customRate === "" ? "default" : "outline"}
size="sm"
onClick={() => { setTvaRate(r); setCustomRate(""); }}
>
{r}%
</Button>
))}
<div className="flex items-center gap-1">
<Input
type="number"
min={0}
max={100}
step={0.5}
value={customRate}
onChange={(e) => setCustomRate(e.target.value)}
placeholder="Altă cotă"
className="w-28 text-sm"
/>
{customRate && (
<span className="text-xs text-muted-foreground">%</span>
)}
</div>
</div>
</div>
<div className="flex gap-2">
<Button
variant={mode === "add" ? "default" : "outline"}
@@ -278,7 +319,7 @@ function TvaCalculator() {
Sumă fără TVA: <strong>{fmt(val)} RON</strong>
</p>
<p>
TVA ({TVA_RATE}%): <strong>{fmt(tvaAmount)} RON</strong>
TVA ({effectiveRate}%): <strong>{fmt(tvaAmount)} RON</strong>
<CopyButton text={fmt(tvaAmount)} />
</p>
<p className="text-base pt-1 border-t">
@@ -293,7 +334,7 @@ function TvaCalculator() {
Sumă cu TVA: <strong>{fmt(val)} RON</strong>
</p>
<p>
TVA ({TVA_RATE}%): <strong>{fmt(tvaAmount)} RON</strong>
TVA ({effectiveRate}%): <strong>{fmt(tvaAmount)} RON</strong>
<CopyButton text={fmt(tvaAmount)} />
</p>
<p className="text-base pt-1 border-t">
@@ -2486,6 +2527,306 @@ function ColorPaletteExtractor() {
);
}
// ─── Calculator Scară ─────────────────────────────────────────────────────────
const SCALE_PRESETS = [
{ label: "1:50", value: 50 },
{ label: "1:100", value: 100 },
{ label: "1:200", value: 200 },
{ label: "1:500", value: 500 },
{ label: "1:1000", value: 1000 },
{ label: "1:5000", value: 5000 },
];
function ScaleCalculator() {
const [scale, setScale] = useState(100);
const [customScale, setCustomScale] = useState("");
const [mode, setMode] = useState<"real-to-drawing" | "drawing-to-real">(
"real-to-drawing",
);
const [inputVal, setInputVal] = useState("");
const effectiveScale =
customScale !== "" ? parseFloat(customScale) || scale : scale;
const val = parseFloat(inputVal);
const result = !isNaN(val)
? mode === "real-to-drawing"
? val / effectiveScale
: val * effectiveScale
: NaN;
const unitIn = mode === "real-to-drawing" ? "m (real)" : "mm (desen)";
const unitOut = mode === "real-to-drawing" ? "mm (desen)" : "m (real)";
const fmtDesen = (n: number) =>
isNaN(n)
? "—"
: n.toLocaleString("ro-RO", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " mm";
const fmtReal = (n: number) =>
isNaN(n)
? "—"
: n.toLocaleString("ro-RO", { minimumFractionDigits: 3, maximumFractionDigits: 3 }) + " m";
return (
<div className="space-y-4">
<div>
<Label className="text-xs text-muted-foreground">Scară</Label>
<div className="mt-1 flex flex-wrap items-center gap-2">
{SCALE_PRESETS.map((p) => (
<Button
key={p.value}
type="button"
variant={scale === p.value && customScale === "" ? "default" : "outline"}
size="sm"
onClick={() => { setScale(p.value); setCustomScale(""); }}
>
{p.label}
</Button>
))}
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">1:</span>
<Input
type="number"
min={1}
value={customScale}
onChange={(e) => setCustomScale(e.target.value)}
placeholder="Altă scară"
className="w-28 text-sm"
/>
</div>
</div>
</div>
<div className="flex gap-2">
<Button
variant={mode === "real-to-drawing" ? "default" : "outline"}
size="sm"
onClick={() => { setMode("real-to-drawing"); setInputVal(""); }}
>
Real Desen
</Button>
<Button
variant={mode === "drawing-to-real" ? "default" : "outline"}
size="sm"
onClick={() => { setMode("drawing-to-real"); setInputVal(""); }}
>
Desen Real
</Button>
</div>
<div>
<Label>
{mode === "real-to-drawing"
? "Dimensiune reală (m)"
: "Dimensiune desen (mm)"}
</Label>
<div className="mt-1 flex gap-2">
<Input
type="number"
value={inputVal}
onChange={(e) => setInputVal(e.target.value)}
placeholder={mode === "real-to-drawing" ? "ex: 5.4" : "ex: 54"}
className="flex-1"
/>
<span className="flex items-center text-sm text-muted-foreground px-2">
{unitIn}
</span>
</div>
</div>
{!isNaN(val) && val > 0 && (
<div className="rounded-md border bg-muted/30 p-4 space-y-2 text-sm">
<p className="text-xs text-muted-foreground">
Scară 1:{effectiveScale}
</p>
<p className="text-base font-medium">
{mode === "real-to-drawing" ? (
<>
{val} m real {" "}
<strong className="text-primary">{fmtDesen(result)}</strong> pe desen
<CopyButton text={isNaN(result) ? "" : result.toFixed(2)} />
</>
) : (
<>
{val} mm desen {" "}
<strong className="text-primary">{fmtReal(result)}</strong> real
<CopyButton text={isNaN(result) ? "" : result.toFixed(3)} />
</>
)}
</p>
{mode === "real-to-drawing" && !isNaN(result) && (
<p className="text-xs text-muted-foreground">
= {(result / 10).toFixed(2)} cm pe desen
</p>
)}
</div>
)}
</div>
);
}
// ─── Calculator Camere ────────────────────────────────────────────────────────
interface Room {
id: string;
name: string;
width: string;
length: string;
height: string;
}
function RoomAreaCalculator() {
const [rooms, setRooms] = useState<Room[]>([
{ id: "1", name: "Living", width: "", length: "", height: "" },
]);
const addRoom = () => {
setRooms((prev) => [
...prev,
{ id: String(Date.now()), name: "", width: "", length: "", height: "" },
]);
};
const removeRoom = (id: string) => {
setRooms((prev) => prev.filter((r) => r.id !== id));
};
const updateRoom = (id: string, field: keyof Room, value: string) => {
setRooms((prev) =>
prev.map((r) => (r.id === id ? { ...r, [field]: value } : r)),
);
};
const roomsWithArea = rooms.map((r) => {
const w = parseFloat(r.width);
const l = parseFloat(r.length);
const h = parseFloat(r.height);
const area = !isNaN(w) && !isNaN(l) ? w * l : NaN;
const volume = !isNaN(area) && !isNaN(h) ? area * h : NaN;
return { ...r, area, volume };
});
const totalArea = roomsWithArea.reduce(
(sum, r) => (isNaN(r.area) ? sum : sum + r.area),
0,
);
const totalVolume = roomsWithArea.reduce(
(sum, r) => (isNaN(r.volume) ? sum : sum + r.volume),
0,
);
const fmtM2 = (n: number) =>
n.toLocaleString("ro-RO", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const copyAll = () => {
const lines = roomsWithArea
.filter((r) => !isNaN(r.area))
.map((r) => `${r.name || "Cameră"}: ${fmtM2(r.area)}`)
.join("\n");
const text = lines + `\n\nTotal: ${fmtM2(totalArea)}`;
navigator.clipboard.writeText(text).catch(() => {});
};
return (
<div className="space-y-4">
<div className="space-y-2">
{roomsWithArea.map((room, idx) => (
<div key={room.id} className="flex flex-wrap items-center gap-2 rounded border p-2">
<Input
placeholder={`Cameră ${idx + 1}`}
value={room.name}
onChange={(e) => updateRoom(room.id, "name", e.target.value)}
className="w-[130px] text-sm"
/>
<div className="flex items-center gap-1">
<Input
type="number"
placeholder="L"
value={room.width}
onChange={(e) => updateRoom(room.id, "width", e.target.value)}
className="w-[80px] text-sm"
min={0}
step={0.01}
/>
<span className="text-muted-foreground text-xs">×</span>
<Input
type="number"
placeholder="l"
value={room.length}
onChange={(e) => updateRoom(room.id, "length", e.target.value)}
className="w-[80px] text-sm"
min={0}
step={0.01}
/>
<span className="text-muted-foreground text-xs">m</span>
</div>
<div className="flex items-center gap-1">
<Input
type="number"
placeholder="H"
value={room.height}
onChange={(e) => updateRoom(room.id, "height", e.target.value)}
className="w-[70px] text-sm"
min={0}
step={0.01}
/>
<span className="text-muted-foreground text-xs">m</span>
</div>
{!isNaN(room.area) && (
<span className="text-sm font-medium text-primary min-w-[80px]">
{fmtM2(room.area)} m²
{!isNaN(room.volume) && (
<span className="block text-[10px] text-muted-foreground font-normal">
{fmtM2(room.volume)} m³
</span>
)}
</span>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive ml-auto"
onClick={() => removeRoom(room.id)}
disabled={rooms.length === 1}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={addRoom}>
<Plus className="mr-1 h-3 w-3" /> Adaugă cameră
</Button>
</div>
{totalArea > 0 && (
<div className="rounded-md border bg-muted/30 p-4 space-y-1 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium">
Total suprafață:{" "}
<strong className="text-primary">{fmtM2(totalArea)} m²</strong>
</span>
<CopyButton text={fmtM2(totalArea)} />
</div>
{totalVolume > 0 && (
<p className="text-xs text-muted-foreground">
Volum total: {fmtM2(totalVolume)} m³
</p>
)}
<Button
type="button"
variant="outline"
size="sm"
className="mt-2"
onClick={copyAll}
>
<Copy className="mr-1 h-3 w-3" /> Copiază tot
</Button>
</div>
)}
</div>
);
}
// ─── Main Module ──────────────────────────────────────────────────────────────
export function MiniUtilitiesModule() {
@@ -2521,6 +2862,12 @@ export function MiniUtilitiesModule() {
<TabsTrigger value="mdlpa">
<Building2 className="mr-1.5 h-3.5 w-3.5" /> MDLPA
</TabsTrigger>
<TabsTrigger value="scale">
<Maximize2 className="mr-1.5 h-3.5 w-3.5" /> Scară
</TabsTrigger>
<TabsTrigger value="rooms">
<LayoutGrid className="mr-1.5 h-3.5 w-3.5" /> Camere
</TabsTrigger>
</TabsList>
<TabsList className="flex h-auto flex-wrap gap-1 rounded-lg bg-muted/50 p-1">
{/* ── Documente & Unelte ── */}
@@ -2578,7 +2925,7 @@ export function MiniUtilitiesModule() {
<TabsContent value="tva">
<Card>
<CardHeader>
<CardTitle className="text-base">Calculator TVA (19%)</CardTitle>
<CardTitle className="text-base">Calculator TVA</CardTitle>
</CardHeader>
<CardContent>
<TvaCalculator />
@@ -2705,6 +3052,26 @@ export function MiniUtilitiesModule() {
</CardContent>
</Card>
</TabsContent>
<TabsContent value="scale">
<Card>
<CardHeader>
<CardTitle className="text-base">Calculator scară desen</CardTitle>
</CardHeader>
<CardContent>
<ScaleCalculator />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="rooms">
<Card>
<CardHeader>
<CardTitle className="text-base">Calculator suprafețe camere</CardTitle>
</CardHeader>
<CardContent>
<RoomAreaCalculator />
</CardContent>
</Card>
</TabsContent>
</Tabs>
);
}
@@ -22,6 +22,10 @@ import {
HardDrive,
MoreHorizontal,
QrCode,
Users,
UserPlus,
ChevronDown,
ChevronUp,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
@@ -50,7 +54,7 @@ import {
} from "@/shared/components/ui/dialog";
import { Switch } from "@/shared/components/ui/switch";
import type { CompanyId } from "@/core/auth/types";
import type { VaultEntry, VaultEntryCategory, CustomField } from "../types";
import type { VaultEntry, VaultEntryCategory, CustomField, VaultUser } from "../types";
import { useVault } from "../hooks/use-vault";
// Category definitions with icons
@@ -429,6 +433,17 @@ export function PasswordVaultModule() {
))}
</div>
)}
{entry.additionalUsers &&
entry.additionalUsers.length > 0 && (
<Badge
variant="outline"
className="text-[10px] gap-1"
>
<Users className="h-2.5 w-2.5" />
+{entry.additionalUsers.length} utilizator
{entry.additionalUsers.length > 1 ? "i" : ""}
</Badge>
)}
</div>
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{/* WiFi QR button */}
@@ -695,6 +710,12 @@ function VaultForm({
const [customFields, setCustomFields] = useState<CustomField[]>(
initial?.customFields ?? [],
);
const [additionalUsers, setAdditionalUsers] = useState<VaultUser[]>(
initial?.additionalUsers ?? [],
);
const [showAdditionalUsers, setShowAdditionalUsers] = useState(
(initial?.additionalUsers?.length ?? 0) > 0,
);
// Password generator state
const [genLength, setGenLength] = useState(16);
@@ -750,6 +771,7 @@ function VaultForm({
company,
notes,
customFields: customFields.filter((cf) => cf.key.trim()),
additionalUsers: additionalUsers.filter((u) => u.username.trim() || u.password.trim()),
tags: initial?.tags ?? [],
visibility: initial?.visibility ?? "admin",
});
@@ -1024,6 +1046,127 @@ function VaultForm({
)}
</div>
{/* Additional users */}
<div>
<button
type="button"
className="flex items-center gap-1.5 text-sm font-medium hover:text-primary transition-colors"
onClick={() => setShowAdditionalUsers((v) => !v)}
>
<Users className="h-3.5 w-3.5" />
Utilizatori suplimentari
{additionalUsers.length > 0 && (
<Badge variant="secondary" className="text-[10px] ml-1">
{additionalUsers.length}
</Badge>
)}
{showAdditionalUsers ? (
<ChevronUp className="h-3.5 w-3.5 ml-auto" />
) : (
<ChevronDown className="h-3.5 w-3.5 ml-auto" />
)}
</button>
{showAdditionalUsers && (
<div className="mt-2 space-y-2 rounded border p-3">
<p className="text-[11px] text-muted-foreground">
Adaugă credențiale suplimentare pentru același cont (ex: mai mulți angajați cu acces).
</p>
{additionalUsers.map((u, i) => (
<div key={i} className="space-y-1.5 rounded-sm border p-2 relative">
<button
type="button"
className="absolute right-1 top-1 text-destructive hover:opacity-80"
onClick={() =>
setAdditionalUsers((prev) => prev.filter((_, idx) => idx !== i))
}
>
<X className="h-3.5 w-3.5" />
</button>
<p className="text-xs font-medium text-muted-foreground">
Utilizator {i + 1}
</p>
<div className="grid gap-1.5 sm:grid-cols-2">
<div>
<Label className="text-xs">Utilizator / Titular</Label>
<Input
value={u.username}
onChange={(e) =>
setAdditionalUsers((prev) =>
prev.map((x, idx) =>
idx === i ? { ...x, username: e.target.value } : x,
),
)
}
className="mt-0.5 text-sm h-8"
placeholder="nume_utilizator"
/>
</div>
<div>
<Label className="text-xs">Email (opțional)</Label>
<Input
type="email"
value={u.email ?? ""}
onChange={(e) =>
setAdditionalUsers((prev) =>
prev.map((x, idx) =>
idx === i ? { ...x, email: e.target.value } : x,
),
)
}
className="mt-0.5 text-sm h-8"
placeholder="email@exemplu.ro"
/>
</div>
</div>
<div>
<Label className="text-xs">Parolă</Label>
<Input
value={u.password}
onChange={(e) =>
setAdditionalUsers((prev) =>
prev.map((x, idx) =>
idx === i ? { ...x, password: e.target.value } : x,
),
)
}
className="mt-0.5 text-sm font-mono h-8"
placeholder="parolă"
/>
</div>
<div>
<Label className="text-xs">Note (opțional)</Label>
<Input
value={u.notes ?? ""}
onChange={(e) =>
setAdditionalUsers((prev) =>
prev.map((x, idx) =>
idx === i ? { ...x, notes: e.target.value } : x,
),
)
}
className="mt-0.5 text-sm h-8"
placeholder="ex: contul lui Mihai"
/>
</div>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setAdditionalUsers((prev) => [
...prev,
{ username: "", password: "", email: "", notes: "" },
])
}
>
<UserPlus className="mr-1 h-3 w-3" /> Adaugă utilizator
</Button>
</div>
)}
</div>
<div>
<Label>Note</Label>
<Textarea
@@ -26,7 +26,11 @@ export function useVault() {
try {
const res = await fetch("/api/vault");
const data = await res.json();
const results = (data.entries ?? []) as VaultEntry[];
const results = ((data.entries ?? []) as VaultEntry[]).map((e) => ({
...e,
additionalUsers: e.additionalUsers ?? [],
customFields: e.customFields ?? [],
}));
results.sort((a, b) => a.label.localeCompare(b.label));
setEntries(results);
} catch (err) {
+10
View File
@@ -18,6 +18,14 @@ export interface CustomField {
value: string;
}
/** Additional user credential for multi-user entries */
export interface VaultUser {
username: string;
password: string;
email?: string;
notes?: string;
}
export interface VaultEntry {
id: string;
label: string;
@@ -29,6 +37,8 @@ export interface VaultEntry {
company: CompanyId;
/** Custom key-value fields */
customFields: CustomField[];
/** Additional users/credentials for shared accounts */
additionalUsers: VaultUser[];
notes: string;
tags: string[];
visibility: Visibility;