d75fcb1d1c
The -dPDFSETTINGS=/screen GS preset overwrites font encoding tables, producing garbled text in output PDFs. Replace with individual params that ONLY compress images while preserving fonts intact. Three quality levels via GS (no Stirling dependency): - extreme: 100 DPI, QFactor 1.2 (~quality 35) - high: 150 DPI, QFactor 0.76 (~quality 50) - balanced: 200 DPI, QFactor 0.4 (~quality 70) Route all UI modes through the GS endpoint with level parameter. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2968 lines
94 KiB
TypeScript
2968 lines
94 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef, useCallback, useEffect } from "react";
|
||
import {
|
||
Copy,
|
||
Check,
|
||
Hash,
|
||
Type,
|
||
Percent,
|
||
Ruler,
|
||
Zap,
|
||
Wand2,
|
||
Building2,
|
||
FileDown,
|
||
ScanText,
|
||
CaseUpper,
|
||
Palette,
|
||
Upload,
|
||
Receipt,
|
||
Layers,
|
||
ArrowUp,
|
||
ArrowDown,
|
||
Unlock,
|
||
PenTool,
|
||
Maximize2,
|
||
} 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 {
|
||
Card,
|
||
CardContent,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/shared/components/ui/card";
|
||
import {
|
||
Tabs,
|
||
TabsContent,
|
||
TabsList,
|
||
TabsTrigger,
|
||
} from "@/shared/components/ui/tabs";
|
||
|
||
function CopyButton({ text }: { text: string }) {
|
||
const [copied, setCopied] = useState(false);
|
||
const handleCopy = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 1500);
|
||
} catch {
|
||
/* silent */
|
||
}
|
||
};
|
||
return (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7"
|
||
onClick={handleCopy}
|
||
disabled={!text}
|
||
>
|
||
{copied ? (
|
||
<Check className="h-3.5 w-3.5 text-green-500" />
|
||
) : (
|
||
<Copy className="h-3.5 w-3.5" />
|
||
)}
|
||
</Button>
|
||
);
|
||
}
|
||
|
||
function TextCaseConverter() {
|
||
const [input, setInput] = useState("");
|
||
const upper = input.toUpperCase();
|
||
const lower = input.toLowerCase();
|
||
const title = input.replace(/\b\w/g, (c) => c.toUpperCase());
|
||
const sentence = input.charAt(0).toUpperCase() + input.slice(1).toLowerCase();
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div>
|
||
<Label>Text sursă</Label>
|
||
<Textarea
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
rows={3}
|
||
className="mt-1"
|
||
placeholder="Introdu text..."
|
||
/>
|
||
</div>
|
||
{[
|
||
{ label: "UPPERCASE", value: upper },
|
||
{ label: "lowercase", value: lower },
|
||
{ label: "Title Case", value: title },
|
||
{ label: "Sentence case", value: sentence },
|
||
].map(({ label, value }) => (
|
||
<div key={label} className="flex items-center gap-2">
|
||
<code className="flex-1 truncate rounded border bg-muted/30 px-2 py-1 text-xs">
|
||
{value || "—"}
|
||
</code>
|
||
<span className="w-24 text-xs text-muted-foreground">{label}</span>
|
||
<CopyButton text={value} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CharacterCounter() {
|
||
const [input, setInput] = useState("");
|
||
const chars = input.length;
|
||
const charsNoSpaces = input.replace(/\s/g, "").length;
|
||
const words = input.trim() ? input.trim().split(/\s+/).length : 0;
|
||
const lines = input ? input.split("\n").length : 0;
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div>
|
||
<Label>Text</Label>
|
||
<Textarea
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
rows={5}
|
||
className="mt-1"
|
||
placeholder="Introdu text..."
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||
<Card>
|
||
<CardContent className="p-3">
|
||
<p className="text-xs text-muted-foreground">Caractere</p>
|
||
<p className="text-xl font-bold">{chars}</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="p-3">
|
||
<p className="text-xs text-muted-foreground">Fără spații</p>
|
||
<p className="text-xl font-bold">{charsNoSpaces}</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="p-3">
|
||
<p className="text-xs text-muted-foreground">Cuvinte</p>
|
||
<p className="text-xl font-bold">{words}</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="p-3">
|
||
<p className="text-xs text-muted-foreground">Linii</p>
|
||
<p className="text-xl font-bold">{lines}</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PercentageCalculator() {
|
||
const [value, setValue] = useState("");
|
||
const [total, setTotal] = useState("");
|
||
const [percent, setPercent] = useState("");
|
||
|
||
const v = parseFloat(value);
|
||
const t = parseFloat(total);
|
||
const p = parseFloat(percent);
|
||
|
||
const pctOfTotal =
|
||
!isNaN(v) && !isNaN(t) && t !== 0 ? ((v / t) * 100).toFixed(2) : "—";
|
||
const valFromPct = !isNaN(p) && !isNaN(t) ? ((p / 100) * t).toFixed(2) : "—";
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="grid gap-3 sm:grid-cols-3">
|
||
<div>
|
||
<Label>Valoare</Label>
|
||
<Input
|
||
type="number"
|
||
value={value}
|
||
onChange={(e) => setValue(e.target.value)}
|
||
className="mt-1"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label>Total</Label>
|
||
<Input
|
||
type="number"
|
||
value={total}
|
||
onChange={(e) => setTotal(e.target.value)}
|
||
className="mt-1"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label>Procent</Label>
|
||
<Input
|
||
type="number"
|
||
value={percent}
|
||
onChange={(e) => setPercent(e.target.value)}
|
||
className="mt-1"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2 rounded-md border bg-muted/30 p-3 text-sm">
|
||
<p>
|
||
<strong>{value || "?"}</strong> din <strong>{total || "?"}</strong> ={" "}
|
||
<strong>{pctOfTotal}%</strong>
|
||
</p>
|
||
<p>
|
||
<strong>{percent || "?"}%</strong> din <strong>{total || "?"}</strong>{" "}
|
||
= <strong>{valFromPct}</strong>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const TVA_PRESETS = [5, 9, 19, 21];
|
||
|
||
function TvaCalculator() {
|
||
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 = effectiveRate / 100;
|
||
|
||
const cuTva = !isNaN(val) && mode === "add" ? val * (1 + tvaMultiplier) : NaN;
|
||
const faraTva =
|
||
!isNaN(val) && mode === "extract" ? val / (1 + tvaMultiplier) : NaN;
|
||
const tvaAmount = !isNaN(val)
|
||
? mode === "add"
|
||
? val * tvaMultiplier
|
||
: val - val / (1 + tvaMultiplier)
|
||
: NaN;
|
||
|
||
const fmt = (n: number) =>
|
||
isNaN(n)
|
||
? "—"
|
||
: n.toLocaleString("ro-RO", {
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2,
|
||
});
|
||
|
||
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"}
|
||
size="sm"
|
||
onClick={() => setMode("add")}
|
||
>
|
||
Adaugă TVA
|
||
</Button>
|
||
<Button
|
||
variant={mode === "extract" ? "default" : "outline"}
|
||
size="sm"
|
||
onClick={() => setMode("extract")}
|
||
>
|
||
Extrage TVA
|
||
</Button>
|
||
</div>
|
||
<div>
|
||
<Label>{mode === "add" ? "Sumă fără TVA" : "Sumă cu TVA"}</Label>
|
||
<div className="mt-1 flex gap-2">
|
||
<Input
|
||
type="number"
|
||
value={amount}
|
||
onChange={(e) => setAmount(e.target.value)}
|
||
placeholder={mode === "add" ? "Ex: 1000" : "Ex: 1190"}
|
||
className="flex-1"
|
||
/>
|
||
<span className="flex items-center text-sm text-muted-foreground">
|
||
RON
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{!isNaN(val) && val > 0 && (
|
||
<div className="rounded-md border bg-muted/30 p-4 space-y-2 text-sm">
|
||
{mode === "add" ? (
|
||
<>
|
||
<p>
|
||
Sumă fără TVA: <strong>{fmt(val)} RON</strong>
|
||
</p>
|
||
<p>
|
||
TVA ({effectiveRate}%): <strong>{fmt(tvaAmount)} RON</strong>
|
||
<CopyButton text={fmt(tvaAmount)} />
|
||
</p>
|
||
<p className="text-base pt-1 border-t">
|
||
Total cu TVA:{" "}
|
||
<strong className="text-primary">{fmt(cuTva)} RON</strong>
|
||
<CopyButton text={fmt(cuTva)} />
|
||
</p>
|
||
</>
|
||
) : (
|
||
<>
|
||
<p>
|
||
Sumă cu TVA: <strong>{fmt(val)} RON</strong>
|
||
</p>
|
||
<p>
|
||
TVA ({effectiveRate}%): <strong>{fmt(tvaAmount)} RON</strong>
|
||
<CopyButton text={fmt(tvaAmount)} />
|
||
</p>
|
||
<p className="text-base pt-1 border-t">
|
||
Sumă fără TVA:{" "}
|
||
<strong className="text-primary">{fmt(faraTva)} RON</strong>
|
||
<CopyButton text={fmt(faraTva)} />
|
||
</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AreaConverter() {
|
||
const units = [
|
||
{ key: "mp", label: "mp (m²)", factor: 1 },
|
||
{ key: "ari", label: "ari (100 m²)", factor: 100 },
|
||
{ key: "ha", label: "hectare (10.000 m²)", factor: 10000 },
|
||
{ key: "km2", label: "km²", factor: 1000000 },
|
||
{ key: "sqft", label: "sq ft", factor: 1 / 10.7639 },
|
||
] as const;
|
||
|
||
const [values, setValues] = useState<Record<string, string>>({});
|
||
const [activeField, setActiveField] = useState<string | null>(null);
|
||
|
||
const handleChange = (key: string, raw: string) => {
|
||
if (raw === "") {
|
||
setValues({});
|
||
return;
|
||
}
|
||
const v = parseFloat(raw);
|
||
if (isNaN(v)) return;
|
||
const unit = units.find((u) => u.key === key);
|
||
if (!unit) return;
|
||
const mp = v * unit.factor;
|
||
const next: Record<string, string> = {};
|
||
for (const u of units) {
|
||
next[u.key] =
|
||
u.key === key
|
||
? raw
|
||
: (mp / u.factor).toPrecision(8).replace(/\.?0+$/, "");
|
||
}
|
||
setValues(next);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<p className="text-xs text-muted-foreground">
|
||
Introdu o valoare în orice câmp — celelalte se calculează automat.
|
||
</p>
|
||
<div className="space-y-2">
|
||
{units.map((u) => (
|
||
<div key={u.key} className="flex items-center gap-2">
|
||
<span className="w-36 text-xs text-muted-foreground">
|
||
{u.label}
|
||
</span>
|
||
<Input
|
||
type="number"
|
||
value={values[u.key] ?? ""}
|
||
onFocus={() => setActiveField(u.key)}
|
||
onBlur={() => setActiveField(null)}
|
||
onChange={(e) => handleChange(u.key, e.target.value)}
|
||
className={`flex-1 ${activeField === u.key ? "ring-1 ring-primary" : ""}`}
|
||
placeholder="0"
|
||
/>
|
||
<CopyButton text={values[u.key] ?? ""} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── U-value → R-value Converter ─────────────────────────────────────────────
|
||
|
||
function UValueConverter() {
|
||
const [mode, setMode] = useState<"u-to-r" | "r-to-u">("u-to-r");
|
||
const [uValue, setUValue] = useState("");
|
||
const [rInput, setRInput] = useState("");
|
||
const [thickness, setThickness] = useState("");
|
||
|
||
const rsi = 0.13;
|
||
const rse = 0.04;
|
||
|
||
// U → R
|
||
const u = parseFloat(uValue);
|
||
const rFromU = !isNaN(u) && u > 0 ? (1 / u).toFixed(4) : null;
|
||
const rTotalFromU =
|
||
rFromU !== null ? (parseFloat(rFromU) + rsi + rse).toFixed(4) : null;
|
||
|
||
// R → U
|
||
const rIn = parseFloat(rInput);
|
||
const uFromR = !isNaN(rIn) && rIn > 0 ? (1 / rIn).toFixed(4) : null;
|
||
const rTotalFromR = uFromR !== null ? (rIn + rsi + rse).toFixed(4) : null;
|
||
|
||
const activeR = mode === "u-to-r" ? rFromU : rInput || null;
|
||
const activeU = mode === "u-to-r" ? uValue || null : uFromR;
|
||
const activeRTotal = mode === "u-to-r" ? rTotalFromU : rTotalFromR;
|
||
|
||
const t = parseFloat(thickness);
|
||
const lambda =
|
||
activeR !== null && !isNaN(t) && t > 0
|
||
? (t / 100 / parseFloat(activeR)).toFixed(4)
|
||
: null;
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant={mode === "u-to-r" ? "default" : "outline"}
|
||
size="sm"
|
||
onClick={() => {
|
||
setMode("u-to-r");
|
||
setRInput("");
|
||
}}
|
||
>
|
||
U → R
|
||
</Button>
|
||
<Button
|
||
variant={mode === "r-to-u" ? "default" : "outline"}
|
||
size="sm"
|
||
onClick={() => {
|
||
setMode("r-to-u");
|
||
setUValue("");
|
||
}}
|
||
>
|
||
R → U
|
||
</Button>
|
||
</div>
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
{mode === "u-to-r" ? (
|
||
<div>
|
||
<Label>Coeficient U (W/m²K)</Label>
|
||
<Input
|
||
type="number"
|
||
step="0.01"
|
||
min="0"
|
||
value={uValue}
|
||
onChange={(e) => setUValue(e.target.value)}
|
||
className="mt-1"
|
||
placeholder="ex: 0.35"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<Label>Rezistență termică R (m²K/W)</Label>
|
||
<Input
|
||
type="number"
|
||
step="0.01"
|
||
min="0"
|
||
value={rInput}
|
||
onChange={(e) => setRInput(e.target.value)}
|
||
className="mt-1"
|
||
placeholder="ex: 2.86"
|
||
/>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<Label>Grosime material (cm) — opțional</Label>
|
||
<Input
|
||
type="number"
|
||
step="0.1"
|
||
min="0"
|
||
value={thickness}
|
||
onChange={(e) => setThickness(e.target.value)}
|
||
className="mt-1"
|
||
placeholder="ex: 20"
|
||
/>
|
||
</div>
|
||
</div>
|
||
{(activeR !== null || activeU !== null) && (
|
||
<div className="space-y-2 rounded-md border bg-muted/30 p-3 text-sm">
|
||
{mode === "u-to-r" && rFromU !== null && (
|
||
<div className="flex items-center justify-between">
|
||
<span className="font-medium">R = 1/U</span>
|
||
<div className="flex items-center gap-1">
|
||
<code className="rounded border bg-muted px-2 py-0.5">
|
||
{rFromU} m²K/W
|
||
</code>
|
||
<CopyButton text={rFromU} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
{mode === "r-to-u" && uFromR !== null && (
|
||
<div className="flex items-center justify-between">
|
||
<span className="font-medium">U = 1/R</span>
|
||
<div className="flex items-center gap-1">
|
||
<code className="rounded border bg-muted px-2 py-0.5">
|
||
{uFromR} W/m²K
|
||
</code>
|
||
<CopyButton text={uFromR} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="flex items-center justify-between text-muted-foreground">
|
||
<span>Rsi (suprafață interioară)</span>
|
||
<code className="rounded border bg-muted px-2 py-0.5">
|
||
{rsi} m²K/W
|
||
</code>
|
||
</div>
|
||
<div className="flex items-center justify-between text-muted-foreground">
|
||
<span>Rse (suprafață exterioară)</span>
|
||
<code className="rounded border bg-muted px-2 py-0.5">
|
||
{rse} m²K/W
|
||
</code>
|
||
</div>
|
||
{activeRTotal !== null && (
|
||
<div className="flex items-center justify-between font-medium border-t pt-2 mt-1">
|
||
<span>R total (cu Rsi + Rse)</span>
|
||
<div className="flex items-center gap-1">
|
||
<code className="rounded border bg-muted px-2 py-0.5">
|
||
{activeRTotal} m²K/W
|
||
</code>
|
||
<CopyButton text={activeRTotal} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
{lambda !== null && (
|
||
<div className="flex items-center justify-between text-muted-foreground border-t pt-2 mt-1">
|
||
<span>Conductivitate λ = d/R</span>
|
||
<div className="flex items-center gap-1">
|
||
<code className="rounded border bg-muted px-2 py-0.5">
|
||
{lambda} W/mK
|
||
</code>
|
||
<CopyButton text={lambda} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Material Thermal Comparison ─────────────────────────────────────────────
|
||
|
||
interface ThermalMaterial {
|
||
id: string;
|
||
name: string;
|
||
lambda: number; // W/mK — design value (λD) from Romanian technical sheets
|
||
category: string;
|
||
source: string; // brand / standard reference
|
||
}
|
||
|
||
// λD values from Romanian-market technical data sheets (fișe tehnice)
|
||
// Sources: Austrotherm, ISOVER, Knauf Insulation, Ytong/Xella, Porotherm/Wienerberger,
|
||
// Baumit, Caparol, Tondach, SR EN ISO 10456, C107
|
||
const THERMAL_MATERIALS: ThermalMaterial[] = [
|
||
// Termoizolații
|
||
{
|
||
id: "eps-70",
|
||
name: "Polistiren expandat EPS 70",
|
||
lambda: 0.037,
|
||
category: "Termoizolații",
|
||
source: "Austrotherm EPS A70",
|
||
},
|
||
{
|
||
id: "eps-80",
|
||
name: "Polistiren expandat EPS 80",
|
||
lambda: 0.035,
|
||
category: "Termoizolații",
|
||
source: "Austrotherm EPS A80",
|
||
},
|
||
{
|
||
id: "eps-100",
|
||
name: "Polistiren expandat EPS 100",
|
||
lambda: 0.033,
|
||
category: "Termoizolații",
|
||
source: "Austrotherm EPS A100",
|
||
},
|
||
{
|
||
id: "eps-graf",
|
||
name: "Polistiren grafitat (EPS-G)",
|
||
lambda: 0.031,
|
||
category: "Termoizolații",
|
||
source: "Austrotherm EPS A150 Grafit",
|
||
},
|
||
{
|
||
id: "xps-30",
|
||
name: "Polistiren extrudat XPS 30",
|
||
lambda: 0.034,
|
||
category: "Termoizolații",
|
||
source: "Austrotherm XPS TOP 30",
|
||
},
|
||
{
|
||
id: "xps-50",
|
||
name: "Polistiren extrudat XPS 50",
|
||
lambda: 0.036,
|
||
category: "Termoizolații",
|
||
source: "Austrotherm XPS TOP 50",
|
||
},
|
||
{
|
||
id: "vata-min",
|
||
name: "Vată minerală bazaltică",
|
||
lambda: 0.036,
|
||
category: "Termoizolații",
|
||
source: "ISOVER PLE / Knauf TP 136",
|
||
},
|
||
{
|
||
id: "vata-stc",
|
||
name: "Vată minerală de sticlă",
|
||
lambda: 0.035,
|
||
category: "Termoizolații",
|
||
source: "ISOVER Uniroll Plus",
|
||
},
|
||
{
|
||
id: "pu-spray",
|
||
name: "Spumă poliuretanică (PUR)",
|
||
lambda: 0.024,
|
||
category: "Termoizolații",
|
||
source: "Basf Elastopor",
|
||
},
|
||
{
|
||
id: "pir",
|
||
name: "Plăci PIR (poliizocianurat)",
|
||
lambda: 0.022,
|
||
category: "Termoizolații",
|
||
source: "Pir/Kingspan",
|
||
},
|
||
// Zidărie
|
||
{
|
||
id: "bca-35",
|
||
name: "BCA densitate 350 kg/m³",
|
||
lambda: 0.09,
|
||
category: "Zidărie",
|
||
source: "Ytong A+ PP2/0.35",
|
||
},
|
||
{
|
||
id: "bca-40",
|
||
name: "BCA densitate 400 kg/m³",
|
||
lambda: 0.1,
|
||
category: "Zidărie",
|
||
source: "Ytong / Celco 400",
|
||
},
|
||
{
|
||
id: "bca-50",
|
||
name: "BCA densitate 500 kg/m³",
|
||
lambda: 0.13,
|
||
category: "Zidărie",
|
||
source: "Celco A+ 500",
|
||
},
|
||
{
|
||
id: "bca-60",
|
||
name: "BCA densitate 600 kg/m³",
|
||
lambda: 0.16,
|
||
category: "Zidărie",
|
||
source: "Ytong PP4/0.6",
|
||
},
|
||
{
|
||
id: "poro-30",
|
||
name: "Porotherm 30 N+F",
|
||
lambda: 0.21,
|
||
category: "Zidărie",
|
||
source: "Wienerberger Porotherm 30",
|
||
},
|
||
{
|
||
id: "poro-38",
|
||
name: "Porotherm 38 Thermo",
|
||
lambda: 0.16,
|
||
category: "Zidărie",
|
||
source: "Wienerberger Porotherm 38 Thermo",
|
||
},
|
||
{
|
||
id: "poro-44",
|
||
name: "Porotherm 44 Thermo",
|
||
lambda: 0.13,
|
||
category: "Zidărie",
|
||
source: "Wienerberger Porotherm 44 Thermo",
|
||
},
|
||
{
|
||
id: "car-plin",
|
||
name: "Cărămidă plină presată",
|
||
lambda: 0.74,
|
||
category: "Zidărie",
|
||
source: "C107 / SR EN ISO 10456",
|
||
},
|
||
{
|
||
id: "car-gol",
|
||
name: "Cărămidă cu goluri G25",
|
||
lambda: 0.46,
|
||
category: "Zidărie",
|
||
source: "C107 / SR EN ISO 10456",
|
||
},
|
||
// Tencuieli / Finisaje
|
||
{
|
||
id: "tenc-term",
|
||
name: "Tencuială termoizolantă",
|
||
lambda: 0.08,
|
||
category: "Tencuieli",
|
||
source: "Baumit ThermoExtra",
|
||
},
|
||
{
|
||
id: "tenc-var",
|
||
name: "Tencuială var-ciment",
|
||
lambda: 0.87,
|
||
category: "Tencuieli",
|
||
source: "C107 / SR EN ISO 10456",
|
||
},
|
||
{
|
||
id: "tenc-cem",
|
||
name: "Mortar de ciment",
|
||
lambda: 1.0,
|
||
category: "Tencuieli",
|
||
source: "C107 / SR EN ISO 10456",
|
||
},
|
||
{
|
||
id: "gips-cart",
|
||
name: "Gips-carton",
|
||
lambda: 0.25,
|
||
category: "Tencuieli",
|
||
source: "Knauf / Rigips",
|
||
},
|
||
{
|
||
id: "tenc-dec",
|
||
name: "Tencuială decorativă",
|
||
lambda: 0.7,
|
||
category: "Tencuieli",
|
||
source: "Baumit / Caparol",
|
||
},
|
||
// Structură
|
||
{
|
||
id: "beton",
|
||
name: "Beton armat",
|
||
lambda: 1.74,
|
||
category: "Structură",
|
||
source: "C107 / SR EN ISO 10456",
|
||
},
|
||
{
|
||
id: "lemn-mol",
|
||
name: "Lemn molid/brad",
|
||
lambda: 0.14,
|
||
category: "Structură",
|
||
source: "C107 / SR EN ISO 10456",
|
||
},
|
||
{
|
||
id: "osb",
|
||
name: "Plăci OSB",
|
||
lambda: 0.13,
|
||
category: "Structură",
|
||
source: "Egger / Kronospan",
|
||
},
|
||
{
|
||
id: "clt",
|
||
name: "Lemn CLT (cross-laminated)",
|
||
lambda: 0.12,
|
||
category: "Structură",
|
||
source: "Stora Enso / Binderholz",
|
||
},
|
||
// Hidroizolații / Bariere
|
||
{
|
||
id: "bpv",
|
||
name: "Barieră de vapori (PE)",
|
||
lambda: 0.4,
|
||
category: "Bariere/Hidro",
|
||
source: "Bramac / Dörken Delta",
|
||
},
|
||
{
|
||
id: "hidro-bit",
|
||
name: "Membrană bituminoasă",
|
||
lambda: 0.23,
|
||
category: "Bariere/Hidro",
|
||
source: "IKO Armourbase / Siplast",
|
||
},
|
||
{
|
||
id: "hidro-pvc",
|
||
name: "Membrană PVC/TPO",
|
||
lambda: 0.16,
|
||
category: "Bariere/Hidro",
|
||
source: "Sika Sarnafil / Renolit",
|
||
},
|
||
{
|
||
id: "folie-difuz",
|
||
name: "Folie difuzie (sub-acoperiș)",
|
||
lambda: 0.4,
|
||
category: "Bariere/Hidro",
|
||
source: "Bramac Pro / Dörken",
|
||
},
|
||
// Pardoseli / Învelitori
|
||
{
|
||
id: "sapa-cem",
|
||
name: "Șapă ciment",
|
||
lambda: 1.4,
|
||
category: "Pardoseli",
|
||
source: "C107 / SR EN ISO 10456",
|
||
},
|
||
{
|
||
id: "sapa-anh",
|
||
name: "Șapă anhidrit",
|
||
lambda: 1.2,
|
||
category: "Pardoseli",
|
||
source: "Knauf FE 80 Eco",
|
||
},
|
||
{
|
||
id: "gresie",
|
||
name: "Gresie/Faianță ceramică",
|
||
lambda: 1.3,
|
||
category: "Pardoseli",
|
||
source: "C107 / SR EN ISO 10456",
|
||
},
|
||
{
|
||
id: "parchet",
|
||
name: "Parchet lemn stejar",
|
||
lambda: 0.18,
|
||
category: "Pardoseli",
|
||
source: "C107 / SR EN ISO 10456",
|
||
},
|
||
{
|
||
id: "tigla-cer",
|
||
name: "Țiglă ceramică",
|
||
lambda: 1.0,
|
||
category: "Pardoseli",
|
||
source: "Tondach / Bramac",
|
||
},
|
||
{
|
||
id: "tigla-bet",
|
||
name: "Țiglă beton",
|
||
lambda: 1.5,
|
||
category: "Pardoseli",
|
||
source: "Bramac",
|
||
},
|
||
{
|
||
id: "tabl-falt",
|
||
name: "Tablă fălțuită (oțel)",
|
||
lambda: 50.0,
|
||
category: "Pardoseli",
|
||
source: "Lindab / Bilka",
|
||
},
|
||
// Umpluturi
|
||
{
|
||
id: "pietris",
|
||
name: "Pietriș/Balast compactat",
|
||
lambda: 2.0,
|
||
category: "Umpluturi",
|
||
source: "C107 / SR EN ISO 10456",
|
||
},
|
||
{
|
||
id: "nisip",
|
||
name: "Nisip compactat",
|
||
lambda: 2.0,
|
||
category: "Umpluturi",
|
||
source: "C107 / SR EN ISO 10456",
|
||
},
|
||
{
|
||
id: "argila-exp",
|
||
name: "Argilă expandată (Leca)",
|
||
lambda: 0.1,
|
||
category: "Umpluturi",
|
||
source: "Leca / Weber Saint-Gobain",
|
||
},
|
||
{
|
||
id: "aer-inch",
|
||
name: "Strat de aer închis (ventilat)",
|
||
lambda: 0.025,
|
||
category: "Umpluturi",
|
||
source: "C107 (Rconvențional)",
|
||
},
|
||
];
|
||
|
||
interface LayerEntry {
|
||
materialId: string;
|
||
thickness: number; // cm
|
||
}
|
||
|
||
interface CompositionTemplate {
|
||
name: string;
|
||
description: string;
|
||
category: string;
|
||
layers: LayerEntry[];
|
||
}
|
||
|
||
const COMPOSITION_TEMPLATES: CompositionTemplate[] = [
|
||
// Pereți exteriori
|
||
{
|
||
name: "Perete BCA 30 + EPS 10",
|
||
description: "Sistem clasic ETICS cu BCA",
|
||
category: "Pereți exteriori",
|
||
layers: [
|
||
{ materialId: "tenc-var", thickness: 1.5 },
|
||
{ materialId: "bca-40", thickness: 30 },
|
||
{ materialId: "eps-80", thickness: 10 },
|
||
{ materialId: "tenc-dec", thickness: 0.5 },
|
||
],
|
||
},
|
||
{
|
||
name: "Perete BCA 30 + vată 10",
|
||
description: "ETICS cu vată minerală (A1 foc)",
|
||
category: "Pereți exteriori",
|
||
layers: [
|
||
{ materialId: "tenc-var", thickness: 1.5 },
|
||
{ materialId: "bca-40", thickness: 30 },
|
||
{ materialId: "vata-min", thickness: 10 },
|
||
{ materialId: "tenc-dec", thickness: 0.5 },
|
||
],
|
||
},
|
||
{
|
||
name: "Perete Porotherm 30 + EPS 10",
|
||
description: "Ceramică + polistiren",
|
||
category: "Pereți exteriori",
|
||
layers: [
|
||
{ materialId: "tenc-var", thickness: 1.5 },
|
||
{ materialId: "poro-30", thickness: 30 },
|
||
{ materialId: "eps-80", thickness: 10 },
|
||
{ materialId: "tenc-dec", thickness: 0.5 },
|
||
],
|
||
},
|
||
{
|
||
name: "Perete Porotherm 38 Thermo neizolat",
|
||
description: "Bloc ceramic termoizolant, fără ETICS",
|
||
category: "Pereți exteriori",
|
||
layers: [
|
||
{ materialId: "tenc-var", thickness: 1.5 },
|
||
{ materialId: "poro-38", thickness: 38 },
|
||
{ materialId: "tenc-dec", thickness: 0.5 },
|
||
],
|
||
},
|
||
{
|
||
name: "Perete Porotherm 44 Thermo neizolat",
|
||
description: "Bloc ceramic termoizolant gros",
|
||
category: "Pereți exteriori",
|
||
layers: [
|
||
{ materialId: "tenc-var", thickness: 1.5 },
|
||
{ materialId: "poro-44", thickness: 44 },
|
||
{ materialId: "tenc-dec", thickness: 0.5 },
|
||
],
|
||
},
|
||
{
|
||
name: "Perete cărămidă + tencuială termoizolantă",
|
||
description: "Renovare cu termoizolantă Baumit",
|
||
category: "Pereți exteriori",
|
||
layers: [
|
||
{ materialId: "tenc-var", thickness: 1.5 },
|
||
{ materialId: "car-gol", thickness: 25 },
|
||
{ materialId: "tenc-term", thickness: 8 },
|
||
{ materialId: "tenc-dec", thickness: 0.5 },
|
||
],
|
||
},
|
||
{
|
||
name: "Perete lemn CLT + vată",
|
||
description: "Structură CLT cu izolație exterioară",
|
||
category: "Pereți exteriori",
|
||
layers: [
|
||
{ materialId: "gips-cart", thickness: 1.25 },
|
||
{ materialId: "clt", thickness: 10 },
|
||
{ materialId: "vata-min", thickness: 15 },
|
||
{ materialId: "folie-difuz", thickness: 0.05 },
|
||
{ materialId: "tenc-dec", thickness: 0.5 },
|
||
],
|
||
},
|
||
// Pereți subsol
|
||
{
|
||
name: "Perete subsol beton + XPS",
|
||
description: "Fundație beton cu izolație XPS în pământ",
|
||
category: "Pereți subsol",
|
||
layers: [
|
||
{ materialId: "tenc-var", thickness: 1.5 },
|
||
{ materialId: "beton", thickness: 25 },
|
||
{ materialId: "hidro-bit", thickness: 0.5 },
|
||
{ materialId: "xps-30", thickness: 10 },
|
||
],
|
||
},
|
||
{
|
||
name: "Perete subsol beton + XPS 15",
|
||
description: "Fundație cu izolație sporită",
|
||
category: "Pereți subsol",
|
||
layers: [
|
||
{ materialId: "tenc-var", thickness: 1.5 },
|
||
{ materialId: "beton", thickness: 30 },
|
||
{ materialId: "hidro-bit", thickness: 0.5 },
|
||
{ materialId: "xps-30", thickness: 15 },
|
||
],
|
||
},
|
||
// Terase / Acoperișuri plate
|
||
{
|
||
name: "Terasă necirculabilă — clasic",
|
||
description: "Placa beton + termoizolație + hidroizolație",
|
||
category: "Terase",
|
||
layers: [
|
||
{ materialId: "tenc-var", thickness: 1.5 },
|
||
{ materialId: "beton", thickness: 15 },
|
||
{ materialId: "bpv", thickness: 0.05 },
|
||
{ materialId: "eps-100", thickness: 15 },
|
||
{ materialId: "sapa-cem", thickness: 4 },
|
||
{ materialId: "hidro-bit", thickness: 1 },
|
||
],
|
||
},
|
||
{
|
||
name: "Terasă necirculabilă — PIR",
|
||
description: "Cu PIR pentru grosime minimă",
|
||
category: "Terase",
|
||
layers: [
|
||
{ materialId: "tenc-var", thickness: 1.5 },
|
||
{ materialId: "beton", thickness: 15 },
|
||
{ materialId: "bpv", thickness: 0.05 },
|
||
{ materialId: "pir", thickness: 10 },
|
||
{ materialId: "sapa-cem", thickness: 4 },
|
||
{ materialId: "hidro-bit", thickness: 1 },
|
||
],
|
||
},
|
||
{
|
||
name: "Terasă circulabilă — gresie",
|
||
description: "Terasă cu finisaj gresie pe plot",
|
||
category: "Terase",
|
||
layers: [
|
||
{ materialId: "tenc-var", thickness: 1.5 },
|
||
{ materialId: "beton", thickness: 15 },
|
||
{ materialId: "bpv", thickness: 0.05 },
|
||
{ materialId: "eps-100", thickness: 15 },
|
||
{ materialId: "sapa-cem", thickness: 5 },
|
||
{ materialId: "hidro-bit", thickness: 1 },
|
||
{ materialId: "gresie", thickness: 2 },
|
||
],
|
||
},
|
||
{
|
||
name: "Acoperiș verde extensiv",
|
||
description: "Strat vegetal pe terasă",
|
||
category: "Terase",
|
||
layers: [
|
||
{ materialId: "tenc-var", thickness: 1.5 },
|
||
{ materialId: "beton", thickness: 15 },
|
||
{ materialId: "bpv", thickness: 0.05 },
|
||
{ materialId: "xps-30", thickness: 15 },
|
||
{ materialId: "sapa-cem", thickness: 4 },
|
||
{ materialId: "hidro-pvc", thickness: 0.2 },
|
||
{ materialId: "argila-exp", thickness: 8 },
|
||
],
|
||
},
|
||
// Șarpante
|
||
{
|
||
name: "Șarpantă lemn + vată între căpriori",
|
||
description: "Izolație între și sub căpriori, clasic",
|
||
category: "Șarpante",
|
||
layers: [
|
||
{ materialId: "gips-cart", thickness: 1.25 },
|
||
{ materialId: "bpv", thickness: 0.05 },
|
||
{ materialId: "vata-min", thickness: 20 },
|
||
{ materialId: "osb", thickness: 1.2 },
|
||
{ materialId: "folie-difuz", thickness: 0.05 },
|
||
{ materialId: "aer-inch", thickness: 4 },
|
||
{ materialId: "tigla-cer", thickness: 2 },
|
||
],
|
||
},
|
||
{
|
||
name: "Șarpantă lemn + vată 30cm",
|
||
description: "Izolație sporită suprapusă",
|
||
category: "Șarpante",
|
||
layers: [
|
||
{ materialId: "gips-cart", thickness: 1.25 },
|
||
{ materialId: "bpv", thickness: 0.05 },
|
||
{ materialId: "vata-min", thickness: 30 },
|
||
{ materialId: "osb", thickness: 1.2 },
|
||
{ materialId: "folie-difuz", thickness: 0.05 },
|
||
{ materialId: "aer-inch", thickness: 4 },
|
||
{ materialId: "tigla-cer", thickness: 2 },
|
||
],
|
||
},
|
||
{
|
||
name: "Șarpantă cu tablă fălțuită",
|
||
description: "Acoperiș metalic cu izolație vată",
|
||
category: "Șarpante",
|
||
layers: [
|
||
{ materialId: "gips-cart", thickness: 1.25 },
|
||
{ materialId: "bpv", thickness: 0.05 },
|
||
{ materialId: "vata-min", thickness: 25 },
|
||
{ materialId: "osb", thickness: 1.2 },
|
||
{ materialId: "folie-difuz", thickness: 0.05 },
|
||
{ materialId: "aer-inch", thickness: 4 },
|
||
{ materialId: "tabl-falt", thickness: 0.06 },
|
||
],
|
||
},
|
||
// Plăci pe sol
|
||
{
|
||
name: "Placă pe sol — EPS sub placă",
|
||
description: "Izolație sub radier, clasic",
|
||
category: "Plăci pe sol",
|
||
layers: [
|
||
{ materialId: "parchet", thickness: 1.5 },
|
||
{ materialId: "sapa-cem", thickness: 6 },
|
||
{ materialId: "bpv", thickness: 0.05 },
|
||
{ materialId: "eps-100", thickness: 10 },
|
||
{ materialId: "hidro-bit", thickness: 0.5 },
|
||
{ materialId: "beton", thickness: 15 },
|
||
{ materialId: "pietris", thickness: 15 },
|
||
],
|
||
},
|
||
{
|
||
name: "Placă pe sol — XPS sub placă",
|
||
description: "XPS rezistent la umiditate",
|
||
category: "Plăci pe sol",
|
||
layers: [
|
||
{ materialId: "gresie", thickness: 1 },
|
||
{ materialId: "sapa-cem", thickness: 6 },
|
||
{ materialId: "bpv", thickness: 0.05 },
|
||
{ materialId: "xps-30", thickness: 10 },
|
||
{ materialId: "hidro-bit", thickness: 0.5 },
|
||
{ materialId: "beton", thickness: 15 },
|
||
{ materialId: "pietris", thickness: 15 },
|
||
],
|
||
},
|
||
{
|
||
name: "Placă pe sol — izolație sporită 15cm",
|
||
description: "Pentru clădiri nZEB",
|
||
category: "Plăci pe sol",
|
||
layers: [
|
||
{ materialId: "parchet", thickness: 1.5 },
|
||
{ materialId: "sapa-cem", thickness: 6 },
|
||
{ materialId: "bpv", thickness: 0.05 },
|
||
{ materialId: "xps-30", thickness: 15 },
|
||
{ materialId: "hidro-bit", thickness: 0.5 },
|
||
{ materialId: "beton", thickness: 15 },
|
||
{ materialId: "pietris", thickness: 15 },
|
||
],
|
||
},
|
||
];
|
||
|
||
function MaterialThermalComparison() {
|
||
const [layersA, setLayersA] = useState<LayerEntry[]>([
|
||
{ materialId: "tenc-var", thickness: 1.5 },
|
||
{ materialId: "bca-40", thickness: 30 },
|
||
{ materialId: "vata-min", thickness: 10 },
|
||
{ materialId: "tenc-dec", thickness: 0.5 },
|
||
]);
|
||
const [layersB, setLayersB] = useState<LayerEntry[]>([
|
||
{ materialId: "tenc-var", thickness: 1.5 },
|
||
{ materialId: "poro-38", thickness: 38 },
|
||
{ materialId: "tenc-dec", thickness: 0.5 },
|
||
]);
|
||
|
||
// Drag-and-drop reorder state
|
||
const dragIdxRef = useRef<number | null>(null);
|
||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||
|
||
const rsi = 0.13;
|
||
const rse = 0.04;
|
||
|
||
const categories = Array.from(
|
||
new Set(THERMAL_MATERIALS.map((m) => m.category)),
|
||
);
|
||
const templateCategories = Array.from(
|
||
new Set(COMPOSITION_TEMPLATES.map((t) => t.category)),
|
||
);
|
||
|
||
const calcR = (layers: LayerEntry[]): number => {
|
||
let r = 0;
|
||
for (const layer of layers) {
|
||
const mat = THERMAL_MATERIALS.find((m) => m.id === layer.materialId);
|
||
if (mat && layer.thickness > 0) {
|
||
r += layer.thickness / 100 / mat.lambda;
|
||
}
|
||
}
|
||
return r;
|
||
};
|
||
|
||
const rA = calcR(layersA);
|
||
const rB = calcR(layersB);
|
||
const rTotalA = rA + rsi + rse;
|
||
const rTotalB = rB + rsi + rse;
|
||
const uA = rTotalA > 0 ? 1 / rTotalA : 0;
|
||
const uB = rTotalB > 0 ? 1 / rTotalB : 0;
|
||
|
||
const updateLayer = (
|
||
setLayers: React.Dispatch<React.SetStateAction<LayerEntry[]>>,
|
||
idx: number,
|
||
field: "materialId" | "thickness",
|
||
value: string,
|
||
) => {
|
||
setLayers((prev) => {
|
||
const next = [...prev];
|
||
const entry = next[idx];
|
||
if (!entry) return prev;
|
||
if (field === "materialId") {
|
||
next[idx] = { ...entry, materialId: value };
|
||
} else {
|
||
next[idx] = { ...entry, thickness: parseFloat(value) || 0 };
|
||
}
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const addLayer = (
|
||
setLayers: React.Dispatch<React.SetStateAction<LayerEntry[]>>,
|
||
) => {
|
||
setLayers((prev) => [...prev, { materialId: "eps-80", thickness: 10 }]);
|
||
};
|
||
|
||
const removeLayer = (
|
||
setLayers: React.Dispatch<React.SetStateAction<LayerEntry[]>>,
|
||
idx: number,
|
||
) => {
|
||
setLayers((prev) => prev.filter((_, i) => i !== idx));
|
||
};
|
||
|
||
const moveLayer = (
|
||
setLayers: React.Dispatch<React.SetStateAction<LayerEntry[]>>,
|
||
idx: number,
|
||
direction: "up" | "down",
|
||
) => {
|
||
setLayers((prev) => {
|
||
const next = [...prev];
|
||
const targetIdx = direction === "up" ? idx - 1 : idx + 1;
|
||
if (targetIdx < 0 || targetIdx >= next.length) return prev;
|
||
const a = next[idx];
|
||
const b = next[targetIdx];
|
||
if (!a || !b) return prev;
|
||
next[idx] = b;
|
||
next[targetIdx] = a;
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const applyTemplate = (
|
||
setLayers: React.Dispatch<React.SetStateAction<LayerEntry[]>>,
|
||
template: CompositionTemplate,
|
||
) => {
|
||
setLayers(template.layers.map((l) => ({ ...l })));
|
||
};
|
||
|
||
const renderComposition = (
|
||
label: string,
|
||
layers: LayerEntry[],
|
||
setLayers: React.Dispatch<React.SetStateAction<LayerEntry[]>>,
|
||
r: number,
|
||
rTotal: number,
|
||
u: number,
|
||
) => (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<h4 className="text-sm font-semibold">{label}</h4>
|
||
<select
|
||
onChange={(e) => {
|
||
const tpl = COMPOSITION_TEMPLATES.find(
|
||
(t) => t.name === e.target.value,
|
||
);
|
||
if (tpl) applyTemplate(setLayers, tpl);
|
||
e.target.value = "";
|
||
}}
|
||
value=""
|
||
className="rounded-md border border-border bg-background px-2 py-1 text-xs max-w-[200px]"
|
||
>
|
||
<option value="" disabled>
|
||
Șablon...
|
||
</option>
|
||
{templateCategories.map((cat) => (
|
||
<optgroup key={cat} label={cat}>
|
||
{COMPOSITION_TEMPLATES.filter((t) => t.category === cat).map(
|
||
(t) => (
|
||
<option key={t.name} value={t.name}>
|
||
{t.name}
|
||
</option>
|
||
),
|
||
)}
|
||
</optgroup>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Interior label */}
|
||
<div className="flex items-center gap-2 text-[10px] uppercase tracking-widest text-blue-500/60 font-medium">
|
||
<div className="h-px flex-1 bg-blue-500/20" />
|
||
Interior
|
||
<div className="h-px flex-1 bg-blue-500/20" />
|
||
</div>
|
||
|
||
{layers.map((layer, idx) => {
|
||
const mat = THERMAL_MATERIALS.find((m) => m.id === layer.materialId);
|
||
return (
|
||
<div
|
||
key={idx}
|
||
draggable
|
||
onDragStart={(e) => {
|
||
dragIdxRef.current = idx;
|
||
e.dataTransfer.effectAllowed = "move";
|
||
}}
|
||
onDragOver={(e) => {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "move";
|
||
setDragOverIdx(idx);
|
||
}}
|
||
onDragLeave={() => setDragOverIdx(null)}
|
||
onDrop={(e) => {
|
||
e.preventDefault();
|
||
const from = dragIdxRef.current;
|
||
if (from !== null && from !== idx) {
|
||
setLayers((prev) => {
|
||
const next = [...prev];
|
||
const item = next[from];
|
||
if (!item) return prev;
|
||
next.splice(from, 1);
|
||
next.splice(idx, 0, item);
|
||
return next;
|
||
});
|
||
}
|
||
dragIdxRef.current = null;
|
||
setDragOverIdx(null);
|
||
}}
|
||
onDragEnd={() => {
|
||
dragIdxRef.current = null;
|
||
setDragOverIdx(null);
|
||
}}
|
||
className={`flex gap-1.5 items-end cursor-grab active:cursor-grabbing transition-all ${
|
||
dragOverIdx === idx
|
||
? "border-t-2 border-primary"
|
||
: "border-t-2 border-transparent"
|
||
}`}
|
||
>
|
||
{/* Move up/down */}
|
||
<div className="flex flex-col gap-0.5 shrink-0">
|
||
{idx === 0 && (
|
||
<Label className="text-xs text-transparent">↕</Label>
|
||
)}
|
||
<div className="flex flex-col">
|
||
<button
|
||
type="button"
|
||
onClick={() => moveLayer(setLayers, idx, "up")}
|
||
disabled={idx === 0}
|
||
className="h-4 w-5 flex items-center justify-center text-muted-foreground hover:text-foreground disabled:opacity-20 disabled:cursor-default"
|
||
>
|
||
<ArrowUp className="h-3 w-3" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => moveLayer(setLayers, idx, "down")}
|
||
disabled={idx === layers.length - 1}
|
||
className="h-4 w-5 flex items-center justify-center text-muted-foreground hover:text-foreground disabled:opacity-20 disabled:cursor-default"
|
||
>
|
||
<ArrowDown className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
{idx === 0 && (
|
||
<Label className="text-xs text-muted-foreground">
|
||
Material
|
||
</Label>
|
||
)}
|
||
<select
|
||
value={layer.materialId}
|
||
onChange={(e) =>
|
||
updateLayer(setLayers, idx, "materialId", e.target.value)
|
||
}
|
||
className="mt-0.5 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm"
|
||
>
|
||
{categories.map((cat) => (
|
||
<optgroup key={cat} label={cat}>
|
||
{THERMAL_MATERIALS.filter((m) => m.category === cat).map(
|
||
(m) => (
|
||
<option key={m.id} value={m.id}>
|
||
{m.name} (λ={m.lambda})
|
||
</option>
|
||
),
|
||
)}
|
||
</optgroup>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="w-16 shrink-0">
|
||
{idx === 0 && (
|
||
<Label className="text-xs text-muted-foreground">cm</Label>
|
||
)}
|
||
<Input
|
||
type="number"
|
||
min="0"
|
||
step="0.5"
|
||
value={layer.thickness || ""}
|
||
onChange={(e) =>
|
||
updateLayer(setLayers, idx, "thickness", e.target.value)
|
||
}
|
||
className="mt-0.5"
|
||
/>
|
||
</div>
|
||
<div className="w-20 shrink-0 text-right">
|
||
{idx === 0 && (
|
||
<Label className="text-xs text-muted-foreground">R</Label>
|
||
)}
|
||
<div className="mt-0.5 rounded-md border bg-muted/30 px-1.5 py-1.5 text-xs text-right tabular-nums">
|
||
{mat && layer.thickness > 0
|
||
? (layer.thickness / 100 / mat.lambda).toFixed(3)
|
||
: "—"}
|
||
</div>
|
||
</div>
|
||
{layers.length > 1 && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive shrink-0"
|
||
onClick={() => removeLayer(setLayers, idx)}
|
||
>
|
||
×
|
||
</Button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Exterior label */}
|
||
<div className="flex items-center gap-2 text-[10px] uppercase tracking-widest text-orange-500/60 font-medium">
|
||
<div className="h-px flex-1 bg-orange-500/20" />
|
||
Exterior
|
||
<div className="h-px flex-1 bg-orange-500/20" />
|
||
</div>
|
||
|
||
<Button variant="outline" size="sm" onClick={() => addLayer(setLayers)}>
|
||
+ Strat
|
||
</Button>
|
||
|
||
<div className="rounded-md border bg-muted/30 p-3 text-sm space-y-1.5">
|
||
<div className="flex justify-between">
|
||
<span className="text-muted-foreground">R straturi</span>
|
||
<code className="rounded border bg-muted px-2 py-0.5 tabular-nums">
|
||
{r.toFixed(3)} m²K/W
|
||
</code>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-muted-foreground">
|
||
+ Rsi ({rsi}) + Rse ({rse})
|
||
</span>
|
||
<code className="rounded border bg-muted px-2 py-0.5 tabular-nums">
|
||
{rTotal.toFixed(3)} m²K/W
|
||
</code>
|
||
</div>
|
||
<div className="flex justify-between font-medium border-t pt-1.5">
|
||
<span>U = 1/R total</span>
|
||
<code className="rounded border bg-muted px-2 py-0.5 tabular-nums">
|
||
{u.toFixed(3)} W/m²K
|
||
</code>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const diff = uA > 0 && uB > 0 ? ((uA - uB) / uA) * 100 : 0;
|
||
const betterSide = uA < uB ? "A" : uA > uB ? "B" : null;
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<p className="text-sm text-muted-foreground">
|
||
Compară două alcătuiri — prima de sus în jos (interior → exterior).
|
||
Trage straturile cu mouse-ul pentru reordonare sau folosește săgețile
|
||
↑↓. Dropdown-ul „Șablon" încarcă sisteme prestabilite.
|
||
</p>
|
||
<div className="grid gap-6 lg:grid-cols-2">
|
||
{renderComposition("Alcătuire A", layersA, setLayersA, rA, rTotalA, uA)}
|
||
{renderComposition("Alcătuire B", layersB, setLayersB, rB, rTotalB, uB)}
|
||
</div>
|
||
{betterSide && (
|
||
<div className="rounded-lg border-2 border-primary/30 bg-primary/5 p-4 text-sm">
|
||
<span className="font-semibold">Verdict:</span> Alcătuirea{" "}
|
||
{betterSide} izolează mai bine cu{" "}
|
||
<span className="font-bold">{Math.abs(diff).toFixed(1)}%</span> (U
|
||
{betterSide === "A" ? "A" : "B"} ={" "}
|
||
{(betterSide === "A" ? uA : uB).toFixed(3)} vs U
|
||
{betterSide === "A" ? "B" : "A"} ={" "}
|
||
{(betterSide === "A" ? uB : uA).toFixed(3)} W/m²K).
|
||
{(betterSide === "A" ? uA : uB) <= 0.28 && (
|
||
<span className="block mt-1 text-green-600 dark:text-green-400">
|
||
Îndeplinește cerința C107 pentru perete exterior (U ≤ 0.28 W/m²K).
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── AI Artifact Cleaner ──────────────────────────────────────────────────────
|
||
|
||
function AiArtifactCleaner() {
|
||
const [input, setInput] = useState("");
|
||
|
||
const clean = (text: string): string => {
|
||
let r = text;
|
||
// Strip markdown
|
||
r = r.replace(/^#{1,6}\s+/gm, "");
|
||
r = r.replace(/\*\*(.+?)\*\*/g, "$1");
|
||
r = r.replace(/\*(.+?)\*/g, "$1");
|
||
r = r.replace(/_{2}(.+?)_{2}/g, "$1");
|
||
r = r.replace(/_(.+?)_/g, "$1");
|
||
r = r.replace(/```[\s\S]*?```/g, "");
|
||
r = r.replace(/`(.+?)`/g, "$1");
|
||
r = r.replace(/^[*\-+]\s+/gm, "");
|
||
r = r.replace(/^\d+\.\s+/gm, "");
|
||
r = r.replace(/^[-_*]{3,}$/gm, "");
|
||
r = r.replace(/\[(.+?)\]\(.*?\)/g, "$1");
|
||
r = r.replace(/^>\s+/gm, "");
|
||
// Fix encoding artifacts (UTF-8 mojibake)
|
||
r = r.replace(/â/g, "â");
|
||
r = r.replace(/î/g, "î");
|
||
r = r.replace(/Ã /g, "à");
|
||
r = r.replace(/Å£/g, "ț");
|
||
r = r.replace(/È™/g, "ș");
|
||
r = r.replace(/È›/g, "ț");
|
||
r = r.replace(/Èš/g, "Ț");
|
||
r = r.replace(/\u015f/g, "ș");
|
||
r = r.replace(/\u0163/g, "ț");
|
||
// Remove zero-width and invisible chars
|
||
r = r.replace(/[\u200b\u200c\u200d\ufeff]/g, "");
|
||
// Remove emoji
|
||
r = r.replace(/\p{Extended_Pictographic}/gu, "");
|
||
r = r.replace(/[\u{1F1E0}-\u{1F1FF}]/gu, ""); // flag emoji
|
||
r = r.replace(/[\u{FE00}-\u{FE0F}\u{20D0}-\u{20FF}]/gu, ""); // variation selectors
|
||
// Normalize typography
|
||
r = r.replace(/[""]/g, '"');
|
||
r = r.replace(/['']/g, "'");
|
||
r = r.replace(/[–—]/g, "-");
|
||
r = r.replace(/…/g, "...");
|
||
// Normalize spacing
|
||
r = r.replace(/ {2,}/g, " ");
|
||
r = r.replace(/\n{3,}/g, "\n\n");
|
||
return r.trim();
|
||
};
|
||
|
||
const cleaned = input ? clean(input) : "";
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
<div>
|
||
<Label>Text original (output AI)</Label>
|
||
<Textarea
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
className="mt-1 h-72 font-mono text-xs"
|
||
placeholder="Lipește textul generat de AI..."
|
||
/>
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center justify-between">
|
||
<Label>Text curățat</Label>
|
||
{cleaned && <CopyButton text={cleaned} />}
|
||
</div>
|
||
<Textarea
|
||
value={cleaned}
|
||
readOnly
|
||
className="mt-1 h-72 font-mono text-xs bg-muted/30"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
Operații: eliminare markdown (###, **, `, liste, citate), emoji,
|
||
corectare encoding românesc (mojibake), curățare Unicode invizibil,
|
||
normalizare ghilimele / cratime / spații multiple.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── MDLPA Date Locale ────────────────────────────────────────────────────────
|
||
|
||
function MdlpaValidator() {
|
||
return (
|
||
<div className="space-y-4">
|
||
<p className="text-sm text-muted-foreground">
|
||
Portalul MDLPA nu permite încadrarea în iframe (blochează via
|
||
X-Frame-Options). Folosește linkurile de mai jos pentru a accesa direct
|
||
portalul.
|
||
</p>
|
||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
<a
|
||
href="https://datelocale.mdlpa.ro"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="flex flex-col gap-1 rounded-lg border p-4 transition-colors hover:bg-muted/50"
|
||
>
|
||
<span className="font-medium text-primary">Portalul principal ↗</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
datelocale.mdlpa.ro — date locale construcții
|
||
</span>
|
||
</a>
|
||
<a
|
||
href="https://datelocale.mdlpa.ro/ro/about/tutorials"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="flex flex-col gap-1 rounded-lg border p-4 transition-colors hover:bg-muted/50"
|
||
>
|
||
<span className="font-medium text-primary">Tutoriale video ↗</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
Ghiduri de utilizare pas cu pas
|
||
</span>
|
||
</a>
|
||
<a
|
||
href="https://datelocale.mdlpa.ro/ro/about/info/reguli"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="flex flex-col gap-1 rounded-lg border p-4 transition-colors hover:bg-muted/50"
|
||
>
|
||
<span className="font-medium text-primary">Reguli de calcul ↗</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
Metodologie și formule de calcul
|
||
</span>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── PDF Reducer (Stirling PDF) ───────────────────────────────────────────────
|
||
|
||
function formatBytes(bytes: number) {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||
}
|
||
|
||
function PdfReducer() {
|
||
const [file, setFile] = useState<File | null>(null);
|
||
const [mode, setMode] = useState<"extreme" | "max" | "balanced">("extreme");
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState("");
|
||
const [result, setResult] = useState<{
|
||
originalSize: number;
|
||
compressedSize: number;
|
||
} | null>(null);
|
||
const [dragging, setDragging] = useState(false);
|
||
const fileRef = useRef<HTMLInputElement>(null);
|
||
|
||
const setFileFromInput = (f: File | null) => {
|
||
setFile(f);
|
||
setError("");
|
||
setResult(null);
|
||
};
|
||
|
||
const handleCompress = async () => {
|
||
if (!file) return;
|
||
setLoading(true);
|
||
setError("");
|
||
setResult(null);
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append("fileInput", file);
|
||
// All modes use the GS endpoint with a level parameter
|
||
formData.append("level", mode === "extreme" ? "extreme" : mode === "max" ? "high" : "balanced");
|
||
|
||
const res = await fetch("/api/compress-pdf/extreme", {
|
||
method: "POST",
|
||
body: formData,
|
||
});
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
throw new Error(data.error ?? `Eroare server: ${res.status}`);
|
||
}
|
||
const blob = await res.blob();
|
||
|
||
const origHeader = res.headers.get("X-Original-Size");
|
||
const compHeader = res.headers.get("X-Compressed-Size");
|
||
setResult({
|
||
originalSize: origHeader ? Number(origHeader) : file.size,
|
||
compressedSize: compHeader ? Number(compHeader) : blob.size,
|
||
});
|
||
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = file.name.replace(/\.pdf$/i, "-comprimat.pdf");
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof Error
|
||
? err.message
|
||
: "Nu s-a putut contacta Stirling PDF.",
|
||
);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="space-y-1.5">
|
||
<Label>Fișier PDF</Label>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept=".pdf"
|
||
className="hidden"
|
||
onChange={(e) => setFileFromInput(e.target.files?.[0] ?? null)}
|
||
/>
|
||
<div
|
||
tabIndex={0}
|
||
className={`flex min-h-[80px] cursor-pointer flex-col items-center justify-center gap-1 rounded-md border-2 border-dashed p-4 text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
||
dragging
|
||
? "border-primary bg-primary/5"
|
||
: "border-border text-muted-foreground hover:border-primary/50"
|
||
}`}
|
||
onClick={() => fileRef.current?.click()}
|
||
onDragOver={(e) => {
|
||
e.preventDefault();
|
||
setDragging(true);
|
||
}}
|
||
onDragLeave={() => setDragging(false)}
|
||
onDrop={(e) => {
|
||
e.preventDefault();
|
||
setDragging(false);
|
||
const f = e.dataTransfer.files[0];
|
||
if (f?.type === "application/pdf") setFileFromInput(f);
|
||
}}
|
||
onPaste={(e) => {
|
||
const f = e.clipboardData.files[0];
|
||
if (f?.type === "application/pdf") setFileFromInput(f);
|
||
}}
|
||
>
|
||
{file ? (
|
||
<span className="font-medium text-foreground">
|
||
{file.name}{" "}
|
||
<span className="text-muted-foreground">
|
||
({formatBytes(file.size)})
|
||
</span>
|
||
</span>
|
||
) : (
|
||
<>
|
||
<Upload className="h-5 w-5" />
|
||
<span>Trage un PDF aici sau apasă pentru a selecta</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<Label>Nivel compresie</Label>
|
||
<select
|
||
value={mode}
|
||
onChange={(e) =>
|
||
setMode(e.target.value as "extreme" | "max" | "balanced")
|
||
}
|
||
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||
>
|
||
<option value="extreme">
|
||
Extremă — imagini 100 DPI, calitate scăzută
|
||
</option>
|
||
<option value="max">Puternică — imagini 150 DPI, calitate medie (recomandat)</option>
|
||
<option value="balanced">Echilibrată — imagini 200 DPI, calitate bună</option>
|
||
</select>
|
||
<p className="text-xs text-muted-foreground">
|
||
{mode === "extreme"
|
||
? "Reduce maxim dimensiunea. Imaginile pot pierde detalii fine."
|
||
: mode === "max"
|
||
? "Balanță bună între dimensiune și calitate. Recomandat pentru majoritatea fișierelor."
|
||
: "Pierdere minimă de calitate. Ideal pentru documente cu grafice detaliate."}
|
||
</p>
|
||
</div>
|
||
|
||
<Button onClick={handleCompress} disabled={!file || loading}>
|
||
{loading ? "Se comprimă..." : "Comprimă PDF"}
|
||
</Button>
|
||
|
||
{result && (
|
||
<div className="rounded-lg border bg-muted/30 p-3">
|
||
<p className="text-sm font-medium">
|
||
{formatBytes(result.originalSize)} →{" "}
|
||
{formatBytes(result.compressedSize)}
|
||
<span className="ml-2 font-bold text-green-600 dark:text-green-400">
|
||
−
|
||
{(
|
||
((result.originalSize - result.compressedSize) /
|
||
result.originalSize) *
|
||
100
|
||
).toFixed(0)}
|
||
% mai mic
|
||
</span>
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── PDF Unlock (Stirling PDF) ────────────────────────────────────────────────
|
||
|
||
function PdfUnlock() {
|
||
const [file, setFile] = useState<File | null>(null);
|
||
const [password, setPassword] = useState("");
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState("");
|
||
const [dragging, setDragging] = useState(false);
|
||
const fileRef = useRef<HTMLInputElement>(null);
|
||
|
||
const setFileFromInput = (f: File | null) => {
|
||
setFile(f);
|
||
setError("");
|
||
};
|
||
|
||
const handleUnlock = async () => {
|
||
if (!file) return;
|
||
setLoading(true);
|
||
setError("");
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append("fileInput", file);
|
||
formData.append("password", password);
|
||
const res = await fetch("/api/compress-pdf/unlock", {
|
||
method: "POST",
|
||
body: formData,
|
||
});
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
throw new Error(data.error ?? `Eroare server: ${res.status}`);
|
||
}
|
||
const blob = await res.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = file.name.replace(/\.pdf$/i, "-deblocat.pdf");
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof Error ? err.message : "Nu s-a putut debloca PDF-ul.",
|
||
);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="space-y-1.5">
|
||
<Label>Fișier PDF protejat</Label>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept=".pdf"
|
||
className="hidden"
|
||
onChange={(e) => setFileFromInput(e.target.files?.[0] ?? null)}
|
||
/>
|
||
<div
|
||
tabIndex={0}
|
||
className={`flex min-h-[80px] cursor-pointer flex-col items-center justify-center gap-1 rounded-md border-2 border-dashed p-4 text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
||
dragging
|
||
? "border-primary bg-primary/5"
|
||
: "border-border text-muted-foreground hover:border-primary/50"
|
||
}`}
|
||
onClick={() => fileRef.current?.click()}
|
||
onDragOver={(e) => {
|
||
e.preventDefault();
|
||
setDragging(true);
|
||
}}
|
||
onDragLeave={() => setDragging(false)}
|
||
onDrop={(e) => {
|
||
e.preventDefault();
|
||
setDragging(false);
|
||
const f = e.dataTransfer.files[0];
|
||
if (f?.type === "application/pdf") setFileFromInput(f);
|
||
}}
|
||
onPaste={(e) => {
|
||
const f = e.clipboardData.files[0];
|
||
if (f?.type === "application/pdf") setFileFromInput(f);
|
||
}}
|
||
>
|
||
{file ? (
|
||
<span className="font-medium text-foreground">{file.name}</span>
|
||
) : (
|
||
<>
|
||
<Upload className="h-5 w-5" />
|
||
<span>Trage un PDF aici sau apasă pentru a selecta</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<Label>Parolă (dacă e criptat cu parolă)</Label>
|
||
<Input
|
||
type="password"
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
placeholder="Lasă gol dacă nu are parolă"
|
||
className="mt-1"
|
||
/>
|
||
</div>
|
||
|
||
<Button onClick={handleUnlock} disabled={!file || loading}>
|
||
{loading ? "Se deblochează..." : "Deblochează PDF"}
|
||
</Button>
|
||
|
||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── DWG → DXF Converter ─────────────────────────────────────────────────────
|
||
|
||
function DwgToDxf() {
|
||
const [file, setFile] = useState<File | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState("");
|
||
const [dragging, setDragging] = useState(false);
|
||
const fileRef = useRef<HTMLInputElement>(null);
|
||
|
||
const setFileFromInput = (f: File | null) => {
|
||
setFile(f);
|
||
setError("");
|
||
};
|
||
|
||
const handleConvert = async () => {
|
||
if (!file) return;
|
||
setLoading(true);
|
||
setError("");
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append("fileInput", file);
|
||
const res = await fetch("/api/dwg-convert", {
|
||
method: "POST",
|
||
body: formData,
|
||
});
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
throw new Error(data.error ?? `Eroare server: ${res.status}`);
|
||
}
|
||
const blob = await res.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = file.name.replace(/\.dwg$/i, ".dxf");
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof Error ? err.message : "Nu s-a putut converti fișierul.",
|
||
);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="space-y-1.5">
|
||
<Label>Fișier DWG</Label>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept=".dwg"
|
||
className="hidden"
|
||
onChange={(e) => setFileFromInput(e.target.files?.[0] ?? null)}
|
||
/>
|
||
<div
|
||
tabIndex={0}
|
||
className={`flex min-h-[80px] cursor-pointer flex-col items-center justify-center gap-1 rounded-md border-2 border-dashed p-4 text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
||
dragging
|
||
? "border-primary bg-primary/5"
|
||
: "border-border text-muted-foreground hover:border-primary/50"
|
||
}`}
|
||
onClick={() => fileRef.current?.click()}
|
||
onDragOver={(e) => {
|
||
e.preventDefault();
|
||
setDragging(true);
|
||
}}
|
||
onDragLeave={() => setDragging(false)}
|
||
onDrop={(e) => {
|
||
e.preventDefault();
|
||
setDragging(false);
|
||
const f = e.dataTransfer.files[0];
|
||
if (f?.name.toLowerCase().endsWith(".dwg")) setFileFromInput(f);
|
||
}}
|
||
onPaste={(e) => {
|
||
const f = e.clipboardData.files[0];
|
||
if (f?.name.toLowerCase().endsWith(".dwg")) setFileFromInput(f);
|
||
}}
|
||
>
|
||
{file ? (
|
||
<span className="font-medium text-foreground">{file.name}</span>
|
||
) : (
|
||
<>
|
||
<Upload className="h-5 w-5" />
|
||
<span>Trage un fișier .dwg aici sau apasă pentru a selecta</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<p className="text-xs text-muted-foreground">
|
||
Convertește AutoCAD DWG în format DXF deschis. Suportă versiuni până la
|
||
DWG 2018. Conversie server-side via libredwg.
|
||
</p>
|
||
|
||
<Button onClick={handleConvert} disabled={!file || loading}>
|
||
{loading ? "Se convertește..." : "Convertește în DXF"}
|
||
</Button>
|
||
|
||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Quick OCR ────────────────────────────────────────────────────────────────
|
||
|
||
function QuickOcr() {
|
||
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||
const [text, setText] = useState("");
|
||
const [progress, setProgress] = useState(0);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState("");
|
||
const [lang, setLang] = useState("ron+eng");
|
||
const fileRef = useRef<HTMLInputElement>(null);
|
||
|
||
const runOcr = async (src: string) => {
|
||
if (loading) return;
|
||
setLoading(true);
|
||
setError("");
|
||
setText("");
|
||
setProgress(0);
|
||
try {
|
||
const { createWorker } = await import("tesseract.js");
|
||
const worker = await createWorker(lang.split("+"), 1, {
|
||
logger: (m: { status: string; progress: number }) => {
|
||
if (m.status === "recognizing text")
|
||
setProgress(Math.round(m.progress * 100));
|
||
},
|
||
});
|
||
const { data } = await worker.recognize(src);
|
||
setText(data.text.trim());
|
||
await worker.terminate();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Eroare OCR necunoscută");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleFile = (file: File) => {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
const src = e.target?.result as string;
|
||
setImageSrc(src);
|
||
runOcr(src);
|
||
};
|
||
reader.readAsDataURL(file);
|
||
};
|
||
|
||
const handleDrop = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
const file = Array.from(e.dataTransfer.files).find((f) =>
|
||
f.type.startsWith("image/"),
|
||
);
|
||
if (file) handleFile(file);
|
||
};
|
||
|
||
const handlePaste = (e: React.ClipboardEvent) => {
|
||
const item = Array.from(e.clipboardData.items).find((i) =>
|
||
i.type.startsWith("image/"),
|
||
);
|
||
const file = item?.getAsFile();
|
||
if (file) handleFile(file);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-3" onPaste={handlePaste}>
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<select
|
||
value={lang}
|
||
onChange={(e) => setLang(e.target.value)}
|
||
className="rounded-md border bg-background px-3 py-1.5 text-sm"
|
||
>
|
||
<option value="ron+eng">Română + Engleză</option>
|
||
<option value="ron">Română</option>
|
||
<option value="eng">Engleză</option>
|
||
</select>
|
||
<span className="text-xs text-muted-foreground">
|
||
sau Ctrl+V pentru a lipi imaginea
|
||
</span>
|
||
</div>
|
||
|
||
<div
|
||
className="flex min-h-[120px] cursor-pointer items-center justify-center rounded-md border-2 border-dashed p-4 text-sm text-muted-foreground transition-colors hover:border-primary/50"
|
||
onClick={() => fileRef.current?.click()}
|
||
onDrop={handleDrop}
|
||
onDragOver={(e) => e.preventDefault()}
|
||
>
|
||
{imageSrc ? (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img
|
||
src={imageSrc}
|
||
alt="preview"
|
||
className="max-h-48 max-w-full rounded object-contain"
|
||
/>
|
||
) : (
|
||
<span>Trage o imagine aici, apasă pentru a selecta, sau Ctrl+V</span>
|
||
)}
|
||
</div>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const f = e.target.files?.[0];
|
||
if (f) handleFile(f);
|
||
}}
|
||
/>
|
||
|
||
{loading && (
|
||
<div className="space-y-1">
|
||
<div className="flex justify-between text-xs text-muted-foreground">
|
||
<span>Se procesează... (primul rulaj descarcă modelul ~10 MB)</span>
|
||
<span>{progress}%</span>
|
||
</div>
|
||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||
<div
|
||
className="h-full bg-primary transition-all"
|
||
style={{ width: `${progress}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||
|
||
{text && (
|
||
<div>
|
||
<div className="flex items-center justify-between">
|
||
<Label>Text extras</Label>
|
||
<CopyButton text={text} />
|
||
</div>
|
||
<Textarea
|
||
value={text}
|
||
readOnly
|
||
className="mt-1 h-56 font-mono text-xs bg-muted/30"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Number to Text (Romanian) ────────────────────────────────────────────────
|
||
|
||
const ONES = [
|
||
"",
|
||
"unu",
|
||
"doi",
|
||
"trei",
|
||
"patru",
|
||
"cinci",
|
||
"șase",
|
||
"șapte",
|
||
"opt",
|
||
"nouă",
|
||
"zece",
|
||
"unsprezece",
|
||
"doisprezece",
|
||
"treisprezece",
|
||
"paisprezece",
|
||
"cincisprezece",
|
||
"șaisprezece",
|
||
"șaptesprezece",
|
||
"optsprezece",
|
||
"nouăsprezece",
|
||
];
|
||
const TENS = [
|
||
"",
|
||
"zece",
|
||
"douăzeci",
|
||
"treizeci",
|
||
"patruzeci",
|
||
"cincizeci",
|
||
"șaizeci",
|
||
"șaptezeci",
|
||
"optzeci",
|
||
"nouăzeci",
|
||
];
|
||
|
||
function numberToRoText(n: number): string {
|
||
if (n === 0) return "zero";
|
||
if (n < 0) return "minus " + numberToRoText(-n);
|
||
|
||
const parts: string[] = [];
|
||
|
||
// Billions
|
||
const mld = Math.floor(n / 1_000_000_000);
|
||
if (mld > 0) {
|
||
if (mld === 1) parts.push("un miliard");
|
||
else if (mld < 20) parts.push(ONES[mld]! + " miliarde");
|
||
else parts.push(twoDigitRo(mld) + " de miliarde");
|
||
n %= 1_000_000_000;
|
||
}
|
||
|
||
// Millions
|
||
const mil = Math.floor(n / 1_000_000);
|
||
if (mil > 0) {
|
||
if (mil === 1) parts.push("un milion");
|
||
else if (mil < 20) parts.push(ONES[mil]! + " milioane");
|
||
else parts.push(twoDigitRo(mil) + " de milioane");
|
||
n %= 1_000_000;
|
||
}
|
||
|
||
// Thousands
|
||
const th = Math.floor(n / 1000);
|
||
if (th > 0) {
|
||
if (th === 1) parts.push("o mie");
|
||
else if (th === 2) parts.push("două mii");
|
||
else if (th < 20) parts.push(ONES[th]! + " mii");
|
||
else if (th < 100) parts.push(twoDigitRo(th) + " de mii");
|
||
else parts.push(threeDigitRo(th) + " de mii");
|
||
n %= 1000;
|
||
}
|
||
|
||
// Hundreds
|
||
const h = Math.floor(n / 100);
|
||
if (h > 0) {
|
||
if (h === 1) parts.push("o sută");
|
||
else if (h === 2) parts.push("două sute");
|
||
else parts.push(ONES[h]! + " sute");
|
||
n %= 100;
|
||
}
|
||
|
||
// Remainder < 100
|
||
if (n > 0) {
|
||
if (parts.length > 0) parts.push("și");
|
||
if (n < 20) {
|
||
parts.push(ONES[n]!);
|
||
} else {
|
||
parts.push(twoDigitRo(n));
|
||
}
|
||
}
|
||
|
||
return parts.join(" ");
|
||
}
|
||
|
||
function twoDigitRo(n: number): string {
|
||
if (n < 20) return ONES[n]!;
|
||
const t = Math.floor(n / 10);
|
||
const o = n % 10;
|
||
if (o === 0) return TENS[t]!;
|
||
return TENS[t]! + " și " + ONES[o]!;
|
||
}
|
||
|
||
function threeDigitRo(n: number): string {
|
||
const h = Math.floor(n / 100);
|
||
const rest = n % 100;
|
||
let s = "";
|
||
if (h === 1) s = "o sută";
|
||
else if (h === 2) s = "două sute";
|
||
else s = ONES[h]! + " sute";
|
||
if (rest > 0) s += " " + twoDigitRo(rest);
|
||
return s;
|
||
}
|
||
|
||
function NumberToText() {
|
||
const [input, setInput] = useState("");
|
||
|
||
const v = parseFloat(input);
|
||
const isValid = !isNaN(v) && isFinite(v) && v >= 0 && v < 1e12;
|
||
|
||
const intPart = isValid ? Math.floor(v) : 0;
|
||
const decStr = input.includes(".") ? (input.split(".")[1] ?? "") : "";
|
||
const decPart = decStr ? parseInt(decStr.slice(0, 2).padEnd(2, "0"), 10) : 0;
|
||
|
||
const intText = isValid ? numberToRoText(intPart) : "";
|
||
const decText = decPart > 0 ? numberToRoText(decPart) : "";
|
||
|
||
// lei + bani version
|
||
const leiBani = isValid
|
||
? intText + " lei" + (decPart > 0 ? " și " + decText + " de bani" : "")
|
||
: "";
|
||
|
||
// Compact (no spaces between groups)
|
||
const compact = isValid
|
||
? intText.replace(/ și /g, "șI").replace(/ /g, "").replace(/șI/g, "și") +
|
||
(decPart > 0
|
||
? " virgulă " +
|
||
decText.replace(/ și /g, "șI").replace(/ /g, "").replace(/șI/g, "și")
|
||
: "")
|
||
: "";
|
||
|
||
// Normal
|
||
const normal = isValid
|
||
? intText + (decPart > 0 ? " virgulă " + decText : "")
|
||
: "";
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div>
|
||
<Label>Sumă (număr)</Label>
|
||
<Input
|
||
type="number"
|
||
step="0.01"
|
||
min="0"
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
className="mt-1"
|
||
placeholder="ex: 432.20"
|
||
/>
|
||
</div>
|
||
{isValid && intText && (
|
||
<div className="space-y-2">
|
||
<div className="flex items-start gap-2 rounded-md border bg-muted/30 p-3">
|
||
<div className="flex-1 text-sm">
|
||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||
Lei și bani
|
||
</p>
|
||
<p>{leiBani}</p>
|
||
</div>
|
||
<CopyButton text={leiBani} />
|
||
</div>
|
||
<div className="flex items-start gap-2 rounded-md border bg-muted/30 p-3">
|
||
<div className="flex-1 text-sm">
|
||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||
Cu spații
|
||
</p>
|
||
<p>{normal}</p>
|
||
</div>
|
||
<CopyButton text={normal} />
|
||
</div>
|
||
<div className="flex items-start gap-2 rounded-md border bg-muted/30 p-3">
|
||
<div className="flex-1 text-sm">
|
||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||
Compact (fără spații)
|
||
</p>
|
||
<p>{compact}</p>
|
||
</div>
|
||
<CopyButton text={compact} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Color Palette Extractor ──────────────────────────────────────────────────
|
||
|
||
function ColorPaletteExtractor() {
|
||
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||
const [colors, setColors] = useState<string[]>([]);
|
||
const [copiedIdx, setCopiedIdx] = useState<number | null>(null);
|
||
const fileRef = useRef<HTMLInputElement>(null);
|
||
|
||
const extractColors = useCallback((src: string) => {
|
||
const img = new Image();
|
||
img.crossOrigin = "anonymous";
|
||
img.onload = () => {
|
||
const canvas = document.createElement("canvas");
|
||
const size = 100;
|
||
canvas.width = size;
|
||
canvas.height = size;
|
||
const ctx = canvas.getContext("2d")!;
|
||
ctx.drawImage(img, 0, 0, size, size);
|
||
const data = ctx.getImageData(0, 0, size, size).data;
|
||
|
||
// Simple K-means-ish clustering: bucket colors
|
||
const buckets = new Map<
|
||
string,
|
||
{ r: number; g: number; b: number; count: number }
|
||
>();
|
||
for (let i = 0; i < data.length; i += 4) {
|
||
const r = Math.round(data[i]! / 32) * 32;
|
||
const g = Math.round(data[i + 1]! / 32) * 32;
|
||
const b = Math.round(data[i + 2]! / 32) * 32;
|
||
const key = `${r},${g},${b}`;
|
||
const existing = buckets.get(key);
|
||
if (existing) {
|
||
existing.r += data[i]!;
|
||
existing.g += data[i + 1]!;
|
||
existing.b += data[i + 2]!;
|
||
existing.count++;
|
||
} else {
|
||
buckets.set(key, {
|
||
r: data[i]!,
|
||
g: data[i + 1]!,
|
||
b: data[i + 2]!,
|
||
count: 1,
|
||
});
|
||
}
|
||
}
|
||
|
||
const sorted = Array.from(buckets.values())
|
||
.sort((a, b) => b.count - a.count)
|
||
.slice(0, 8);
|
||
|
||
const palette = sorted.map((b) => {
|
||
const r = Math.round(b.r / b.count);
|
||
const g = Math.round(b.g / b.count);
|
||
const bl = Math.round(b.b / b.count);
|
||
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${bl.toString(16).padStart(2, "0")}`;
|
||
});
|
||
|
||
setColors(palette);
|
||
};
|
||
img.src = src;
|
||
}, []);
|
||
|
||
const handleFile = (file: File) => {
|
||
if (!file.type.startsWith("image/")) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
const src = e.target?.result as string;
|
||
setImageSrc(src);
|
||
extractColors(src);
|
||
};
|
||
reader.readAsDataURL(file);
|
||
};
|
||
|
||
const copyColor = async (hex: string, idx: number) => {
|
||
try {
|
||
await navigator.clipboard.writeText(hex.toUpperCase());
|
||
setCopiedIdx(idx);
|
||
setTimeout(() => setCopiedIdx(null), 1500);
|
||
} catch {
|
||
/* silent */
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div
|
||
tabIndex={0}
|
||
className="flex min-h-[120px] cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed p-4 text-sm text-muted-foreground transition-colors outline-none hover:border-primary/50 focus-visible:ring-2 focus-visible:ring-ring"
|
||
onClick={() => fileRef.current?.click()}
|
||
onDragOver={(e) => e.preventDefault()}
|
||
onDrop={(e) => {
|
||
e.preventDefault();
|
||
const f = e.dataTransfer.files[0];
|
||
if (f) handleFile(f);
|
||
}}
|
||
onPaste={(e) => {
|
||
const f = e.clipboardData.files[0];
|
||
if (f) handleFile(f);
|
||
// Also support pasting image data from clipboard
|
||
const items = e.clipboardData.items;
|
||
for (let i = 0; i < items.length; i++) {
|
||
const item = items[i];
|
||
if (item?.type.startsWith("image/")) {
|
||
const blob = item.getAsFile();
|
||
if (blob) handleFile(blob);
|
||
break;
|
||
}
|
||
}
|
||
}}
|
||
>
|
||
{imageSrc ? (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img
|
||
src={imageSrc}
|
||
alt="preview"
|
||
className="max-h-40 max-w-full rounded object-contain"
|
||
/>
|
||
) : (
|
||
<>
|
||
<Upload className="h-6 w-6" />
|
||
<span>Trage o imagine, apasă, sau lipește (Ctrl+V)</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const f = e.target.files?.[0];
|
||
if (f) handleFile(f);
|
||
}}
|
||
/>
|
||
{colors.length > 0 && (
|
||
<div className="space-y-2">
|
||
<Label>Paletă extrasă ({colors.length} culori)</Label>
|
||
<div className="flex flex-wrap gap-2">
|
||
{colors.map((hex, i) => (
|
||
<button
|
||
key={i}
|
||
className="group relative flex flex-col items-center gap-1 rounded-lg border p-2 transition-colors hover:bg-muted/50"
|
||
onClick={() => void copyColor(hex, i)}
|
||
title={`Click pentru a copia ${hex.toUpperCase()}`}
|
||
>
|
||
<div
|
||
className="h-12 w-12 rounded-md border shadow-sm"
|
||
style={{ backgroundColor: hex }}
|
||
/>
|
||
<code className="text-[10px] text-muted-foreground">
|
||
{copiedIdx === i ? "Copiat!" : hex.toUpperCase()}
|
||
</code>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="flex items-center gap-2 pt-1">
|
||
<CopyButton text={colors.map((c) => c.toUpperCase()).join(", ")} />
|
||
<span className="text-xs text-muted-foreground">
|
||
Copiază toate culorile
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Calculator Scară ─────────────────────────────────────────────────────────
|
||
|
||
const SCALE_PRESETS = [
|
||
{ label: "1:20", value: 20 },
|
||
{ 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 },
|
||
];
|
||
|
||
// Real unit → multiplier to convert to mm
|
||
const REAL_UNITS = [
|
||
{ id: "mm", label: "mm", toMm: 1 },
|
||
{ id: "cm", label: "cm", toMm: 10 },
|
||
{ id: "m", label: "m", toMm: 1_000 },
|
||
{ id: "km", label: "km", toMm: 1_000_000 },
|
||
] as const;
|
||
type RealUnit = (typeof REAL_UNITS)[number]["id"];
|
||
|
||
/**
|
||
* Scale calculator.
|
||
*
|
||
* Core identity (all in mm):
|
||
* drawing_mm = real_mm / scale
|
||
* real_mm = drawing_mm × scale
|
||
*
|
||
* Real input in any unit → convert to mm → apply scale → drawing in mm.
|
||
* Drawing input in mm → apply scale → real mm → convert to chosen unit.
|
||
*/
|
||
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 [realUnit, setRealUnit] = useState<RealUnit>("cm");
|
||
const [inputVal, setInputVal] = useState("");
|
||
|
||
const effectiveScale = customScale !== "" ? parseFloat(customScale) || scale : scale;
|
||
const val = parseFloat(inputVal);
|
||
|
||
const unitDef = REAL_UNITS.find((u) => u.id === realUnit)!;
|
||
|
||
// drawing_mm = real_in_unit × unitDef.toMm / scale
|
||
// real_in_unit = drawing_mm × scale / unitDef.toMm
|
||
const drawing_mm = !isNaN(val) && mode === "real-to-drawing"
|
||
? (val * unitDef.toMm) / effectiveScale
|
||
: NaN;
|
||
const real_in_unit = !isNaN(val) && mode === "drawing-to-real"
|
||
? (val * effectiveScale) / unitDef.toMm
|
||
: NaN;
|
||
|
||
// For secondary display: real_mm (whichever mode)
|
||
const real_mm = mode === "real-to-drawing"
|
||
? val * unitDef.toMm
|
||
: val * effectiveScale;
|
||
|
||
const fmt = (n: number, decimals = 2) =>
|
||
isNaN(n) || !isFinite(n)
|
||
? "—"
|
||
: n.toLocaleString("ro-RO", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||
|
||
// Build secondary real conversions (exclude current unit)
|
||
const secondaryReal = (mm: number) =>
|
||
REAL_UNITS.filter((u) => u.id !== realUnit)
|
||
.map((u) => `${fmt(mm / u.toMm)} ${u.id}`)
|
||
.join(" · ");
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Scale picker */}
|
||
<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>
|
||
|
||
{/* Mode + real unit picker */}
|
||
<div className="flex flex-wrap gap-3 items-center">
|
||
<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 className="flex items-center gap-1.5">
|
||
<span className="text-xs text-muted-foreground">Unitate reală:</span>
|
||
{REAL_UNITS.map((u) => (
|
||
<Button
|
||
key={u.id}
|
||
type="button"
|
||
variant={realUnit === u.id ? "secondary" : "ghost"}
|
||
size="sm"
|
||
className="h-7 px-2 text-xs"
|
||
onClick={() => { setRealUnit(u.id); setInputVal(""); }}
|
||
>
|
||
{u.label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Input */}
|
||
<div>
|
||
<Label>
|
||
{mode === "real-to-drawing"
|
||
? `Dimensiune reală (${realUnit})`
|
||
: "Dimensiune desen (mm)"}
|
||
</Label>
|
||
<div className="mt-1 flex gap-2 items-center">
|
||
<Input
|
||
type="number"
|
||
min={0}
|
||
step={realUnit === "km" ? 0.001 : realUnit === "m" ? 0.01 : 0.5}
|
||
value={inputVal}
|
||
onChange={(e) => setInputVal(e.target.value)}
|
||
placeholder={
|
||
mode === "real-to-drawing"
|
||
? realUnit === "m" ? "ex: 3.5" : realUnit === "km" ? "ex: 0.350" : "ex: 350"
|
||
: "ex: 35"
|
||
}
|
||
className="flex-1"
|
||
/>
|
||
<span className="text-sm text-muted-foreground shrink-0 w-8">
|
||
{mode === "real-to-drawing" ? realUnit : "mm"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Result */}
|
||
{!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 font-medium">Scară 1:{effectiveScale}</p>
|
||
{mode === "real-to-drawing" ? (
|
||
<>
|
||
<p>
|
||
Real:{" "}
|
||
<strong>{fmt(val)} {realUnit}</strong>
|
||
{realUnit !== "mm" && (
|
||
<span className="text-xs text-muted-foreground ml-2">
|
||
({secondaryReal(real_mm)})
|
||
</span>
|
||
)}
|
||
</p>
|
||
<p className="text-base border-t pt-2">
|
||
Pe desen:{" "}
|
||
<strong className="text-primary">{fmt(drawing_mm)} mm</strong>
|
||
<CopyButton text={fmt(drawing_mm)} />
|
||
</p>
|
||
{!isNaN(drawing_mm) && (
|
||
<p className="text-xs text-muted-foreground">
|
||
= {fmt(drawing_mm / 10)} cm pe desen
|
||
</p>
|
||
)}
|
||
</>
|
||
) : (
|
||
<>
|
||
<p>
|
||
Pe desen: <strong>{fmt(val)} mm</strong>
|
||
</p>
|
||
<p className="text-base border-t pt-2">
|
||
Real:{" "}
|
||
<strong className="text-primary">{fmt(real_in_unit)} {realUnit}</strong>
|
||
<CopyButton text={fmt(real_in_unit)} />
|
||
</p>
|
||
{!isNaN(real_mm) && (
|
||
<p className="text-xs text-muted-foreground">
|
||
{secondaryReal(real_mm)}
|
||
</p>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Main Module ──────────────────────────────────────────────────────────────
|
||
|
||
export function MiniUtilitiesModule() {
|
||
return (
|
||
<Tabs defaultValue="text-case" className="space-y-4">
|
||
<div className="space-y-2">
|
||
<TabsList className="flex h-auto flex-wrap gap-1 rounded-lg bg-muted/50 p-1">
|
||
{/* ── Text & Calcule ── */}
|
||
<TabsTrigger value="text-case">
|
||
<Type className="mr-1.5 h-3.5 w-3.5" /> Text
|
||
</TabsTrigger>
|
||
<TabsTrigger value="char-count">
|
||
<Hash className="mr-1.5 h-3.5 w-3.5" /> Caractere
|
||
</TabsTrigger>
|
||
<TabsTrigger value="num-to-text">
|
||
<CaseUpper className="mr-1.5 h-3.5 w-3.5" /> Nr→Text
|
||
</TabsTrigger>
|
||
<TabsTrigger value="percentage">
|
||
<Percent className="mr-1.5 h-3.5 w-3.5" /> Procente
|
||
</TabsTrigger>
|
||
<TabsTrigger value="tva">
|
||
<Receipt className="mr-1.5 h-3.5 w-3.5" /> TVA
|
||
</TabsTrigger>
|
||
<TabsTrigger value="area">
|
||
<Ruler className="mr-1.5 h-3.5 w-3.5" /> Suprafețe
|
||
</TabsTrigger>
|
||
<TabsTrigger value="u-value">
|
||
<Zap className="mr-1.5 h-3.5 w-3.5" /> U↔R
|
||
</TabsTrigger>
|
||
<TabsTrigger value="thermal-compare">
|
||
<Layers className="mr-1.5 h-3.5 w-3.5" /> Termică
|
||
</TabsTrigger>
|
||
<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>
|
||
</TabsList>
|
||
<TabsList className="flex h-auto flex-wrap gap-1 rounded-lg bg-muted/50 p-1">
|
||
{/* ── Documente & Unelte ── */}
|
||
<TabsTrigger value="pdf-reducer">
|
||
<FileDown className="mr-1.5 h-3.5 w-3.5" /> Compresie PDF
|
||
</TabsTrigger>
|
||
<TabsTrigger value="pdf-unlock">
|
||
<Unlock className="mr-1.5 h-3.5 w-3.5" /> Deblocare PDF
|
||
</TabsTrigger>
|
||
<TabsTrigger value="dwg-dxf">
|
||
<PenTool className="mr-1.5 h-3.5 w-3.5" /> DWG→DXF
|
||
</TabsTrigger>
|
||
<TabsTrigger value="ocr">
|
||
<ScanText className="mr-1.5 h-3.5 w-3.5" /> OCR
|
||
</TabsTrigger>
|
||
<TabsTrigger value="ai-cleaner">
|
||
<Wand2 className="mr-1.5 h-3.5 w-3.5" /> Curățare AI
|
||
</TabsTrigger>
|
||
<TabsTrigger value="color-palette">
|
||
<Palette className="mr-1.5 h-3.5 w-3.5" /> Culori
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
</div>
|
||
|
||
<TabsContent value="text-case">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Transformare text</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<TextCaseConverter />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
<TabsContent value="char-count">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Numărare caractere</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<CharacterCounter />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
<TabsContent value="percentage">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Calculator procente</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<PercentageCalculator />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
<TabsContent value="tva">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Calculator TVA</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<TvaCalculator />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
<TabsContent value="area">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Convertor suprafețe</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<AreaConverter />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
<TabsContent value="u-value">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">
|
||
Convertor U ↔ R (termoizolație)
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<UValueConverter />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
<TabsContent value="thermal-compare">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">
|
||
Comparație termică materiale
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<MaterialThermalComparison />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
<TabsContent value="ai-cleaner">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Curățare text AI</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<AiArtifactCleaner />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
<TabsContent value="mdlpa">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">
|
||
MDLPA — Date locale construcții
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<MdlpaValidator />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
<TabsContent value="pdf-reducer">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Reducere dimensiune PDF</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<PdfReducer />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
<TabsContent value="pdf-unlock">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Deblocare PDF</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<PdfUnlock />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
<TabsContent value="dwg-dxf">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Convertor DWG → DXF</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<DwgToDxf />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
<TabsContent value="ocr">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">
|
||
OCR — extragere text din imagini
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<QuickOcr />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
<TabsContent value="num-to-text">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">
|
||
Transformare numere în litere
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<NumberToText />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
<TabsContent value="color-palette">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Extractor paletă culori</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ColorPaletteExtractor />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
<TabsContent value="scale">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Calculator scară desen</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ScaleCalculator />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
</Tabs>
|
||
);
|
||
}
|