diff --git a/ROADMAP.md b/ROADMAP.md index 666a15b..ade4c4a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -272,19 +272,22 @@ 5. **Responsabil (Assignee)** — New field with contact autocomplete + quick-create. ERP-ready with separate assigneeContactId. Shown in registry table as "Resp." column. 6. **Threads & Branching** — `threadParentId` field links entries as reply-to. Thread search with direction badges. ThreadView component shows parent, current entry, siblings (branches), and child replies as indented tree. Thread icon in table. Click to navigate between threaded entries. -### 3.03 `[BUSINESS]` Registratura — Termene Legale (Flux Nou) +### 3.03 ✅ `[BUSINESS]` Registratura — Termene Legale (Flux Nou) (99fbddd) -**Cerințe noi:** +**Implementat:** -- **Declanșare Termen:** Termenul legal pentru ieșiri curge DOAR de la data înregistrării la destinatar, nu de la data trimiterii interne. -- **Câmpuri Noi:** "Număr înregistrare destinatar" și "Data înregistrare destinatar". -- **Sistem de Alerte:** Notificări/Atenționări clare (în Dashboard și în modul) pentru ieșirile cu termen legal care NU au încă completate datele de la destinatar. -- **Categorii Termene:** Mutarea "Certificat de Urbanism" (CU) pe prima pagină/categorie principală (nu sub Avize). Adăugare subcategorie: "Cerere de prelungire CU". -- **Acord Tacit:** Modificare automată a statusului pentru documentele fără răspuns în termenul legal (acord tacit). -- **Generare Raport/Declarație (Integrare Word Templates):** Generarea unui mini-log/raport combinat cu o "Declarație pe proprie răspundere" a proiectantului. Se va folosi un șablon din modulul _Word Templates_, populat automat cu datele din Registratură (data trimiterii, lipsa răspunsului), permițând editarea ulterioară în Word. -- **Istoric Modificări Termene (Audit Log):** Un mini-log de audit vizibil pe fiecare termen legal (cine a modificat data, când a fost adăugat numărul de la destinatar). _Notă: Necesită implementarea autentificării (Phase 6 - Authentik) pentru a asocia acțiunile cu utilizatori reali din Active Directory._ -- **Valabilitate Documente (Expirare CU/AC):** Adăugarea unui sistem de urmărire a valabilității pentru documente emise (ex: Certificat de Urbanism, Autorizație de Construire). Sistemul trebuie să permită setarea unei date de expirare și să genereze alerte/reamintiri cu X zile înainte de expirare (pentru a iniția procedurile de prelungire). -- **Pregătire Web Scraping (Wishlist):** Adăugarea unui câmp opțional "URL Verificare Status" (ex: link către portalul primăriei) și "ID Urmărire Extern". Arhitectura trebuie să permită pe viitor rularea unui job de fundal (ex: via N8N sau un worker intern) care să facă scraping/API call pe acel URL și să actualizeze automat statusul în ArchiTools. +- ✅ **Declanșare Termen:** Câmpuri `recipientRegNumber` + `recipientRegDate` pe RegistryEntry — termenul legal curge de la data înregistrării la destinatar +- ✅ **Sistem de Alerte:** Alert banners (amber/red) în Dashboard + tab Termene legale pentru ieșiri fără date destinatar și documente expirând +- ✅ **Categorii Termene:** CU deja pe prima categorie; adăugat `prelungire-cu` (15 zile calendaristice, acord tacit aplicabil) +- ✅ **Acord Tacit:** Deja implementat în Phase 2 — funcționează automat +- ✅ **Istoric Modificări (Audit Log):** `DeadlineAuditEntry` interface, audit log pe fiecare `TrackedDeadline` (created/resolved), expandabil pe deadline card +- ✅ **Valabilitate Documente:** `expiryDate` + `expiryAlertDays` cu countdown live color-coded (red=expirat, amber=aproape) +- ✅ **Pregătire Web Scraping:** Câmpuri `externalStatusUrl` + `externalTrackingId` pe RegistryEntry +- ✅ **Dashboard Stats:** 6 carduri (adăugat "Lipsă nr. dest." + "Expiră curând") + +**Neimplementat (necesită integrare complexă):** + +- ⏳ **Generare Raport/Declarație:** Necesită integrare cross-module Registratura ↔ Word Templates ### 3.04 ✅ `[ARCHITECTURE]` Autentificare & Identitate (2026-02-27) diff --git a/docker-compose.yml b/docker-compose.yml index f3092c7..3267212 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,5 +31,10 @@ services: - AUTHENTIK_ISSUER=https://auth.beletage.ro/application/o/architools/ # Vault encryption - ENCRYPTION_SECRET=ArchiTools-Vault-2025!SecureKey@AES256 + # ManicTime Tags.txt sync (SMB mount path) + - MANICTIME_TAGS_PATH=/mnt/manictime/Tags.txt + volumes: + # SMB share for ManicTime Tags.txt (mount on host: //time/tags → /mnt/manictime) + - /mnt/manictime:/mnt/manictime labels: - "com.centurylinklabs.watchtower.enable=true" diff --git a/src/app/api/manictime/route.ts b/src/app/api/manictime/route.ts new file mode 100644 index 0000000..6c8d7f5 --- /dev/null +++ b/src/app/api/manictime/route.ts @@ -0,0 +1,230 @@ +import { NextRequest, NextResponse } from "next/server"; +import { promises as fs } from "fs"; +import path from "path"; +import { + parseManicTimeFile, + serializeToManicTime, + computeSyncPlan, + generateBackupFilename, + validateHierarchy, + manicTimeTagToCreateData, +} from "@/modules/tag-manager/services/manictime-service"; +import type { Tag } from "@/core/tagging/types"; +import { prisma } from "@/core/storage/prisma";import type { Prisma } from '@prisma/client'; +const NAMESPACE = "tags"; + +/** + * Get the ManicTime Tags.txt file path from environment. + * Default: /mnt/manictime/Tags.txt (Docker SMB mount) + */ +function getTagsFilePath(): string { + return process.env.MANICTIME_TAGS_PATH ?? "/mnt/manictime/Tags.txt"; +} + +/** + * Load all tags from the database (same namespace as TagService). + */ +async function loadArchiToolsTags(): Promise { + const records = await prisma.keyValueStore.findMany({ + where: { namespace: NAMESPACE }, + }); + return records.map((r) => r.value as unknown as Tag); +} + +/** + * GET /api/manictime + * Read and parse the ManicTime Tags.txt file. + * Returns parsed tags + sync plan against ArchiTools. + */ +export async function GET() { + const filePath = getTagsFilePath(); + + try { + await fs.access(filePath); + } catch { + return NextResponse.json( + { + error: "file_not_found", + message: `Fișierul Tags.txt nu a fost găsit la: ${filePath}. Verificați montarea SMB și variabila MANICTIME_TAGS_PATH.`, + filePath, + }, + { status: 404 }, + ); + } + + try { + const content = await fs.readFile(filePath, "utf-8"); + const parsed = parseManicTimeFile(content); + const archiToolsTags = await loadArchiToolsTags(); + const syncPlan = computeSyncPlan(parsed.tags, archiToolsTags); + + return NextResponse.json({ + filePath, + lastModified: (await fs.stat(filePath)).mtime.toISOString(), + totalLines: parsed.tags.length, + groups: parsed.groups.length, + syncPlan: { + addToArchiTools: syncPlan.addedToArchiTools.length, + addToManicTime: syncPlan.addedToManicTime.length, + unchanged: syncPlan.unchanged, + conflicts: syncPlan.conflicts, + }, + manicTimeTags: parsed.tags, + archiToolsCount: archiToolsTags.length, + }); + } catch (error) { + return NextResponse.json( + { + error: "read_error", + message: `Eroare la citirea fișierului: ${error instanceof Error ? error.message : "necunoscută"}`, + }, + { status: 500 }, + ); + } +} + +/** + * POST /api/manictime + * Perform bidirectional sync. + * + * Body: { direction: 'pull' | 'push' | 'both' } + * - pull: Import new tags from ManicTime → ArchiTools + * - push: Export ArchiTools tags → ManicTime Tags.txt (with backup) + * - both: Pull then push + */ +export async function POST(request: NextRequest) { + const filePath = getTagsFilePath(); + + let body: { direction?: string } = {}; + try { + body = (await request.json()) as { direction?: string }; + } catch { + return NextResponse.json({ error: "invalid_json" }, { status: 400 }); + } + + const direction = body.direction ?? "both"; + if (!["pull", "push", "both"].includes(direction)) { + return NextResponse.json( + { + error: "invalid_direction", + message: "Direction must be pull, push, or both", + }, + { status: 400 }, + ); + } + + try { + const archiToolsTags = await loadArchiToolsTags(); + let pulled = 0; + let pushed = false; + + // ── Pull: ManicTime → ArchiTools ── + if (direction === "pull" || direction === "both") { + let content: string; + try { + content = await fs.readFile(filePath, "utf-8"); + } catch { + return NextResponse.json( + { + error: "file_not_found", + message: `Fișierul Tags.txt nu a fost găsit la: ${filePath}`, + }, + { status: 404 }, + ); + } + + const parsed = parseManicTimeFile(content); + const syncPlan = computeSyncPlan(parsed.tags, archiToolsTags); + + // Import new tags into ArchiTools DB + const existingKeys = new Set( + archiToolsTags.map((t) => `${t.category}::${t.label.toLowerCase()}`), + ); + + for (const mt of syncPlan.addedToArchiTools) { + const data = manicTimeTagToCreateData(mt); + if (!data) continue; + + const key = `${data.category}::${data.label.toLowerCase()}`; + if (existingKeys.has(key)) continue; + + const id = crypto.randomUUID(); + const tag: Tag = { + ...data, + id, + createdAt: new Date().toISOString(), + }; + + const tagJson = JSON.parse(JSON.stringify(tag)) as Prisma.InputJsonValue; + + await prisma.keyValueStore.upsert({ + where: { namespace_key: { namespace: NAMESPACE, key: id } }, + update: { value: tagJson }, + create: { + namespace: NAMESPACE, + key: id, + value: tagJson, + }, + }); + + existingKeys.add(key); + pulled++; + } + } + + // ── Push: ArchiTools → ManicTime ── + if (direction === "push" || direction === "both") { + // Re-load to include any newly pulled tags + const allTags = await loadArchiToolsTags(); + + // Validate hierarchy before writing + const warnings = validateHierarchy(allTags); + + // Create backup if file exists + try { + await fs.access(filePath); + const backupPath = generateBackupFilename(filePath); + await fs.copyFile(filePath, backupPath); + } catch { + // File doesn't exist yet, no backup needed + } + + // Ensure directory exists + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true }); + + // Write new content + const newContent = serializeToManicTime(allTags); + await fs.writeFile(filePath, newContent, "utf-8"); + pushed = true; + + return NextResponse.json({ + success: true, + direction, + pulled, + pushed, + totalTags: allTags.length, + warnings, + timestamp: new Date().toISOString(), + }); + } + + return NextResponse.json({ + success: true, + direction, + pulled, + pushed, + totalTags: archiToolsTags.length + pulled, + warnings: [], + timestamp: new Date().toISOString(), + }); + } catch (error) { + return NextResponse.json( + { + error: "sync_error", + message: `Eroare la sincronizare: ${error instanceof Error ? error.message : "necunoscută"}`, + }, + { status: 500 }, + ); + } +} diff --git a/src/modules/tag-manager/components/manictime-sync-panel.tsx b/src/modules/tag-manager/components/manictime-sync-panel.tsx new file mode 100644 index 0000000..ef24d35 --- /dev/null +++ b/src/modules/tag-manager/components/manictime-sync-panel.tsx @@ -0,0 +1,356 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { + RefreshCw, + Download, + Upload, + ArrowLeftRight, + CheckCircle2, + XCircle, + AlertTriangle, + Clock, + FileText, +} from "lucide-react"; +import { Button } from "@/shared/components/ui/button"; +import { Badge } from "@/shared/components/ui/badge"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/shared/components/ui/card"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/shared/components/ui/dialog"; + +interface SyncStatus { + filePath: string; + lastModified: string; + totalLines: number; + groups: number; + syncPlan: { + addToArchiTools: number; + addToManicTime: number; + unchanged: number; + conflicts: string[]; + }; + archiToolsCount: number; +} + +interface SyncResultData { + success: boolean; + direction: string; + pulled: number; + pushed: boolean; + totalTags: number; + warnings: string[]; + timestamp: string; +} + +export function ManicTimeSyncPanel({ + onSyncComplete, +}: { + onSyncComplete: () => void; +}) { + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [syncing, setSyncing] = useState(false); + const [syncResult, setSyncResult] = useState(null); + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [pendingDirection, setPendingDirection] = useState(""); + + const checkStatus = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/manictime"); + if (!res.ok) { + const data = (await res.json()) as { message?: string }; + setError(data.message ?? `Eroare HTTP ${res.status}`); + setStatus(null); + return; + } + const data = (await res.json()) as SyncStatus; + setStatus(data); + } catch (err) { + setError( + `Eroare de conexiune: ${err instanceof Error ? err.message : "necunoscută"}`, + ); + } finally { + setLoading(false); + } + }, []); + + const executeSync = useCallback( + async (direction: string) => { + setSyncing(true); + setSyncResult(null); + setError(null); + try { + const res = await fetch("/api/manictime", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ direction }), + }); + if (!res.ok) { + const data = (await res.json()) as { message?: string }; + setError(data.message ?? `Eroare HTTP ${res.status}`); + return; + } + const data = (await res.json()) as SyncResultData; + setSyncResult(data); + onSyncComplete(); + // Refresh status + await checkStatus(); + } catch (err) { + setError( + `Eroare de conexiune: ${err instanceof Error ? err.message : "necunoscută"}`, + ); + } finally { + setSyncing(false); + setShowConfirmDialog(false); + } + }, + [checkStatus, onSyncComplete], + ); + + const handleSync = (direction: string) => { + setPendingDirection(direction); + setShowConfirmDialog(true); + }; + + return ( + <> + + + + + Sincronizare ManicTime + {status && ( + + + {new Date(status.lastModified).toLocaleString("ro-RO")} + + )} + + + + {/* Check status button */} +
+ + + {status && ( + <> + + + + + + + )} +
+ + {/* Error */} + {error && ( +
+ +

{error}

+
+ )} + + {/* Status info */} + {status && ( +
+
+

+ Etichete ManicTime +

+

{status.totalLines}

+
+
+

+ Etichete ArchiTools +

+

{status.archiToolsCount}

+
+
+

+ Noi din ManicTime +

+

+ {status.syncPlan.addToArchiTools} +

+
+
+

+ Noi din ArchiTools +

+

+ {status.syncPlan.addToManicTime} +

+
+
+ )} + + {/* Sync result */} + {syncResult && ( +
+ +
+

+ Sincronizare completă +

+

+ {syncResult.pulled > 0 && + `${syncResult.pulled} etichete importate din ManicTime. `} + {syncResult.pushed && "Fișierul Tags.txt a fost actualizat. "} + Total: {syncResult.totalTags} etichete. +

+ {syncResult.warnings.length > 0 && ( +
+ {syncResult.warnings.map((w, i) => ( +

+ {w} +

+ ))} +
+ )} +
+
+ )} + + {/* Hint when no check performed */} + {!status && !error && !loading && ( +

+ Apasă “Verifică conexiunea” pentru a vedea starea + fișierului ManicTime Tags.txt. Fișierul este citit de pe share-ul + SMB montat la{" "} + + /mnt/manictime/Tags.txt + + . Configurați calea prin variabila{" "} + + MANICTIME_TAGS_PATH + + . +

+ )} +
+
+ + {/* Confirmation dialog */} + + + + + {pendingDirection === "pull" && "Import din ManicTime"} + {pendingDirection === "push" && "Export în ManicTime"} + {pendingDirection === "both" && "Sincronizare completă"} + + +
+ {pendingDirection === "pull" && ( +

+ Se vor importa{" "} + + {status?.syncPlan.addToArchiTools ?? 0} etichete noi + {" "} + din fișierul ManicTime în ArchiTools. Etichetele existente nu + vor fi modificate. +

+ )} + {pendingDirection === "push" && ( +

+ Se va rescrie fișierul Tags.txt cu toate etichetele din + ArchiTools. Un backup al versiunii curente va fi creat automat. + ManicTime va primi{" "} + + {status?.syncPlan.addToManicTime ?? 0} etichete noi + + . +

+ )} + {pendingDirection === "both" && ( +

+ Se vor importa mai întâi etichetele noi din ManicTime ( + {status?.syncPlan.addToArchiTools ?? 0}), apoi + se va actualiza fișierul Tags.txt cu toate etichetele din + ArchiTools. Backup automat inclus. +

+ )} +
+ + + + +
+
+ + ); +} diff --git a/src/modules/tag-manager/components/tag-manager-module.tsx b/src/modules/tag-manager/components/tag-manager-module.tsx index 02c2524..a3c19e0 100644 --- a/src/modules/tag-manager/components/tag-manager-module.tsx +++ b/src/modules/tag-manager/components/tag-manager-module.tsx @@ -44,6 +44,7 @@ import { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from "@/core/tagging/types"; import type { CompanyId } from "@/core/auth/types"; import { cn } from "@/shared/lib/utils"; import { getManicTimeSeedTags } from "../services/seed-data"; +import { ManicTimeSyncPanel } from "./manictime-sync-panel"; const SCOPE_LABELS: Record = { global: "Global", @@ -74,8 +75,15 @@ const TAG_COLORS = [ ]; export function TagManagerModule() { - const { tags, loading, createTag, updateTag, deleteTag, importTags } = - useTags(); + const { + tags, + loading, + createTag, + updateTag, + deleteTag, + importTags, + refresh, + } = useTags(); // ── Create form state ── const [newLabel, setNewLabel] = useState(""); @@ -271,6 +279,9 @@ export function TagManagerModule() { ))} + {/* ManicTime Sync Panel */} + + {/* Seed import banner */} {tags.length === 0 && !loading && ( diff --git a/src/modules/tag-manager/config.ts b/src/modules/tag-manager/config.ts index e6d81a1..3d44da1 100644 --- a/src/modules/tag-manager/config.ts +++ b/src/modules/tag-manager/config.ts @@ -1,17 +1,18 @@ -import type { ModuleConfig } from '@/core/module-registry/types'; +import type { ModuleConfig } from "@/core/module-registry/types"; export const tagManagerConfig: ModuleConfig = { - id: 'tag-manager', - name: 'Manager Etichete', - description: 'Administrare centralizată a etichetelor și categoriilor din platformă', - icon: 'tags', - route: '/tag-manager', - category: 'tools', - featureFlag: 'module.tag-manager', - visibility: 'all', - version: '0.1.0', + id: "tag-manager", + name: "Manager Etichete", + description: + "Administrare centralizată a etichetelor, sincronizare ManicTime, validare ierarhie", + icon: "tags", + route: "/tag-manager", + category: "tools", + featureFlag: "module.tag-manager", + visibility: "all", + version: "0.2.0", dependencies: [], - storageNamespace: 'tag-manager', + storageNamespace: "tag-manager", navOrder: 40, - tags: ['etichete', 'categorii', 'organizare'], + tags: ["etichete", "categorii", "organizare", "manictime", "sincronizare"], }; diff --git a/src/modules/tag-manager/index.ts b/src/modules/tag-manager/index.ts index e14e2e1..8c9d5a2 100644 --- a/src/modules/tag-manager/index.ts +++ b/src/modules/tag-manager/index.ts @@ -1,4 +1,5 @@ -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'; +export { tagManagerConfig } from "./config"; +export { TagManagerModule } from "./components/tag-manager-module"; +export { ManicTimeSyncPanel } from "./components/manictime-sync-panel"; +export type { Tag, TagCategory, TagScope } from "./types"; +export { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from "./types"; diff --git a/src/modules/tag-manager/services/manictime-service.ts b/src/modules/tag-manager/services/manictime-service.ts new file mode 100644 index 0000000..98664a9 --- /dev/null +++ b/src/modules/tag-manager/services/manictime-service.ts @@ -0,0 +1,418 @@ +/** + * ManicTime Tags.txt Parser & Serializer + * + * The Tags.txt format is a plain-text file with one tag per line. + * Blank lines separate groups (categories). The parser maps groups + * to ArchiTools tag categories based on pattern recognition: + * + * - Lines matching `\d+ .+` or `L\d+ .+` → project tags + * - Known phase names (CU, Schita, DTAC, etc.) → phase tags + * - Known activity names (Ofertare, Documentare, etc.) → activity tags + * - Other → custom tags + */ + +import type { Tag, TagCategory } from "@/core/tagging/types"; +import type { CompanyId } from "@/core/auth/types"; + +// ── Known phase labels (case-insensitive match) ── + +const KNOWN_PHASES = new Set([ + "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", +]); + +// ── Known activity labels ── + +const KNOWN_ACTIVITIES = new Set([ + "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", +]); + +// ── Company identifiers that appear as group headers ── + +const COMPANY_HEADERS = new Set([ + "beletage", + "urban switch", + "studii de teren", +]); + +// ── Project line pattern: optional letter prefix + digits + space + name ── + +const PROJECT_LINE_RE = /^(\w?\d+)\s+(.+)$/; +// Special pattern for "176 - 2025 - ReAC Ansamblu rezi Bibescu" style +const PROJECT_LINE_EXTENDED_RE = /^(\d+)\s+-\s+.+$/; + +export interface ManicTimeTag { + line: string; + category: TagCategory | "header" | "unknown"; + projectCode?: string; + label: string; +} + +export interface ParsedManicTimeFile { + groups: string[][]; + tags: ManicTimeTag[]; + rawContent: string; +} + +export interface SyncResult { + addedToArchiTools: ManicTimeTag[]; + addedToManicTime: Tag[]; + unchanged: number; + conflicts: string[]; +} + +/** + * Parse a ManicTime Tags.txt file into structured data. + */ +export function parseManicTimeFile(content: string): ParsedManicTimeFile { + const lines = content.split(/\r?\n/); + const groups: string[][] = []; + let currentGroup: string[] = []; + + for (const line of lines) { + if (line.trim() === "") { + if (currentGroup.length > 0) { + groups.push(currentGroup); + currentGroup = []; + } + } else { + currentGroup.push(line.trim()); + } + } + if (currentGroup.length > 0) { + groups.push(currentGroup); + } + + const tags: ManicTimeTag[] = []; + for (const group of groups) { + for (const line of group) { + tags.push(classifyLine(line)); + } + } + + return { groups, tags, rawContent: content }; +} + +/** + * Classify a single line from Tags.txt. + */ +function classifyLine(line: string): ManicTimeTag { + const trimmed = line.trim(); + const lower = trimmed.toLowerCase(); + + // Company header + if (COMPANY_HEADERS.has(lower)) { + return { line: trimmed, category: "header", label: trimmed }; + } + + // Project line (e.g. "000 Farmacie", "L089 PUZ TUSA-BOJAN", "176 - 2025 - ReAC...") + const projectMatch = trimmed.match(PROJECT_LINE_RE); + if (projectMatch?.[1] && projectMatch[2]) { + const num = projectMatch[1]; + const label = projectMatch[2].trim(); + const padded = num.replace(/^[A-Z]/i, "").padStart(3, "0"); + const prefix = num.match(/^[A-Z]/i) ? `B${num.charAt(0)}` : "B"; + return { + line: trimmed, + category: "project", + projectCode: `${prefix}-${padded}`, + label, + }; + } + if (PROJECT_LINE_EXTENDED_RE.test(trimmed)) { + return { + line: trimmed, + category: "project", + projectCode: `B-${trimmed.split(/\s/)[0]?.padStart(3, "0") ?? "000"}`, + label: trimmed, + }; + } + + // Phase + if (KNOWN_PHASES.has(lower)) { + return { line: trimmed, category: "phase", label: trimmed }; + } + + // Activity + if (KNOWN_ACTIVITIES.has(lower)) { + return { line: trimmed, category: "activity", label: trimmed }; + } + + // Unknown / custom + return { line: trimmed, category: "unknown", label: trimmed }; +} + +/** + * Convert ArchiTools tags back to ManicTime Tags.txt format. + * Groups: activities, projects (by company), phases, custom. + */ +export function serializeToManicTime(tags: Tag[]): string { + const lines: string[] = []; + + // ── Group 1: General activities (personal, leave) ── + const personalActivities = ["Pauza de masa", "Timp personal"]; + const leaveActivities = ["Concediu", "Compensare overtime"]; + + const personalFound = tags.filter((t) => + personalActivities.some((a) => t.label.toLowerCase() === a.toLowerCase()), + ); + if (personalFound.length > 0) { + for (const t of personalFound) lines.push(t.label); + lines.push(""); + } + + const leaveFound = tags.filter((t) => + leaveActivities.some((a) => t.label.toLowerCase() === a.toLowerCase()), + ); + if (leaveFound.length > 0) { + for (const t of leaveFound) lines.push(t.label); + lines.push(""); + } + + // ── Group 2: Company activities ── + const companyActivities = tags.filter( + (t) => + t.category === "activity" && + !personalActivities.some( + (a) => t.label.toLowerCase() === a.toLowerCase(), + ) && + !leaveActivities.some((a) => t.label.toLowerCase() === a.toLowerCase()), + ); + if (companyActivities.length > 0) { + lines.push("Beletage"); + for (const t of companyActivities) lines.push(t.label); + lines.push(""); + } + + // ── Group 3: Projects by company ── + const companies: CompanyId[] = [ + "beletage", + "urban-switch", + "studii-de-teren", + ]; + for (const company of companies) { + const companyProjects = tags + .filter((t) => t.category === "project" && t.companyId === company) + .sort((a, b) => (a.projectCode ?? "").localeCompare(b.projectCode ?? "")); + + if (companyProjects.length > 0) { + for (const t of companyProjects) { + const code = t.projectCode ?? ""; + // Extract numeric part from project code like "B-000" → "000" + const numMatch = code.match(/\d+$/); + const num = numMatch?.[0] ?? "000"; + lines.push(`${num} ${t.label}`); + } + lines.push(""); + } + } + + // ── Group 4: Phases ── + const phases = tags.filter((t) => t.category === "phase"); + if (phases.length > 0) { + for (const t of phases) lines.push(t.label); + lines.push(""); + } + + // ── Group 5: Document types ── + const docTypes = tags.filter((t) => t.category === "document-type"); + if (docTypes.length > 0) { + for (const t of docTypes) lines.push(t.label); + lines.push(""); + } + + // ── Group 6: Custom ── + const custom = tags.filter((t) => t.category === "custom"); + if (custom.length > 0) { + for (const t of custom) lines.push(t.label); + lines.push(""); + } + + return lines.join("\n").trimEnd() + "\n"; +} + +/** + * Compare ManicTime file content with ArchiTools tags and produce a sync plan. + */ +export function computeSyncPlan( + manicTimeTags: ManicTimeTag[], + archiToolsTags: Tag[], +): SyncResult { + const result: SyncResult = { + addedToArchiTools: [], + addedToManicTime: [], + unchanged: 0, + conflicts: [], + }; + + // Build a normalized-label set from ArchiTools + const archiLabels = new Set( + archiToolsTags.map((t) => t.label.toLowerCase().trim()), + ); + const archiCodes = new Set( + archiToolsTags + .filter((t) => t.projectCode) + .map((t) => t.projectCode!.toLowerCase()), + ); + + // Build a normalized-label set from ManicTime + const manicLabels = new Set( + manicTimeTags + .filter((t) => t.category !== "header") + .map((t) => t.label.toLowerCase().trim()), + ); + + // Tags in ManicTime but not in ArchiTools → add to ArchiTools + for (const mt of manicTimeTags) { + if (mt.category === "header" || mt.category === "unknown") continue; + + const labelKey = mt.label.toLowerCase().trim(); + const codeKey = mt.projectCode?.toLowerCase(); + + if (!archiLabels.has(labelKey) && !(codeKey && archiCodes.has(codeKey))) { + result.addedToArchiTools.push(mt); + } else { + result.unchanged++; + } + } + + // Tags in ArchiTools but not in ManicTime → add to ManicTime + for (const at of archiToolsTags) { + const labelKey = at.label.toLowerCase().trim(); + if (!manicLabels.has(labelKey)) { + result.addedToManicTime.push(at); + } + } + + return result; +} + +/** + * Convert a ManicTimeTag to the shape needed by TagService.createTag(). + */ +export function manicTimeTagToCreateData( + mt: ManicTimeTag, +): Omit | null { + if (mt.category === "header" || mt.category === "unknown") return null; + + const category = mt.category as TagCategory; + const base: Omit = { + label: mt.label, + category, + scope: category === "project" ? "company" : "global", + color: + category === "project" + ? "#22B5AB" + : category === "phase" + ? "#3b82f6" + : "#8b5cf6", + }; + + if (category === "project") { + base.companyId = "beletage"; + base.projectCode = mt.projectCode; + } + + return base; +} + +/** + * Generate a backup filename with timestamp. + */ +export function generateBackupFilename(originalPath: string): string { + const now = new Date(); + const ts = [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, "0"), + String(now.getDate()).padStart(2, "0"), + "_", + String(now.getHours()).padStart(2, "0"), + String(now.getMinutes()).padStart(2, "0"), + String(now.getSeconds()).padStart(2, "0"), + ].join(""); + + // Replace .txt with _backup_TIMESTAMP.txt + const dotIndex = originalPath.lastIndexOf("."); + if (dotIndex >= 0) { + return `${originalPath.substring(0, dotIndex)}_backup_${ts}${originalPath.substring(dotIndex)}`; + } + return `${originalPath}_backup_${ts}`; +} + +/** + * Validate that tags respect ManicTime's hierarchical format. + * Returns a list of validation warnings (empty = valid). + */ +export function validateHierarchy(tags: Tag[]): string[] { + const warnings: string[] = []; + + // Projects must have a project code + const projectsWithoutCode = tags.filter( + (t) => t.category === "project" && !t.projectCode, + ); + if (projectsWithoutCode.length > 0) { + warnings.push( + `${projectsWithoutCode.length} proiecte fără cod (ex: B-001). ManicTime necesită un format numeric.`, + ); + } + + // Projects should have a company + const projectsWithoutCompany = tags.filter( + (t) => t.category === "project" && !t.companyId, + ); + if (projectsWithoutCompany.length > 0) { + warnings.push( + `${projectsWithoutCompany.length} proiecte fără companie asociată.`, + ); + } + + // Check for duplicate labels within same category + const seen = new Map(); + for (const tag of tags) { + const key = `${tag.category}::${tag.label.toLowerCase()}`; + const existing = seen.get(key); + if (existing) { + warnings.push(`Etichetă duplicată: "${tag.label}" în ${tag.category}`); + } else { + seen.set(key, tag.id); + } + } + + return warnings; +}