diff --git a/ROADMAP.md b/ROADMAP.md index 557a3e0..80e174a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -195,7 +195,7 @@ **Status:** ✅ Done. `placeholder-parser.ts` uses JSZip to read all `word/*.xml` files from the .docx ZIP, searches for `{{...}}` patterns in both raw XML and stripped text (handles Word’s split-run encoding). Form now has: “Alege fișier .docx” button (local file picker, most reliable — no CORS) and a Wand icon on the URL field for URL-based detection (may fail on CORS). Parsing spinner shown during detection. Detected placeholders auto-populate the field. Build ok, pushed. -### 1.11 `[STANDARD]` Dashboard — Activity Feed + KPI Panels +### ✅ 1.11 `[STANDARD]` Dashboard — Activity Feed + KPI Panels **What:** @@ -205,6 +205,8 @@ **Files to modify:** `src/modules/dashboard/components/` or `src/app/(modules)/page.tsx` +**Status:** ✅ Done. Created `src/modules/dashboard/hooks/use-dashboard-data.ts` — scans all `architools:*` localStorage keys directly, extracts entities with timestamps, builds activity feed (last 20, sorted by `updatedAt`) and KPI counters. Updated `src/app/page.tsx`: KPI grid (6 cards: registratura this week, open dosare, deadlines this week, overdue in red, new contacts this month, active IT equipment), activity feed with module icon + label + action + relative time (Romanian locale). Build ok, pushed. + --- ### 1.12 `[LIGHT]` Registratura — Increase Linked-Entry Selector Limit diff --git a/SESSION-LOG.md b/SESSION-LOG.md index 7b1bb91..c554a48 100644 --- a/SESSION-LOG.md +++ b/SESSION-LOG.md @@ -4,6 +4,30 @@ --- +## Session — 2026-02-19 (GitHub Copilot - Claude Sonnet 4.6) [continued] + +### Completed + +- **Task 1.11: Dashboard — Activity Feed + KPI Panels** ✅ + - Created `src/modules/dashboard/hooks/use-dashboard-data.ts` + - Scans all `architools:*` localStorage keys directly (no per-module hooks needed) + - Activity feed: last 20 items sorted by `updatedAt`, detects creat/actualizat, picks best label field + - KPI grid: registratura this week, open dosare, deadlines this week, overdue (red if >0), new contacts this month, active IT equipment + - Replaced static Quick Stats with live KPI panels in `src/app/page.tsx` + - Relative timestamps in Romanian via `Intl.RelativeTimeFormat` + - Build passes zero errors + +### Commits + +- (this session) feat(dashboard): activity feed and KPI panels + +### Notes + +- Build verified: `npx next build` → ✓ Compiled successfully +- Next task: **1.12** — Registratura linked-entry selector fix + +--- + ## Session — 2026-02-19 (GitHub Copilot - Claude Sonnet 4.6) ### Completed diff --git a/src/app/page.tsx b/src/app/page.tsx index 370346f..d81fc90 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,22 +1,53 @@ -'use client'; +"use client"; -import Link from 'next/link'; -import * as Icons from 'lucide-react'; -import { getAllModules } from '@/core/module-registry'; -import { useFeatureFlag } from '@/core/feature-flags'; -import { useI18n } from '@/core/i18n'; -import { EXTERNAL_TOOLS } from '@/config/external-tools'; -import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/shared/components/ui/card'; -import { Badge } from '@/shared/components/ui/badge'; +import Link from "next/link"; +import * as Icons from "lucide-react"; +import { getAllModules } from "@/core/module-registry"; +import { useFeatureFlag } from "@/core/feature-flags"; +import { useI18n } from "@/core/i18n"; +import { EXTERNAL_TOOLS } from "@/config/external-tools"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/shared/components/ui/card"; +import { Badge } from "@/shared/components/ui/badge"; +import { useDashboardData } from "@/modules/dashboard/hooks/use-dashboard-data"; -function DynamicIcon({ name, className }: { name: string; className?: string }) { - const pascalName = name.replace(/(^|-)([a-z])/g, (_, _p, c: string) => c.toUpperCase()); - const IconComponent = (Icons as unknown as Record>)[pascalName]; +function DynamicIcon({ + name, + className, +}: { + name: string; + className?: string; +}) { + const pascalName = name.replace(/(^|-)([a-z])/g, (_, _p, c: string) => + c.toUpperCase(), + ); + const IconComponent = ( + Icons as unknown as Record< + string, + React.ComponentType<{ className?: string }> + > + )[pascalName]; if (!IconComponent) return ; return ; } -function ModuleCard({ module }: { module: { id: string; name: string; description: string; icon: string; route: string; featureFlag: string } }) { +function ModuleCard({ + module, +}: { + module: { + id: string; + name: string; + description: string; + icon: string; + route: string; + featureFlag: string; + }; +}) { const enabled = useFeatureFlag(module.featureFlag); if (!enabled) return null; @@ -29,7 +60,9 @@ function ModuleCard({ module }: { module: { id: string; name: string; descriptio
{module.name} - {module.description} + + {module.description} +
@@ -37,59 +70,166 @@ function ModuleCard({ module }: { module: { id: string; name: string; descriptio ); } +const RELATIVE_LABELS: Intl.RelativeTimeFormatUnit[] = [ + "year", + "month", + "week", + "day", + "hour", + "minute", + "second", +]; +const RELATIVE_MS = [ + 31536000000, 2592000000, 604800000, 86400000, 3600000, 60000, 1000, +]; + +function relativeTime(isoString: string): string { + const diff = new Date(isoString).getTime() - Date.now(); + const rtf = new Intl.RelativeTimeFormat("ro", { numeric: "auto" }); + for (let i = 0; i < RELATIVE_MS.length; i++) { + const ms = RELATIVE_MS[i]; + if (ms === undefined) continue; + const absMs = RELATIVE_LABELS[i]; + if (absMs === undefined) continue; + if (Math.abs(diff) >= ms || i === RELATIVE_MS.length - 1) { + return rtf.format(Math.round(diff / ms), absMs); + } + } + return "acum"; +} + +const MODULE_ICONS: Record = { + registratura: "BookOpen", + "address-book": "Users", + "it-inventory": "Monitor", + "password-vault": "KeyRound", + "digital-signatures": "PenLine", + "word-templates": "FileText", + "tag-manager": "Tag", + "prompt-generator": "Wand2", +}; + const CATEGORY_LABELS: Record = { - dev: 'Dezvoltare', - tools: 'Instrumente', - monitoring: 'Monitorizare', - security: 'Securitate', + dev: "Dezvoltare", + tools: "Instrumente", + monitoring: "Monitorizare", + security: "Securitate", }; export default function DashboardPage() { const { t } = useI18n(); const modules = getAllModules(); + const { activity, kpis } = useDashboardData(); - const toolCategories = Object.keys(CATEGORY_LABELS).filter( - (cat) => EXTERNAL_TOOLS.some((tool) => tool.category === cat) + const toolCategories = Object.keys(CATEGORY_LABELS).filter((cat) => + EXTERNAL_TOOLS.some((tool) => tool.category === cat), ); return (
-

{t('dashboard.welcome')}

-

{t('dashboard.subtitle')}

+

+ {t("dashboard.welcome")} +

+

{t("dashboard.subtitle")}

- {/* Quick stats */} -
- - -

Module active

-

{modules.length}

-
-
- - -

Companii

-

3

-
-
- - -

Instrumente externe

-

{EXTERNAL_TOOLS.length}

-
-
- - -

Stocare

-

localStorage

-
-
+ {/* KPI panels */} +
+

Indicatori cheie

+
+ + +

+ Registratură — intrări săptămâna aceasta +

+

{kpis.registraturaThisWeek}

+
+
+ + +

Dosare deschise

+

{kpis.registraturaOpen}

+
+
+ + +

+ Termene legale săptămâna aceasta +

+

{kpis.deadlinesThisWeek}

+
+
+ + +

Termene depășite

+

0 ? "text-destructive" : ""}`} + > + {kpis.overdueDeadlines} +

+
+
+ + +

+ Contacte noi luna aceasta +

+

{kpis.contactsThisMonth}

+
+
+ + +

+ Echipamente IT active +

+

{kpis.inventoryActive}

+
+
+
+ {/* Activity feed */} + {activity.length > 0 && ( +
+

Activitate recentă

+ + + {activity.map((item) => ( +
+
+ +
+
+

+ {item.label} + + {item.action} + +

+

+ {item.moduleLabel} +

+
+ + {relativeTime(item.timestamp)} + +
+ ))} +
+
+
+ )} + {/* Modules grid */}
-

{t('dashboard.modules')}

+

{t("dashboard.modules")}

{modules.map((m) => ( @@ -103,27 +243,44 @@ export default function DashboardPage() {
{toolCategories.map((cat) => (
- {CATEGORY_LABELS[cat]} + + {CATEGORY_LABELS[cat]} +
- {EXTERNAL_TOOLS.filter((tool) => tool.category === cat).map((tool) => { - const cardContent = ( - - - -
-

{tool.name}

-

{tool.description}

-
-
-
- ); - if (!tool.url) return cardContent; - return ( - - {cardContent} - - ); - })} + {EXTERNAL_TOOLS.filter((tool) => tool.category === cat).map( + (tool) => { + const cardContent = ( + + + +
+

{tool.name}

+

+ {tool.description} +

+
+
+
+ ); + if (!tool.url) return cardContent; + return ( + + {cardContent} + + ); + }, + )}
))} diff --git a/src/modules/dashboard/hooks/use-dashboard-data.ts b/src/modules/dashboard/hooks/use-dashboard-data.ts new file mode 100644 index 0000000..af921f4 --- /dev/null +++ b/src/modules/dashboard/hooks/use-dashboard-data.ts @@ -0,0 +1,193 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export interface ActivityItem { + id: string; + namespace: string; + moduleLabel: string; + label: string; + action: "creat" | "actualizat"; + timestamp: string; // ISO +} + +export interface DashboardKpis { + registraturaThisWeek: number; + registraturaOpen: number; + deadlinesThisWeek: number; + overdueDeadlines: number; + contactsThisMonth: number; + inventoryActive: number; +} + +const MODULE_LABELS: Record = { + registratura: "Registratură", + "address-book": "Agendă", + "it-inventory": "IT Inventory", + "password-vault": "Parole", + "digital-signatures": "Semnături", + "word-templates": "Șabloane Word", + "tag-manager": "Tag Manager", + "prompt-generator": "Prompt Generator", +}; + +/** Extract a human-readable label from a stored entity */ +function pickLabel(obj: Record): string { + const candidates = ["subject", "name", "label", "title", "number"]; + for (const key of candidates) { + const val = obj[key]; + if (typeof val === "string" && val.trim()) return val.trim(); + } + return "(fără titlu)"; +} + +function startOfWeek(): Date { + const d = new Date(); + d.setHours(0, 0, 0, 0); + d.setDate(d.getDate() - d.getDay() + 1); // Monday + return d; +} + +function startOfMonth(): Date { + const d = new Date(); + d.setHours(0, 0, 0, 0); + d.setDate(1); + return d; +} + +function readAllNamespaceItems(): Record[]> { + if (typeof window === "undefined") return {}; + const PREFIX = "architools:"; + const result: Record[]> = {}; + + for (let i = 0; i < window.localStorage.length; i++) { + const fullKey = window.localStorage.key(i); + if (!fullKey?.startsWith(PREFIX)) continue; + const rest = fullKey.slice(PREFIX.length); + const colonIdx = rest.indexOf(":"); + if (colonIdx === -1) continue; + const ns = rest.slice(0, colonIdx); + try { + const raw = window.localStorage.getItem(fullKey); + if (!raw) continue; + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + if (!result[ns]) result[ns] = []; + result[ns].push(parsed as Record); + } + } catch { + // ignore malformed entries + } + } + return result; +} + +export function useDashboardData() { + const [activity, setActivity] = useState([]); + const [kpis, setKpis] = useState({ + registraturaThisWeek: 0, + registraturaOpen: 0, + deadlinesThisWeek: 0, + overdueDeadlines: 0, + contactsThisMonth: 0, + inventoryActive: 0, + }); + + useEffect(() => { + const allItems = readAllNamespaceItems(); + const weekStart = startOfWeek(); + const monthStart = startOfMonth(); + const now = new Date(); + + // --- Activity feed --- + const activityItems: ActivityItem[] = []; + + for (const [ns, items] of Object.entries(allItems)) { + const moduleLabel = MODULE_LABELS[ns] ?? ns; + for (const item of items) { + const updatedAt = + typeof item.updatedAt === "string" ? item.updatedAt : null; + const createdAt = + typeof item.createdAt === "string" ? item.createdAt : null; + const id = + typeof item.id === "string" ? item.id : String(Math.random()); + if (!updatedAt && !createdAt) continue; + const timestamp = updatedAt ?? createdAt ?? ""; + const created = createdAt ? new Date(createdAt) : null; + const updated = updatedAt ? new Date(updatedAt) : null; + const action: "creat" | "actualizat" = + created && + updated && + Math.abs(updated.getTime() - created.getTime()) < 2000 + ? "creat" + : "actualizat"; + + activityItems.push({ + id: `${ns}:${id}`, + namespace: ns, + moduleLabel, + label: pickLabel(item), + action, + timestamp, + }); + } + } + + activityItems.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + setActivity(activityItems.slice(0, 20)); + + // --- KPIs --- + const registraturaItems = allItems["registratura"] ?? []; + const registraturaThisWeek = registraturaItems.filter((e) => { + const d = typeof e.date === "string" ? new Date(e.date) : null; + return d && d >= weekStart; + }).length; + + const registraturaOpen = registraturaItems.filter( + (e) => e.status === "deschis", + ).length; + + // Deadlines + let deadlinesThisWeek = 0; + let overdueDeadlines = 0; + for (const entry of registraturaItems) { + const deadlines = Array.isArray(entry.trackedDeadlines) + ? (entry.trackedDeadlines as Record[]) + : []; + for (const dl of deadlines) { + if (dl.resolution !== "pending") continue; + const dueDate = + typeof dl.dueDate === "string" ? new Date(dl.dueDate) : null; + if (!dueDate) continue; + if (dueDate < now) overdueDeadlines++; + if ( + dueDate >= weekStart && + dueDate <= new Date(weekStart.getTime() + 7 * 86400000) + ) + deadlinesThisWeek++; + } + } + + const contactItems = allItems["address-book"] ?? []; + const contactsThisMonth = contactItems.filter((c) => { + const d = typeof c.createdAt === "string" ? new Date(c.createdAt) : null; + return d && d >= monthStart; + }).length; + + const inventoryItems = allItems["it-inventory"] ?? []; + const inventoryActive = inventoryItems.filter( + (i) => i.status === "active", + ).length; + + setKpis({ + registraturaThisWeek, + registraturaOpen, + deadlinesThisWeek, + overdueDeadlines, + contactsThisMonth, + inventoryActive, + }); + }, []); + + return { activity, kpis }; +}