feat(registratura): atomic numbering, reserved slots, audit trail, API endpoints + theme toggle animation

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 <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-10 07:54:32 +02:00
parent f94529c380
commit a0dd35a066
15 changed files with 1354 additions and 124 deletions
+30
View File
@@ -82,3 +82,33 @@ model GisUat {
@@index([name]) @@index([name])
@@index([county]) @@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])
}
+80
View File
@@ -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<Actor | null> {
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 });
}
}
+180
View File
@@ -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<Actor | null> {
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<RegistryEntry[]> {
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 });
}
+426
View File
@@ -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<Actor | null> {
// 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<string, unknown> = {};
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<RegistryEntry[]> {
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<RegistryEntry | null> {
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<void> {
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<void> {
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<string, RegistryDirection> = {
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<RegistryEntry>;
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<RegistryEntry> };
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<string, unknown>,
updated as unknown as Record<string, unknown>,
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 });
}
@@ -2,6 +2,7 @@
import { import {
ArrowDownToLine, ArrowDownToLine,
ArrowRightLeft,
ArrowUpFromLine, ArrowUpFromLine,
Calendar, Calendar,
CheckCircle2, CheckCircle2,
@@ -64,6 +65,12 @@ const DIRECTION_CONFIG = {
class: class:
"bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300", "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; } as const;
const STATUS_CONFIG = { const STATUS_CONFIG = {
@@ -76,6 +83,10 @@ const STATUS_CONFIG = {
label: "Închis", label: "Închis",
class: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400", 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; } as const;
const RESOLUTION_LABELS: Record<string, string> = { const RESOLUTION_LABELS: Record<string, string> = {
@@ -15,6 +15,7 @@ import {
Globe, Globe,
ArrowDownToLine, ArrowDownToLine,
ArrowUpFromLine, ArrowUpFromLine,
ArrowRightLeft,
HardDrive, HardDrive,
FolderOpen, FolderOpen,
Link2, Link2,
@@ -554,6 +555,19 @@ export function RegistryEntryForm({
<ArrowUpFromLine className="h-4 w-4" /> <ArrowUpFromLine className="h-4 w-4" />
Ieșit Ieșit
</button> </button>
<button
type="button"
onClick={() => setDirection("intern")}
className={cn(
"flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-all",
direction === "intern"
? "bg-purple-500 text-white shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background",
)}
>
<ArrowRightLeft className="h-4 w-4" />
Intern
</button>
</div> </div>
</div> </div>
<div> <div>
@@ -41,6 +41,7 @@ export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) {
<SelectItem value="all">Toate</SelectItem> <SelectItem value="all">Toate</SelectItem>
<SelectItem value="intrat">Intrat</SelectItem> <SelectItem value="intrat">Intrat</SelectItem>
<SelectItem value="iesit">Ieșit</SelectItem> <SelectItem value="iesit">Ieșit</SelectItem>
<SelectItem value="intern">Intern</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -74,6 +75,7 @@ export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) {
<SelectItem value="all">Toate</SelectItem> <SelectItem value="all">Toate</SelectItem>
<SelectItem value="deschis">Deschis</SelectItem> <SelectItem value="deschis">Deschis</SelectItem>
<SelectItem value="inchis">Închis</SelectItem> <SelectItem value="inchis">Închis</SelectItem>
<SelectItem value="reserved">Rezervat</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -82,7 +82,7 @@ const COLUMNS: ColumnDef[] = [
id: "direction", id: "direction",
label: "Dir.", label: "Dir.",
tooltip: 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, defaultVisible: true,
}, },
{ {
@@ -141,6 +141,7 @@ const STORAGE_KEY = "registratura:visible-columns";
const DIRECTION_LABELS: Record<string, string> = { const DIRECTION_LABELS: Record<string, string> = {
intrat: "Intrat", intrat: "Intrat",
iesit: "Ieșit", iesit: "Ieșit",
intern: "Intern",
}; };
function getDocTypeLabel(type: string): string { function getDocTypeLabel(type: string): string {
@@ -152,6 +153,7 @@ function getDocTypeLabel(type: string): string {
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
deschis: "Deschis", deschis: "Deschis",
inchis: "Închis", inchis: "Închis",
reserved: "Rezervat",
}; };
function loadVisibleColumns(): Set<ColumnId> { function loadVisibleColumns(): Set<ColumnId> {
@@ -327,7 +329,11 @@ export function RegistryTable({
<td className="px-3 py-2"> <td className="px-3 py-2">
<Badge <Badge
variant={ variant={
entry.direction === "intrat" ? "default" : "secondary" entry.direction === "intrat"
? "default"
: entry.direction === "intern"
? "outline"
: "secondary"
} }
className="text-xs" className="text-xs"
> >
@@ -424,9 +430,16 @@ export function RegistryTable({
<td className="px-3 py-2"> <td className="px-3 py-2">
<Badge <Badge
variant={ variant={
entry.status === "deschis" ? "default" : "outline" entry.status === "deschis"
? "default"
: entry.status === "reserved"
? "secondary"
: "outline"
} }
className="text-xs" className={cn(
"text-xs",
entry.status === "reserved" && "border-dashed",
)}
> >
{STATUS_LABELS[entry.status]} {STATUS_LABELS[entry.status]}
</Badge> </Badge>
+59 -30
View File
@@ -16,8 +16,8 @@ import {
getFullEntry, getFullEntry,
saveEntry, saveEntry,
deleteEntry, deleteEntry,
generateRegistryNumber,
} from "../services/registry-service"; } from "../services/registry-service";
import type { RegistryAuditEvent } from "../types";
import { import {
createTrackedDeadline, createTrackedDeadline,
resolveDeadline as resolveDeadlineFn, resolveDeadline as resolveDeadlineFn,
@@ -71,44 +71,42 @@ export function useRegistry() {
async ( async (
data: Omit<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">, data: Omit<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
) => { ) => {
const freshEntries = await getAllEntries(storage); // Use the API for atomic server-side numbering + audit
const now = new Date().toISOString(); const res = await fetch("/api/registratura", {
const number = generateRegistryNumber( method: "POST",
data.company, headers: { "Content-Type": "application/json" },
data.date, body: JSON.stringify({ entry: data }),
freshEntries, });
); const result = await res.json();
const entry: RegistryEntry = {
...data, if (!result.success) {
id: uuid(), throw new Error(result.error ?? "Failed to create entry");
number, }
registrationDate: new Date().toISOString().slice(0, 10),
createdAt: now, const entry = result.entry as RegistryEntry;
updatedAt: now,
};
await saveEntry(storage, blobStorage, entry);
setEntries((prev) => [entry, ...prev]); setEntries((prev) => [entry, ...prev]);
return entry; return entry;
}, },
[storage, blobStorage], [],
); );
const updateEntry = useCallback( const updateEntry = useCallback(
async (id: string, updates: Partial<RegistryEntry>) => { async (id: string, updates: Partial<RegistryEntry>) => {
const existing = entries.find((e) => e.id === id); // Use the API for server-side diff audit
if (!existing) return; const res = await fetch("/api/registratura", {
const updated: RegistryEntry = { method: "PUT",
...existing, headers: { "Content-Type": "application/json" },
...updates, body: JSON.stringify({ id, updates }),
id: existing.id, });
number: existing.number, const result = await res.json();
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(), if (!result.success) {
}; throw new Error(result.error ?? "Failed to update entry");
await saveEntry(storage, blobStorage, updated); }
await refresh(); await refresh();
}, },
[storage, blobStorage, refresh, entries], [refresh],
); );
const removeEntry = useCallback( const removeEntry = useCallback(
@@ -268,6 +266,35 @@ export function useRegistry() {
[storage, blobStorage], [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<RegistryAuditEvent[]> => {
const res = await fetch(
`/api/registratura/audit?entryId=${encodeURIComponent(entryId)}`,
);
const result = await res.json();
return result.events ?? [];
},
[],
);
return { return {
entries: filteredEntries, entries: filteredEntries,
allEntries: entries, allEntries: entries,
@@ -282,6 +309,8 @@ export function useRegistry() {
addDeadline, addDeadline,
resolveDeadline, resolveDeadline,
removeDeadline, removeDeadline,
createReservedSlots,
loadAuditHistory,
refresh, refresh,
}; };
} }
+9 -1
View File
@@ -4,6 +4,7 @@ export type {
RegistryEntry, RegistryEntry,
RegistryDirection, RegistryDirection,
RegistryStatus, RegistryStatus,
RegistrationType,
ClosureInfo, ClosureInfo,
DocumentType, DocumentType,
DeadlineDayType, DeadlineDayType,
@@ -12,5 +13,12 @@ export type {
DeadlineTypeDef, DeadlineTypeDef,
TrackedDeadline, TrackedDeadline,
DeadlineAuditEntry, DeadlineAuditEntry,
RegistryAuditAction,
RegistryAuditEvent,
} from "./types";
export {
DEFAULT_DOCUMENT_TYPES,
DEFAULT_DOC_TYPE_LABELS,
DIRECTION_TYPE_CODE,
REGISTRY_COMPANY_PREFIX,
} from "./types"; } from "./types";
export { DEFAULT_DOCUMENT_TYPES, DEFAULT_DOC_TYPE_LABELS } from "./types";
@@ -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<string, unknown>;
}): Promise<void> {
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<RegistryAuditEvent[]> {
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<RegistryAuditEvent[]> {
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<string, unknown>,
newEntry: Record<string, unknown>,
fieldsToTrack: string[],
): Record<string, { old: unknown; new: unknown }> | null {
const changes: Record<string, { old: unknown; new: unknown }> = {};
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<string, unknown>) ?? 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",
];
@@ -1,5 +1,6 @@
import type { CompanyId } from "@/core/auth/types"; 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:"; const STORAGE_PREFIX = "entry:";
@@ -173,6 +174,8 @@ export async function deleteEntry(
await blobStorage.delete(id).catch(() => {}); await blobStorage.delete(id).catch(() => {});
} }
// ── Old-format numbering (deprecated — kept for backward compatibility) ──
const COMPANY_PREFIXES: Record<CompanyId, string> = { const COMPANY_PREFIXES: Record<CompanyId, string> = {
beletage: "B", beletage: "B",
"urban-switch": "US", "urban-switch": "US",
@@ -181,9 +184,8 @@ const COMPANY_PREFIXES: Record<CompanyId, string> = {
}; };
/** /**
* Generate company-specific registry number: B-0001/2026 * @deprecated Use allocateSequenceNumber() via the API instead.
* Uses the highest existing number + 1 for that company in that year. * Client-side numbering — race-condition prone, kept only for fallback.
* Parses actual numbers from entries to prevent duplicates.
*/ */
export function generateRegistryNumber( export function generateRegistryNumber(
company: CompanyId, company: CompanyId,
@@ -194,8 +196,6 @@ export function generateRegistryNumber(
const year = now.getFullYear(); const year = now.getFullYear();
const prefix = COMPANY_PREFIXES[company]; 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}$`); const regex = new RegExp(`^${prefix}-(\\d+)/${year}$`);
let maxNum = 0; let maxNum = 0;
@@ -212,6 +212,91 @@ export function generateRegistryNumber(
return `${prefix}-${padded}/${year}`; 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<Array<{ lastSeq: number }>>`
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) */ /** Calculate days overdue (negative = days remaining, positive = overdue) */
export function getOverdueDays(deadline: string | undefined): number | null { export function getOverdueDays(deadline: string | undefined): number | null {
if (!deadline) return null; if (!deadline) return null;
@@ -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 (MonFri, 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<RegistryEntry[]> {
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;
}
+61 -4
View File
@@ -1,8 +1,26 @@
import type { Visibility } from "@/core/module-registry/types"; import type { Visibility } from "@/core/module-registry/types";
import type { CompanyId } from "@/core/auth/types"; import type { CompanyId } from "@/core/auth/types";
/** Document direction — simplified from the old 3-way type */ /** Document direction */
export type RegistryDirection = "intrat" | "iesit"; export type RegistryDirection = "intrat" | "iesit" | "intern";
/** Maps direction to the numbering type code */
export const DIRECTION_TYPE_CODE: Record<RegistryDirection, string> = {
intrat: "IN",
iesit: "OUT",
intern: "INT",
};
/** New-format company prefixes for registry numbering */
export const REGISTRY_COMPANY_PREFIX: Record<CompanyId, string> = {
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 */ /** Default document types — user can add custom types that sync with Tag Manager */
export const DEFAULT_DOCUMENT_TYPES = [ export const DEFAULT_DOCUMENT_TYPES = [
@@ -37,8 +55,8 @@ export const DEFAULT_DOC_TYPE_LABELS: Record<string, string> = {
altele: "Altele", altele: "Altele",
}; };
/** Status — simplified to open/closed */ /** Status — open/closed/reserved */
export type RegistryStatus = "deschis" | "inchis"; export type RegistryStatus = "deschis" | "inchis" | "reserved";
/** Closure resolution — why the entry was closed */ /** Closure resolution — why the entry was closed */
export type ClosureResolution = export type ClosureResolution =
@@ -262,4 +280,43 @@ export interface RegistryEntry {
visibility: Visibility; visibility: Visibility;
createdAt: string; createdAt: string;
updatedAt: 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<string, unknown>;
createdAt: string;
} }
+103 -79
View File
@@ -1,108 +1,132 @@
"use client"; "use client";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Sun, Moon } from "lucide-react";
import { cn } from "@/shared/lib/utils"; 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() { export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme(); const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const svgRef = useRef<SVGSVGElement>(null);
useEffect(() => setMounted(true), []); useEffect(() => setMounted(true), []);
if (!mounted) { if (!mounted) {
return <div className="h-8 w-16 rounded-full bg-muted" />; return <div className="h-9 w-9 rounded-xl bg-muted" />;
} }
const isDark = resolvedTheme === "dark"; 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 ( return (
<button <button
type="button" type="button"
role="switch" aria-label={isDark ? "Treci la modul luminos" : "Treci la modul intunecat"}
aria-checked={isDark} onClick={handleClick}
aria-label="Schimbă tema"
onClick={() => setTheme(isDark ? "light" : "dark")}
className={cn( className={cn(
"relative inline-flex h-8 w-16 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-all duration-500 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "relative flex h-9 w-9 items-center justify-center rounded-xl",
isDark "transition-colors duration-200 hover:bg-accent/60",
? "bg-gradient-to-r from-indigo-950 via-indigo-900 to-slate-800" "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
: "bg-gradient-to-r from-sky-400 via-sky-300 to-amber-200",
)} )}
> >
{/* Stars — visible in dark mode */} <svg
<span ref={svgRef}
className={cn( viewBox="0 0 24 24"
"pointer-events-none absolute left-2 top-1.5 flex gap-1 transition-all duration-500", fill="none"
isDark ? "opacity-100 scale-100" : "opacity-0 scale-50", className="h-[22px] w-[22px]"
)}
> >
<span className="h-[3px] w-[3px] rounded-full bg-white/90 animate-[pulse_2s_ease-in-out_infinite]" /> <defs>
<span <mask id="theme-mask">
className="mt-1.5 h-[2px] w-[2px] rounded-full bg-white/60 animate-[pulse_3s_ease-in-out_infinite]" <rect width="24" height="24" fill="white" />
style={{ animationDelay: "0.7s" }} {/* Slides in to carve the crescent; slides out to reveal full circle */}
/> <circle
<span r="6.5"
className="-mt-0.5 h-[2px] w-[2px] rounded-full bg-white/50 animate-[pulse_2.5s_ease-in-out_infinite]" fill="black"
style={{ animationDelay: "1.2s" }} cx={showSun ? 28 : 16}
/> cy={showSun ? 2 : 6}
</span> style={{ transition: `cx ${dur} ${ease}, cy ${dur} ${ease}` }}
/>
</mask>
</defs>
{/* Clouds — visible in light mode */} {/* Main body — full sun circle that morphs into crescent moon */}
<span <circle
className={cn( cx="12"
"pointer-events-none absolute right-2 bottom-1 transition-all duration-500", cy="12"
isDark r={showSun ? 4.5 : 5.5}
? "translate-y-3 opacity-0 scale-75" fill="currentColor"
: "translate-y-0 opacity-100 scale-100", mask="url(#theme-mask)"
)} className={showSun ? "text-amber-400" : "text-slate-400 dark:text-indigo-300"}
> style={{ transition: `r ${dur} ${ease}, color ${dur}` }}
<span className="inline-block h-2 w-4 rounded-full bg-white/70" />
<span className="absolute -right-1 -top-0.5 h-1.5 w-2.5 rounded-full bg-white/50" />
</span>
{/* Sliding knob with Sun/Moon icon */}
<span
className={cn(
"pointer-events-none relative z-10 inline-flex h-6 w-6 items-center justify-center rounded-full shadow-lg transition-all duration-500 ease-in-out",
isDark ? "translate-x-[33px]" : "translate-x-[3px]",
isDark
? "bg-indigo-100 shadow-indigo-300/40"
: "bg-amber-50 shadow-amber-400/50",
)}
>
{/* Sun icon */}
<Sun
className={cn(
"absolute h-4 w-4 text-amber-500 transition-all duration-500",
isDark
? "rotate-90 scale-0 opacity-0"
: "rotate-0 scale-100 opacity-100",
)}
strokeWidth={2.5}
/>
{/* Moon icon */}
<Moon
className={cn(
"absolute h-3.5 w-3.5 text-indigo-600 transition-all duration-500",
isDark
? "rotate-0 scale-100 opacity-100"
: "-rotate-90 scale-0 opacity-0",
)}
strokeWidth={2.5}
/> />
{/* Glow ring */} {/* Sun rays — retract into center when moon */}
<span <g
className={cn( stroke="currentColor"
"absolute inset-0 rounded-full transition-all duration-500", strokeWidth="2"
isDark strokeLinecap="round"
? "shadow-[0_0_8px_2px_rgba(129,140,248,0.3)]" className={showSun ? "text-amber-400" : "text-slate-400 dark:text-indigo-300"}
: "shadow-[0_0_8px_2px_rgba(251,191,36,0.3)] animate-[pulse_2s_ease-in-out_infinite]", style={{
)} transition: `opacity 300ms ${ease}, color ${dur}`,
/> opacity: showSun ? 1 : 0,
</span> }}
>
{rays.map((ray, i) => (
<line
key={i}
x1={showSun ? ray.x1 : 12}
y1={showSun ? ray.y1 : 12}
x2={showSun ? ray.x2 : 12}
y2={showSun ? ray.y2 : 12}
style={{
transition: [
`x1 ${dur} ${ease} ${i * 20}ms`,
`y1 ${dur} ${ease} ${i * 20}ms`,
`x2 ${dur} ${ease} ${i * 20}ms`,
`y2 ${dur} ${ease} ${i * 20}ms`,
].join(", "),
}}
/>
))}
</g>
</svg>
</button> </button>
); );
} }