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 {
|
|||||||
ArrowDown,
|
ArrowDown,
|
||||||
Unlock,
|
Unlock,
|
||||||
PenTool,
|
PenTool,
|
||||||
|
Maximize2,
|
||||||
|
LayoutGrid,
|
||||||
|
Plus,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
@@ -212,13 +216,18 @@ function PercentageCalculator() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TVA_PRESETS = [5, 9, 19, 21];
|
||||||
|
|
||||||
function TvaCalculator() {
|
function TvaCalculator() {
|
||||||
const TVA_RATE = 19; // Romania standard VAT
|
const [tvaRate, setTvaRate] = useState(19);
|
||||||
|
const [customRate, setCustomRate] = useState("");
|
||||||
const [amount, setAmount] = useState("");
|
const [amount, setAmount] = useState("");
|
||||||
const [mode, setMode] = useState<"add" | "extract">("add");
|
const [mode, setMode] = useState<"add" | "extract">("add");
|
||||||
|
|
||||||
|
const effectiveRate =
|
||||||
|
customRate !== "" ? parseFloat(customRate) || tvaRate : tvaRate;
|
||||||
const val = parseFloat(amount);
|
const val = parseFloat(amount);
|
||||||
const tvaMultiplier = TVA_RATE / 100;
|
const tvaMultiplier = effectiveRate / 100;
|
||||||
|
|
||||||
const cuTva = !isNaN(val) && mode === "add" ? val * (1 + tvaMultiplier) : NaN;
|
const cuTva = !isNaN(val) && mode === "add" ? val * (1 + tvaMultiplier) : NaN;
|
||||||
const faraTva =
|
const faraTva =
|
||||||
@@ -239,6 +248,38 @@ function TvaCalculator() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant={mode === "add" ? "default" : "outline"}
|
variant={mode === "add" ? "default" : "outline"}
|
||||||
@@ -278,7 +319,7 @@ function TvaCalculator() {
|
|||||||
Sumă fără TVA: <strong>{fmt(val)} RON</strong>
|
Sumă fără TVA: <strong>{fmt(val)} RON</strong>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
TVA ({TVA_RATE}%): <strong>{fmt(tvaAmount)} RON</strong>
|
TVA ({effectiveRate}%): <strong>{fmt(tvaAmount)} RON</strong>
|
||||||
<CopyButton text={fmt(tvaAmount)} />
|
<CopyButton text={fmt(tvaAmount)} />
|
||||||
</p>
|
</p>
|
||||||
<p className="text-base pt-1 border-t">
|
<p className="text-base pt-1 border-t">
|
||||||
@@ -293,7 +334,7 @@ function TvaCalculator() {
|
|||||||
Sumă cu TVA: <strong>{fmt(val)} RON</strong>
|
Sumă cu TVA: <strong>{fmt(val)} RON</strong>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
TVA ({TVA_RATE}%): <strong>{fmt(tvaAmount)} RON</strong>
|
TVA ({effectiveRate}%): <strong>{fmt(tvaAmount)} RON</strong>
|
||||||
<CopyButton text={fmt(tvaAmount)} />
|
<CopyButton text={fmt(tvaAmount)} />
|
||||||
</p>
|
</p>
|
||||||
<p className="text-base pt-1 border-t">
|
<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)} m²`)
|
||||||
|
.join("\n");
|
||||||
|
const text = lines + `\n\nTotal: ${fmtM2(totalArea)} m²`;
|
||||||
|
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 ──────────────────────────────────────────────────────────────
|
// ─── Main Module ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function MiniUtilitiesModule() {
|
export function MiniUtilitiesModule() {
|
||||||
@@ -2521,6 +2862,12 @@ export function MiniUtilitiesModule() {
|
|||||||
<TabsTrigger value="mdlpa">
|
<TabsTrigger value="mdlpa">
|
||||||
<Building2 className="mr-1.5 h-3.5 w-3.5" /> MDLPA
|
<Building2 className="mr-1.5 h-3.5 w-3.5" /> MDLPA
|
||||||
</TabsTrigger>
|
</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>
|
||||||
<TabsList className="flex h-auto flex-wrap gap-1 rounded-lg bg-muted/50 p-1">
|
<TabsList className="flex h-auto flex-wrap gap-1 rounded-lg bg-muted/50 p-1">
|
||||||
{/* ── Documente & Unelte ── */}
|
{/* ── Documente & Unelte ── */}
|
||||||
@@ -2578,7 +2925,7 @@ export function MiniUtilitiesModule() {
|
|||||||
<TabsContent value="tva">
|
<TabsContent value="tva">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Calculator TVA (19%)</CardTitle>
|
<CardTitle className="text-base">Calculator TVA</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<TvaCalculator />
|
<TvaCalculator />
|
||||||
@@ -2705,6 +3052,26 @@ export function MiniUtilitiesModule() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</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>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ import {
|
|||||||
HardDrive,
|
HardDrive,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
QrCode,
|
QrCode,
|
||||||
|
Users,
|
||||||
|
UserPlus,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
@@ -50,7 +54,7 @@ import {
|
|||||||
} from "@/shared/components/ui/dialog";
|
} from "@/shared/components/ui/dialog";
|
||||||
import { Switch } from "@/shared/components/ui/switch";
|
import { Switch } from "@/shared/components/ui/switch";
|
||||||
import type { CompanyId } from "@/core/auth/types";
|
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";
|
import { useVault } from "../hooks/use-vault";
|
||||||
|
|
||||||
// Category definitions with icons
|
// Category definitions with icons
|
||||||
@@ -429,6 +433,17 @@ export function PasswordVaultModule() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
{/* WiFi QR button */}
|
{/* WiFi QR button */}
|
||||||
@@ -695,6 +710,12 @@ function VaultForm({
|
|||||||
const [customFields, setCustomFields] = useState<CustomField[]>(
|
const [customFields, setCustomFields] = useState<CustomField[]>(
|
||||||
initial?.customFields ?? [],
|
initial?.customFields ?? [],
|
||||||
);
|
);
|
||||||
|
const [additionalUsers, setAdditionalUsers] = useState<VaultUser[]>(
|
||||||
|
initial?.additionalUsers ?? [],
|
||||||
|
);
|
||||||
|
const [showAdditionalUsers, setShowAdditionalUsers] = useState(
|
||||||
|
(initial?.additionalUsers?.length ?? 0) > 0,
|
||||||
|
);
|
||||||
|
|
||||||
// Password generator state
|
// Password generator state
|
||||||
const [genLength, setGenLength] = useState(16);
|
const [genLength, setGenLength] = useState(16);
|
||||||
@@ -750,6 +771,7 @@ function VaultForm({
|
|||||||
company,
|
company,
|
||||||
notes,
|
notes,
|
||||||
customFields: customFields.filter((cf) => cf.key.trim()),
|
customFields: customFields.filter((cf) => cf.key.trim()),
|
||||||
|
additionalUsers: additionalUsers.filter((u) => u.username.trim() || u.password.trim()),
|
||||||
tags: initial?.tags ?? [],
|
tags: initial?.tags ?? [],
|
||||||
visibility: initial?.visibility ?? "admin",
|
visibility: initial?.visibility ?? "admin",
|
||||||
});
|
});
|
||||||
@@ -1024,6 +1046,127 @@ function VaultForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<Label>Note</Label>
|
<Label>Note</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ export function useVault() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch("/api/vault");
|
const res = await fetch("/api/vault");
|
||||||
const data = await res.json();
|
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));
|
results.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
setEntries(results);
|
setEntries(results);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ export interface CustomField {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Additional user credential for multi-user entries */
|
||||||
|
export interface VaultUser {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
email?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VaultEntry {
|
export interface VaultEntry {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -29,6 +37,8 @@ export interface VaultEntry {
|
|||||||
company: CompanyId;
|
company: CompanyId;
|
||||||
/** Custom key-value fields */
|
/** Custom key-value fields */
|
||||||
customFields: CustomField[];
|
customFields: CustomField[];
|
||||||
|
/** Additional users/credentials for shared accounts */
|
||||||
|
additionalUsers: VaultUser[];
|
||||||
notes: string;
|
notes: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
|
|||||||
Reference in New Issue
Block a user