3.13 Tag Manager ManicTime sync bidirectional sync, backup, hierarchy validation
- ManicTime parser service: parse/serialize Tags.txt format, classify lines into project/phase/activity - API route /api/manictime: GET (read + sync plan), POST (pull/push/both with backup versioning) - ManicTimeSyncPanel component: connection check, stats grid, import/export/full sync with confirmation dialog - Integrated into Tag Manager module with live sync status - Docker: MANICTIME_TAGS_PATH env var, SMB volume mount /mnt/manictime - Hierarchy validation: project codes, company association, duplicate detection - Version bump to 0.2.0
This commit is contained in:
+14
-11
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<Tag[]> {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<SyncStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState<SyncResultData | null>(null);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [pendingDirection, setPendingDirection] = useState<string>("");
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileText className="h-4 w-4" />
|
||||
Sincronizare ManicTime
|
||||
{status && (
|
||||
<Badge variant="outline" className="ml-auto text-xs">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
{new Date(status.lastModified).toLocaleString("ro-RO")}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Check status button */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={checkStatus}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-1.5 h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{loading
|
||||
? "Se verifică..."
|
||||
: status
|
||||
? "Reîmprospătează"
|
||||
: "Verifică conexiunea"}
|
||||
</Button>
|
||||
|
||||
{status && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSync("pull")}
|
||||
disabled={syncing}
|
||||
>
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
Import din ManicTime
|
||||
{status.syncPlan.addToArchiTools > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5">
|
||||
+{status.syncPlan.addToArchiTools}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSync("push")}
|
||||
disabled={syncing}
|
||||
>
|
||||
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
||||
Export în ManicTime
|
||||
{status.syncPlan.addToManicTime > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5">
|
||||
+{status.syncPlan.addToManicTime}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSync("both")}
|
||||
disabled={syncing}
|
||||
>
|
||||
<ArrowLeftRight className="mr-1.5 h-3.5 w-3.5" />
|
||||
{syncing ? "Se sincronizează..." : "Sincronizare completă"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/50 bg-destructive/5 p-3">
|
||||
<XCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status info */}
|
||||
{status && (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="rounded-md border p-2.5 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Etichete ManicTime
|
||||
</p>
|
||||
<p className="text-lg font-bold">{status.totalLines}</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-2.5 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Etichete ArchiTools
|
||||
</p>
|
||||
<p className="text-lg font-bold">{status.archiToolsCount}</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-2.5 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Noi din ManicTime
|
||||
</p>
|
||||
<p className="text-lg font-bold text-blue-600">
|
||||
{status.syncPlan.addToArchiTools}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-2.5 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Noi din ArchiTools
|
||||
</p>
|
||||
<p className="text-lg font-bold text-violet-600">
|
||||
{status.syncPlan.addToManicTime}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sync result */}
|
||||
{syncResult && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-950/30">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-green-600" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
Sincronizare completă
|
||||
</p>
|
||||
<p className="text-xs text-green-700 dark:text-green-300">
|
||||
{syncResult.pulled > 0 &&
|
||||
`${syncResult.pulled} etichete importate din ManicTime. `}
|
||||
{syncResult.pushed && "Fișierul Tags.txt a fost actualizat. "}
|
||||
Total: {syncResult.totalTags} etichete.
|
||||
</p>
|
||||
{syncResult.warnings.length > 0 && (
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{syncResult.warnings.map((w, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className="flex items-center gap-1 text-xs text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3" /> {w}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hint when no check performed */}
|
||||
{!status && !error && !loading && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Apasă “Verifică conexiunea” pentru a vedea starea
|
||||
fișierului ManicTime Tags.txt. Fișierul este citit de pe share-ul
|
||||
SMB montat la{" "}
|
||||
<code className="rounded bg-muted px-1 text-[11px]">
|
||||
/mnt/manictime/Tags.txt
|
||||
</code>
|
||||
. Configurați calea prin variabila{" "}
|
||||
<code className="rounded bg-muted px-1 text-[11px]">
|
||||
MANICTIME_TAGS_PATH
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Confirmation dialog */}
|
||||
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{pendingDirection === "pull" && "Import din ManicTime"}
|
||||
{pendingDirection === "push" && "Export în ManicTime"}
|
||||
{pendingDirection === "both" && "Sincronizare completă"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
{pendingDirection === "pull" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Se vor importa{" "}
|
||||
<strong>
|
||||
{status?.syncPlan.addToArchiTools ?? 0} etichete noi
|
||||
</strong>{" "}
|
||||
din fișierul ManicTime în ArchiTools. Etichetele existente nu
|
||||
vor fi modificate.
|
||||
</p>
|
||||
)}
|
||||
{pendingDirection === "push" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Se va rescrie fișierul Tags.txt cu toate etichetele din
|
||||
ArchiTools. Un backup al versiunii curente va fi creat automat.
|
||||
ManicTime va primi{" "}
|
||||
<strong>
|
||||
{status?.syncPlan.addToManicTime ?? 0} etichete noi
|
||||
</strong>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
{pendingDirection === "both" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Se vor importa mai întâi etichetele noi din ManicTime (
|
||||
<strong>{status?.syncPlan.addToArchiTools ?? 0}</strong>), apoi
|
||||
se va actualiza fișierul Tags.txt cu toate etichetele din
|
||||
ArchiTools. Backup automat inclus.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowConfirmDialog(false)}
|
||||
>
|
||||
Anulează
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => executeSync(pendingDirection)}
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing ? "Se sincronizează..." : "Confirmă"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<TagScope, string> = {
|
||||
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() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ManicTime Sync Panel */}
|
||||
<ManicTimeSyncPanel onSyncComplete={refresh} />
|
||||
|
||||
{/* Seed import banner */}
|
||||
{tags.length === 0 && !loading && (
|
||||
<Card className="border-dashed border-2">
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<Tag, "id" | "createdAt"> | null {
|
||||
if (mt.category === "header" || mt.category === "unknown") return null;
|
||||
|
||||
const category = mt.category as TagCategory;
|
||||
const base: Omit<Tag, "id" | "createdAt"> = {
|
||||
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<string, string>();
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user