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

View File

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