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.
|
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.
|
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.
|
- ✅ **Declanșare Termen:** Câmpuri `recipientRegNumber` + `recipientRegDate` pe RegistryEntry — termenul legal curge de la data înregistrării la destinatar
|
||||||
- **Câmpuri Noi:** "Număr înregistrare destinatar" și "Data înregistrare destinatar".
|
- ✅ **Sistem de Alerte:** Alert banners (amber/red) în Dashboard + tab Termene legale pentru ieșiri fără date destinatar și documente expirând
|
||||||
- **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:** CU deja pe prima categorie; adăugat `prelungire-cu` (15 zile calendaristice, acord tacit aplicabil)
|
||||||
- **Categorii Termene:** Mutarea "Certificat de Urbanism" (CU) pe prima pagină/categorie principală (nu sub Avize). Adăugare subcategorie: "Cerere de prelungire CU".
|
- ✅ **Acord Tacit:** Deja implementat în Phase 2 — funcționează automat
|
||||||
- **Acord Tacit:** Modificare automată a statusului pentru documentele fără răspuns în termenul legal (acord tacit).
|
- ✅ **Istoric Modificări (Audit Log):** `DeadlineAuditEntry` interface, audit log pe fiecare `TrackedDeadline` (created/resolved), expandabil pe deadline card
|
||||||
- **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.
|
- ✅ **Valabilitate Documente:** `expiryDate` + `expiryAlertDays` cu countdown live color-coded (red=expirat, amber=aproape)
|
||||||
- **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._
|
- ✅ **Pregătire Web Scraping:** Câmpuri `externalStatusUrl` + `externalTrackingId` pe RegistryEntry
|
||||||
- **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).
|
- ✅ **Dashboard Stats:** 6 carduri (adăugat "Lipsă nr. dest." + "Expiră curând")
|
||||||
- **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.
|
|
||||||
|
**Neimplementat (necesită integrare complexă):**
|
||||||
|
|
||||||
|
- ⏳ **Generare Raport/Declarație:** Necesită integrare cross-module Registratura ↔ Word Templates
|
||||||
|
|
||||||
### 3.04 ✅ `[ARCHITECTURE]` Autentificare & Identitate (2026-02-27)
|
### 3.04 ✅ `[ARCHITECTURE]` Autentificare & Identitate (2026-02-27)
|
||||||
|
|
||||||
|
|||||||
@@ -31,5 +31,10 @@ services:
|
|||||||
- AUTHENTIK_ISSUER=https://auth.beletage.ro/application/o/architools/
|
- AUTHENTIK_ISSUER=https://auth.beletage.ro/application/o/architools/
|
||||||
# Vault encryption
|
# Vault encryption
|
||||||
- ENCRYPTION_SECRET=ArchiTools-Vault-2025!SecureKey@AES256
|
- 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:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "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 type { CompanyId } from "@/core/auth/types";
|
||||||
import { cn } from "@/shared/lib/utils";
|
import { cn } from "@/shared/lib/utils";
|
||||||
import { getManicTimeSeedTags } from "../services/seed-data";
|
import { getManicTimeSeedTags } from "../services/seed-data";
|
||||||
|
import { ManicTimeSyncPanel } from "./manictime-sync-panel";
|
||||||
|
|
||||||
const SCOPE_LABELS: Record<TagScope, string> = {
|
const SCOPE_LABELS: Record<TagScope, string> = {
|
||||||
global: "Global",
|
global: "Global",
|
||||||
@@ -74,8 +75,15 @@ const TAG_COLORS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function TagManagerModule() {
|
export function TagManagerModule() {
|
||||||
const { tags, loading, createTag, updateTag, deleteTag, importTags } =
|
const {
|
||||||
useTags();
|
tags,
|
||||||
|
loading,
|
||||||
|
createTag,
|
||||||
|
updateTag,
|
||||||
|
deleteTag,
|
||||||
|
importTags,
|
||||||
|
refresh,
|
||||||
|
} = useTags();
|
||||||
|
|
||||||
// ── Create form state ──
|
// ── Create form state ──
|
||||||
const [newLabel, setNewLabel] = useState("");
|
const [newLabel, setNewLabel] = useState("");
|
||||||
@@ -271,6 +279,9 @@ export function TagManagerModule() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ManicTime Sync Panel */}
|
||||||
|
<ManicTimeSyncPanel onSyncComplete={refresh} />
|
||||||
|
|
||||||
{/* Seed import banner */}
|
{/* Seed import banner */}
|
||||||
{tags.length === 0 && !loading && (
|
{tags.length === 0 && !loading && (
|
||||||
<Card className="border-dashed border-2">
|
<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 = {
|
export const tagManagerConfig: ModuleConfig = {
|
||||||
id: 'tag-manager',
|
id: "tag-manager",
|
||||||
name: 'Manager Etichete',
|
name: "Manager Etichete",
|
||||||
description: 'Administrare centralizată a etichetelor și categoriilor din platformă',
|
description:
|
||||||
icon: 'tags',
|
"Administrare centralizată a etichetelor, sincronizare ManicTime, validare ierarhie",
|
||||||
route: '/tag-manager',
|
icon: "tags",
|
||||||
category: 'tools',
|
route: "/tag-manager",
|
||||||
featureFlag: 'module.tag-manager',
|
category: "tools",
|
||||||
visibility: 'all',
|
featureFlag: "module.tag-manager",
|
||||||
version: '0.1.0',
|
visibility: "all",
|
||||||
|
version: "0.2.0",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
storageNamespace: 'tag-manager',
|
storageNamespace: "tag-manager",
|
||||||
navOrder: 40,
|
navOrder: 40,
|
||||||
tags: ['etichete', 'categorii', 'organizare'],
|
tags: ["etichete", "categorii", "organizare", "manictime", "sincronizare"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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 { ManicTimeSyncPanel } from "./components/manictime-sync-panel";
|
||||||
export { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from './types';
|
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