feat(tag-manager): overhaul with 5 ordered categories, hierarchy, editing, and ManicTime seed import
- Reduce TagCategory to 5 ordered types: project, phase, activity, document-type, custom - Add tag hierarchy (parent-child), projectCode field, updatedAt timestamp - Add getChildren, updateTag, cascading deleteTag, searchTags, importTags to TagService - ManicTime seed data parser (~95 Beletage projects, phases, activities, document types) - Full UI rewrite: seed import dialog, inline editing, collapsible category sections Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,27 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Plus, Trash2, Tag as TagIcon } from 'lucide-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';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
||||
import {
|
||||
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 { TagCategory, TagScope } from '@/core/tagging/types';
|
||||
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';
|
||||
|
||||
const CATEGORY_LABELS: Record<TagCategory, string> = {
|
||||
project: 'Proiect',
|
||||
phase: 'Fază',
|
||||
activity: 'Activitate',
|
||||
'document-type': 'Tip document',
|
||||
company: 'Companie',
|
||||
priority: 'Prioritate',
|
||||
status: 'Status',
|
||||
custom: 'Personalizat',
|
||||
};
|
||||
import { getManicTimeSeedTags } from '../services/seed-data';
|
||||
|
||||
const SCOPE_LABELS: Record<TagScope, string> = {
|
||||
global: 'Global',
|
||||
@@ -29,20 +29,102 @@ const SCOPE_LABELS: Record<TagScope, string> = {
|
||||
company: 'Companie',
|
||||
};
|
||||
|
||||
const COMPANY_LABELS: Record<CompanyId, string> = {
|
||||
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',
|
||||
'#ec4899', '#64748b', '#22B5AB', '#6366f1',
|
||||
];
|
||||
|
||||
export function TagManagerModule() {
|
||||
const { tags, loading, createTag, deleteTag } = 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(TAG_COLORS[5]);
|
||||
const [filterCategory, setFilterCategory] = useState<TagCategory | 'all'>('all');
|
||||
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 [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
() => 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');
|
||||
|
||||
// ── Seed import state ──
|
||||
const [showSeedDialog, setShowSeedDialog] = useState(false);
|
||||
const [seedImporting, setSeedImporting] = useState(false);
|
||||
const [seedResult, setSeedResult] = useState<string | null>(null);
|
||||
|
||||
// ── Computed ──
|
||||
const filteredTags = useMemo(() => {
|
||||
let result = tags;
|
||||
if (filterCategory !== 'all') {
|
||||
result = result.filter((t) => t.category === filterCategory);
|
||||
}
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(t) =>
|
||||
t.label.toLowerCase().includes(q) ||
|
||||
(t.projectCode?.toLowerCase().includes(q) ?? false)
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [tags, filterCategory, searchQuery]);
|
||||
|
||||
const groupedByCategory = useMemo(() => {
|
||||
const groups: Record<string, Tag[]> = {};
|
||||
for (const cat of TAG_CATEGORY_ORDER) {
|
||||
const catTags = filteredTags.filter((t) => t.category === cat);
|
||||
if (catTags.length > 0) {
|
||||
groups[cat] = catTags;
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}, [filteredTags]);
|
||||
|
||||
/** Build a parent→children map for hierarchy display */
|
||||
const childrenMap = useMemo(() => {
|
||||
const map: Record<string, Tag[]> = {};
|
||||
for (const tag of tags) {
|
||||
if (tag.parentId) {
|
||||
const existing = map[tag.parentId];
|
||||
if (existing) {
|
||||
existing.push(tag);
|
||||
} else {
|
||||
map[tag.parentId] = [tag];
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [tags]);
|
||||
|
||||
const parentCandidates = useMemo(() => {
|
||||
return tags.filter(
|
||||
(t) => t.category === newCategory && !t.parentId
|
||||
);
|
||||
}, [tags, newCategory]);
|
||||
|
||||
// ── Handlers ──
|
||||
const handleCreate = async () => {
|
||||
if (!newLabel.trim()) return;
|
||||
await createTag({
|
||||
@@ -50,158 +132,475 @@ export function TagManagerModule() {
|
||||
category: newCategory,
|
||||
scope: newScope,
|
||||
color: newColor,
|
||||
companyId: newScope === 'company' ? newCompanyId : undefined,
|
||||
projectCode: newCategory === 'project' && newProjectCode ? newProjectCode : undefined,
|
||||
parentId: newParentId || undefined,
|
||||
});
|
||||
setNewLabel('');
|
||||
setNewProjectCode('');
|
||||
setNewParentId('');
|
||||
};
|
||||
|
||||
const filteredTags = filterCategory === 'all'
|
||||
? tags
|
||||
: tags.filter((t) => t.category === filterCategory);
|
||||
const startEdit = (tag: Tag) => {
|
||||
setEditingTag(tag);
|
||||
setEditLabel(tag.label);
|
||||
setEditColor(tag.color ?? '#3b82f6');
|
||||
setEditProjectCode(tag.projectCode ?? '');
|
||||
setEditScope(tag.scope);
|
||||
setEditCompanyId(tag.companyId ?? 'beletage');
|
||||
};
|
||||
|
||||
const groupedByCategory = filteredTags.reduce<Record<string, typeof tags>>((acc, tag) => {
|
||||
const key = tag.category;
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(tag);
|
||||
return acc;
|
||||
}, {});
|
||||
const saveEdit = async () => {
|
||||
if (!editingTag || !editLabel.trim()) return;
|
||||
await updateTag(editingTag.id, {
|
||||
label: editLabel.trim(),
|
||||
color: editColor,
|
||||
projectCode: editingTag.category === 'project' && editProjectCode ? editProjectCode : undefined,
|
||||
scope: editScope,
|
||||
companyId: editScope === 'company' ? editCompanyId : undefined,
|
||||
});
|
||||
setEditingTag(null);
|
||||
};
|
||||
|
||||
const cancelEdit = () => setEditingTag(null);
|
||||
|
||||
const handleSeedImport = async () => {
|
||||
setSeedImporting(true);
|
||||
setSeedResult(null);
|
||||
const seedTags = getManicTimeSeedTags();
|
||||
const count = await importTags(seedTags);
|
||||
setSeedResult(`${count} etichete importate din ${seedTags.length} disponibile.`);
|
||||
setSeedImporting(false);
|
||||
};
|
||||
|
||||
const toggleCategory = (cat: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(cat)) next.delete(cat);
|
||||
else next.add(cat);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ── Stats ──
|
||||
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-4">
|
||||
<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">Categorii folosite</p>
|
||||
<p className="text-2xl font-bold">{new Set(tags.map((t) => t.category)).size}</p>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">Globale</p>
|
||||
<p className="text-2xl font-bold">{tags.filter((t) => t.scope === 'global').length}</p>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">Personalizate</p>
|
||||
<p className="text-2xl font-bold">{tags.filter((t) => t.category === 'custom').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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Seed import banner */}
|
||||
{tags.length === 0 && !loading && (
|
||||
<Card className="border-dashed border-2">
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowSeedDialog(true)}>
|
||||
<Download className="mr-1.5 h-4 w-4" /> Importă date inițiale
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create new tag */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Etichetă nouă</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="min-w-[200px] flex-1">
|
||||
<Label>Nume</Label>
|
||||
<Input
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
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>
|
||||
<SelectContent>
|
||||
{(Object.keys(CATEGORY_LABELS) as TagCategory[]).map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>{CATEGORY_LABELS[cat]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-[130px]">
|
||||
<Label>Vizibilitate</Label>
|
||||
<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>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Culoare</Label>
|
||||
<div className="flex gap-1">
|
||||
{TAG_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
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'
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="min-w-[200px] flex-1">
|
||||
<Label>Nume</Label>
|
||||
<Input
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
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>
|
||||
<SelectContent>
|
||||
{TAG_CATEGORY_ORDER.map((cat) => (
|
||||
<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>
|
||||
<SelectContent>
|
||||
{(Object.keys(SCOPE_LABELS) as TagScope[]).map((s) => (
|
||||
<SelectItem key={s} value={s}>{SCOPE_LABELS[s]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{newScope === 'company' && (
|
||||
<div className="w-[150px]">
|
||||
<Label>Companie</Label>
|
||||
<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>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
{newCategory === 'project' && (
|
||||
<div className="w-[140px]">
|
||||
<Label>Cod proiect</Label>
|
||||
<Input
|
||||
value={newProjectCode}
|
||||
onChange={(e) => setNewProjectCode(e.target.value)}
|
||||
placeholder="B-001"
|
||||
className="mt-1 font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">— Niciun părinte —</SelectItem>
|
||||
{parentCandidates.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.projectCode ? `${p.projectCode} ` : ''}{p.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Culoare</Label>
|
||||
<div className="flex gap-1">
|
||||
{TAG_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
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'
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleCreate} disabled={!newLabel.trim()}>
|
||||
<Plus className="mr-1 h-4 w-4" /> Adaugă
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={handleCreate} disabled={!newLabel.trim()}>
|
||||
<Plus className="mr-1 h-4 w-4" /> Adaugă
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Label>Filtrează:</Label>
|
||||
{/* Search + Filter bar */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative min-w-[200px] flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Caută etichete..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={filterCategory} onValueChange={(v) => setFilterCategory(v as TagCategory | 'all')}>
|
||||
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toate categoriile</SelectItem>
|
||||
{(Object.keys(CATEGORY_LABELS) as TagCategory[]).map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>{CATEGORY_LABELS[cat]}</SelectItem>
|
||||
{TAG_CATEGORY_ORDER.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>{TAG_CATEGORY_LABELS[cat]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tags.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={() => setShowSeedDialog(true)}>
|
||||
<Download className="mr-1 h-3.5 w-3.5" /> Importă ManicTime
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tag list by category */}
|
||||
{/* Tag list by category with hierarchy */}
|
||||
{loading ? (
|
||||
<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ă.</p>
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
Nicio etichetă găsită. Creează prima etichetă sau importă datele inițiale.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(groupedByCategory).map(([category, catTags]) => (
|
||||
<Card key={category}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<TagIcon className="h-4 w-4" />
|
||||
{CATEGORY_LABELS[category as TagCategory] ?? category}
|
||||
<Badge variant="secondary" className="ml-1">{catTags.length}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{catTags.map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="group flex items-center gap-1.5 rounded-full border py-1 pl-3 pr-1.5 text-sm"
|
||||
>
|
||||
{tag.color && (
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: tag.color }} />
|
||||
)}
|
||||
<span>{tag.label}</span>
|
||||
<Badge variant="outline" className="text-[10px] px-1">{SCOPE_LABELS[tag.scope]}</Badge>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteTag(tag.id)}
|
||||
className="ml-0.5 rounded-full p-0.5 opacity-0 transition-opacity hover:bg-destructive/10 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
</button>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(groupedByCategory).map(([category, catTags]) => {
|
||||
const isExpanded = expandedCategories.has(category);
|
||||
const rootTags = catTags.filter((t) => !t.parentId);
|
||||
return (
|
||||
<Card key={category}>
|
||||
<CardHeader
|
||||
className="cursor-pointer pb-3"
|
||||
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" />}
|
||||
<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>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{isExpanded && (
|
||||
<CardContent>
|
||||
<div className="space-y-1">
|
||||
{rootTags.map((tag) => (
|
||||
<TagRow
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
children={childrenMap[tag.id]}
|
||||
editingTag={editingTag}
|
||||
editLabel={editLabel}
|
||||
editColor={editColor}
|
||||
editProjectCode={editProjectCode}
|
||||
editScope={editScope}
|
||||
editCompanyId={editCompanyId}
|
||||
onStartEdit={startEdit}
|
||||
onSaveEdit={saveEdit}
|
||||
onCancelEdit={cancelEdit}
|
||||
onDelete={deleteTag}
|
||||
setEditLabel={setEditLabel}
|
||||
setEditColor={setEditColor}
|
||||
setEditProjectCode={setEditProjectCode}
|
||||
setEditScope={setEditScope}
|
||||
setEditCompanyId={setEditCompanyId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Seed Import Dialog */}
|
||||
<Dialog open={showSeedDialog} onOpenChange={setShowSeedDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Importă date inițiale ManicTime</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Aceasta va importa proiectele Beletage, 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>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowSeedDialog(false)}>Închide</Button>
|
||||
<Button onClick={handleSeedImport} disabled={seedImporting}>
|
||||
{seedImporting ? 'Se importă...' : 'Importă'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tag Row with inline editing ──
|
||||
|
||||
interface TagRowProps {
|
||||
tag: Tag;
|
||||
children?: Tag[];
|
||||
editingTag: Tag | null;
|
||||
editLabel: string;
|
||||
editColor: string;
|
||||
editProjectCode: string;
|
||||
editScope: TagScope;
|
||||
editCompanyId: CompanyId;
|
||||
onStartEdit: (tag: Tag) => void;
|
||||
onSaveEdit: () => void;
|
||||
onCancelEdit: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
setEditLabel: (v: string) => void;
|
||||
setEditColor: (v: string) => void;
|
||||
setEditProjectCode: (v: string) => void;
|
||||
setEditScope: (v: TagScope) => void;
|
||||
setEditCompanyId: (v: CompanyId) => void;
|
||||
}
|
||||
|
||||
function TagRow({
|
||||
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);
|
||||
const hasChildren = children && children.length > 0;
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-2">
|
||||
{tag.category === 'project' && (
|
||||
<Input
|
||||
value={editProjectCode}
|
||||
onChange={(e) => setEditProjectCode(e.target.value)}
|
||||
className="w-[100px] font-mono text-xs"
|
||||
placeholder="B-001"
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
value={editLabel}
|
||||
onChange={(e) => setEditLabel(e.target.value)}
|
||||
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>
|
||||
<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>
|
||||
<SelectContent>
|
||||
{(Object.keys(COMPANY_LABELS) as CompanyId[]).map((c) => (
|
||||
<SelectItem key={c} value={c}>{COMPANY_LABELS[c]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
{TAG_COLORS.slice(0, 6).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
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'
|
||||
)}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<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}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
{!hasChildren && <span className="w-5" />}
|
||||
{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="flex-1 text-sm">{tag.label}</span>
|
||||
{tag.companyId && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5">
|
||||
{COMPANY_LABELS[tag.companyId]}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="text-[10px] px-1">
|
||||
{SCOPE_LABELS[tag.scope]}
|
||||
</Badge>
|
||||
<div className="flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStartEdit(tag)}
|
||||
className="rounded p-1 hover:bg-muted"
|
||||
>
|
||||
<Pencil className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(tag.id)}
|
||||
className="rounded p-1 hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{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">
|
||||
<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 }} />
|
||||
)}
|
||||
{child.projectCode && (
|
||||
<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">
|
||||
<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">
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { tagManagerConfig } from './config';
|
||||
export { TagManagerModule } from './components/tag-manager-module';
|
||||
export type { Tag, TagCategory, TagScope } from './types';
|
||||
export { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from './types';
|
||||
|
||||
188
src/modules/tag-manager/services/seed-data.ts
Normal file
188
src/modules/tag-manager/services/seed-data.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import type { Tag, TagCategory } from '@/core/tagging/types';
|
||||
import type { CompanyId } from '@/core/auth/types';
|
||||
|
||||
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 {
|
||||
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;
|
||||
return { code: `${codePrefix}-${padded}`, label };
|
||||
}
|
||||
|
||||
export function getManicTimeSeedTags(): SeedTag[] {
|
||||
const tags: 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',
|
||||
];
|
||||
|
||||
for (const line of beletageProjects) {
|
||||
const parsed = parseProjectLine(line, 'B');
|
||||
if (parsed) {
|
||||
tags.push({
|
||||
label: parsed.label,
|
||||
category: 'project',
|
||||
scope: 'company',
|
||||
companyId: 'beletage' as CompanyId,
|
||||
projectCode: parsed.code,
|
||||
color: '#22B5AB',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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',
|
||||
];
|
||||
|
||||
for (const phase of phases) {
|
||||
tags.push({
|
||||
label: phase,
|
||||
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',
|
||||
];
|
||||
|
||||
for (const activity of activities) {
|
||||
tags.push({
|
||||
label: activity,
|
||||
category: 'activity',
|
||||
scope: 'global',
|
||||
color: '#8b5cf6',
|
||||
});
|
||||
}
|
||||
|
||||
// ── Document type tags ──
|
||||
const docTypes = [
|
||||
'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',
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export type { Tag, TagCategory, TagScope } from '@/core/tagging/types';
|
||||
export { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from '@/core/tagging/types';
|
||||
|
||||
Reference in New Issue
Block a user