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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user