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 { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from './types';
export { TagService } from './tag-service';
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> {
const tag: Tag = {
...data,
@@ -43,19 +47,41 @@ export class TagService {
async updateTag(id: string, updates: Partial<Omit<Tag, 'id' | 'createdAt'>>): Promise<Tag | null> {
const existing = await this.storage.get<Tag>(NAMESPACE, id);
if (!existing) return null;
const updated = { ...existing, ...updates };
const updated: Tag = { ...existing, ...updates, updatedAt: new Date().toISOString() };
await this.storage.set(NAMESPACE, id, updated);
return updated;
}
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);
}
async searchTags(query: string): Promise<Tag[]> {
const lower = query.toLowerCase();
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'
| 'activity'
| 'document-type'
| 'company'
| 'priority'
| 'status'
| '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 interface Tag {
@@ -21,7 +35,11 @@ export interface Tag {
scope: TagScope;
moduleId?: string;
companyId?: CompanyId;
/** For hierarchy: parent tag id */
parentId?: string;
/** For project tags: numbered code e.g. "B-001", "US-024" */
projectCode?: string;
metadata?: Record<string, string>;
createdAt: string;
updatedAt?: string;
}

View File

@@ -33,6 +33,15 @@ export function useTags(category?: TagCategory) {
[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(
async (id: string) => {
await service.deleteTag(id);
@@ -41,5 +50,21 @@ export function useTags(category?: TagCategory) {
[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 };
}