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,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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
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 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