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:
Marius Tarau
2026-02-18 06:35:11 +02:00
parent cb5e01b189
commit f555258dcb
8 changed files with 797 additions and 138 deletions

View File

@@ -1,3 +1,4 @@
export type { Tag, TagCategory, TagScope } from './types'; export type { Tag, TagCategory, TagScope } from './types';
export { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from './types';
export { TagService } from './tag-service'; export { TagService } from './tag-service';
export { useTags } from './use-tags'; export { useTags } from './use-tags';

View File

@@ -30,6 +30,10 @@ export class TagService {
}); });
} }
async getChildren(parentId: string): Promise<Tag[]> {
return this.storage.query<Tag>(NAMESPACE, (tag) => tag.parentId === parentId);
}
async createTag(data: Omit<Tag, 'id' | 'createdAt'>): Promise<Tag> { async createTag(data: Omit<Tag, 'id' | 'createdAt'>): Promise<Tag> {
const tag: Tag = { const tag: Tag = {
...data, ...data,
@@ -43,19 +47,41 @@ export class TagService {
async updateTag(id: string, updates: Partial<Omit<Tag, 'id' | 'createdAt'>>): Promise<Tag | null> { async updateTag(id: string, updates: Partial<Omit<Tag, 'id' | 'createdAt'>>): Promise<Tag | null> {
const existing = await this.storage.get<Tag>(NAMESPACE, id); const existing = await this.storage.get<Tag>(NAMESPACE, id);
if (!existing) return null; if (!existing) return null;
const updated = { ...existing, ...updates }; const updated: Tag = { ...existing, ...updates, updatedAt: new Date().toISOString() };
await this.storage.set(NAMESPACE, id, updated); await this.storage.set(NAMESPACE, id, updated);
return updated; return updated;
} }
async deleteTag(id: string): Promise<void> { async deleteTag(id: string): Promise<void> {
// Also delete children
const children = await this.getChildren(id);
for (const child of children) {
await this.storage.delete(NAMESPACE, child.id);
}
await this.storage.delete(NAMESPACE, id); await this.storage.delete(NAMESPACE, id);
} }
async searchTags(query: string): Promise<Tag[]> { async searchTags(query: string): Promise<Tag[]> {
const lower = query.toLowerCase(); const lower = query.toLowerCase();
return this.storage.query<Tag>(NAMESPACE, (tag) => return this.storage.query<Tag>(NAMESPACE, (tag) =>
tag.label.toLowerCase().includes(lower) tag.label.toLowerCase().includes(lower) ||
(tag.projectCode?.toLowerCase().includes(lower) ?? false)
); );
} }
/** Bulk import tags (for seed data). Skips tags whose label already exists in same category. */
async importTags(tags: Omit<Tag, 'id' | 'createdAt'>[]): Promise<number> {
const existing = await this.getAllTags();
const existingKeys = new Set(existing.map((t) => `${t.category}::${t.label}`));
let imported = 0;
for (const data of tags) {
const key = `${data.category}::${data.label}`;
if (!existingKeys.has(key)) {
await this.createTag(data);
existingKeys.add(key);
imported++;
}
}
return imported;
}
} }

View File

@@ -5,11 +5,25 @@ export type TagCategory =
| 'phase' | 'phase'
| 'activity' | 'activity'
| 'document-type' | 'document-type'
| 'company'
| 'priority'
| 'status'
| 'custom'; | 'custom';
/** Display order for categories — project & phase are mandatory */
export const TAG_CATEGORY_ORDER: TagCategory[] = [
'project',
'phase',
'activity',
'document-type',
'custom',
];
export const TAG_CATEGORY_LABELS: Record<TagCategory, string> = {
project: 'Proiect',
phase: 'Fază',
activity: 'Activitate',
'document-type': 'Tip document',
custom: 'Personalizat',
};
export type TagScope = 'global' | 'module' | 'company'; export type TagScope = 'global' | 'module' | 'company';
export interface Tag { export interface Tag {
@@ -21,7 +35,11 @@ export interface Tag {
scope: TagScope; scope: TagScope;
moduleId?: string; moduleId?: string;
companyId?: CompanyId; companyId?: CompanyId;
/** For hierarchy: parent tag id */
parentId?: string; parentId?: string;
/** For project tags: numbered code e.g. "B-001", "US-024" */
projectCode?: string;
metadata?: Record<string, string>; metadata?: Record<string, string>;
createdAt: string; createdAt: string;
updatedAt?: string;
} }

View File

@@ -33,6 +33,15 @@ export function useTags(category?: TagCategory) {
[service, refresh] [service, refresh]
); );
const updateTag = useCallback(
async (id: string, updates: Partial<Omit<Tag, 'id' | 'createdAt'>>) => {
const tag = await service.updateTag(id, updates);
await refresh();
return tag;
},
[service, refresh]
);
const deleteTag = useCallback( const deleteTag = useCallback(
async (id: string) => { async (id: string) => {
await service.deleteTag(id); await service.deleteTag(id);
@@ -41,5 +50,21 @@ export function useTags(category?: TagCategory) {
[service, refresh] [service, refresh]
); );
return { tags, loading, createTag, deleteTag, refresh }; const importTags = useCallback(
async (data: Omit<Tag, 'id' | 'createdAt'>[]) => {
const count = await service.importTags(data);
await refresh();
return count;
},
[service, refresh]
);
const searchTags = useCallback(
async (query: string) => {
return service.searchTags(query);
},
[service]
);
return { tags, loading, createTag, updateTag, deleteTag, importTags, searchTags, refresh, service };
} }

View File

@@ -1,27 +1,27 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { Plus, Trash2, Tag as TagIcon } from 'lucide-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 { Button } from '@/shared/components/ui/button';
import { Input } from '@/shared/components/ui/input'; import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label'; import { Label } from '@/shared/components/ui/label';
import { Badge } from '@/shared/components/ui/badge'; import { Badge } from '@/shared/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card'; 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 { 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'; import { cn } from '@/shared/lib/utils';
import { getManicTimeSeedTags } from '../services/seed-data';
const CATEGORY_LABELS: Record<TagCategory, string> = {
project: 'Proiect',
phase: 'Fază',
activity: 'Activitate',
'document-type': 'Tip document',
company: 'Companie',
priority: 'Prioritate',
status: 'Status',
custom: 'Personalizat',
};
const SCOPE_LABELS: Record<TagScope, string> = { const SCOPE_LABELS: Record<TagScope, string> = {
global: 'Global', global: 'Global',
@@ -29,20 +29,102 @@ const SCOPE_LABELS: Record<TagScope, string> = {
company: 'Companie', 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 = [ const TAG_COLORS = [
'#ef4444', '#f97316', '#f59e0b', '#84cc16', '#ef4444', '#f97316', '#f59e0b', '#84cc16',
'#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6', '#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6',
'#ec4899', '#64748b', '#ec4899', '#64748b', '#22B5AB', '#6366f1',
]; ];
export function TagManagerModule() { 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 [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(TAG_COLORS[5]); const [newColor, setNewColor] = useState('#3b82f6');
const [filterCategory, setFilterCategory] = useState<TagCategory | 'all'>('all'); 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 () => { const handleCreate = async () => {
if (!newLabel.trim()) return; if (!newLabel.trim()) return;
await createTag({ await createTag({
@@ -50,47 +132,100 @@ export function TagManagerModule() {
category: newCategory, category: newCategory,
scope: newScope, scope: newScope,
color: newColor, color: newColor,
companyId: newScope === 'company' ? newCompanyId : undefined,
projectCode: newCategory === 'project' && newProjectCode ? newProjectCode : undefined,
parentId: newParentId || undefined,
}); });
setNewLabel(''); setNewLabel('');
setNewProjectCode('');
setNewParentId('');
}; };
const filteredTags = filterCategory === 'all' const startEdit = (tag: Tag) => {
? tags setEditingTag(tag);
: tags.filter((t) => t.category === filterCategory); 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 saveEdit = async () => {
const key = tag.category; if (!editingTag || !editLabel.trim()) return;
if (!acc[key]) acc[key] = []; await updateTag(editingTag.id, {
acc[key].push(tag); label: editLabel.trim(),
return acc; 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Stats */} {/* 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"> <Card><CardContent className="p-4">
<p className="text-xs text-muted-foreground">Total etichete</p> <p className="text-xs text-muted-foreground">Total etichete</p>
<p className="text-2xl font-bold">{tags.length}</p> <p className="text-2xl font-bold">{tags.length}</p>
</CardContent></Card> </CardContent></Card>
<Card><CardContent className="p-4"> {TAG_CATEGORY_ORDER.map((cat) => (
<p className="text-xs text-muted-foreground">Categorii folosite</p> <Card key={cat}><CardContent className="p-4">
<p className="text-2xl font-bold">{new Set(tags.map((t) => t.category)).size}</p> <p className="text-xs text-muted-foreground">{TAG_CATEGORY_LABELS[cat]}</p>
</CardContent></Card> <p className="text-2xl font-bold">
<Card><CardContent className="p-4"> {tags.filter((t) => t.category === cat).length}
<p className="text-xs text-muted-foreground">Globale</p> </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> </CardContent></Card>
))}
</div> </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 */} {/* 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="flex flex-wrap items-end gap-3"> <div className="flex flex-wrap items-end gap-3">
<div className="min-w-[200px] flex-1"> <div className="min-w-[200px] flex-1">
<Label>Nume</Label> <Label>Nume</Label>
@@ -107,13 +242,13 @@ export function TagManagerModule() {
<Select value={newCategory} onValueChange={(v) => setNewCategory(v as TagCategory)}> <Select value={newCategory} onValueChange={(v) => setNewCategory(v as TagCategory)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> <SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
{(Object.keys(CATEGORY_LABELS) as TagCategory[]).map((cat) => ( {TAG_CATEGORY_ORDER.map((cat) => (
<SelectItem key={cat} value={cat}>{CATEGORY_LABELS[cat]}</SelectItem> <SelectItem key={cat} value={cat}>{TAG_CATEGORY_LABELS[cat]}</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="w-[130px]"> <div className="w-[140px]">
<Label>Vizibilitate</Label> <Label>Vizibilitate</Label>
<Select value={newScope} onValueChange={(v) => setNewScope(v as TagScope)}> <Select value={newScope} onValueChange={(v) => setNewScope(v as TagScope)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> <SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
@@ -124,6 +259,49 @@ export function TagManagerModule() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </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> <div>
<Label className="mb-1.5 block">Culoare</Label> <Label className="mb-1.5 block">Culoare</Label>
<div className="flex gap-1"> <div className="flex gap-1">
@@ -145,63 +323,284 @@ export function TagManagerModule() {
<Plus className="mr-1 h-4 w-4" /> Adaugă <Plus className="mr-1 h-4 w-4" /> Adaugă
</Button> </Button>
</div> </div>
</div>
</CardContent> </CardContent>
</Card> </Card>
{/* Filter */} {/* Search + Filter bar */}
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Label>Filtrează:</Label> <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')}> <Select value={filterCategory} onValueChange={(v) => setFilterCategory(v as TagCategory | 'all')}>
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger> <SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Toate categoriile</SelectItem> <SelectItem value="all">Toate categoriile</SelectItem>
{(Object.keys(CATEGORY_LABELS) as TagCategory[]).map((cat) => ( {TAG_CATEGORY_ORDER.map((cat) => (
<SelectItem key={cat} value={cat}>{CATEGORY_LABELS[cat]}</SelectItem> <SelectItem key={cat} value={cat}>{TAG_CATEGORY_LABELS[cat]}</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </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> </div>
{/* Tag list by category */} {/* 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">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"> <div className="space-y-3">
{Object.entries(groupedByCategory).map(([category, catTags]) => ( {Object.entries(groupedByCategory).map(([category, catTags]) => {
const isExpanded = expandedCategories.has(category);
const rootTags = catTags.filter((t) => !t.parentId);
return (
<Card key={category}> <Card key={category}>
<CardHeader className="pb-3"> <CardHeader
className="cursor-pointer pb-3"
onClick={() => toggleCategory(category)}
>
<CardTitle className="flex items-center gap-2 text-sm"> <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" /> <TagIcon className="h-4 w-4" />
{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">{catTags.length}</Badge>
{(category === 'project' || category === 'phase') && (
<Badge variant="default" className="ml-1 text-[10px]">obligatoriu</Badge>
)}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
{isExpanded && (
<CardContent> <CardContent>
<div className="flex flex-wrap gap-2"> <div className="space-y-1">
{catTags.map((tag) => ( {rootTags.map((tag) => (
<div <TagRow
key={tag.id} key={tag.id}
className="group flex items-center gap-1.5 rounded-full border py-1 pl-3 pr-1.5 text-sm" tag={tag}
> children={childrenMap[tag.id]}
{tag.color && ( editingTag={editingTag}
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: tag.color }} /> 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>
</CardContent>
)} )}
<span>{tag.label}</span> </Card>
<Badge variant="outline" className="text-[10px] px-1">{SCOPE_LABELS[tag.scope]}</Badge> );
})}
</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 <button
type="button" type="button"
onClick={() => deleteTag(tag.id)} onClick={() => onStartEdit(tag)}
className="ml-0.5 rounded-full p-0.5 opacity-0 transition-opacity hover:bg-destructive/10 group-hover:opacity-100" 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" /> <Trash2 className="h-3 w-3 text-destructive" />
</button> </button>
</div> </div>
))}
</div> </div>
</CardContent> {hasChildren && showChildren && (
</Card> <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> </div>
)} )}

View File

@@ -1,3 +1,4 @@
export { tagManagerConfig } from './config'; export { tagManagerConfig } from './config';
export { TagManagerModule } from './components/tag-manager-module'; export { TagManagerModule } from './components/tag-manager-module';
export type { Tag, TagCategory, TagScope } from './types'; export type { Tag, TagCategory, TagScope } from './types';
export { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from './types';

View 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;
}

View File

@@ -1 +1,2 @@
export type { Tag, TagCategory, TagScope } from '@/core/tagging/types'; export type { Tag, TagCategory, TagScope } from '@/core/tagging/types';
export { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from '@/core/tagging/types';