feat(mini-utilities): add 5 new tools - U/R converter, AI cleaner, MDLPA, PDF reducer, OCR

This commit is contained in:
AI Assistant
2026-02-19 00:22:17 +02:00
parent 81cfdd6aa8
commit 7a5206e771
3 changed files with 1167 additions and 368 deletions

View File

@@ -1,13 +1,35 @@
'use client'; "use client";
import { useState } from 'react'; import { useState, useRef } from "react";
import { Copy, Check, Hash, Type, Percent, Ruler } from 'lucide-react'; import {
import { Button } from '@/shared/components/ui/button'; Copy,
import { Input } from '@/shared/components/ui/input'; Check,
import { Label } from '@/shared/components/ui/label'; Hash,
import { Textarea } from '@/shared/components/ui/textarea'; Type,
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card'; Percent,
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/components/ui/tabs'; Ruler,
Zap,
Wand2,
Building2,
FileDown,
ScanText,
} 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 }) { function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -16,17 +38,29 @@ function CopyButton({ text }: { text: string }) {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 1500); setTimeout(() => setCopied(false), 1500);
} catch { /* silent */ } } catch {
/* silent */
}
}; };
return ( return (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopy} disabled={!text}> <Button
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />} 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> </Button>
); );
} }
function TextCaseConverter() { function TextCaseConverter() {
const [input, setInput] = useState(''); const [input, setInput] = useState("");
const upper = input.toUpperCase(); const upper = input.toUpperCase();
const lower = input.toLowerCase(); const lower = input.toLowerCase();
const title = input.replace(/\b\w/g, (c) => c.toUpperCase()); const title = input.replace(/\b\w/g, (c) => c.toUpperCase());
@@ -34,15 +68,26 @@ function TextCaseConverter() {
return ( return (
<div className="space-y-3"> <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> <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: "UPPERCASE", value: upper },
{ label: 'lowercase', value: lower }, { label: "lowercase", value: lower },
{ label: 'Title Case', value: title }, { label: "Title Case", value: title },
{ label: 'Sentence case', value: sentence }, { label: "Sentence case", value: sentence },
].map(({ label, value }) => ( ].map(({ label, value }) => (
<div key={label} className="flex items-center gap-2"> <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> <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> <span className="w-24 text-xs text-muted-foreground">{label}</span>
<CopyButton text={value} /> <CopyButton text={value} />
</div> </div>
@@ -52,73 +97,148 @@ function TextCaseConverter() {
} }
function CharacterCounter() { function CharacterCounter() {
const [input, setInput] = useState(''); const [input, setInput] = useState("");
const chars = input.length; const chars = input.length;
const charsNoSpaces = input.replace(/\s/g, '').length; const charsNoSpaces = input.replace(/\s/g, "").length;
const words = input.trim() ? input.trim().split(/\s+/).length : 0; const words = input.trim() ? input.trim().split(/\s+/).length : 0;
const lines = input ? input.split('\n').length : 0; const lines = input ? input.split("\n").length : 0;
return ( return (
<div className="space-y-3"> <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>
<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"> <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>
<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> <CardContent className="p-3">
<Card><CardContent className="p-3"><p className="text-xs text-muted-foreground">Cuvinte</p><p className="text-xl font-bold">{words}</p></CardContent></Card> <p className="text-xs text-muted-foreground">Caractere</p>
<Card><CardContent className="p-3"><p className="text-xs text-muted-foreground">Linii</p><p className="text-xl font-bold">{lines}</p></CardContent></Card> <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>
</div> </div>
); );
} }
function PercentageCalculator() { function PercentageCalculator() {
const [value, setValue] = useState(''); const [value, setValue] = useState("");
const [total, setTotal] = useState(''); const [total, setTotal] = useState("");
const [percent, setPercent] = useState(''); const [percent, setPercent] = useState("");
const v = parseFloat(value); const v = parseFloat(value);
const t = parseFloat(total); const t = parseFloat(total);
const p = parseFloat(percent); const p = parseFloat(percent);
const pctOfTotal = !isNaN(v) && !isNaN(t) && t !== 0 ? ((v / t) * 100).toFixed(2) : '—'; const pctOfTotal =
const valFromPct = !isNaN(p) && !isNaN(t) ? ((p / 100) * t).toFixed(2) : '—'; !isNaN(v) && !isNaN(t) && t !== 0 ? ((v / t) * 100).toFixed(2) : "—";
const valFromPct = !isNaN(p) && !isNaN(t) ? ((p / 100) * t).toFixed(2) : "—";
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3"> <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>
<div><Label>Total</Label><Input type="number" value={total} onChange={(e) => setTotal(e.target.value)} className="mt-1" /></div> <Label>Valoare</Label>
<div><Label>Procent</Label><Input type="number" value={percent} onChange={(e) => setPercent(e.target.value)} className="mt-1" /></div> <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>
<div className="space-y-2 rounded-md border bg-muted/30 p-3 text-sm"> <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>
<p><strong>{percent || '?'}%</strong> din <strong>{total || '?'}</strong> = <strong>{valFromPct}</strong></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>
</div> </div>
); );
} }
function AreaConverter() { function AreaConverter() {
const [mp, setMp] = useState(''); const [mp, setMp] = useState("");
const v = parseFloat(mp); const v = parseFloat(mp);
const conversions = !isNaN(v) ? [ const conversions = !isNaN(v)
{ label: 'mp (m²)', value: v.toFixed(2) }, ? [
{ label: 'ari (100 m²)', value: (v / 100).toFixed(4) }, { label: "mp (m²)", value: v.toFixed(2) },
{ label: 'hectare (10.000 m²)', value: (v / 10000).toFixed(6) }, { label: "ari (100 m²)", value: (v / 100).toFixed(4) },
{ label: 'km²', value: (v / 1000000).toFixed(8) }, { label: "hectare (10.000 m²)", value: (v / 10000).toFixed(6) },
{ label: 'sq ft', value: (v * 10.7639).toFixed(2) }, { label: "km²", value: (v / 1000000).toFixed(8) },
] : []; { label: "sq ft", value: (v * 10.7639).toFixed(2) },
]
: [];
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div><Label>Suprafață (m²)</Label><Input type="number" value={mp} onChange={(e) => setMp(e.target.value)} className="mt-1" placeholder="Introdu suprafața..." /></div> <div>
<Label>Suprafață (m²)</Label>
<Input
type="number"
value={mp}
onChange={(e) => setMp(e.target.value)}
className="mt-1"
placeholder="Introdu suprafața..."
/>
</div>
{conversions.length > 0 && ( {conversions.length > 0 && (
<div className="space-y-1.5"> <div className="space-y-1.5">
{conversions.map(({ label, value: val }) => ( {conversions.map(({ label, value: val }) => (
<div key={label} className="flex items-center gap-2"> <div key={label} className="flex items-center gap-2">
<code className="flex-1 rounded border bg-muted/30 px-2 py-1 text-xs">{val}</code> <code className="flex-1 rounded border bg-muted/30 px-2 py-1 text-xs">
<span className="w-36 text-xs text-muted-foreground">{label}</span> {val}
</code>
<span className="w-36 text-xs text-muted-foreground">
{label}
</span>
<CopyButton text={val} /> <CopyButton text={val} />
</div> </div>
))} ))}
@@ -128,31 +248,491 @@ function AreaConverter() {
); );
} }
// ─── U-value → R-value Converter ─────────────────────────────────────────────
function UValueConverter() {
const [uValue, setUValue] = useState("");
const [thickness, setThickness] = useState("");
const u = parseFloat(uValue);
const t = parseFloat(thickness);
const rValue = !isNaN(u) && u > 0 ? (1 / u).toFixed(4) : null;
const rsi = 0.13;
const rse = 0.04;
const rTotal =
rValue !== null ? (parseFloat(rValue) + rsi + rse).toFixed(4) : null;
const lambda =
rValue !== null && !isNaN(t) && t > 0
? (t / 100 / parseFloat(rValue)).toFixed(4)
: null;
return (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<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>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>
{rValue !== null && (
<div className="space-y-2 rounded-md border bg-muted/30 p-3 text-sm">
<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">
{rValue} m²K/W
</code>
<CopyButton text={rValue} />
</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>
<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">
{rTotal} m²K/W
</code>
<CopyButton text={rTotal ?? ""} />
</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>
);
}
// ─── 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, "");
// 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), 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-3">
<div className="flex flex-wrap items-center gap-3 text-sm">
<a
href="https://datelocale.mdlpa.ro"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
Deschide datelocale.mdlpa.ro ↗
</a>
<span className="text-muted-foreground">•</span>
<a
href="https://datelocale.mdlpa.ro/ro/about/tutorials"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
Tutoriale video ↗
</a>
<span className="text-muted-foreground">•</span>
<a
href="https://datelocale.mdlpa.ro/ro/about/info/reguli"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
Reguli de calcul ↗
</a>
</div>
<div
className="overflow-hidden rounded-md border"
style={{ height: "560px" }}
>
<iframe
src="https://datelocale.mdlpa.ro"
className="h-full w-full"
title="MDLPA — Date Locale"
allow="fullscreen"
/>
</div>
</div>
);
}
// ─── PDF Reducer (Stirling PDF) ───────────────────────────────────────────────
function PdfReducer() {
const [file, setFile] = useState<File | null>(null);
const [optimizeLevel, setOptimizeLevel] = useState("2");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const fileRef = useRef<HTMLInputElement>(null);
const handleCompress = async () => {
if (!file) return;
setLoading(true);
setError("");
try {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("optimizeLevel", optimizeLevel);
const res = await fetch(
"http://10.10.10.166:8087/api/v1/misc/compress-pdf",
{
method: "POST",
body: formData,
},
);
if (!res.ok) throw new 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, "-comprimat.pdf");
a.click();
URL.revokeObjectURL(url);
} catch {
setError(
"Nu s-a putut conecta la Stirling PDF. Folosește linkul de mai jos pentru a deschide aplicația manual.",
);
} 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) => {
setFile(e.target.files?.[0] ?? null);
setError("");
}}
/>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={() => fileRef.current?.click()}>
Selectează PDF...
</Button>
{file && (
<span className="text-sm text-muted-foreground">{file.name}</span>
)}
</div>
</div>
<div className="space-y-1.5">
<Label>Nivel compresie</Label>
<select
value={optimizeLevel}
onChange={(e) => setOptimizeLevel(e.target.value)}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
>
<option value="0">0 — fără modificări (test)</option>
<option value="1">1 — compresie minimă</option>
<option value="2">2 — echilibrat (recomandat)</option>
<option value="3">3 — compresie mare</option>
<option value="4">4 — compresie maximă</option>
</select>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={handleCompress} disabled={!file || loading}>
{loading ? "Se comprimă..." : "Comprimă PDF"}
</Button>
<Button variant="ghost" asChild>
<a
href="http://10.10.10.166:8087/compress-pdf"
target="_blank"
rel="noopener noreferrer"
>
Deschide Stirling PDF ↗
</a>
</Button>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
}
// ─── Quick OCR ────────────────────────────────────────────────────────────────
function QuickOcr() {
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3 text-sm">
<a
href="https://ocr.z.ai"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
Deschide ocr.z.ai ↗
</a>
<span className="text-muted-foreground">•</span>
<span className="text-muted-foreground text-xs">
Extragere text din imagini și PDF-uri scanate
</span>
</div>
<div
className="overflow-hidden rounded-md border"
style={{ height: "560px" }}
>
<iframe
src="https://ocr.z.ai"
className="h-full w-full"
title="OCR — extragere text din imagini"
allow="fullscreen"
/>
</div>
</div>
);
}
// ─── Main Module ──────────────────────────────────────────────────────────────
export function MiniUtilitiesModule() { export function MiniUtilitiesModule() {
return ( return (
<Tabs defaultValue="text-case" className="space-y-4"> <Tabs defaultValue="text-case" className="space-y-4">
<TabsList className="flex-wrap"> <TabsList className="flex-wrap">
<TabsTrigger value="text-case"><Type className="mr-1 h-3.5 w-3.5" /> Transformare text</TabsTrigger> <TabsTrigger value="text-case">
<TabsTrigger value="char-count"><Hash className="mr-1 h-3.5 w-3.5" /> Numărare caractere</TabsTrigger> <Type className="mr-1 h-3.5 w-3.5" /> Transformare text
<TabsTrigger value="percentage"><Percent className="mr-1 h-3.5 w-3.5" /> Procente</TabsTrigger> </TabsTrigger>
<TabsTrigger value="area"><Ruler className="mr-1 h-3.5 w-3.5" /> Convertor suprafețe</TabsTrigger> <TabsTrigger value="char-count">
<Hash className="mr-1 h-3.5 w-3.5" /> Numărare caractere
</TabsTrigger>
<TabsTrigger value="percentage">
<Percent className="mr-1 h-3.5 w-3.5" /> Procente
</TabsTrigger>
<TabsTrigger value="area">
<Ruler className="mr-1 h-3.5 w-3.5" /> Suprafețe
</TabsTrigger>
<TabsTrigger value="u-value">
<Zap className="mr-1 h-3.5 w-3.5" /> U → R
</TabsTrigger>
<TabsTrigger value="ai-cleaner">
<Wand2 className="mr-1 h-3.5 w-3.5" /> Curățare AI
</TabsTrigger>
<TabsTrigger value="mdlpa">
<Building2 className="mr-1 h-3.5 w-3.5" /> MDLPA
</TabsTrigger>
<TabsTrigger value="pdf-reducer">
<FileDown className="mr-1 h-3.5 w-3.5" /> Reducere PDF
</TabsTrigger>
<TabsTrigger value="ocr">
<ScanText className="mr-1 h-3.5 w-3.5" /> OCR
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="text-case"> <TabsContent value="text-case">
<Card><CardHeader><CardTitle className="text-base">Transformare text</CardTitle></CardHeader> <Card>
<CardContent><TextCaseConverter /></CardContent></Card> <CardHeader>
<CardTitle className="text-base">Transformare text</CardTitle>
</CardHeader>
<CardContent>
<TextCaseConverter />
</CardContent>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="char-count"> <TabsContent value="char-count">
<Card><CardHeader><CardTitle className="text-base">Numărare caractere</CardTitle></CardHeader> <Card>
<CardContent><CharacterCounter /></CardContent></Card> <CardHeader>
<CardTitle className="text-base">Numărare caractere</CardTitle>
</CardHeader>
<CardContent>
<CharacterCounter />
</CardContent>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="percentage"> <TabsContent value="percentage">
<Card><CardHeader><CardTitle className="text-base">Calculator procente</CardTitle></CardHeader> <Card>
<CardContent><PercentageCalculator /></CardContent></Card> <CardHeader>
<CardTitle className="text-base">Calculator procente</CardTitle>
</CardHeader>
<CardContent>
<PercentageCalculator />
</CardContent>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="area"> <TabsContent value="area">
<Card><CardHeader><CardTitle className="text-base">Convertor suprafețe</CardTitle></CardHeader> <Card>
<CardContent><AreaConverter /></CardContent></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="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="ocr">
<Card>
<CardHeader>
<CardTitle className="text-base">
OCR — extragere text din imagini
</CardTitle>
</CardHeader>
<CardContent>
<QuickOcr />
</CardContent>
</Card>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
); );

View File

@@ -1,73 +1,107 @@
'use client'; "use client";
import { useState, useMemo } from 'react'; import { useState, useMemo } from "react";
import { import {
Plus, Trash2, Pencil, Check, X, Download, ChevronDown, ChevronRight, Plus,
Tag as TagIcon, Search, FolderTree, Trash2,
} from 'lucide-react'; Pencil,
import { Button } from '@/shared/components/ui/button'; Check,
import { Input } from '@/shared/components/ui/input'; X,
import { Label } from '@/shared/components/ui/label'; Download,
import { Badge } from '@/shared/components/ui/badge'; ChevronDown,
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card'; ChevronRight,
Tag as TagIcon,
Search,
FolderTree,
} 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 { Badge } from "@/shared/components/ui/badge";
import { import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Card,
} from '@/shared/components/ui/select'; CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, Select,
} from '@/shared/components/ui/dialog'; SelectContent,
import { useTags } from '@/core/tagging'; SelectItem,
import type { Tag, TagCategory, TagScope } from '@/core/tagging/types'; SelectTrigger,
import { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from '@/core/tagging/types'; SelectValue,
import type { CompanyId } from '@/core/auth/types'; } from "@/shared/components/ui/select";
import { cn } from '@/shared/lib/utils'; import {
import { getManicTimeSeedTags } from '../services/seed-data'; Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/shared/components/ui/dialog";
import { useTags } from "@/core/tagging";
import type { Tag, TagCategory, TagScope } from "@/core/tagging/types";
import { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from "@/core/tagging/types";
import type { CompanyId } from "@/core/auth/types";
import { cn } from "@/shared/lib/utils";
import { getManicTimeSeedTags } from "../services/seed-data";
const SCOPE_LABELS: Record<TagScope, string> = { const SCOPE_LABELS: Record<TagScope, string> = {
global: 'Global', global: "Global",
module: 'Modul', module: "Modul",
company: 'Companie', company: "Companie",
}; };
const COMPANY_LABELS: Record<CompanyId, string> = { const COMPANY_LABELS: Record<CompanyId, string> = {
beletage: 'Beletage', beletage: "Beletage",
'urban-switch': 'Urban Switch', "urban-switch": "Urban Switch",
'studii-de-teren': 'Studii de Teren', "studii-de-teren": "Studii de Teren",
group: 'Grup', group: "Grup",
}; };
const TAG_COLORS = [ const TAG_COLORS = [
'#ef4444', '#f97316', '#f59e0b', '#84cc16', "#ef4444",
'#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6', "#f97316",
'#ec4899', '#64748b', '#22B5AB', '#6366f1', "#f59e0b",
"#84cc16",
"#22c55e",
"#06b6d4",
"#3b82f6",
"#8b5cf6",
"#ec4899",
"#64748b",
"#22B5AB",
"#6366f1",
]; ];
export function TagManagerModule() { export function TagManagerModule() {
const { tags, loading, createTag, updateTag, deleteTag, importTags } = useTags(); const { tags, loading, createTag, updateTag, deleteTag, importTags } =
useTags();
// ── Create form state ── // ── Create form state ──
const [newLabel, setNewLabel] = useState(''); const [newLabel, setNewLabel] = useState("");
const [newCategory, setNewCategory] = useState<TagCategory>('custom'); const [newCategory, setNewCategory] = useState<TagCategory>("custom");
const [newScope, setNewScope] = useState<TagScope>('global'); const [newScope, setNewScope] = useState<TagScope>("global");
const [newColor, setNewColor] = useState('#3b82f6'); const [newColor, setNewColor] = useState("#3b82f6");
const [newCompanyId, setNewCompanyId] = useState<CompanyId>('beletage'); const [newCompanyId, setNewCompanyId] = useState<CompanyId>("beletage");
const [newProjectCode, setNewProjectCode] = useState(''); const [newProjectCode, setNewProjectCode] = useState("");
const [newParentId, setNewParentId] = useState(''); const [newParentId, setNewParentId] = useState("");
// ── Filter / search state ── // ── Filter / search state ──
const [filterCategory, setFilterCategory] = useState<TagCategory | 'all'>('all'); const [filterCategory, setFilterCategory] = useState<TagCategory | "all">(
const [searchQuery, setSearchQuery] = useState(''); "all",
);
const [searchQuery, setSearchQuery] = useState("");
const [expandedCategories, setExpandedCategories] = useState<Set<string>>( const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
() => new Set(TAG_CATEGORY_ORDER) () => new Set(TAG_CATEGORY_ORDER),
); );
// ── Edit state ── // ── Edit state ──
const [editingTag, setEditingTag] = useState<Tag | null>(null); const [editingTag, setEditingTag] = useState<Tag | null>(null);
const [editLabel, setEditLabel] = useState(''); const [editLabel, setEditLabel] = useState("");
const [editColor, setEditColor] = useState(''); const [editColor, setEditColor] = useState("");
const [editProjectCode, setEditProjectCode] = useState(''); const [editProjectCode, setEditProjectCode] = useState("");
const [editScope, setEditScope] = useState<TagScope>('global'); const [editScope, setEditScope] = useState<TagScope>("global");
const [editCompanyId, setEditCompanyId] = useState<CompanyId>('beletage'); const [editCompanyId, setEditCompanyId] = useState<CompanyId>("beletage");
// ── Seed import state ── // ── Seed import state ──
const [showSeedDialog, setShowSeedDialog] = useState(false); const [showSeedDialog, setShowSeedDialog] = useState(false);
@@ -77,7 +111,7 @@ export function TagManagerModule() {
// ── Computed ── // ── Computed ──
const filteredTags = useMemo(() => { const filteredTags = useMemo(() => {
let result = tags; let result = tags;
if (filterCategory !== 'all') { if (filterCategory !== "all") {
result = result.filter((t) => t.category === filterCategory); result = result.filter((t) => t.category === filterCategory);
} }
if (searchQuery) { if (searchQuery) {
@@ -85,7 +119,7 @@ export function TagManagerModule() {
result = result.filter( result = result.filter(
(t) => (t) =>
t.label.toLowerCase().includes(q) || t.label.toLowerCase().includes(q) ||
(t.projectCode?.toLowerCase().includes(q) ?? false) (t.projectCode?.toLowerCase().includes(q) ?? false),
); );
} }
return result; return result;
@@ -119,9 +153,7 @@ export function TagManagerModule() {
}, [tags]); }, [tags]);
const parentCandidates = useMemo(() => { const parentCandidates = useMemo(() => {
return tags.filter( return tags.filter((t) => t.category === newCategory && !t.parentId);
(t) => t.category === newCategory && !t.parentId
);
}, [tags, newCategory]); }, [tags, newCategory]);
// ── Validation state ── // ── Validation state ──
@@ -131,13 +163,17 @@ export function TagManagerModule() {
const handleCreate = async () => { const handleCreate = async () => {
const errors: string[] = []; const errors: string[] = [];
if (!newLabel.trim()) { if (!newLabel.trim()) {
errors.push('Numele etichetei este obligatoriu.'); errors.push("Numele etichetei este obligatoriu.");
} }
if (newCategory === 'project' && !newProjectCode.trim()) { if (newCategory === "project" && !newProjectCode.trim()) {
errors.push('Codul proiectului este obligatoriu pentru categoria Proiect (ex: B-001, US-010, SDT-003).'); errors.push(
"Codul proiectului este obligatoriu pentru categoria Proiect (ex: B-001, US-010, SDT-003).",
);
} }
if (newCategory === 'project' && newScope !== 'company') { if (newCategory === "project" && newScope !== "company") {
errors.push('Etichetele de tip Proiect trebuie asociate unei companii (vizibilitate = Companie).'); errors.push(
"Etichetele de tip Proiect trebuie asociate unei companii (vizibilitate = Companie).",
);
} }
if (errors.length > 0) { if (errors.length > 0) {
setValidationErrors(errors); setValidationErrors(errors);
@@ -149,22 +185,25 @@ export function TagManagerModule() {
category: newCategory, category: newCategory,
scope: newScope, scope: newScope,
color: newColor, color: newColor,
companyId: newScope === 'company' ? newCompanyId : undefined, companyId: newScope === "company" ? newCompanyId : undefined,
projectCode: newCategory === 'project' && newProjectCode ? newProjectCode : undefined, projectCode:
newCategory === "project" && newProjectCode
? newProjectCode
: undefined,
parentId: newParentId || undefined, parentId: newParentId || undefined,
}); });
setNewLabel(''); setNewLabel("");
setNewProjectCode(''); setNewProjectCode("");
setNewParentId(''); setNewParentId("");
}; };
const startEdit = (tag: Tag) => { const startEdit = (tag: Tag) => {
setEditingTag(tag); setEditingTag(tag);
setEditLabel(tag.label); setEditLabel(tag.label);
setEditColor(tag.color ?? '#3b82f6'); setEditColor(tag.color ?? "#3b82f6");
setEditProjectCode(tag.projectCode ?? ''); setEditProjectCode(tag.projectCode ?? "");
setEditScope(tag.scope); setEditScope(tag.scope);
setEditCompanyId(tag.companyId ?? 'beletage'); setEditCompanyId(tag.companyId ?? "beletage");
}; };
const saveEdit = async () => { const saveEdit = async () => {
@@ -172,9 +211,12 @@ export function TagManagerModule() {
await updateTag(editingTag.id, { await updateTag(editingTag.id, {
label: editLabel.trim(), label: editLabel.trim(),
color: editColor, color: editColor,
projectCode: editingTag.category === 'project' && editProjectCode ? editProjectCode : undefined, projectCode:
editingTag.category === "project" && editProjectCode
? editProjectCode
: undefined,
scope: editScope, scope: editScope,
companyId: editScope === 'company' ? editCompanyId : undefined, companyId: editScope === "company" ? editCompanyId : undefined,
}); });
setEditingTag(null); setEditingTag(null);
}; };
@@ -186,7 +228,9 @@ export function TagManagerModule() {
setSeedResult(null); setSeedResult(null);
const seedTags = getManicTimeSeedTags(); const seedTags = getManicTimeSeedTags();
const count = await importTags(seedTags); const count = await importTags(seedTags);
setSeedResult(`${count} etichete importate din ${seedTags.length} disponibile.`); setSeedResult(
`${count} etichete importate din ${seedTags.length} disponibile.`,
);
setSeedImporting(false); setSeedImporting(false);
}; };
@@ -200,24 +244,30 @@ export function TagManagerModule() {
}; };
// ── Stats ── // ── Stats ──
const projectCount = tags.filter((t) => t.category === 'project').length; const projectCount = tags.filter((t) => t.category === "project").length;
const phaseCount = tags.filter((t) => t.category === 'phase').length; const phaseCount = tags.filter((t) => t.category === "phase").length;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<Card><CardContent className="p-4"> <Card>
<p className="text-xs text-muted-foreground">Total etichete</p> <CardContent className="p-4">
<p className="text-2xl font-bold">{tags.length}</p> <p className="text-xs text-muted-foreground">Total etichete</p>
</CardContent></Card> <p className="text-2xl font-bold">{tags.length}</p>
</CardContent>
</Card>
{TAG_CATEGORY_ORDER.map((cat) => ( {TAG_CATEGORY_ORDER.map((cat) => (
<Card key={cat}><CardContent className="p-4"> <Card key={cat}>
<p className="text-xs text-muted-foreground">{TAG_CATEGORY_LABELS[cat]}</p> <CardContent className="p-4">
<p className="text-2xl font-bold"> <p className="text-xs text-muted-foreground">
{tags.filter((t) => t.category === cat).length} {TAG_CATEGORY_LABELS[cat]}
</p> </p>
</CardContent></Card> <p className="text-2xl font-bold">
{tags.filter((t) => t.category === cat).length}
</p>
</CardContent>
</Card>
))} ))}
</div> </div>
@@ -228,7 +278,8 @@ export function TagManagerModule() {
<div> <div>
<p className="font-medium">Nicio etichetă găsită</p> <p className="font-medium">Nicio etichetă găsită</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Importă datele din ManicTime pentru a popula proiectele, fazele și activitățile. Importă datele din ManicTime pentru a popula proiectele, fazele
și activitățile.
</p> </p>
</div> </div>
<Button onClick={() => setShowSeedDialog(true)}> <Button onClick={() => setShowSeedDialog(true)}>
@@ -240,7 +291,9 @@ export function TagManagerModule() {
{/* Create new tag */} {/* Create new tag */}
<Card> <Card>
<CardHeader><CardTitle className="text-base">Etichetă nouă</CardTitle></CardHeader> <CardHeader>
<CardTitle className="text-base">Etichetă nouă</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap items-end gap-3"> <div className="flex flex-wrap items-end gap-3">
@@ -249,41 +302,62 @@ export function TagManagerModule() {
<Input <Input
value={newLabel} value={newLabel}
onChange={(e) => setNewLabel(e.target.value)} onChange={(e) => setNewLabel(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()} onKeyDown={(e) => e.key === "Enter" && handleCreate()}
placeholder="Numele etichetei..." placeholder="Numele etichetei..."
className="mt-1" className="mt-1"
/> />
</div> </div>
<div className="w-[160px]"> <div className="w-[160px]">
<Label>Categorie</Label> <Label>Categorie</Label>
<Select value={newCategory} onValueChange={(v) => setNewCategory(v as TagCategory)}> <Select
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> value={newCategory}
onValueChange={(v) => setNewCategory(v as TagCategory)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
{TAG_CATEGORY_ORDER.map((cat) => ( {TAG_CATEGORY_ORDER.map((cat) => (
<SelectItem key={cat} value={cat}>{TAG_CATEGORY_LABELS[cat]}</SelectItem> <SelectItem key={cat} value={cat}>
{TAG_CATEGORY_LABELS[cat]}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="w-[140px]"> <div className="w-[140px]">
<Label>Vizibilitate</Label> <Label>Vizibilitate</Label>
<Select value={newScope} onValueChange={(v) => setNewScope(v as TagScope)}> <Select
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> value={newScope}
onValueChange={(v) => setNewScope(v as TagScope)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
{(Object.keys(SCOPE_LABELS) as TagScope[]).map((s) => ( {(Object.keys(SCOPE_LABELS) as TagScope[]).map((s) => (
<SelectItem key={s} value={s}>{SCOPE_LABELS[s]}</SelectItem> <SelectItem key={s} value={s}>
{SCOPE_LABELS[s]}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{newScope === 'company' && ( {newScope === "company" && (
<div className="w-[150px]"> <div className="w-[150px]">
<Label>Companie</Label> <Label>Companie</Label>
<Select value={newCompanyId} onValueChange={(v) => setNewCompanyId(v as CompanyId)}> <Select
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> value={newCompanyId}
onValueChange={(v) => setNewCompanyId(v as CompanyId)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
{(Object.keys(COMPANY_LABELS) as CompanyId[]).map((c) => ( {(Object.keys(COMPANY_LABELS) as CompanyId[]).map((c) => (
<SelectItem key={c} value={c}>{COMPANY_LABELS[c]}</SelectItem> <SelectItem key={c} value={c}>
{COMPANY_LABELS[c]}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -292,7 +366,7 @@ export function TagManagerModule() {
</div> </div>
<div className="flex flex-wrap items-end gap-3"> <div className="flex flex-wrap items-end gap-3">
{newCategory === 'project' && ( {newCategory === "project" && (
<div className="w-[140px]"> <div className="w-[140px]">
<Label>Cod proiect</Label> <Label>Cod proiect</Label>
<Input <Input
@@ -306,13 +380,23 @@ export function TagManagerModule() {
{parentCandidates.length > 0 && ( {parentCandidates.length > 0 && (
<div className="w-[200px]"> <div className="w-[200px]">
<Label>Tag părinte (opțional)</Label> <Label>Tag părinte (opțional)</Label>
<Select value={newParentId || '__none__'} onValueChange={(v) => setNewParentId(v === '__none__' ? '' : v)}> <Select
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> value={newParentId || "__none__"}
onValueChange={(v) =>
setNewParentId(v === "__none__" ? "" : v)
}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="__none__"> Niciun părinte </SelectItem> <SelectItem value="__none__">
Niciun părinte
</SelectItem>
{parentCandidates.map((p) => ( {parentCandidates.map((p) => (
<SelectItem key={p.id} value={p.id}> <SelectItem key={p.id} value={p.id}>
{p.projectCode ? `${p.projectCode} ` : ''}{p.label} {p.projectCode ? `${p.projectCode} ` : ""}
{p.label}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -328,8 +412,10 @@ export function TagManagerModule() {
type="button" type="button"
onClick={() => setNewColor(color)} onClick={() => setNewColor(color)}
className={cn( className={cn(
'h-7 w-7 rounded-full border-2 transition-all', "h-7 w-7 rounded-full border-2 transition-all",
newColor === color ? 'border-primary scale-110' : 'border-transparent hover:scale-105' newColor === color
? "border-primary scale-110"
: "border-transparent hover:scale-105",
)} )}
style={{ backgroundColor: color }} style={{ backgroundColor: color }}
/> />
@@ -345,16 +431,19 @@ export function TagManagerModule() {
{validationErrors.length > 0 && ( {validationErrors.length > 0 && (
<div className="rounded-md border border-destructive/50 bg-destructive/5 p-3"> <div className="rounded-md border border-destructive/50 bg-destructive/5 p-3">
{validationErrors.map((err) => ( {validationErrors.map((err) => (
<p key={err} className="text-sm text-destructive">{err}</p> <p key={err} className="text-sm text-destructive">
{err}
</p>
))} ))}
</div> </div>
)} )}
{/* Hint for mandatory categories */} {/* Hint for mandatory categories */}
{(newCategory === 'project' || newCategory === 'phase') && ( {(newCategory === "project" || newCategory === "phase") && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
<strong>Notă:</strong> Categoriile <em>Proiect</em> și <em>Fază</em> sunt obligatorii <strong>Notă:</strong> Categoriile <em>Proiect</em> și{" "}
în structura de etichete. Proiectele necesită un cod (ex: B-001, US-010, SDT-003) și <em>Fază</em> sunt obligatorii în structura de etichete.
Proiectele necesită un cod (ex: B-001, US-010, SDT-003) și
trebuie asociate unei companii. trebuie asociate unei companii.
</p> </p>
)} )}
@@ -373,17 +462,28 @@ export function TagManagerModule() {
className="pl-9" className="pl-9"
/> />
</div> </div>
<Select value={filterCategory} onValueChange={(v) => setFilterCategory(v as TagCategory | 'all')}> <Select
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger> value={filterCategory}
onValueChange={(v) => setFilterCategory(v as TagCategory | "all")}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Toate categoriile</SelectItem> <SelectItem value="all">Toate categoriile</SelectItem>
{TAG_CATEGORY_ORDER.map((cat) => ( {TAG_CATEGORY_ORDER.map((cat) => (
<SelectItem key={cat} value={cat}>{TAG_CATEGORY_LABELS[cat]}</SelectItem> <SelectItem key={cat} value={cat}>
{TAG_CATEGORY_LABELS[cat]}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{tags.length > 0 && ( {tags.length > 0 && (
<Button variant="outline" size="sm" onClick={() => setShowSeedDialog(true)}> <Button
variant="outline"
size="sm"
onClick={() => setShowSeedDialog(true)}
>
<Download className="mr-1 h-3.5 w-3.5" /> Importă ManicTime <Download className="mr-1 h-3.5 w-3.5" /> Importă ManicTime
</Button> </Button>
)} )}
@@ -391,10 +491,13 @@ export function TagManagerModule() {
{/* Tag list by category with hierarchy */} {/* Tag list by category with hierarchy */}
{loading ? ( {loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p> <p className="py-8 text-center text-sm text-muted-foreground">
Se încarcă...
</p>
) : Object.keys(groupedByCategory).length === 0 ? ( ) : Object.keys(groupedByCategory).length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground"> <p className="py-8 text-center text-sm text-muted-foreground">
Nicio etichetă găsită. Creează prima etichetă sau importă datele inițiale. Nicio etichetă găsită. Creează prima etichetă sau importă datele
inițiale.
</p> </p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@@ -408,14 +511,20 @@ export function TagManagerModule() {
onClick={() => toggleCategory(category)} onClick={() => toggleCategory(category)}
> >
<CardTitle className="flex items-center gap-2 text-sm"> <CardTitle className="flex items-center gap-2 text-sm">
{isExpanded {isExpanded ? (
? <ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
: <ChevronRight className="h-4 w-4" />} ) : (
<ChevronRight className="h-4 w-4" />
)}
<TagIcon className="h-4 w-4" /> <TagIcon className="h-4 w-4" />
{TAG_CATEGORY_LABELS[category as TagCategory] ?? category} {TAG_CATEGORY_LABELS[category as TagCategory] ?? category}
<Badge variant="secondary" className="ml-1">{catTags.length}</Badge> <Badge variant="secondary" className="ml-1">
{(category === 'project' || category === 'phase') && ( {catTags.length}
<Badge variant="default" className="ml-1 text-[10px]">obligatoriu</Badge> </Badge>
{(category === "project" || category === "phase") && (
<Badge variant="default" className="ml-1 text-[10px]">
obligatoriu
</Badge>
)} )}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@@ -461,18 +570,22 @@ export function TagManagerModule() {
</DialogHeader> </DialogHeader>
<div className="space-y-3 py-2"> <div className="space-y-3 py-2">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Aceasta va importa proiectele Beletage, Urban Switch și Studii de Teren, Aceasta va importa proiectele Beletage, Urban Switch și Studii de
fazele, activitățile și tipurile de documente din lista ManicTime. Teren, fazele, activitățile și tipurile de documente din lista
Etichetele existente nu vor fi duplicate. ManicTime. Etichetele existente nu vor fi duplicate.
</p> </p>
{seedResult && ( {seedResult && (
<p className="rounded bg-muted p-2 text-sm font-medium">{seedResult}</p> <p className="rounded bg-muted p-2 text-sm font-medium">
{seedResult}
</p>
)} )}
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setShowSeedDialog(false)}>Închide</Button> <Button variant="outline" onClick={() => setShowSeedDialog(false)}>
Închide
</Button>
<Button onClick={handleSeedImport} disabled={seedImporting}> <Button onClick={handleSeedImport} disabled={seedImporting}>
{seedImporting ? 'Se importă...' : 'Importă'} {seedImporting ? "Se importă..." : "Importă"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -504,10 +617,23 @@ interface TagRowProps {
} }
function TagRow({ function TagRow({
tag, children, editingTag, editLabel, editColor, editProjectCode, tag,
editScope, editCompanyId, children,
onStartEdit, onSaveEdit, onCancelEdit, onDelete, editingTag,
setEditLabel, setEditColor, setEditProjectCode, setEditScope, setEditCompanyId, editLabel,
editColor,
editProjectCode,
editScope,
editCompanyId,
onStartEdit,
onSaveEdit,
onCancelEdit,
onDelete,
setEditLabel,
setEditColor,
setEditProjectCode,
setEditScope,
setEditCompanyId,
}: TagRowProps) { }: TagRowProps) {
const isEditing = editingTag?.id === tag.id; const isEditing = editingTag?.id === tag.id;
const [showChildren, setShowChildren] = useState(false); const [showChildren, setShowChildren] = useState(false);
@@ -516,7 +642,7 @@ function TagRow({
if (isEditing) { if (isEditing) {
return ( return (
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-2"> <div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-2">
{tag.category === 'project' && ( {tag.category === "project" && (
<Input <Input
value={editProjectCode} value={editProjectCode}
onChange={(e) => setEditProjectCode(e.target.value)} onChange={(e) => setEditProjectCode(e.target.value)}
@@ -527,24 +653,39 @@ function TagRow({
<Input <Input
value={editLabel} value={editLabel}
onChange={(e) => setEditLabel(e.target.value)} onChange={(e) => setEditLabel(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') onSaveEdit(); if (e.key === 'Escape') onCancelEdit(); }} onKeyDown={(e) => {
if (e.key === "Enter") onSaveEdit();
if (e.key === "Escape") onCancelEdit();
}}
className="min-w-[200px] flex-1" className="min-w-[200px] flex-1"
autoFocus autoFocus
/> />
<Select value={editScope} onValueChange={(v) => setEditScope(v as TagScope)}> <Select
<SelectTrigger className="w-[120px]"><SelectValue /></SelectTrigger> value={editScope}
onValueChange={(v) => setEditScope(v as TagScope)}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="global">Global</SelectItem> <SelectItem value="global">Global</SelectItem>
<SelectItem value="module">Modul</SelectItem> <SelectItem value="module">Modul</SelectItem>
<SelectItem value="company">Companie</SelectItem> <SelectItem value="company">Companie</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{editScope === 'company' && ( {editScope === "company" && (
<Select value={editCompanyId} onValueChange={(v) => setEditCompanyId(v as CompanyId)}> <Select
<SelectTrigger className="w-[140px]"><SelectValue /></SelectTrigger> value={editCompanyId}
onValueChange={(v) => setEditCompanyId(v as CompanyId)}
>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
{(Object.keys(COMPANY_LABELS) as CompanyId[]).map((c) => ( {(Object.keys(COMPANY_LABELS) as CompanyId[]).map((c) => (
<SelectItem key={c} value={c}>{COMPANY_LABELS[c]}</SelectItem> <SelectItem key={c} value={c}>
{COMPANY_LABELS[c]}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -556,17 +697,29 @@ function TagRow({
type="button" type="button"
onClick={() => setEditColor(c)} onClick={() => setEditColor(c)}
className={cn( className={cn(
'h-5 w-5 rounded-full border-2 transition-all', "h-5 w-5 rounded-full border-2 transition-all",
editColor === c ? 'border-primary scale-110' : 'border-transparent' editColor === c
? "border-primary scale-110"
: "border-transparent",
)} )}
style={{ backgroundColor: c }} style={{ backgroundColor: c }}
/> />
))} ))}
</div> </div>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onSaveEdit}> <Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={onSaveEdit}
>
<Check className="h-4 w-4 text-green-600" /> <Check className="h-4 w-4 text-green-600" />
</Button> </Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onCancelEdit}> <Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={onCancelEdit}
>
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
@@ -577,18 +730,29 @@ function TagRow({
<div> <div>
<div className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/30"> <div className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/30">
{hasChildren && ( {hasChildren && (
<button type="button" onClick={() => setShowChildren(!showChildren)} className="p-0.5"> <button
{showChildren type="button"
? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" /> onClick={() => setShowChildren(!showChildren)}
: <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />} className="p-0.5"
>
{showChildren ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
)}
</button> </button>
)} )}
{!hasChildren && <span className="w-5" />} {!hasChildren && <span className="w-5" />}
{tag.color && ( {tag.color && (
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: tag.color }} /> <span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: tag.color }}
/>
)} )}
{tag.projectCode && ( {tag.projectCode && (
<span className="font-mono text-xs text-muted-foreground">{tag.projectCode}</span> <span className="font-mono text-xs text-muted-foreground">
{tag.projectCode}
</span>
)} )}
<span className="flex-1 text-sm">{tag.label}</span> <span className="flex-1 text-sm">{tag.label}</span>
{tag.companyId && ( {tag.companyId && (
@@ -619,20 +783,36 @@ function TagRow({
{hasChildren && showChildren && ( {hasChildren && showChildren && (
<div className="ml-6 border-l pl-2"> <div className="ml-6 border-l pl-2">
{children.map((child) => ( {children.map((child) => (
<div key={child.id} className="group flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/30"> <div
key={child.id}
className="group flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/30"
>
<FolderTree className="h-3 w-3 text-muted-foreground" /> <FolderTree className="h-3 w-3 text-muted-foreground" />
{child.color && ( {child.color && (
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: child.color }} /> <span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: child.color }}
/>
)} )}
{child.projectCode && ( {child.projectCode && (
<span className="font-mono text-[11px] text-muted-foreground">{child.projectCode}</span> <span className="font-mono text-[11px] text-muted-foreground">
{child.projectCode}
</span>
)} )}
<span className="flex-1 text-sm">{child.label}</span> <span className="flex-1 text-sm">{child.label}</span>
<div className="flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"> <div className="flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<button type="button" onClick={() => onStartEdit(child)} className="rounded p-1 hover:bg-muted"> <button
type="button"
onClick={() => onStartEdit(child)}
className="rounded p-1 hover:bg-muted"
>
<Pencil className="h-3 w-3 text-muted-foreground" /> <Pencil className="h-3 w-3 text-muted-foreground" />
</button> </button>
<button type="button" onClick={() => onDelete(child.id)} className="rounded p-1 hover:bg-destructive/10"> <button
type="button"
onClick={() => onDelete(child.id)}
className="rounded p-1 hover:bg-destructive/10"
>
<Trash2 className="h-3 w-3 text-destructive" /> <Trash2 className="h-3 w-3 text-destructive" />
</button> </button>
</div> </div>

View File

@@ -1,16 +1,19 @@
import type { Tag, TagCategory } from '@/core/tagging/types'; import type { Tag, TagCategory } from "@/core/tagging/types";
import type { CompanyId } from '@/core/auth/types'; import type { CompanyId } from "@/core/auth/types";
type SeedTag = Omit<Tag, 'id' | 'createdAt'>; type SeedTag = Omit<Tag, "id" | "createdAt">;
/** Parse project line like "000 Farmacie" → { code: "B-000", label: "Farmacie" } */ /** Parse project line like "000 Farmacie" → { code: "B-000", label: "Farmacie" } */
function parseProjectLine(line: string, prefix: string): { code: string; label: string } | null { function parseProjectLine(
line: string,
prefix: string,
): { code: string; label: string } | null {
const match = line.match(/^(\w?\d+)\s+(.+)$/); const match = line.match(/^(\w?\d+)\s+(.+)$/);
if (!match?.[1] || !match[2]) return null; if (!match?.[1] || !match[2]) return null;
const num = match[1]; const num = match[1];
const label = match[2].trim(); const label = match[2].trim();
const padded = num.replace(/^[A-Z]/, '').padStart(3, '0'); const padded = num.replace(/^[A-Z]/, "").padStart(3, "0");
const codePrefix = num.startsWith('L') ? `${prefix}L` : prefix; const codePrefix = num.startsWith("L") ? `${prefix}L` : prefix;
return { code: `${codePrefix}-${padded}`, label }; return { code: `${codePrefix}-${padded}`, label };
} }
@@ -19,224 +22,260 @@ export function getManicTimeSeedTags(): SeedTag[] {
// ── Beletage projects ── // ── Beletage projects ──
const beletageProjects = [ const beletageProjects = [
'000 Farmacie', "000 Farmacie",
'002 Cladire birouri Stratec', "002 Cladire birouri Stratec",
'003 PUZ Bellavista', "003 PUZ Bellavista",
'007 Design Apartament Teodora', "007 Design Apartament Teodora",
'010 Casa Doinei', "010 Casa Doinei",
'016 Duplex Eremia', "016 Duplex Eremia",
'024 Bloc Petofi', "024 Bloc Petofi",
'028 PUZ Borhanci-Sopor', "028 PUZ Borhanci-Sopor",
'033 Mansardare Branului', "033 Mansardare Branului",
'039 Cabinete Stoma Scala', "039 Cabinete Stoma Scala",
'041 Imobil mixt Progresului', "041 Imobil mixt Progresului",
'045 Casa Andrei Muresanu', "045 Casa Andrei Muresanu",
'052 PUZ Carpenului', "052 PUZ Carpenului",
'059 PUZ Nordului', "059 PUZ Nordului",
'064 Casa Salicea', "064 Casa Salicea",
'066 Terasa Gherase', "066 Terasa Gherase",
'070 Bloc Fanatelor', "070 Bloc Fanatelor",
'073 Case Frumoasa', "073 Case Frumoasa",
'074 PUG Cosbuc', "074 PUG Cosbuc",
'076 Casa Copernicus', "076 Casa Copernicus",
'077 PUZ Schimbare destinatie Brancusi', "077 PUZ Schimbare destinatie Brancusi",
'078 Service auto Linistei', "078 Service auto Linistei",
'079 Amenajare drum Servitute Eremia', "079 Amenajare drum Servitute Eremia",
'080 Bloc Tribunul', "080 Bloc Tribunul",
'081 Extindere casa Gherase', "081 Extindere casa Gherase",
'083 Modificari casa Zsigmund 18', "083 Modificari casa Zsigmund 18",
'084 Mansardare Petofi 21', "084 Mansardare Petofi 21",
'085 Container CT Spital Tabacarilor', "085 Container CT Spital Tabacarilor",
'086 Imprejmuire casa sat Gheorgheni', "086 Imprejmuire casa sat Gheorgheni",
'087 Duplex Oasului fn', "087 Duplex Oasului fn",
'089 PUZ A-Liu Sopor', "089 PUZ A-Liu Sopor",
'090 VR MedEvents', "090 VR MedEvents",
'091 Reclama Caparol', "091 Reclama Caparol",
'092 Imobil birouri 13 Septembrie', "092 Imobil birouri 13 Septembrie",
'093 Casa Salistea Noua', "093 Casa Salistea Noua",
'094 PUD Casa Rediu', "094 PUD Casa Rediu",
'095 Duplex Vanatorului', "095 Duplex Vanatorului",
'096 Design apartament Sopor', "096 Design apartament Sopor",
'097 Cabana Gilau', "097 Cabana Gilau",
'101 PUZ Gilau', "101 PUZ Gilau",
'102 PUZ Ghimbav', "102 PUZ Ghimbav",
'103 Piscine Lunca Noua', "103 Piscine Lunca Noua",
'104 PUZ REGHIN', "104 PUZ REGHIN",
'105 CUT&Crust', "105 CUT&Crust",
'106 PUZ Mihai Romanu Nord', "106 PUZ Mihai Romanu Nord",
'108 Reabilitare Bloc Beiusului', "108 Reabilitare Bloc Beiusului",
'109 Case Samboleni', "109 Case Samboleni",
'110 Penny Crasna', "110 Penny Crasna",
'111 Anexa Piscina Borhanci', "111 Anexa Piscina Borhanci",
'112 PUZ Blocuri Bistrita', "112 PUZ Blocuri Bistrita",
'113 PUZ VARATEC-FIRIZA', "113 PUZ VARATEC-FIRIZA",
'114 PUG Husi', "114 PUG Husi",
'115 PUG Josenii Bargaului', "115 PUG Josenii Bargaului",
'116 PUG Monor', "116 PUG Monor",
'117 Schimbare Destinatie Mihai Viteazu 2', "117 Schimbare Destinatie Mihai Viteazu 2",
'120 Anexa Brasov', "120 Anexa Brasov",
'121 Imprejurare imobil Mesterul Manole 9', "121 Imprejurare imobil Mesterul Manole 9",
'122 Fastfood Bashar', "122 Fastfood Bashar",
'123 PUD Rediu 2', "123 PUD Rediu 2",
'127 Casa Socaciu Ciurila', "127 Casa Socaciu Ciurila",
'128 Schimbare de destinatie Danubius', "128 Schimbare de destinatie Danubius",
'129 (re) Casa Sarca-Sorescu', "129 (re) Casa Sarca-Sorescu",
'130 Casa Suta-Wonderland', "130 Casa Suta-Wonderland",
'131 PUD Oasului Hufi', "131 PUD Oasului Hufi",
'132 Reabilitare Camin Cultural Baciu', "132 Reabilitare Camin Cultural Baciu",
'133 PUG Feldru', "133 PUG Feldru",
'134 DALI Blocuri Murfatlar', "134 DALI Blocuri Murfatlar",
'135 Case de vacanta Dianei', "135 Case de vacanta Dianei",
'136 PUG BROSTENI', "136 PUG BROSTENI",
'139 Casa Turda', "139 Casa Turda",
'140 Releveu Bistrita (Morariu)', "140 Releveu Bistrita (Morariu)",
'141 PUZ Janovic Jeno', "141 PUZ Janovic Jeno",
'142 Penny Borhanci', "142 Penny Borhanci",
'143 Pavilion Politie Radauti', "143 Pavilion Politie Radauti",
'149 Duplex Sorescu 31-33', "149 Duplex Sorescu 31-33",
'150 DALI SF Scoala Baciu', "150 DALI SF Scoala Baciu",
'151 Casa Alexandru Bohatiel 17', "151 Casa Alexandru Bohatiel 17",
'152 PUZ Penny Tautii Magheraus', "152 PUZ Penny Tautii Magheraus",
'153 PUG Banita', "153 PUG Banita",
'155 PT Scoala Floresti', "155 PT Scoala Floresti",
'156 Case Sorescu', "156 Case Sorescu",
'157 Gradi-Cresa Baciu', "157 Gradi-Cresa Baciu",
'158 Duplex Sorescu 21-23', "158 Duplex Sorescu 21-23",
'159 Amenajare Spatiu Grenke PBC', "159 Amenajare Spatiu Grenke PBC",
'160 Etajare Primaria Baciu', "160 Etajare Primaria Baciu",
'161 Extindere Ap Baciu', "161 Extindere Ap Baciu",
'164 SD salon Aurel Vlaicu', "164 SD salon Aurel Vlaicu",
'165 Reclama Marasti', "165 Reclama Marasti",
'166 Catei Apahida', "166 Catei Apahida",
'167 Apartament Mircea Zaciu 13-15', "167 Apartament Mircea Zaciu 13-15",
'169 Casa PETRILA 37', "169 Casa PETRILA 37",
'170 Cabana Campeni AB', "170 Cabana Campeni AB",
'171 Camin Apahida', "171 Camin Apahida",
'L089 PUZ TUSA-BOJAN', "L089 PUZ TUSA-BOJAN",
'172 Design casa Iugoslaviei 18', "172 Design casa Iugoslaviei 18",
'173 Reabilitare spitale Sighetu', "173 Reabilitare spitale Sighetu",
'174 StudX UMFST', "174 StudX UMFST",
'176 - 2025 - ReAC Ansamblu rezi Bibescu', "176 - 2025 - ReAC Ansamblu rezi Bibescu",
]; ];
for (const line of beletageProjects) { for (const line of beletageProjects) {
const parsed = parseProjectLine(line, 'B'); const parsed = parseProjectLine(line, "B");
if (parsed) { if (parsed) {
tags.push({ tags.push({
label: parsed.label, label: parsed.label,
category: 'project', category: "project",
scope: 'company', scope: "company",
companyId: 'beletage' as CompanyId, companyId: "beletage" as CompanyId,
projectCode: parsed.code, projectCode: parsed.code,
color: '#22B5AB', color: "#22B5AB",
}); });
} }
} }
// ── Urban Switch projects ── // ── Urban Switch projects ──
const urbanSwitchProjects = [ const urbanSwitchProjects = [
'001 PUZ Sopor - Ansamblu Rezidential', "001 PUZ Sopor - Ansamblu Rezidential",
'002 PUZ Borhanci Nord', "002 PUZ Borhanci Nord",
'003 PUZ Zona Centrala Cluj', "003 PUZ Zona Centrala Cluj",
'004 PUG Floresti', "004 PUG Floresti",
'005 PUZ Dezmir - Zona Industriala', "005 PUZ Dezmir - Zona Industriala",
'006 PUZ Gilau Est', "006 PUZ Gilau Est",
'007 PUZ Baciu - Extensie Intravilan', "007 PUZ Baciu - Extensie Intravilan",
'008 PUG Apahida', "008 PUG Apahida",
'009 PUZ Iris - Reconversie', "009 PUZ Iris - Reconversie",
'010 PUZ Faget - Zona Turistica', "010 PUZ Faget - Zona Turistica",
]; ];
for (const line of urbanSwitchProjects) { for (const line of urbanSwitchProjects) {
const parsed = parseProjectLine(line, 'US'); const parsed = parseProjectLine(line, "US");
if (parsed) { if (parsed) {
tags.push({ tags.push({
label: parsed.label, label: parsed.label,
category: 'project', category: "project",
scope: 'company', scope: "company",
companyId: 'urban-switch' as CompanyId, companyId: "urban-switch" as CompanyId,
projectCode: parsed.code, projectCode: parsed.code,
color: '#345476', color: "#345476",
}); });
} }
} }
// ── Studii de Teren projects ── // ── Studii de Teren projects ──
const studiiDeTerenProjects = [ const studiiDeTerenProjects = [
'001 Studiu Geo - Sopor Rezidential', "001 Studiu Geo - Sopor Rezidential",
'002 Studiu Geo - Borhanci Vila', "002 Studiu Geo - Borhanci Vila",
'003 Studiu Geo - Floresti Ansamblu', "003 Studiu Geo - Floresti Ansamblu",
'004 Ridicare Topo - Dezmir Industrial', "004 Ridicare Topo - Dezmir Industrial",
'005 Studiu Geo - Gilau Est', "005 Studiu Geo - Gilau Est",
'006 Ridicare Topo - Baciu Extensie', "006 Ridicare Topo - Baciu Extensie",
'007 Studiu Geo - Apahida Centru', "007 Studiu Geo - Apahida Centru",
'008 Ridicare Topo - Faget', "008 Ridicare Topo - Faget",
'009 Studiu Geo - Iris Reconversie', "009 Studiu Geo - Iris Reconversie",
'010 Studiu Geo - Turda Rezidential', "010 Studiu Geo - Turda Rezidential",
]; ];
for (const line of studiiDeTerenProjects) { for (const line of studiiDeTerenProjects) {
const parsed = parseProjectLine(line, 'SDT'); const parsed = parseProjectLine(line, "SDT");
if (parsed) { if (parsed) {
tags.push({ tags.push({
label: parsed.label, label: parsed.label,
category: 'project', category: "project",
scope: 'company', scope: "company",
companyId: 'studii-de-teren' as CompanyId, companyId: "studii-de-teren" as CompanyId,
projectCode: parsed.code, projectCode: parsed.code,
color: '#0182A1', color: "#0182A1",
}); });
} }
} }
// ── Phase tags ── // ── Phase tags ──
const phases = [ const phases = [
'CU', 'Schita', 'Avize', 'PUD', 'AO', 'PUZ', 'PUG', "CU",
'DTAD', 'DTAC', 'PT', 'Detalii de Executie', 'Studii de fundamentare', "Schita",
'Regulament', 'Parte desenata', 'Parte scrisa', "Avize",
'Consultanta client', 'Macheta', 'Consultanta receptie', "PUD",
'Redactare', 'Depunere', 'Ridicare', 'Verificare proiect', "AO",
'Vizita santier', "PUZ",
"PUG",
"DTAD",
"DTAC",
"PT",
"Detalii de Executie",
"Studii de fundamentare",
"Regulament",
"Parte desenata",
"Parte scrisa",
"Consultanta client",
"Macheta",
"Consultanta receptie",
"Redactare",
"Depunere",
"Ridicare",
"Verificare proiect",
"Vizita santier",
]; ];
for (const phase of phases) { for (const phase of phases) {
tags.push({ tags.push({
label: phase, label: phase,
category: 'phase', category: "phase",
scope: 'global', scope: "global",
color: '#3b82f6', color: "#3b82f6",
}); });
} }
// ── Activity tags ── // ── Activity tags ──
const activities = [ const activities = [
'Ofertare', 'Configurari', 'Organizare initiala', 'Pregatire Portofoliu', "Ofertare",
'Website', 'Documentare', 'Design grafic', 'Design interior', "Configurari",
'Design exterior', 'Releveu', 'Reclama', 'Master MATDR', "Organizare initiala",
'Pauza de masa', 'Timp personal', 'Concediu', 'Compensare overtime', "Pregatire Portofoliu",
"Website",
"Documentare",
"Design grafic",
"Design interior",
"Design exterior",
"Releveu",
"Reclama",
"Master MATDR",
"Pauza de masa",
"Timp personal",
"Concediu",
"Compensare overtime",
]; ];
for (const activity of activities) { for (const activity of activities) {
tags.push({ tags.push({
label: activity, label: activity,
category: 'activity', category: "activity",
scope: 'global', scope: "global",
color: '#8b5cf6', color: "#8b5cf6",
}); });
} }
// ── Document type tags ── // ── Document type tags ──
const docTypes = [ const docTypes = [
'Contract', 'Ofertă', 'Factură', 'Scrisoare', "Contract",
'Aviz', 'Notă de comandă', 'Raport', 'Cerere', 'Altele', "Ofertă",
"Factură",
"Scrisoare",
"Aviz",
"Notă de comandă",
"Raport",
"Cerere",
"Altele",
]; ];
for (const dt of docTypes) { for (const dt of docTypes) {
tags.push({ tags.push({
label: dt, label: dt,
category: 'document-type', category: "document-type",
scope: 'global', scope: "global",
color: '#f59e0b', color: "#f59e0b",
}); });
} }