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:
@@ -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<string, string> = {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Globe,
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
ArrowRightLeft,
|
||||
HardDrive,
|
||||
FolderOpen,
|
||||
Link2,
|
||||
@@ -554,6 +555,19 @@ export function RegistryEntryForm({
|
||||
<ArrowUpFromLine className="h-4 w-4" />
|
||||
Ieșit
|
||||
</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>
|
||||
|
||||
@@ -41,6 +41,7 @@ export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) {
|
||||
<SelectItem value="all">Toate</SelectItem>
|
||||
<SelectItem value="intrat">Intrat</SelectItem>
|
||||
<SelectItem value="iesit">Ieșit</SelectItem>
|
||||
<SelectItem value="intern">Intern</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -74,6 +75,7 @@ export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) {
|
||||
<SelectItem value="all">Toate</SelectItem>
|
||||
<SelectItem value="deschis">Deschis</SelectItem>
|
||||
<SelectItem value="inchis">Închis</SelectItem>
|
||||
<SelectItem value="reserved">Rezervat</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
deschis: "Deschis",
|
||||
inchis: "Închis",
|
||||
reserved: "Rezervat",
|
||||
};
|
||||
|
||||
function loadVisibleColumns(): Set<ColumnId> {
|
||||
@@ -327,7 +329,11 @@ export function RegistryTable({
|
||||
<td className="px-3 py-2">
|
||||
<Badge
|
||||
variant={
|
||||
entry.direction === "intrat" ? "default" : "secondary"
|
||||
entry.direction === "intrat"
|
||||
? "default"
|
||||
: entry.direction === "intern"
|
||||
? "outline"
|
||||
: "secondary"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
@@ -424,9 +430,16 @@ export function RegistryTable({
|
||||
<td className="px-3 py-2">
|
||||
<Badge
|
||||
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]}
|
||||
</Badge>
|
||||
|
||||
@@ -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<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
|
||||
) => {
|
||||
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<RegistryEntry>) => {
|
||||
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<RegistryAuditEvent[]> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 { 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<CompanyId, string> = {
|
||||
beletage: "B",
|
||||
"urban-switch": "US",
|
||||
@@ -181,9 +184,8 @@ const COMPANY_PREFIXES: Record<CompanyId, string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<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) */
|
||||
export function getOverdueDays(deadline: string | undefined): number | 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 (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<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;
|
||||
}
|
||||
@@ -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<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 */
|
||||
export const DEFAULT_DOCUMENT_TYPES = [
|
||||
@@ -37,8 +55,8 @@ export const DEFAULT_DOC_TYPE_LABELS: Record<string, string> = {
|
||||
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<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user