From a0dd35a06628aff137b61467e9ed456b24aff1bb Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Tue, 10 Mar 2026 07:54:32 +0200 Subject: [PATCH] feat(registratura): atomic numbering, reserved slots, audit trail, API endpoints + theme toggle animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registratura module: - Atomic sequence numbering (BTG-2026-IN-00125 format) via PostgreSQL upsert - Reserved monthly slots (2/company/month) for late registrations - Append-only audit trail with diff tracking - REST API: /api/registratura (CRUD), /api/registratura/reserved, /api/registratura/audit - Auth: NextAuth session + Bearer API key support - New "intern" direction type with UI support (form, filters, table, detail panel) - Prisma models: RegistrySequence, RegistryAudit Theme toggle: - SVG mask-based sun/moon morph with 360° spin animation - Inverted logic (sun in dark mode, moon in light mode) Co-Authored-By: Claude Opus 4.6 --- prisma/schema.prisma | 30 ++ src/app/api/registratura/audit/route.ts | 80 ++++ src/app/api/registratura/reserved/route.ts | 180 ++++++++ src/app/api/registratura/route.ts | 426 ++++++++++++++++++ .../components/registry-entry-detail.tsx | 11 + .../components/registry-entry-form.tsx | 14 + .../components/registry-filters.tsx | 2 + .../components/registry-table.tsx | 21 +- .../registratura/hooks/use-registry.ts | 89 ++-- src/modules/registratura/index.ts | 10 +- .../registratura/services/audit-service.ts | 126 ++++++ .../registratura/services/registry-service.ts | 97 +++- .../services/reserved-slots-service.ts | 145 ++++++ src/modules/registratura/types.ts | 65 ++- src/shared/components/common/theme-toggle.tsx | 182 ++++---- 15 files changed, 1354 insertions(+), 124 deletions(-) create mode 100644 src/app/api/registratura/audit/route.ts create mode 100644 src/app/api/registratura/reserved/route.ts create mode 100644 src/app/api/registratura/route.ts create mode 100644 src/modules/registratura/services/audit-service.ts create mode 100644 src/modules/registratura/services/reserved-slots-service.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4bb1e7c..93567fc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -82,3 +82,33 @@ model GisUat { @@index([name]) @@index([county]) } + +// ─── Registratura: Atomic Sequences + Audit ──────────────────────── + +model RegistrySequence { + id String @id @default(uuid()) + company String // BTG, SDT, USW, GRP + year Int + type String // IN, OUT, INT + lastSeq Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([company, year, type]) + @@index([company, year]) +} + +model RegistryAudit { + id String @id @default(uuid()) + entryId String + entryNumber String + action String // created, updated, reserved_created, reserved_claimed, late_registration, closed, deleted + actor String + actorName String? + company String + detail Json? + createdAt DateTime @default(now()) + + @@index([entryId]) + @@index([company, createdAt]) +} diff --git a/src/app/api/registratura/audit/route.ts b/src/app/api/registratura/audit/route.ts new file mode 100644 index 0000000..3a936cd --- /dev/null +++ b/src/app/api/registratura/audit/route.ts @@ -0,0 +1,80 @@ +/** + * Registratura Audit API — Read-only access to audit trail. + * + * GET — Retrieve audit events by entry ID or company. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getAuthSession } from "@/core/auth"; +import { + getAuditHistory, + getAuditByCompany, +} from "@/modules/registratura/services/audit-service"; + +// ── Auth ── + +interface Actor { + id: string; + name: string; +} + +async function authenticateRequest(req: NextRequest): Promise { + const session = await getAuthSession(); + if (session?.user) { + const u = session.user as { id?: string; name?: string | null; email?: string | null }; + return { + id: u.id ?? u.email ?? "unknown", + name: u.name ?? u.email ?? "unknown", + }; + } + + const apiKey = process.env.REGISTRY_API_KEY; + if (apiKey) { + const auth = req.headers.get("authorization"); + if (auth === `Bearer ${apiKey}`) { + return { id: "api-key", name: "ERP Integration" }; + } + } + + return null; +} + +// ── GET — Audit history ── + +export async function GET(req: NextRequest) { + const actor = await authenticateRequest(req); + if (!actor) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const url = new URL(req.url); + const entryId = url.searchParams.get("entryId"); + const company = url.searchParams.get("company"); + const from = url.searchParams.get("from"); + const to = url.searchParams.get("to"); + const limit = url.searchParams.get("limit"); + + try { + if (entryId) { + const events = await getAuditHistory(entryId); + return NextResponse.json({ success: true, events, total: events.length }); + } + + if (company) { + const events = await getAuditByCompany(company, { + from: from ?? undefined, + to: to ?? undefined, + limit: limit ? parseInt(limit, 10) : undefined, + }); + return NextResponse.json({ success: true, events, total: events.length }); + } + + return NextResponse.json( + { error: "Provide entryId or company query parameter" }, + { status: 400 }, + ); + } catch (err) { + const message = err instanceof Error ? err.message : "Internal error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/registratura/reserved/route.ts b/src/app/api/registratura/reserved/route.ts new file mode 100644 index 0000000..de86716 --- /dev/null +++ b/src/app/api/registratura/reserved/route.ts @@ -0,0 +1,180 @@ +/** + * Reserved Slots API — Generate and list reserved registration slots. + * + * POST — Generate reserved slots for a company + month + * GET — List reserved slots (with claimed/unclaimed status) + */ + +import { NextRequest, NextResponse } from "next/server"; +import type { Prisma } from "@prisma/client"; +import { prisma } from "@/core/storage/prisma"; +import { getAuthSession } from "@/core/auth"; +import { + generateReservedSlots, + countReservedSlots, +} from "@/modules/registratura/services/reserved-slots-service"; +import { logAuditEvent } from "@/modules/registratura/services/audit-service"; +import type { RegistryEntry } from "@/modules/registratura/types"; +import type { CompanyId } from "@/core/auth/types"; + +const NAMESPACE = "registratura"; +const STORAGE_PREFIX = "entry:"; + +// ── Auth (same as main route) ── + +interface Actor { + id: string; + name: string; +} + +async function authenticateRequest(req: NextRequest): Promise { + const session = await getAuthSession(); + if (session?.user) { + const u = session.user as { id?: string; name?: string | null; email?: string | null }; + return { + id: u.id ?? u.email ?? "unknown", + name: u.name ?? u.email ?? "unknown", + }; + } + + const apiKey = process.env.REGISTRY_API_KEY; + if (apiKey) { + const auth = req.headers.get("authorization"); + if (auth === `Bearer ${apiKey}`) { + return { id: "api-key", name: "ERP Integration" }; + } + } + + return null; +} + +// ── Helpers ── + +async function loadAllEntries(): Promise { + const rows = await prisma.keyValueStore.findMany({ + where: { namespace: NAMESPACE }, + select: { key: true, value: true }, + }); + const entries: RegistryEntry[] = []; + for (const row of rows) { + if (row.key.startsWith(STORAGE_PREFIX) && row.value) { + entries.push(row.value as unknown as RegistryEntry); + } + } + return entries; +} + +// ── POST — Generate reserved slots ── + +export async function POST(req: NextRequest) { + const actor = await authenticateRequest(req); + if (!actor) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = await req.json(); + const { company, year, month } = body as { + company: CompanyId; + year: number; + month: number; // 0-indexed + }; + + if (!company || year == null || month == null) { + return NextResponse.json( + { error: "Missing required fields: company, year, month" }, + { status: 400 }, + ); + } + + if (month < 0 || month > 11) { + return NextResponse.json( + { error: "Month must be 0-11 (0-indexed)" }, + { status: 400 }, + ); + } + + // Check if slots already exist for this company+month + const allEntries = await loadAllEntries(); + const existing = countReservedSlots(allEntries, company, year, month); + if (existing >= 2) { + return NextResponse.json( + { error: "Reserved slots already exist for this month", existingCount: existing }, + { status: 409 }, + ); + } + + // Generate slots + const slots = await generateReservedSlots( + company, + year, + month, + actor.id, + actor.name, + ); + + // Save to KeyValueStore + for (const slot of slots) { + await prisma.keyValueStore.upsert({ + where: { + namespace_key: { + namespace: NAMESPACE, + key: `${STORAGE_PREFIX}${slot.id}`, + }, + }, + update: { value: slot as unknown as Prisma.InputJsonValue }, + create: { + namespace: NAMESPACE, + key: `${STORAGE_PREFIX}${slot.id}`, + value: slot as unknown as Prisma.InputJsonValue, + }, + }); + + await logAuditEvent({ + entryId: slot.id, + entryNumber: slot.number, + action: "reserved_created", + actor: actor.id, + actorName: actor.name, + company, + detail: { date: slot.date, month, year }, + }); + } + + return NextResponse.json({ success: true, slots }, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Internal error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +// ── GET — List reserved slots ── + +export async function GET(req: NextRequest) { + const actor = await authenticateRequest(req); + if (!actor) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const url = new URL(req.url); + const company = url.searchParams.get("company"); + const year = url.searchParams.get("year"); + const month = url.searchParams.get("month"); + + const allEntries = await loadAllEntries(); + let reserved = allEntries.filter((e) => e.isReserved === true); + + if (company) reserved = reserved.filter((e) => e.company === company); + if (year) { + const yr = parseInt(year, 10); + reserved = reserved.filter((e) => new Date(e.date).getFullYear() === yr); + } + if (month) { + const m = parseInt(month, 10); + reserved = reserved.filter((e) => new Date(e.date).getMonth() === m); + } + + reserved.sort((a, b) => a.date.localeCompare(b.date)); + + return NextResponse.json({ success: true, slots: reserved, total: reserved.length }); +} diff --git a/src/app/api/registratura/route.ts b/src/app/api/registratura/route.ts new file mode 100644 index 0000000..0ac298a --- /dev/null +++ b/src/app/api/registratura/route.ts @@ -0,0 +1,426 @@ +/** + * Registratura API — Main CRUD endpoints. + * + * POST — Create entry (atomic numbering, late registration, audit) + * GET — List entries / get single entry + * PUT — Update entry (diff audit) + * DELETE — Delete entry (audit) + * + * Auth: NextAuth session OR Bearer API key (REGISTRY_API_KEY env). + */ + +import { NextRequest, NextResponse } from "next/server"; +import type { Prisma } from "@prisma/client"; +import { v4 as uuid } from "uuid"; +import { prisma } from "@/core/storage/prisma"; +import { getAuthSession } from "@/core/auth"; +import { allocateSequenceNumber } from "@/modules/registratura/services/registry-service"; +import { + logAuditEvent, + computeEntryDiff, + TRACKED_FIELDS, +} from "@/modules/registratura/services/audit-service"; +import { findAvailableReservedSlot } from "@/modules/registratura/services/reserved-slots-service"; +import type { RegistryEntry, RegistryDirection } from "@/modules/registratura/types"; +import type { CompanyId } from "@/core/auth/types"; + +const NAMESPACE = "registratura"; +const BLOB_NAMESPACE = "registratura-blobs"; +const STORAGE_PREFIX = "entry:"; + +// ── Auth ── + +interface Actor { + id: string; + name: string; + company?: string; +} + +async function authenticateRequest(req: NextRequest): Promise { + // 1. Check NextAuth session + const session = await getAuthSession(); + if (session?.user) { + const u = session.user as { id?: string; name?: string | null; email?: string | null; company?: string }; + return { + id: u.id ?? u.email ?? "unknown", + name: u.name ?? u.email ?? "unknown", + company: u.company, + }; + } + + // 2. Check API key + const apiKey = process.env.REGISTRY_API_KEY; + if (apiKey) { + const auth = req.headers.get("authorization"); + if (auth === `Bearer ${apiKey}`) { + return { id: "api-key", name: "ERP Integration" }; + } + } + + return null; +} + +// ── Helpers ── + +/** Strip base64 data >1KB from entry JSON (same logic as /api/storage lightweight) */ +function stripHeavyFields(obj: unknown): unknown { + if (typeof obj === "string") return obj.length > 1024 ? "__stripped__" : obj; + if (Array.isArray(obj)) return obj.map(stripHeavyFields); + if (obj && typeof obj === "object") { + const result: Record = {}; + for (const [k, v] of Object.entries(obj)) { + if ((k === "data" || k === "fileData") && typeof v === "string" && v.length > 1024) { + result[k] = "__stripped__"; + } else { + result[k] = stripHeavyFields(v); + } + } + return result; + } + return obj; +} + +async function loadAllEntries(lightweight = true): Promise { + const rows = await prisma.keyValueStore.findMany({ + where: { namespace: NAMESPACE }, + select: { key: true, value: true }, + }); + + const entries: RegistryEntry[] = []; + for (const row of rows) { + if (row.key.startsWith(STORAGE_PREFIX) && row.value) { + const val = lightweight ? stripHeavyFields(row.value) : row.value; + entries.push(val as unknown as RegistryEntry); + } + } + entries.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + return entries; +} + +async function loadEntry(id: string): Promise { + const row = await prisma.keyValueStore.findUnique({ + where: { namespace_key: { namespace: NAMESPACE, key: `${STORAGE_PREFIX}${id}` } }, + }); + return (row?.value as unknown as RegistryEntry) ?? null; +} + +async function saveEntryToDB(entry: RegistryEntry): Promise { + await prisma.keyValueStore.upsert({ + where: { namespace_key: { namespace: NAMESPACE, key: `${STORAGE_PREFIX}${entry.id}` } }, + update: { value: entry as unknown as Prisma.InputJsonValue }, + create: { + namespace: NAMESPACE, + key: `${STORAGE_PREFIX}${entry.id}`, + value: entry as unknown as Prisma.InputJsonValue, + }, + }); +} + +async function deleteEntryFromDB(id: string): Promise { + await prisma.keyValueStore.deleteMany({ + where: { namespace: NAMESPACE, key: `${STORAGE_PREFIX}${id}` }, + }); + // Clean up blobs + await prisma.keyValueStore.deleteMany({ + where: { namespace: BLOB_NAMESPACE, key: id }, + }); +} + +// ── GET — List or get single entry ── + +export async function GET(req: NextRequest) { + const actor = await authenticateRequest(req); + if (!actor) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const url = new URL(req.url); + const id = url.searchParams.get("id"); + const company = url.searchParams.get("company"); + const year = url.searchParams.get("year"); + const type = url.searchParams.get("type"); + const status = url.searchParams.get("status"); + const full = url.searchParams.get("full") === "true"; + + // Single entry + if (id) { + const entry = await loadEntry(id); + if (!entry) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return NextResponse.json({ success: true, entry }); + } + + // List with optional filters + let entries = await loadAllEntries(!full); + + if (company) entries = entries.filter((e) => e.company === company); + if (year) { + const yr = parseInt(year, 10); + entries = entries.filter((e) => { + const d = new Date(e.date); + return d.getFullYear() === yr; + }); + } + if (type) { + const dirMap: Record = { + IN: "intrat", + OUT: "iesit", + INT: "intern", + }; + const dir = dirMap[type.toUpperCase()]; + if (dir) entries = entries.filter((e) => e.direction === dir); + } + if (status) entries = entries.filter((e) => e.status === status); + + return NextResponse.json({ success: true, entries, total: entries.length }); +} + +// ── POST — Create entry with atomic numbering ── + +export async function POST(req: NextRequest) { + const actor = await authenticateRequest(req); + if (!actor) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = await req.json(); + const entryData = body.entry as Partial; + + if (!entryData.company || !entryData.direction) { + return NextResponse.json( + { error: "Missing required fields: company, direction" }, + { status: 400 }, + ); + } + + const company = entryData.company as CompanyId; + const direction = entryData.direction as RegistryDirection; + const now = new Date().toISOString(); + const entryDate = entryData.date ?? now.slice(0, 10); + + // Check if this is a late registration (document date is in a past month) + const docDate = new Date(entryDate); + const today = new Date(); + const isPastMonth = + docDate.getFullYear() < today.getFullYear() || + (docDate.getFullYear() === today.getFullYear() && + docDate.getMonth() < today.getMonth()); + + let registryNumber: string; + let registrationType: "normal" | "late" | "reserved-claimed" = "normal"; + let claimedSlotId: string | undefined; + + if (isPastMonth && direction === "intrat") { + // Try to claim a reserved slot + const allEntries = await loadAllEntries(true); + const slot = findAvailableReservedSlot( + allEntries, + company, + docDate.getFullYear(), + docDate.getMonth(), + ); + + if (slot) { + // Claim the reserved slot — reuse its number + registryNumber = slot.number; + registrationType = "reserved-claimed"; + claimedSlotId = slot.id; + + // Delete the placeholder slot + await deleteEntryFromDB(slot.id); + + await logAuditEvent({ + entryId: slot.id, + entryNumber: slot.number, + action: "reserved_claimed", + actor: actor.id, + actorName: actor.name, + company: company, + detail: { claimedBy: entryData.subject ?? "late registration" }, + }); + } else { + // No reserved slot — allocate new number, mark as late + const { number } = await allocateSequenceNumber(company, direction); + registryNumber = number; + registrationType = "late"; + } + } else if (isPastMonth) { + // OUT/INT late registration — always get new number + const { number } = await allocateSequenceNumber(company, direction); + registryNumber = number; + registrationType = "late"; + } else { + // Normal registration + const { number } = await allocateSequenceNumber(company, direction); + registryNumber = number; + } + + const entry: RegistryEntry = { + id: entryData.id ?? uuid(), + number: registryNumber, + date: entryDate, + registrationDate: entryData.registrationDate ?? now.slice(0, 10), + direction, + documentType: entryData.documentType ?? "altele", + subject: entryData.subject ?? "", + sender: entryData.sender ?? "", + senderContactId: entryData.senderContactId, + recipient: entryData.recipient ?? "", + recipientContactId: entryData.recipientContactId, + recipientRegNumber: entryData.recipientRegNumber, + recipientRegDate: entryData.recipientRegDate, + company, + status: entryData.status ?? "deschis", + closureInfo: entryData.closureInfo, + deadline: entryData.deadline, + assignee: entryData.assignee, + assigneeContactId: entryData.assigneeContactId, + threadParentId: entryData.threadParentId, + linkedEntryIds: entryData.linkedEntryIds ?? [], + attachments: entryData.attachments ?? [], + trackedDeadlines: entryData.trackedDeadlines, + expiryDate: entryData.expiryDate, + expiryAlertDays: entryData.expiryAlertDays, + externalStatusUrl: entryData.externalStatusUrl, + externalTrackingId: entryData.externalTrackingId, + acValidity: entryData.acValidity, + tags: entryData.tags ?? [], + notes: entryData.notes ?? "", + visibility: entryData.visibility ?? "internal", + createdAt: now, + updatedAt: now, + isReserved: false, + registrationType, + createdBy: actor.id, + createdByName: actor.name, + claimedReservedSlotId: claimedSlotId, + }; + + await saveEntryToDB(entry); + + // Audit: created + await logAuditEvent({ + entryId: entry.id, + entryNumber: entry.number, + action: registrationType === "late" ? "late_registration" : "created", + actor: actor.id, + actorName: actor.name, + company: company, + detail: { + direction, + registrationType, + ...(claimedSlotId ? { claimedSlotId } : {}), + }, + }); + + return NextResponse.json({ success: true, entry }, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Internal error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +// ── PUT — Update entry ── + +export async function PUT(req: NextRequest) { + const actor = await authenticateRequest(req); + if (!actor) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = await req.json(); + const { id, updates } = body as { id: string; updates: Partial }; + + if (!id) { + return NextResponse.json({ error: "Missing id" }, { status: 400 }); + } + + const existing = await loadEntry(id); + if (!existing) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + // Prevent changing immutable fields + delete updates.id; + delete updates.number; + delete updates.createdAt; + delete updates.createdBy; + delete updates.createdByName; + + const updated: RegistryEntry = { + ...existing, + ...updates, + updatedAt: new Date().toISOString(), + }; + + // Compute diff for audit + const diff = computeEntryDiff( + existing as unknown as Record, + updated as unknown as Record, + TRACKED_FIELDS, + ); + + await saveEntryToDB(updated); + + if (diff) { + const action = updates.status === "inchis" && existing.status !== "inchis" + ? "closed" as const + : updates.status === "deschis" && existing.status === "inchis" + ? "reopened" as const + : "updated" as const; + + await logAuditEvent({ + entryId: id, + entryNumber: existing.number, + action, + actor: actor.id, + actorName: actor.name, + company: existing.company, + detail: { changes: diff }, + }); + } + + return NextResponse.json({ success: true, entry: updated }); + } catch (err) { + const message = err instanceof Error ? err.message : "Internal error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +// ── DELETE — Delete entry ── + +export async function DELETE(req: NextRequest) { + const actor = await authenticateRequest(req); + if (!actor) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const url = new URL(req.url); + const id = url.searchParams.get("id"); + + if (!id) { + return NextResponse.json({ error: "Missing id" }, { status: 400 }); + } + + const existing = await loadEntry(id); + if (!existing) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + await deleteEntryFromDB(id); + + await logAuditEvent({ + entryId: id, + entryNumber: existing.number, + action: "deleted", + actor: actor.id, + actorName: actor.name, + company: existing.company, + detail: { subject: existing.subject }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/src/modules/registratura/components/registry-entry-detail.tsx b/src/modules/registratura/components/registry-entry-detail.tsx index 7d47866..c4eb9f3 100644 --- a/src/modules/registratura/components/registry-entry-detail.tsx +++ b/src/modules/registratura/components/registry-entry-detail.tsx @@ -2,6 +2,7 @@ import { ArrowDownToLine, + ArrowRightLeft, ArrowUpFromLine, Calendar, CheckCircle2, @@ -64,6 +65,12 @@ const DIRECTION_CONFIG = { class: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300", }, + intern: { + label: "Intern", + icon: ArrowRightLeft, + class: + "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300", + }, } as const; const STATUS_CONFIG = { @@ -76,6 +83,10 @@ const STATUS_CONFIG = { label: "Închis", class: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400", }, + reserved: { + label: "Rezervat", + class: "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300", + }, } as const; const RESOLUTION_LABELS: Record = { diff --git a/src/modules/registratura/components/registry-entry-form.tsx b/src/modules/registratura/components/registry-entry-form.tsx index 2b13651..2e014ed 100644 --- a/src/modules/registratura/components/registry-entry-form.tsx +++ b/src/modules/registratura/components/registry-entry-form.tsx @@ -15,6 +15,7 @@ import { Globe, ArrowDownToLine, ArrowUpFromLine, + ArrowRightLeft, HardDrive, FolderOpen, Link2, @@ -554,6 +555,19 @@ export function RegistryEntryForm({ Ieșit +
diff --git a/src/modules/registratura/components/registry-filters.tsx b/src/modules/registratura/components/registry-filters.tsx index 4c5a8d3..881c252 100644 --- a/src/modules/registratura/components/registry-filters.tsx +++ b/src/modules/registratura/components/registry-filters.tsx @@ -41,6 +41,7 @@ export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) { Toate Intrat Ieșit + Intern @@ -74,6 +75,7 @@ export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) { Toate Deschis Închis + Rezervat diff --git a/src/modules/registratura/components/registry-table.tsx b/src/modules/registratura/components/registry-table.tsx index f595fc4..e280d54 100644 --- a/src/modules/registratura/components/registry-table.tsx +++ b/src/modules/registratura/components/registry-table.tsx @@ -82,7 +82,7 @@ const COLUMNS: ColumnDef[] = [ id: "direction", label: "Dir.", tooltip: - "Direcție: Intrat = primit de la terți, Ieșit = trimis către terți", + "Direcție: Intrat = primit, Ieșit = trimis, Intern = intern", defaultVisible: true, }, { @@ -141,6 +141,7 @@ const STORAGE_KEY = "registratura:visible-columns"; const DIRECTION_LABELS: Record = { intrat: "Intrat", iesit: "Ieșit", + intern: "Intern", }; function getDocTypeLabel(type: string): string { @@ -152,6 +153,7 @@ function getDocTypeLabel(type: string): string { const STATUS_LABELS: Record = { deschis: "Deschis", inchis: "Închis", + reserved: "Rezervat", }; function loadVisibleColumns(): Set { @@ -327,7 +329,11 @@ export function RegistryTable({ @@ -424,9 +430,16 @@ export function RegistryTable({ {STATUS_LABELS[entry.status]} diff --git a/src/modules/registratura/hooks/use-registry.ts b/src/modules/registratura/hooks/use-registry.ts index 33dea9c..1c19496 100644 --- a/src/modules/registratura/hooks/use-registry.ts +++ b/src/modules/registratura/hooks/use-registry.ts @@ -16,8 +16,8 @@ import { getFullEntry, saveEntry, deleteEntry, - generateRegistryNumber, } from "../services/registry-service"; +import type { RegistryAuditEvent } from "../types"; import { createTrackedDeadline, resolveDeadline as resolveDeadlineFn, @@ -71,44 +71,42 @@ export function useRegistry() { async ( data: Omit, ) => { - const freshEntries = await getAllEntries(storage); - const now = new Date().toISOString(); - const number = generateRegistryNumber( - data.company, - data.date, - freshEntries, - ); - const entry: RegistryEntry = { - ...data, - id: uuid(), - number, - registrationDate: new Date().toISOString().slice(0, 10), - createdAt: now, - updatedAt: now, - }; - await saveEntry(storage, blobStorage, entry); + // Use the API for atomic server-side numbering + audit + const res = await fetch("/api/registratura", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entry: data }), + }); + const result = await res.json(); + + if (!result.success) { + throw new Error(result.error ?? "Failed to create entry"); + } + + const entry = result.entry as RegistryEntry; setEntries((prev) => [entry, ...prev]); return entry; }, - [storage, blobStorage], + [], ); const updateEntry = useCallback( async (id: string, updates: Partial) => { - const existing = entries.find((e) => e.id === id); - if (!existing) return; - const updated: RegistryEntry = { - ...existing, - ...updates, - id: existing.id, - number: existing.number, - createdAt: existing.createdAt, - updatedAt: new Date().toISOString(), - }; - await saveEntry(storage, blobStorage, updated); + // Use the API for server-side diff audit + const res = await fetch("/api/registratura", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, updates }), + }); + const result = await res.json(); + + if (!result.success) { + throw new Error(result.error ?? "Failed to update entry"); + } + await refresh(); }, - [storage, blobStorage, refresh, entries], + [refresh], ); const removeEntry = useCallback( @@ -268,6 +266,35 @@ export function useRegistry() { [storage, blobStorage], ); + // ── Reserved slots ── + + const createReservedSlots = useCallback( + async (company: string, year: number, month: number) => { + const res = await fetch("/api/registratura/reserved", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ company, year, month }), + }); + const result = await res.json(); + if (result.success) await refresh(); + return result; + }, + [refresh], + ); + + // ── Audit trail ── + + const loadAuditHistory = useCallback( + async (entryId: string): Promise => { + const res = await fetch( + `/api/registratura/audit?entryId=${encodeURIComponent(entryId)}`, + ); + const result = await res.json(); + return result.events ?? []; + }, + [], + ); + return { entries: filteredEntries, allEntries: entries, @@ -282,6 +309,8 @@ export function useRegistry() { addDeadline, resolveDeadline, removeDeadline, + createReservedSlots, + loadAuditHistory, refresh, }; } diff --git a/src/modules/registratura/index.ts b/src/modules/registratura/index.ts index a0aa096..ccf349d 100644 --- a/src/modules/registratura/index.ts +++ b/src/modules/registratura/index.ts @@ -4,6 +4,7 @@ export type { RegistryEntry, RegistryDirection, RegistryStatus, + RegistrationType, ClosureInfo, DocumentType, DeadlineDayType, @@ -12,5 +13,12 @@ export type { DeadlineTypeDef, TrackedDeadline, DeadlineAuditEntry, + RegistryAuditAction, + RegistryAuditEvent, +} from "./types"; +export { + DEFAULT_DOCUMENT_TYPES, + DEFAULT_DOC_TYPE_LABELS, + DIRECTION_TYPE_CODE, + REGISTRY_COMPANY_PREFIX, } from "./types"; -export { DEFAULT_DOCUMENT_TYPES, DEFAULT_DOC_TYPE_LABELS } from "./types"; diff --git a/src/modules/registratura/services/audit-service.ts b/src/modules/registratura/services/audit-service.ts new file mode 100644 index 0000000..c894ebc --- /dev/null +++ b/src/modules/registratura/services/audit-service.ts @@ -0,0 +1,126 @@ +/** + * Append-only audit trail for Registratura entries. + * Server-side only — uses Prisma directly. + */ + +import { prisma } from "@/core/storage/prisma"; +import { Prisma } from "@prisma/client"; +import type { RegistryAuditAction, RegistryAuditEvent } from "../types"; + +/** Log a single audit event (append-only, never updated or deleted) */ +export async function logAuditEvent(params: { + entryId: string; + entryNumber: string; + action: RegistryAuditAction; + actor: string; + actorName?: string; + company: string; + detail?: Record; +}): Promise { + await prisma.registryAudit.create({ + data: { + entryId: params.entryId, + entryNumber: params.entryNumber, + action: params.action, + actor: params.actor, + actorName: params.actorName ?? null, + company: params.company, + detail: (params.detail as Prisma.InputJsonValue) ?? Prisma.JsonNull, + }, + }); +} + +/** Get chronological audit history for a single entry */ +export async function getAuditHistory( + entryId: string, +): Promise { + const rows = await prisma.registryAudit.findMany({ + where: { entryId }, + orderBy: { createdAt: "asc" }, + }); + return rows.map(mapRow); +} + +/** Get audit events for a company, optionally filtered by date range */ +export async function getAuditByCompany( + company: string, + options?: { from?: string; to?: string; limit?: number }, +): Promise { + const where: Prisma.RegistryAuditWhereInput = { company }; + + if (options?.from || options?.to) { + const createdAt: Prisma.DateTimeFilter = {}; + if (options.from) createdAt.gte = new Date(options.from); + if (options.to) createdAt.lte = new Date(options.to); + where.createdAt = createdAt; + } + + const rows = await prisma.registryAudit.findMany({ + where, + orderBy: { createdAt: "desc" }, + take: options?.limit ?? 100, + }); + return rows.map(mapRow); +} + +/** Compute diff between old and new entry for "updated" audit events */ +export function computeEntryDiff( + oldEntry: Record, + newEntry: Record, + fieldsToTrack: string[], +): Record | null { + const changes: Record = {}; + + for (const field of fieldsToTrack) { + const oldVal = oldEntry[field]; + const newVal = newEntry[field]; + if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) { + changes[field] = { old: oldVal, new: newVal }; + } + } + + return Object.keys(changes).length > 0 ? changes : null; +} + +// ── Internal helpers ── + +function mapRow(r: { + id: string; + entryId: string; + entryNumber: string; + action: string; + actor: string; + actorName: string | null; + company: string; + detail: Prisma.JsonValue; + createdAt: Date; +}): RegistryAuditEvent { + return { + id: r.id, + entryId: r.entryId, + entryNumber: r.entryNumber, + action: r.action as RegistryAuditAction, + actor: r.actor, + actorName: r.actorName ?? undefined, + company: r.company, + detail: (r.detail as Record) ?? undefined, + createdAt: r.createdAt.toISOString(), + }; +} + +/** Fields tracked for update diffs */ +export const TRACKED_FIELDS = [ + "subject", + "sender", + "recipient", + "direction", + "documentType", + "status", + "company", + "deadline", + "assignee", + "date", + "notes", + "tags", + "expiryDate", +]; diff --git a/src/modules/registratura/services/registry-service.ts b/src/modules/registratura/services/registry-service.ts index 4d1458b..bc79d08 100644 --- a/src/modules/registratura/services/registry-service.ts +++ b/src/modules/registratura/services/registry-service.ts @@ -1,5 +1,6 @@ import type { CompanyId } from "@/core/auth/types"; -import type { RegistryEntry, RegistryAttachment } from "../types"; +import type { RegistryEntry, RegistryAttachment, RegistryDirection } from "../types"; +import { REGISTRY_COMPANY_PREFIX, DIRECTION_TYPE_CODE } from "../types"; const STORAGE_PREFIX = "entry:"; @@ -173,6 +174,8 @@ export async function deleteEntry( await blobStorage.delete(id).catch(() => {}); } +// ── Old-format numbering (deprecated — kept for backward compatibility) ── + const COMPANY_PREFIXES: Record = { beletage: "B", "urban-switch": "US", @@ -181,9 +184,8 @@ const COMPANY_PREFIXES: Record = { }; /** - * Generate company-specific registry number: B-0001/2026 - * Uses the highest existing number + 1 for that company in that year. - * Parses actual numbers from entries to prevent duplicates. + * @deprecated Use allocateSequenceNumber() via the API instead. + * Client-side numbering — race-condition prone, kept only for fallback. */ export function generateRegistryNumber( company: CompanyId, @@ -194,8 +196,6 @@ export function generateRegistryNumber( const year = now.getFullYear(); const prefix = COMPANY_PREFIXES[company]; - // Parse the numeric part from existing numbers for this company+year - // Pattern: PREFIX-NNNN/YYYY const regex = new RegExp(`^${prefix}-(\\d+)/${year}$`); let maxNum = 0; @@ -212,6 +212,91 @@ export function generateRegistryNumber( return `${prefix}-${padded}/${year}`; } +// ── New-format numbering (server-side, atomic) ── + +/** + * Allocate the next sequence number atomically via PostgreSQL. + * Format: BTG-2026-IN-00001 + * + * Uses INSERT ... ON CONFLICT ... UPDATE RETURNING for race-condition safety. + * Must be called from server-side only (API routes). + */ +export async function allocateSequenceNumber( + company: CompanyId, + direction: RegistryDirection, + year?: number, +): Promise<{ number: string; sequence: number }> { + // Dynamic import — prisma is only available server-side + const { prisma } = await import("@/core/storage/prisma"); + + const companyPrefix = REGISTRY_COMPANY_PREFIX[company]; + const typeCode = DIRECTION_TYPE_CODE[direction]; + const yr = year ?? new Date().getFullYear(); + + const result = await prisma.$queryRaw>` + INSERT INTO "RegistrySequence" (id, company, year, type, "lastSeq", "createdAt", "updatedAt") + VALUES (gen_random_uuid()::text, ${companyPrefix}, ${yr}, ${typeCode}, 1, NOW(), NOW()) + ON CONFLICT (company, year, type) + DO UPDATE SET "lastSeq" = "RegistrySequence"."lastSeq" + 1, "updatedAt" = NOW() + RETURNING "lastSeq" + `; + + const row = result[0]; + if (!row) throw new Error("Failed to allocate sequence number"); + const seq = row.lastSeq; + const padded = String(seq).padStart(5, "0"); + + return { + number: `${companyPrefix}-${yr}-${typeCode}-${padded}`, + sequence: seq, + }; +} + +// ── Number format detection + parsing ── + +export interface ParsedRegistryNumber { + company: string; + year: number; + type?: string; + sequence: number; + format: "old" | "new"; +} + +/** Detect whether a number uses the new format (BTG-2026-IN-00125) */ +export function isNewFormat(num: string): boolean { + return /^(BTG|SDT|USW|GRP)-\d{4}-(IN|OUT|INT)-\d{5}$/.test(num); +} + +/** Parse a registry number in either old or new format */ +export function parseRegistryNumber(num: string): ParsedRegistryNumber | null { + // New format: BTG-2026-IN-00125 + const newMatch = num.match( + /^(BTG|SDT|USW|GRP)-(\d{4})-(IN|OUT|INT)-(\d{5})$/, + ); + if (newMatch) { + return { + company: newMatch[1]!, + year: parseInt(newMatch[2]!, 10), + type: newMatch[3]!, + sequence: parseInt(newMatch[4]!, 10), + format: "new", + }; + } + + // Old format: B-0001/2026 + const oldMatch = num.match(/^(B|US|SDT|G)-(\d+)\/(\d{4})$/); + if (oldMatch) { + return { + company: oldMatch[1]!, + year: parseInt(oldMatch[3]!, 10), + sequence: parseInt(oldMatch[2]!, 10), + format: "old", + }; + } + + return null; +} + /** Calculate days overdue (negative = days remaining, positive = overdue) */ export function getOverdueDays(deadline: string | undefined): number | null { if (!deadline) return null; diff --git a/src/modules/registratura/services/reserved-slots-service.ts b/src/modules/registratura/services/reserved-slots-service.ts new file mode 100644 index 0000000..2556804 --- /dev/null +++ b/src/modules/registratura/services/reserved-slots-service.ts @@ -0,0 +1,145 @@ +/** + * Reserved registration slots for late registrations. + * + * Each month gets 2 reserved slots per company: + * - mid-month (~15th, snapped to nearest previous working day) + * - end-of-month (~last day, snapped to nearest previous working day) + * + * Slots use the IN sequence by default. When a late IN document arrives, + * it claims the slot's number. OUT/INT docs get new typed numbers instead. + */ + +import { v4 as uuid } from "uuid"; +import { isWorkingDay } from "./working-days"; +import { allocateSequenceNumber } from "./registry-service"; +import type { CompanyId } from "@/core/auth/types"; +import type { RegistryEntry } from "../types"; + +/** Snap a date to the nearest previous working day (Mon–Fri, not a holiday) */ +function nearestPreviousWorkingDay(date: Date): Date { + const d = new Date(date); + while (!isWorkingDay(d)) { + d.setDate(d.getDate() - 1); + } + return d; +} + +/** Get the last calendar day of a month */ +function lastDayOfMonth(year: number, month: number): Date { + return new Date(year, month + 1, 0); // month is 0-indexed +} + +/** Compute the two reservation target dates for a given month */ +export function getReservationDates( + year: number, + month: number, +): [Date, Date] { + const mid = nearestPreviousWorkingDay(new Date(year, month, 15)); + const end = nearestPreviousWorkingDay(lastDayOfMonth(year, month)); + + // Edge case: if both land on the same day, shift mid one working day earlier + if (mid.getTime() === end.getTime()) { + mid.setDate(mid.getDate() - 1); + while (!isWorkingDay(mid)) { + mid.setDate(mid.getDate() - 1); + } + } + + return [mid, end]; +} + +/** Format a Date as YYYY-MM-DD */ +function toDateStr(d: Date): string { + return d.toISOString().slice(0, 10); +} + +/** + * Generate 2 reserved slot entries for a given company + month. + * Allocates real IN sequence numbers so they occupy positions in the registry. + */ +export async function generateReservedSlots( + company: CompanyId, + year: number, + month: number, + actorId: string, + actorName: string, +): Promise { + const [midDate, endDate] = getReservationDates(year, month); + const now = new Date().toISOString(); + const slots: RegistryEntry[] = []; + + for (const targetDate of [midDate, endDate]) { + const { number } = await allocateSequenceNumber( + company, + "intrat", + year, + ); + const dateStr = toDateStr(targetDate); + + const entry: RegistryEntry = { + id: uuid(), + number, + date: dateStr, + registrationDate: dateStr, + direction: "intrat", + documentType: "altele", + subject: `[Slot rezervat] ${dateStr}`, + sender: "", + recipient: "", + company, + status: "reserved", + linkedEntryIds: [], + attachments: [], + tags: [], + notes: "", + visibility: "internal", + isReserved: true, + registrationType: "normal", + createdBy: actorId, + createdByName: actorName, + createdAt: now, + updatedAt: now, + }; + slots.push(entry); + } + + return slots; +} + +/** + * Find an unclaimed reserved slot for a given company + month. + * Returns the first available slot, or null if none available. + */ +export function findAvailableReservedSlot( + entries: RegistryEntry[], + company: CompanyId, + targetYear: number, + targetMonth: number, +): RegistryEntry | null { + for (const e of entries) { + if (!e.isReserved || e.status !== "reserved") continue; + if (e.company !== company) continue; + const d = new Date(e.date); + if (d.getFullYear() === targetYear && d.getMonth() === targetMonth) { + return e; + } + } + return null; +} + +/** + * Check if reserved slots already exist for a given company + month. + */ +export function countReservedSlots( + entries: RegistryEntry[], + company: CompanyId, + year: number, + month: number, +): number { + return entries.filter((e) => { + if (!e.isReserved) return false; + if (e.company !== company) return false; + const d = new Date(e.date); + return d.getFullYear() === year && d.getMonth() === month; + }).length; +} diff --git a/src/modules/registratura/types.ts b/src/modules/registratura/types.ts index 2416edd..71800e8 100644 --- a/src/modules/registratura/types.ts +++ b/src/modules/registratura/types.ts @@ -1,8 +1,26 @@ import type { Visibility } from "@/core/module-registry/types"; import type { CompanyId } from "@/core/auth/types"; -/** Document direction — simplified from the old 3-way type */ -export type RegistryDirection = "intrat" | "iesit"; +/** Document direction */ +export type RegistryDirection = "intrat" | "iesit" | "intern"; + +/** Maps direction to the numbering type code */ +export const DIRECTION_TYPE_CODE: Record = { + intrat: "IN", + iesit: "OUT", + intern: "INT", +}; + +/** New-format company prefixes for registry numbering */ +export const REGISTRY_COMPANY_PREFIX: Record = { + beletage: "BTG", + "urban-switch": "USW", + "studii-de-teren": "SDT", + group: "GRP", +}; + +/** Registration type — normal, late, or claimed from reserved slot */ +export type RegistrationType = "normal" | "late" | "reserved-claimed"; /** Default document types — user can add custom types that sync with Tag Manager */ export const DEFAULT_DOCUMENT_TYPES = [ @@ -37,8 +55,8 @@ export const DEFAULT_DOC_TYPE_LABELS: Record = { altele: "Altele", }; -/** Status — simplified to open/closed */ -export type RegistryStatus = "deschis" | "inchis"; +/** Status — open/closed/reserved */ +export type RegistryStatus = "deschis" | "inchis" | "reserved"; /** Closure resolution — why the entry was closed */ export type ClosureResolution = @@ -262,4 +280,43 @@ export interface RegistryEntry { visibility: Visibility; createdAt: string; updatedAt: string; + + // ── New fields for numbering/reserved/audit ── + + /** Whether this is a reserved slot (auto-generated placeholder) */ + isReserved?: boolean; + /** Registration type — normal, late, or reserved-claimed */ + registrationType?: RegistrationType; + /** Who created this entry (SSO user ID) */ + createdBy?: string; + /** Who created this entry (display name) */ + createdByName?: string; + /** Original reserved slot ID if this entry claimed a reserved slot */ + claimedReservedSlotId?: string; + /** Old-format number if entry was migrated */ + legacyNumber?: string; +} + +// ── Audit types ── + +export type RegistryAuditAction = + | "created" + | "updated" + | "reserved_created" + | "reserved_claimed" + | "late_registration" + | "closed" + | "reopened" + | "deleted"; + +export interface RegistryAuditEvent { + id: string; + entryId: string; + entryNumber: string; + action: RegistryAuditAction; + actor: string; + actorName?: string; + company: string; + detail?: Record; + createdAt: string; } diff --git a/src/shared/components/common/theme-toggle.tsx b/src/shared/components/common/theme-toggle.tsx index 6f6ef48..acbc616 100644 --- a/src/shared/components/common/theme-toggle.tsx +++ b/src/shared/components/common/theme-toggle.tsx @@ -1,108 +1,132 @@ "use client"; import { useTheme } from "next-themes"; -import { useEffect, useState } from "react"; -import { Sun, Moon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { cn } from "@/shared/lib/utils"; +/** + * Sun↔Moon morphing toggle. + * + * One single SVG. On click it immediately spins 360°, and mid-spin + * the sun circle morphs into a crescent (via sliding SVG mask) while + * the rays retract with staggered timing. Everything is one fluid motion. + * + * Inverted logic: dark → shows sun, light → shows moon. + */ export function ThemeToggle() { const { resolvedTheme, setTheme } = useTheme(); const [mounted, setMounted] = useState(false); + const svgRef = useRef(null); useEffect(() => setMounted(true), []); if (!mounted) { - return
; + return
; } const isDark = resolvedTheme === "dark"; + // Inverted: dark → sun (action: go light), light → moon (action: go dark) + const showSun = isDark; + + const handleClick = () => { + // Trigger spin via Web Animations API — instant, no state re-render needed + svgRef.current?.animate( + [ + { transform: "rotate(0deg) scale(1)" }, + { transform: "rotate(180deg) scale(1.12)", offset: 0.5 }, + { transform: "rotate(360deg) scale(1)" }, + ], + { duration: 550, easing: "cubic-bezier(0.4, 0, 0.2, 1)" }, + ); + setTheme(isDark ? "light" : "dark"); + }; + + // Ray endpoints — 8 rays at 45° intervals + const rays = [0, 45, 90, 135, 180, 225, 270, 315].map((deg) => { + const r = (deg * Math.PI) / 180; + return { + x1: 12 + 6.5 * Math.cos(r), + y1: 12 + 6.5 * Math.sin(r), + x2: 12 + 9 * Math.cos(r), + y2: 12 + 9 * Math.sin(r), + }; + }); + + const ease = "cubic-bezier(0.4, 0, 0.2, 1)"; + const dur = "500ms"; return ( ); }