feat: simplify deadline dashboard + add flow diagrams for document chains
Major UX overhaul of the "Termene legale" and thread tabs: Deadline dashboard: - Replace 6 KPI cards with simple summary bar (active/urgent/overdue) - Replace flat table with grouped list by entry (cards with progress bars) - Chain deadlines collapsed by default with expand toggle - Auto-tracked/background deadlines hidden from main list Flow diagram (new component): - CSS-only horizontal flow diagram showing document chains - Nodes with direction bar (blue=intrat, orange=iesit), number, subject, status - Solid arrows for thread links, dashed for conex/linked entries - Used in both "Dosare" tab (full) and detail panel (compact, max 5 nodes) Thread explorer → Dosare: - Renamed tab "Fire conversatie" → "Dosare" - Each thread shown as a card with flow diagram inside - Simplified stats (just active/finalized count) Background tracking: - comunicare-aviz-beneficiar marked as backgroundOnly (not shown in dashboard) - Transmission status computed and shown in detail panel (on-time/late) Auto-resolution: - When closing entry via reply, matching parent deadlines auto-resolve - Resolution note includes the reply entry number Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,28 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Label } from "@/shared/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/shared/components/ui/select";
|
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { CheckCircle2, ChevronDown, ChevronRight } from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
RegistryEntry,
|
RegistryEntry,
|
||||||
TrackedDeadline,
|
TrackedDeadline,
|
||||||
DeadlineResolution,
|
DeadlineResolution,
|
||||||
DeadlineCategory,
|
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { aggregateDeadlines } from "../services/deadline-service";
|
import {
|
||||||
import { CATEGORY_LABELS, getDeadlineType } from "../services/deadline-catalog";
|
aggregateDeadlines,
|
||||||
import { useDeadlineFilters } from "../hooks/use-deadline-filters";
|
groupDeadlinesByEntry,
|
||||||
import { DeadlineTable } from "./deadline-table";
|
type DeadlineEntryGroup,
|
||||||
|
type DeadlineDisplayStatus,
|
||||||
|
} from "../services/deadline-service";
|
||||||
|
import { getDeadlineType } from "../services/deadline-catalog";
|
||||||
import { DeadlineResolveDialog } from "./deadline-resolve-dialog";
|
import { DeadlineResolveDialog } from "./deadline-resolve-dialog";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
interface DeadlineDashboardProps {
|
interface DeadlineDashboardProps {
|
||||||
entries: RegistryEntry[];
|
entries: RegistryEntry[];
|
||||||
@@ -41,12 +36,26 @@ interface DeadlineDashboardProps {
|
|||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RESOLUTION_LABELS: Record<string, string> = {
|
const VARIANT_DOT: Record<string, string> = {
|
||||||
pending: "În așteptare",
|
green: "bg-green-500",
|
||||||
completed: "Finalizat",
|
yellow: "bg-amber-500",
|
||||||
"aprobat-tacit": "Aprobat tacit",
|
red: "bg-red-500",
|
||||||
respins: "Respins",
|
blue: "bg-blue-500",
|
||||||
anulat: "Anulat",
|
gray: "bg-muted-foreground",
|
||||||
|
};
|
||||||
|
|
||||||
|
const VARIANT_TEXT: Record<string, string> = {
|
||||||
|
green: "text-green-700 dark:text-green-400",
|
||||||
|
yellow: "text-amber-700 dark:text-amber-400",
|
||||||
|
red: "text-red-700 dark:text-red-400",
|
||||||
|
blue: "text-blue-700 dark:text-blue-400",
|
||||||
|
gray: "text-muted-foreground",
|
||||||
|
};
|
||||||
|
|
||||||
|
const VARIANT_BG: Record<string, string> = {
|
||||||
|
red: "border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20",
|
||||||
|
yellow:
|
||||||
|
"border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DeadlineDashboard({
|
export function DeadlineDashboard({
|
||||||
@@ -54,34 +63,37 @@ export function DeadlineDashboard({
|
|||||||
onResolveDeadline,
|
onResolveDeadline,
|
||||||
onAddChainedDeadline,
|
onAddChainedDeadline,
|
||||||
}: DeadlineDashboardProps) {
|
}: DeadlineDashboardProps) {
|
||||||
const { filters, updateFilter } = useDeadlineFilters();
|
const [urgentOnly, setUrgentOnly] = useState(false);
|
||||||
const [resolvingEntry, setResolvingEntry] = useState<string | null>(null);
|
const [resolvingEntry, setResolvingEntry] = useState<string | null>(null);
|
||||||
const [resolvingDeadline, setResolvingDeadline] =
|
const [resolvingDeadline, setResolvingDeadline] =
|
||||||
useState<TrackedDeadline | null>(null);
|
useState<TrackedDeadline | null>(null);
|
||||||
|
const [expandedChains, setExpandedChains] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const stats = useMemo(() => aggregateDeadlines(entries), [entries]);
|
const stats = useMemo(() => aggregateDeadlines(entries), [entries]);
|
||||||
|
const groups = useMemo(() => groupDeadlinesByEntry(entries), [entries]);
|
||||||
|
|
||||||
const filteredRows = useMemo(() => {
|
const filteredGroups = useMemo(() => {
|
||||||
return stats.all.filter((row) => {
|
if (!urgentOnly) return groups;
|
||||||
if (filters.category !== "all") {
|
return groups
|
||||||
const def = getDeadlineType(row.deadline.typeId);
|
.map((g) => ({
|
||||||
if (def && def.category !== filters.category) return false;
|
...g,
|
||||||
}
|
mainDeadlines: g.mainDeadlines.filter(
|
||||||
if (filters.resolution !== "all") {
|
(m) =>
|
||||||
// Map tacit display status to actual resolution filter
|
m.deadline.resolution === "pending" &&
|
||||||
if (filters.resolution === "pending") {
|
(m.status.variant === "yellow" || m.status.variant === "red"),
|
||||||
if (row.deadline.resolution !== "pending") return false;
|
),
|
||||||
} else if (row.deadline.resolution !== filters.resolution) {
|
}))
|
||||||
return false;
|
.filter((g) => g.mainDeadlines.length > 0);
|
||||||
}
|
}, [groups, urgentOnly]);
|
||||||
}
|
|
||||||
if (filters.urgentOnly) {
|
const toggleChain = (deadlineId: string) => {
|
||||||
if (row.status.variant !== "yellow" && row.status.variant !== "red")
|
setExpandedChains((prev) => {
|
||||||
return false;
|
const next = new Set(prev);
|
||||||
}
|
if (next.has(deadlineId)) next.delete(deadlineId);
|
||||||
return true;
|
else next.add(deadlineId);
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
}, [stats.all, filters]);
|
};
|
||||||
|
|
||||||
const handleResolveClick = (entryId: string, deadline: TrackedDeadline) => {
|
const handleResolveClick = (entryId: string, deadline: TrackedDeadline) => {
|
||||||
setResolvingEntry(entryId);
|
setResolvingEntry(entryId);
|
||||||
@@ -102,7 +114,6 @@ export function DeadlineDashboard({
|
|||||||
chainNext,
|
chainNext,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle chain creation
|
|
||||||
if (chainNext) {
|
if (chainNext) {
|
||||||
const def = getDeadlineType(resolvingDeadline.typeId);
|
const def = getDeadlineType(resolvingDeadline.typeId);
|
||||||
if (def?.chainNextTypeId) {
|
if (def?.chainNextTypeId) {
|
||||||
@@ -122,116 +133,52 @@ export function DeadlineDashboard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Stats */}
|
{/* Summary bar */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
<div className="flex items-center gap-6 rounded-lg border bg-card px-4 py-3">
|
||||||
<StatCard label="Active" value={stats.active} />
|
<SummaryItem
|
||||||
<StatCard
|
count={stats.active}
|
||||||
label="Urgente"
|
label="active"
|
||||||
value={stats.urgent}
|
variant="green"
|
||||||
variant={stats.urgent > 0 ? "destructive" : undefined}
|
showIfZero
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<SummaryItem count={stats.urgent} label="urgente" variant="yellow" />
|
||||||
label="Depășit termen"
|
<SummaryItem
|
||||||
value={stats.overdue}
|
count={stats.overdue}
|
||||||
variant={stats.overdue > 0 ? "destructive" : undefined}
|
label={stats.overdue === 1 ? "depasit" : "depasite"}
|
||||||
/>
|
variant="red"
|
||||||
<StatCard
|
|
||||||
label="Aprobat tacit"
|
|
||||||
value={stats.tacit}
|
|
||||||
variant={stats.tacit > 0 ? "blue" : undefined}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Lipsă nr. dest."
|
|
||||||
value={stats.missingRecipientReg}
|
|
||||||
variant={stats.missingRecipientReg > 0 ? "destructive" : undefined}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Expiră curând"
|
|
||||||
value={stats.expiringSoon}
|
|
||||||
variant={stats.expiringSoon > 0 ? "destructive" : undefined}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="ml-auto">
|
||||||
|
<Button
|
||||||
|
variant={urgentOnly ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setUrgentOnly(!urgentOnly)}
|
||||||
|
>
|
||||||
|
Doar urgente
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Alert banners */}
|
{/* Grouped deadline list */}
|
||||||
{stats.missingRecipientReg > 0 && (
|
{filteredGroups.length === 0 ? (
|
||||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-2 text-xs text-amber-700 dark:text-amber-400">
|
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||||
⚠ {stats.missingRecipientReg} ieșir
|
{urgentOnly
|
||||||
{stats.missingRecipientReg === 1 ? "e" : "i"} cu termene legale nu{" "}
|
? "Niciun termen urgent sau depasit."
|
||||||
{stats.missingRecipientReg === 1 ? "are" : "au"} completat
|
: "Niciun termen legal activ."}
|
||||||
numărul/data de înregistrare la destinatar. Termenele legale nu
|
</p>
|
||||||
pornesc fără aceste date.
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredGroups.map((group) => (
|
||||||
|
<EntryDeadlineCard
|
||||||
|
key={group.entry.id}
|
||||||
|
group={group}
|
||||||
|
expandedChains={expandedChains}
|
||||||
|
onToggleChain={toggleChain}
|
||||||
|
onResolve={handleResolveClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{stats.expiringSoon > 0 && (
|
|
||||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-4 py-2 text-xs text-red-700 dark:text-red-400">
|
|
||||||
⚠ {stats.expiringSoon} document{stats.expiringSoon === 1 ? "" : "e"}{" "}
|
|
||||||
cu termen de valabilitate se apropie de expirare sau{" "}
|
|
||||||
{stats.expiringSoon === 1 ? "a" : "au"} expirat. Inițiați procedurile
|
|
||||||
de prelungire.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs">Categorie</Label>
|
|
||||||
<Select
|
|
||||||
value={filters.category}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
updateFilter("category", v as DeadlineCategory | "all")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-1 w-[160px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">Toate</SelectItem>
|
|
||||||
{(
|
|
||||||
Object.entries(CATEGORY_LABELS) as [DeadlineCategory, string][]
|
|
||||||
).map(([key, label]) => (
|
|
||||||
<SelectItem key={key} value={key}>
|
|
||||||
{label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs">Status</Label>
|
|
||||||
<Select
|
|
||||||
value={filters.resolution}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
updateFilter("resolution", v as DeadlineResolution | "all")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-1 w-[160px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">Toate</SelectItem>
|
|
||||||
{Object.entries(RESOLUTION_LABELS).map(([key, label]) => (
|
|
||||||
<SelectItem key={key} value={key}>
|
|
||||||
{label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant={filters.urgentOnly ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => updateFilter("urgentOnly", !filters.urgentOnly)}
|
|
||||||
>
|
|
||||||
Doar urgente
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<DeadlineTable rows={filteredRows} onResolve={handleResolveClick} />
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{filteredRows.length} din {stats.all.length} termene afișate
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<DeadlineResolveDialog
|
<DeadlineResolveDialog
|
||||||
open={resolvingDeadline !== null}
|
open={resolvingDeadline !== null}
|
||||||
@@ -248,27 +195,261 @@ export function DeadlineDashboard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({
|
// ── Summary item ──
|
||||||
|
|
||||||
|
function SummaryItem({
|
||||||
|
count,
|
||||||
label,
|
label,
|
||||||
value,
|
|
||||||
variant,
|
variant,
|
||||||
|
showIfZero,
|
||||||
}: {
|
}: {
|
||||||
|
count: number;
|
||||||
label: string;
|
label: string;
|
||||||
value: number;
|
variant: string;
|
||||||
variant?: "destructive" | "blue";
|
showIfZero?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
if (count === 0 && !showIfZero) return null;
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="flex items-center gap-2">
|
||||||
<CardContent className="p-4">
|
<span
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
className={cn("h-2.5 w-2.5 rounded-full", VARIANT_DOT[variant])}
|
||||||
<p
|
/>
|
||||||
className={`text-2xl font-bold ${
|
<span className={cn("text-sm font-semibold", VARIANT_TEXT[variant])}>
|
||||||
variant === "destructive" && value > 0 ? "text-destructive" : ""
|
{count}
|
||||||
}${variant === "blue" && value > 0 ? "text-blue-600" : ""}`}
|
</span>
|
||||||
>
|
<span className="text-sm text-muted-foreground">{label}</span>
|
||||||
{value}
|
</div>
|
||||||
</p>
|
);
|
||||||
</CardContent>
|
}
|
||||||
</Card>
|
|
||||||
|
// ── Entry card with its deadlines ──
|
||||||
|
|
||||||
|
function EntryDeadlineCard({
|
||||||
|
group,
|
||||||
|
expandedChains,
|
||||||
|
onToggleChain,
|
||||||
|
onResolve,
|
||||||
|
}: {
|
||||||
|
group: DeadlineEntryGroup;
|
||||||
|
expandedChains: Set<string>;
|
||||||
|
onToggleChain: (id: string) => void;
|
||||||
|
onResolve: (entryId: string, deadline: TrackedDeadline) => void;
|
||||||
|
}) {
|
||||||
|
const { entry, mainDeadlines } = group;
|
||||||
|
|
||||||
|
// Worst status for card border color
|
||||||
|
const worstVariant = mainDeadlines.reduce<string>((worst, m) => {
|
||||||
|
if (m.deadline.resolution !== "pending") return worst;
|
||||||
|
const order: Record<string, number> = {
|
||||||
|
red: 0,
|
||||||
|
yellow: 1,
|
||||||
|
blue: 2,
|
||||||
|
green: 3,
|
||||||
|
gray: 4,
|
||||||
|
};
|
||||||
|
const cur = order[m.status.variant] ?? 4;
|
||||||
|
const w = order[worst] ?? 4;
|
||||||
|
return cur < w ? m.status.variant : worst;
|
||||||
|
}, "green");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card",
|
||||||
|
VARIANT_BG[worstVariant] ?? "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Entry header */}
|
||||||
|
<div className="flex items-center gap-3 border-b px-4 py-2.5">
|
||||||
|
<span className="font-mono text-xs font-semibold">
|
||||||
|
{entry.number}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">|</span>
|
||||||
|
<span className="truncate text-xs">{entry.subject}</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto shrink-0 text-[10px]",
|
||||||
|
entry.direction === "intrat"
|
||||||
|
? "border-blue-300 text-blue-700 dark:border-blue-800 dark:text-blue-400"
|
||||||
|
: "border-orange-300 text-orange-700 dark:border-orange-800 dark:text-orange-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{entry.direction === "intrat" ? "Intrat" : "Iesit"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deadlines */}
|
||||||
|
<div className="divide-y">
|
||||||
|
{mainDeadlines.map((item) => {
|
||||||
|
const isExpanded = expandedChains.has(item.deadline.id);
|
||||||
|
const chainCount = item.chainChildren.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.deadline.id}>
|
||||||
|
<DeadlineRow
|
||||||
|
deadline={item.deadline}
|
||||||
|
status={item.status}
|
||||||
|
entryId={entry.id}
|
||||||
|
chainCount={chainCount}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
onToggleChain={() => onToggleChain(item.deadline.id)}
|
||||||
|
onResolve={onResolve}
|
||||||
|
/>
|
||||||
|
{/* Chain children (expanded) */}
|
||||||
|
{isExpanded &&
|
||||||
|
chainCount > 0 &&
|
||||||
|
item.chainChildren.map((child) => (
|
||||||
|
<DeadlineRow
|
||||||
|
key={child.deadline.id}
|
||||||
|
deadline={child.deadline}
|
||||||
|
status={child.status}
|
||||||
|
entryId={entry.id}
|
||||||
|
isChainChild
|
||||||
|
onResolve={onResolve}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Single deadline row ──
|
||||||
|
|
||||||
|
function DeadlineRow({
|
||||||
|
deadline,
|
||||||
|
status,
|
||||||
|
entryId,
|
||||||
|
chainCount,
|
||||||
|
isExpanded,
|
||||||
|
isChainChild,
|
||||||
|
onToggleChain,
|
||||||
|
onResolve,
|
||||||
|
}: {
|
||||||
|
deadline: TrackedDeadline;
|
||||||
|
status: DeadlineDisplayStatus;
|
||||||
|
entryId: string;
|
||||||
|
chainCount?: number;
|
||||||
|
isExpanded?: boolean;
|
||||||
|
isChainChild?: boolean;
|
||||||
|
onToggleChain?: () => void;
|
||||||
|
onResolve: (entryId: string, deadline: TrackedDeadline) => void;
|
||||||
|
}) {
|
||||||
|
const def = getDeadlineType(deadline.typeId);
|
||||||
|
const isPending = deadline.resolution === "pending";
|
||||||
|
|
||||||
|
// Progress calculation
|
||||||
|
const totalDays = (() => {
|
||||||
|
const start = new Date(deadline.startDate).getTime();
|
||||||
|
const due = new Date(deadline.dueDate).getTime();
|
||||||
|
return Math.max(1, Math.ceil((due - start) / (1000 * 60 * 60 * 24)));
|
||||||
|
})();
|
||||||
|
|
||||||
|
const elapsedDays = (() => {
|
||||||
|
const start = new Date(deadline.startDate).getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
return Math.ceil((now - start) / (1000 * 60 * 60 * 24));
|
||||||
|
})();
|
||||||
|
|
||||||
|
const progressPct = isPending
|
||||||
|
? Math.min(100, Math.max(0, (elapsedDays / totalDays) * 100))
|
||||||
|
: 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 px-4 py-2",
|
||||||
|
isChainChild && "bg-muted/30 pl-8",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Chain expand toggle */}
|
||||||
|
{!isChainChild && (chainCount ?? 0) > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={onToggleChain}
|
||||||
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : isChainChild ? (
|
||||||
|
<span className="text-muted-foreground/40 text-xs">└</span>
|
||||||
|
) : (
|
||||||
|
<span className="w-3.5" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Type label */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className={cn("text-xs", isChainChild && "text-muted-foreground")}>
|
||||||
|
{def?.label ?? deadline.typeId}
|
||||||
|
</span>
|
||||||
|
{!isChainChild && (chainCount ?? 0) > 0 && (
|
||||||
|
<span className="ml-2 text-[10px] text-muted-foreground">
|
||||||
|
(+{chainCount} etap{chainCount === 1 ? "a" : "e"})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar (only for pending) */}
|
||||||
|
{isPending && (
|
||||||
|
<div className="hidden w-24 sm:block">
|
||||||
|
<div className="h-1.5 rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-1.5 rounded-full transition-all",
|
||||||
|
status.variant === "red"
|
||||||
|
? "bg-red-500"
|
||||||
|
: status.variant === "yellow"
|
||||||
|
? "bg-amber-500"
|
||||||
|
: "bg-green-500",
|
||||||
|
)}
|
||||||
|
style={{ width: `${progressPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Countdown / status */}
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
{isPending && status.daysRemaining !== null ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-medium",
|
||||||
|
VARIANT_TEXT[status.variant],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status.daysRemaining < 0
|
||||||
|
? `${Math.abs(status.daysRemaining)}z depasit`
|
||||||
|
: status.daysRemaining === 0
|
||||||
|
? "azi"
|
||||||
|
: `${status.daysRemaining}z ramase`}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resolve button */}
|
||||||
|
<div className="w-7 shrink-0">
|
||||||
|
{isPending && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-green-600"
|
||||||
|
onClick={() => onResolve(entryId, deadline)}
|
||||||
|
title="Rezolva"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import type { RegistryEntry } from "../types";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
interface FlowNode {
|
||||||
|
entry: RegistryEntry;
|
||||||
|
/** Direct thread children (replied via "Inchide") */
|
||||||
|
threadChildren: FlowNode[];
|
||||||
|
/** Linked/conex entries (not part of thread chain) */
|
||||||
|
linkedEntries: RegistryEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowDiagramProps {
|
||||||
|
/** The chain of entries (thread root + descendants), chronologically ordered */
|
||||||
|
chain: RegistryEntry[];
|
||||||
|
/** All entries in the system (for resolving linkedEntryIds) */
|
||||||
|
allEntries: RegistryEntry[];
|
||||||
|
/** Highlight this entry (current selection in detail panel) */
|
||||||
|
highlightEntryId?: string;
|
||||||
|
/** Compact mode for detail panel (smaller nodes) */
|
||||||
|
compact?: boolean;
|
||||||
|
/** Click handler for a node */
|
||||||
|
onNodeClick?: (entry: RegistryEntry) => void;
|
||||||
|
/** Max nodes to show (for compact mode) */
|
||||||
|
maxNodes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build tree from flat chain ──
|
||||||
|
|
||||||
|
function buildFlowTree(
|
||||||
|
chain: RegistryEntry[],
|
||||||
|
allEntries: RegistryEntry[],
|
||||||
|
): FlowNode | null {
|
||||||
|
if (chain.length === 0) return null;
|
||||||
|
|
||||||
|
// Find root (entry without threadParentId in the chain, or first entry)
|
||||||
|
const chainIds = new Set(chain.map((e) => e.id));
|
||||||
|
const root =
|
||||||
|
chain.find((e) => !e.threadParentId || !chainIds.has(e.threadParentId)) ??
|
||||||
|
chain[0];
|
||||||
|
if (!root) return null;
|
||||||
|
|
||||||
|
const entryMap = new Map(chain.map((e) => [e.id, e]));
|
||||||
|
|
||||||
|
function buildNode(entry: RegistryEntry): FlowNode {
|
||||||
|
// Thread children: entries whose threadParentId is this entry
|
||||||
|
const threadChildren = chain
|
||||||
|
.filter((e) => e.threadParentId === entry.id)
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date))
|
||||||
|
.map((child) => buildNode(child));
|
||||||
|
|
||||||
|
// Linked entries: resolve linkedEntryIds that aren't in the thread chain
|
||||||
|
const linkedEntries = (entry.linkedEntryIds ?? [])
|
||||||
|
.filter((id) => !entryMap.has(id)) // exclude entries already in the chain
|
||||||
|
.map((id) => allEntries.find((e) => e.id === id))
|
||||||
|
.filter((e): e is RegistryEntry => !!e);
|
||||||
|
|
||||||
|
return { entry, threadChildren, linkedEntries };
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildNode(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Flatten tree to a linear main chain + branches ──
|
||||||
|
|
||||||
|
interface FlattenedChain {
|
||||||
|
mainChain: RegistryEntry[];
|
||||||
|
branches: Map<string, RegistryEntry[]>; // parentId -> linked entries
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenTree(root: FlowNode): FlattenedChain {
|
||||||
|
const mainChain: RegistryEntry[] = [];
|
||||||
|
const branches = new Map<string, RegistryEntry[]>();
|
||||||
|
|
||||||
|
function walkMain(node: FlowNode) {
|
||||||
|
mainChain.push(node.entry);
|
||||||
|
|
||||||
|
if (node.linkedEntries.length > 0) {
|
||||||
|
branches.set(node.entry.id, node.linkedEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow the first thread child as "main" chain, others as branches
|
||||||
|
if (node.threadChildren.length > 0) {
|
||||||
|
walkMain(node.threadChildren[0]!);
|
||||||
|
// Additional thread children treated as branches
|
||||||
|
for (let i = 1; i < node.threadChildren.length; i++) {
|
||||||
|
const child = node.threadChildren[i]!;
|
||||||
|
const branchEntries = branches.get(node.entry.id) ?? [];
|
||||||
|
branchEntries.push(child.entry);
|
||||||
|
branches.set(node.entry.id, branchEntries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walkMain(root);
|
||||||
|
return { mainChain, branches };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function FlowDiagram({
|
||||||
|
chain,
|
||||||
|
allEntries,
|
||||||
|
highlightEntryId,
|
||||||
|
compact = false,
|
||||||
|
onNodeClick,
|
||||||
|
maxNodes,
|
||||||
|
}: FlowDiagramProps) {
|
||||||
|
const { mainChain, branches, truncated } = useMemo(() => {
|
||||||
|
const tree = buildFlowTree(chain, allEntries);
|
||||||
|
if (!tree) return { mainChain: [], branches: new Map(), truncated: 0 };
|
||||||
|
|
||||||
|
const flat = flattenTree(tree);
|
||||||
|
let mc = flat.mainChain;
|
||||||
|
let trunc = 0;
|
||||||
|
|
||||||
|
if (maxNodes && mc.length > maxNodes) {
|
||||||
|
// Show entries around the highlighted one, or the last N
|
||||||
|
if (highlightEntryId) {
|
||||||
|
const idx = mc.findIndex((e) => e.id === highlightEntryId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const start = Math.max(0, idx - Math.floor(maxNodes / 2));
|
||||||
|
const end = Math.min(mc.length, start + maxNodes);
|
||||||
|
trunc = mc.length - (end - start);
|
||||||
|
mc = mc.slice(start, end);
|
||||||
|
} else {
|
||||||
|
trunc = mc.length - maxNodes;
|
||||||
|
mc = mc.slice(-maxNodes);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
trunc = mc.length - maxNodes;
|
||||||
|
mc = mc.slice(-maxNodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mainChain: mc, branches: flat.branches, truncated: trunc };
|
||||||
|
}, [chain, allEntries, highlightEntryId, maxNodes]);
|
||||||
|
|
||||||
|
if (mainChain.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-muted-foreground">Niciun document in lant.</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-start gap-0 overflow-x-auto py-2", compact && "py-1")}>
|
||||||
|
{truncated > 0 && (
|
||||||
|
<div className="flex shrink-0 items-center self-center px-2">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
+{truncated} ...
|
||||||
|
</span>
|
||||||
|
<Arrow />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mainChain.map((entry, i) => {
|
||||||
|
const branchEntries: RegistryEntry[] = branches.get(entry.id) ?? [];
|
||||||
|
const isHighlighted = entry.id === highlightEntryId;
|
||||||
|
const isLast = i === mainChain.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={entry.id} className="flex shrink-0 items-start">
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
{/* Main node */}
|
||||||
|
<FlowNode
|
||||||
|
entry={entry}
|
||||||
|
isHighlighted={isHighlighted}
|
||||||
|
compact={compact}
|
||||||
|
onClick={() => onNodeClick?.(entry)}
|
||||||
|
/>
|
||||||
|
{/* Branch entries below */}
|
||||||
|
{branchEntries.length > 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<div className="h-3 w-px border-l border-dashed border-muted-foreground/40" />
|
||||||
|
{branchEntries.map((be) => (
|
||||||
|
<FlowNode
|
||||||
|
key={be.id}
|
||||||
|
entry={be}
|
||||||
|
isHighlighted={be.id === highlightEntryId}
|
||||||
|
compact={compact}
|
||||||
|
isBranch
|
||||||
|
onClick={() => onNodeClick?.(be)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Arrow to next */}
|
||||||
|
{!isLast && (
|
||||||
|
<div className="flex items-center self-center">
|
||||||
|
<Arrow />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Arrow connector ──
|
||||||
|
|
||||||
|
function Arrow() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center px-1">
|
||||||
|
<div className="h-px w-6 bg-border" />
|
||||||
|
<div className="border-y-[4px] border-l-[6px] border-y-transparent border-l-border" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Single flow node ──
|
||||||
|
|
||||||
|
function FlowNode({
|
||||||
|
entry,
|
||||||
|
isHighlighted,
|
||||||
|
compact,
|
||||||
|
isBranch,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
entry: RegistryEntry;
|
||||||
|
isHighlighted?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
isBranch?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) {
|
||||||
|
const isIntrat = entry.direction === "intrat";
|
||||||
|
const isOpen = entry.status === "deschis";
|
||||||
|
const hasDeadlines = (entry.trackedDeadlines ?? []).some(
|
||||||
|
(dl) => dl.resolution === "pending",
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"group relative flex flex-col rounded-lg border text-left transition-all hover:shadow-sm",
|
||||||
|
compact ? "w-[130px] p-2" : "w-[160px] p-2.5",
|
||||||
|
isHighlighted
|
||||||
|
? "border-primary ring-2 ring-primary/30"
|
||||||
|
: "border-border hover:border-foreground/20",
|
||||||
|
isBranch && "border-dashed",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Direction bar */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-0 left-0 right-0 h-1 rounded-t-lg",
|
||||||
|
isIntrat
|
||||||
|
? "bg-blue-500"
|
||||||
|
: "bg-orange-500",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Number */}
|
||||||
|
<div className="mt-1 flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-mono font-semibold",
|
||||||
|
compact ? "text-[10px]" : "text-xs",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{entry.number}
|
||||||
|
</span>
|
||||||
|
{hasDeadlines && (
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subject */}
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"mt-1 text-muted-foreground",
|
||||||
|
compact
|
||||||
|
? "line-clamp-1 text-[9px]"
|
||||||
|
: "line-clamp-2 text-[10px] leading-tight",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{entry.subject || "—"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Date + status */}
|
||||||
|
<div className="mt-1.5 flex items-center justify-between">
|
||||||
|
<span className="text-[9px] text-muted-foreground">
|
||||||
|
{formatShortDate(entry.date)}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"h-4 px-1 text-[8px]",
|
||||||
|
isOpen
|
||||||
|
? "border-green-300 text-green-700 dark:border-green-800 dark:text-green-400"
|
||||||
|
: "border-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isOpen ? "Deschis" : "Inchis"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShortDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}.${d.getFullYear()}`;
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,11 @@ import { DeadlineDashboard } from "./deadline-dashboard";
|
|||||||
import { ThreadExplorer } from "./thread-explorer";
|
import { ThreadExplorer } from "./thread-explorer";
|
||||||
import { CloseGuardDialog } from "./close-guard-dialog";
|
import { CloseGuardDialog } from "./close-guard-dialog";
|
||||||
import { getOverdueDays } from "../services/registry-service";
|
import { getOverdueDays } from "../services/registry-service";
|
||||||
import { aggregateDeadlines } from "../services/deadline-service";
|
import {
|
||||||
|
aggregateDeadlines,
|
||||||
|
findAutoResolvableDeadlines,
|
||||||
|
resolveDeadline as resolveDeadlinePure,
|
||||||
|
} from "../services/deadline-service";
|
||||||
import type { RegistryEntry, DeadlineResolution, ClosureInfo } from "../types";
|
import type { RegistryEntry, DeadlineResolution, ClosureInfo } from "../types";
|
||||||
import type { AddressContact } from "@/modules/address-book/types";
|
import type { AddressContact } from "@/modules/address-book/types";
|
||||||
|
|
||||||
@@ -142,22 +146,39 @@ export function RegistraturaModule() {
|
|||||||
const handleAdd = async (
|
const handleAdd = async (
|
||||||
data: Omit<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
|
data: Omit<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
|
||||||
) => {
|
) => {
|
||||||
await addEntry(data);
|
const newEntry = await addEntry(data);
|
||||||
// If this new entry closes a parent, close the parent automatically
|
// If this new entry closes a parent, close the parent automatically
|
||||||
if (closesEntryId) {
|
if (closesEntryId) {
|
||||||
const parentEntry = allEntries.find((e) => e.id === closesEntryId);
|
const parentEntry = allEntries.find((e) => e.id === closesEntryId);
|
||||||
if (parentEntry && parentEntry.status === "deschis") {
|
if (parentEntry && parentEntry.status === "deschis") {
|
||||||
|
// Auto-resolve matching deadlines on the parent
|
||||||
|
const replyProxy = { ...data, id: "", number: "", createdAt: "", updatedAt: "" } as RegistryEntry;
|
||||||
|
const resolvable = findAutoResolvableDeadlines(parentEntry, replyProxy);
|
||||||
|
let updatedDeadlines = parentEntry.trackedDeadlines ?? [];
|
||||||
|
if (resolvable.length > 0) {
|
||||||
|
const resolveIds = new Set(resolvable.map((d) => d.id));
|
||||||
|
updatedDeadlines = updatedDeadlines.map((dl) =>
|
||||||
|
resolveIds.has(dl.id)
|
||||||
|
? resolveDeadlinePure(
|
||||||
|
dl,
|
||||||
|
"completed",
|
||||||
|
`Rezolvat automat — primit raspuns ${newEntry?.number ?? "conex"}`,
|
||||||
|
)
|
||||||
|
: dl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const closureInfo: ClosureInfo = {
|
const closureInfo: ClosureInfo = {
|
||||||
resolution: "finalizat",
|
resolution: "finalizat",
|
||||||
reason: "Inchis prin inregistrare conex",
|
reason: "Inchis prin inregistrare conex",
|
||||||
closedBy: "",
|
closedBy: "",
|
||||||
closedAt: new Date().toISOString(),
|
closedAt: new Date().toISOString(),
|
||||||
hadActiveDeadlines:
|
hadActiveDeadlines: resolvable.length > 0,
|
||||||
(parentEntry.trackedDeadlines ?? []).some(
|
|
||||||
(d) => d.resolution === "pending",
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
await updateEntry(closesEntryId, { closureInfo });
|
await updateEntry(closesEntryId, {
|
||||||
|
closureInfo,
|
||||||
|
trackedDeadlines: updatedDeadlines,
|
||||||
|
});
|
||||||
await closeEntry(closesEntryId, false);
|
await closeEntry(closesEntryId, false);
|
||||||
}
|
}
|
||||||
setClosesEntryId(null);
|
setClosesEntryId(null);
|
||||||
@@ -320,7 +341,7 @@ export function RegistraturaModule() {
|
|||||||
<Tabs defaultValue="registru">
|
<Tabs defaultValue="registru">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="registru">Registru</TabsTrigger>
|
<TabsTrigger value="registru">Registru</TabsTrigger>
|
||||||
<TabsTrigger value="fire">Fire conversație</TabsTrigger>
|
<TabsTrigger value="fire">Dosare</TabsTrigger>
|
||||||
<TabsTrigger value="termene">
|
<TabsTrigger value="termene">
|
||||||
Termene legale
|
Termene legale
|
||||||
{alertCount > 0 && (
|
{alertCount > 0 && (
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ import {
|
|||||||
getPreviewableAttachments,
|
getPreviewableAttachments,
|
||||||
} from "./attachment-preview";
|
} from "./attachment-preview";
|
||||||
import { findAuthorityForContact } from "../services/authority-catalog";
|
import { findAuthorityForContact } from "../services/authority-catalog";
|
||||||
|
import { computeTransmissionStatus } from "../services/deadline-service";
|
||||||
import { StatusMonitorConfig } from "./status-monitor-config";
|
import { StatusMonitorConfig } from "./status-monitor-config";
|
||||||
|
import { FlowDiagram } from "./flow-diagram";
|
||||||
|
|
||||||
interface RegistryEntryDetailProps {
|
interface RegistryEntryDetailProps {
|
||||||
entry: RegistryEntry | null;
|
entry: RegistryEntry | null;
|
||||||
@@ -191,6 +193,36 @@ export function RegistryEntryDetail({
|
|||||||
(e) => e.threadParentId === entry.id,
|
(e) => e.threadParentId === entry.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Build full chain for mini flow diagram
|
||||||
|
const threadChain = useMemo(() => {
|
||||||
|
if (!threadParent && threadChildren.length === 0) return [];
|
||||||
|
const byId = new Map(allEntries.map((e) => [e.id, e]));
|
||||||
|
// Walk up to root
|
||||||
|
let root = entry;
|
||||||
|
while (root.threadParentId) {
|
||||||
|
const parent = byId.get(root.threadParentId);
|
||||||
|
if (!parent) break;
|
||||||
|
root = parent;
|
||||||
|
}
|
||||||
|
// BFS down from root
|
||||||
|
const chain: RegistryEntry[] = [];
|
||||||
|
const queue = [root.id];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const id = queue.shift()!;
|
||||||
|
if (visited.has(id)) continue;
|
||||||
|
visited.add(id);
|
||||||
|
const e = byId.get(id);
|
||||||
|
if (!e) continue;
|
||||||
|
chain.push(e);
|
||||||
|
for (const child of allEntries.filter((c) => c.threadParentId === id)) {
|
||||||
|
queue.push(child.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chain.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
return chain;
|
||||||
|
}, [entry, threadParent, threadChildren, allEntries]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
@@ -387,48 +419,52 @@ export function RegistryEntryDetail({
|
|||||||
)}
|
)}
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
{/* ── Thread links ── */}
|
{/* ── Thread links (flow diagram) ── */}
|
||||||
{(threadParent ||
|
{(threadChain.length >= 2 ||
|
||||||
threadChildren.length > 0 ||
|
|
||||||
(entry.linkedEntryIds ?? []).length > 0) && (
|
(entry.linkedEntryIds ?? []).length > 0) && (
|
||||||
<DetailSection title="Legături">
|
<DetailSection title="Dosar">
|
||||||
{threadParent && (
|
{threadChain.length >= 2 && (
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<FlowDiagram
|
||||||
<GitBranch className="h-3.5 w-3.5 text-muted-foreground" />
|
chain={threadChain}
|
||||||
<span className="text-muted-foreground">Răspuns la:</span>
|
allEntries={allEntries}
|
||||||
<span className="font-mono text-xs">
|
highlightEntryId={entry.id}
|
||||||
{threadParent.number}
|
compact
|
||||||
</span>
|
maxNodes={5}
|
||||||
<span className="truncate text-muted-foreground">
|
/>
|
||||||
— {threadParent.subject}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
{/* Transmission status for thread children */}
|
||||||
{threadChildren.length > 0 && (
|
{threadChildren.length > 0 && (
|
||||||
<div className="mt-1 space-y-0.5">
|
<div className="mt-2 space-y-1">
|
||||||
<p className="text-xs text-muted-foreground">
|
{threadChildren.map((child) => {
|
||||||
Răspunsuri ({threadChildren.length}):
|
const txStatus = computeTransmissionStatus(entry, child);
|
||||||
</p>
|
if (!txStatus) return null;
|
||||||
{threadChildren.map((child) => (
|
return (
|
||||||
<div
|
<div
|
||||||
key={child.id}
|
key={child.id}
|
||||||
className="flex items-center gap-2 text-xs ml-4"
|
className="flex items-center gap-2 text-[10px]"
|
||||||
>
|
>
|
||||||
<span className="font-mono">{child.number}</span>
|
<span className="font-mono">{child.number}</span>
|
||||||
<span className="truncate text-muted-foreground">
|
{txStatus === "on-time" ? (
|
||||||
{child.subject}
|
<span className="text-green-600 dark:text-green-400">
|
||||||
</span>
|
Transmis in termen
|
||||||
</div>
|
</span>
|
||||||
))}
|
) : (
|
||||||
|
<span className="text-amber-600 dark:text-amber-400">
|
||||||
|
Transmis cu intarziere
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(entry.linkedEntryIds ?? []).length > 0 && (
|
{(entry.linkedEntryIds ?? []).length > 0 && (
|
||||||
<div className="mt-1">
|
<div className="mt-2">
|
||||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
<Link2 className="h-3 w-3" />
|
<Link2 className="h-3 w-3" />
|
||||||
{entry.linkedEntryIds.length} înregistrăr
|
{entry.linkedEntryIds.length} inregistrar
|
||||||
{entry.linkedEntryIds.length === 1 ? "e" : "i"} legat
|
{entry.linkedEntryIds.length === 1 ? "e" : "i"} legat
|
||||||
{entry.linkedEntryIds.length === 1 ? "ă" : "e"}
|
{entry.linkedEntryIds.length === 1 ? "a" : "e"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,32 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import {
|
import { GitBranch, Search, Calendar, Clock } from "lucide-react";
|
||||||
GitBranch,
|
|
||||||
Search,
|
|
||||||
ArrowDown,
|
|
||||||
ArrowUp,
|
|
||||||
Calendar,
|
|
||||||
Clock,
|
|
||||||
Building2,
|
|
||||||
FileDown,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
import { Label } from "@/shared/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/shared/components/ui/select";
|
|
||||||
import type { RegistryEntry } from "../types";
|
import type { RegistryEntry } from "../types";
|
||||||
import { DEFAULT_DOC_TYPE_LABELS } from "../types";
|
import { FlowDiagram } from "./flow-diagram";
|
||||||
import { cn } from "@/shared/lib/utils";
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
interface ThreadExplorerProps {
|
interface ThreadExplorerProps {
|
||||||
@@ -37,23 +17,12 @@ interface ThreadExplorerProps {
|
|||||||
/** A thread is a chain of entries linked by threadParentId */
|
/** A thread is a chain of entries linked by threadParentId */
|
||||||
interface Thread {
|
interface Thread {
|
||||||
id: string;
|
id: string;
|
||||||
/** Root entry (no parent) */
|
|
||||||
root: RegistryEntry;
|
root: RegistryEntry;
|
||||||
/** All entries in the thread, ordered chronologically */
|
|
||||||
chain: RegistryEntry[];
|
chain: RegistryEntry[];
|
||||||
/** Total calendar days from first to last entry */
|
|
||||||
totalDays: number;
|
totalDays: number;
|
||||||
/** Days spent "at us" vs "at institution" */
|
|
||||||
daysAtUs: number;
|
|
||||||
daysAtInstitution: number;
|
|
||||||
/** Whether thread is still open (has open entries) */
|
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDocTypeLabel(type: string): string {
|
|
||||||
return DEFAULT_DOC_TYPE_LABELS[type] ?? type;
|
|
||||||
}
|
|
||||||
|
|
||||||
function daysBetween(d1: string, d2: string): number {
|
function daysBetween(d1: string, d2: string): number {
|
||||||
const a = new Date(d1);
|
const a = new Date(d1);
|
||||||
const b = new Date(d2);
|
const b = new Date(d2);
|
||||||
@@ -65,18 +34,6 @@ function daysBetween(d1: string, d2: string): number {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleDateString("ro-RO", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatShortDate(iso: string): string {
|
function formatShortDate(iso: string): string {
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleDateString("ro-RO", {
|
return new Date(iso).toLocaleDateString("ro-RO", {
|
||||||
@@ -94,7 +51,6 @@ function buildThreads(entries: RegistryEntry[]): Thread[] {
|
|||||||
const childrenMap = new Map<string, RegistryEntry[]>();
|
const childrenMap = new Map<string, RegistryEntry[]>();
|
||||||
const rootIds = new Set<string>();
|
const rootIds = new Set<string>();
|
||||||
|
|
||||||
// Build parent->children map
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.threadParentId) {
|
if (entry.threadParentId) {
|
||||||
const existing = childrenMap.get(entry.threadParentId) ?? [];
|
const existing = childrenMap.get(entry.threadParentId) ?? [];
|
||||||
@@ -103,12 +59,10 @@ function buildThreads(entries: RegistryEntry[]): Thread[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find roots: entries that are parents or have children, but no parent themselves
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.threadParentId && childrenMap.has(entry.id)) {
|
if (!entry.threadParentId && childrenMap.has(entry.id)) {
|
||||||
rootIds.add(entry.id);
|
rootIds.add(entry.id);
|
||||||
}
|
}
|
||||||
// Also find the root of entries that have parents
|
|
||||||
if (entry.threadParentId) {
|
if (entry.threadParentId) {
|
||||||
let current = entry;
|
let current = entry;
|
||||||
while (current.threadParentId) {
|
while (current.threadParentId) {
|
||||||
@@ -126,7 +80,6 @@ function buildThreads(entries: RegistryEntry[]): Thread[] {
|
|||||||
const root = byId.get(rootId);
|
const root = byId.get(rootId);
|
||||||
if (!root) continue;
|
if (!root) continue;
|
||||||
|
|
||||||
// Collect all entries in this thread (BFS)
|
|
||||||
const chain: RegistryEntry[] = [];
|
const chain: RegistryEntry[] = [];
|
||||||
const queue = [rootId];
|
const queue = [rootId];
|
||||||
const visited = new Set<string>();
|
const visited = new Set<string>();
|
||||||
@@ -144,49 +97,20 @@ function buildThreads(entries: RegistryEntry[]): Thread[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort chronologically
|
|
||||||
chain.sort(
|
chain.sort(
|
||||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (chain.length < 2) continue; // Only show actual threads (2+ entries)
|
if (chain.length < 2) continue;
|
||||||
|
|
||||||
// Calculate days
|
|
||||||
const firstDate = chain[0]!.date;
|
const firstDate = chain[0]!.date;
|
||||||
const lastDate = chain[chain.length - 1]!.date;
|
const lastDate = chain[chain.length - 1]!.date;
|
||||||
const totalDays = daysBetween(firstDate, lastDate);
|
const totalDays = daysBetween(firstDate, lastDate);
|
||||||
|
|
||||||
// Calculate days at us vs institution
|
|
||||||
let daysAtUs = 0;
|
|
||||||
let daysAtInstitution = 0;
|
|
||||||
for (let i = 0; i < chain.length - 1; i++) {
|
|
||||||
const current = chain[i]!;
|
|
||||||
const next = chain[i + 1]!;
|
|
||||||
const gap = daysBetween(current.date, next.date);
|
|
||||||
|
|
||||||
// If current is outgoing (iesit), the document is at the institution
|
|
||||||
// If current is incoming (intrat), the document is at us
|
|
||||||
if (current.direction === "iesit") {
|
|
||||||
daysAtInstitution += gap;
|
|
||||||
} else {
|
|
||||||
daysAtUs += gap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isActive = chain.some((e) => e.status === "deschis");
|
const isActive = chain.some((e) => e.status === "deschis");
|
||||||
|
|
||||||
threads.push({
|
threads.push({ id: rootId, root, chain, totalDays, isActive });
|
||||||
id: rootId,
|
|
||||||
root,
|
|
||||||
chain,
|
|
||||||
totalDays,
|
|
||||||
daysAtUs,
|
|
||||||
daysAtInstitution,
|
|
||||||
isActive,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort threads: active first, then by most recent activity
|
|
||||||
threads.sort((a, b) => {
|
threads.sort((a, b) => {
|
||||||
if (a.isActive !== b.isActive) return a.isActive ? -1 : 1;
|
if (a.isActive !== b.isActive) return a.isActive ? -1 : 1;
|
||||||
const aLast = a.chain[a.chain.length - 1]!.date;
|
const aLast = a.chain[a.chain.length - 1]!.date;
|
||||||
@@ -197,92 +121,18 @@ function buildThreads(entries: RegistryEntry[]): Thread[] {
|
|||||||
return threads;
|
return threads;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generate a text report for a thread */
|
|
||||||
function generateThreadReport(thread: Thread): string {
|
|
||||||
const lines: string[] = [];
|
|
||||||
lines.push("═══════════════════════════════════════════════════════");
|
|
||||||
lines.push(`RAPORT FIR CONVERSAȚIE — ${thread.root.subject}`);
|
|
||||||
lines.push("═══════════════════════════════════════════════════════");
|
|
||||||
lines.push("");
|
|
||||||
lines.push(`Nr. înregistrare inițial: ${thread.root.number}`);
|
|
||||||
lines.push(
|
|
||||||
`Perioada: ${formatDate(thread.chain[0]!.date)} — ${formatDate(thread.chain[thread.chain.length - 1]!.date)}`,
|
|
||||||
);
|
|
||||||
lines.push(`Durată totală: ${thread.totalDays} zile`);
|
|
||||||
lines.push(
|
|
||||||
`Zile la noi: ${thread.daysAtUs} | Zile la instituție: ${thread.daysAtInstitution}`,
|
|
||||||
);
|
|
||||||
lines.push(`Documente în fir: ${thread.chain.length}`);
|
|
||||||
lines.push(`Status: ${thread.isActive ? "Activ" : "Finalizat"}`);
|
|
||||||
lines.push("");
|
|
||||||
lines.push("───────────────────────────────────────────────────────");
|
|
||||||
lines.push("TIMELINE");
|
|
||||||
lines.push("───────────────────────────────────────────────────────");
|
|
||||||
|
|
||||||
for (let i = 0; i < thread.chain.length; i++) {
|
|
||||||
const entry = thread.chain[i]!;
|
|
||||||
const prev = i > 0 ? thread.chain[i - 1] : null;
|
|
||||||
const gap = prev ? daysBetween(prev.date, entry.date) : 0;
|
|
||||||
const location = prev
|
|
||||||
? prev.direction === "iesit"
|
|
||||||
? "la instituție"
|
|
||||||
: "la noi"
|
|
||||||
: "";
|
|
||||||
|
|
||||||
if (prev && gap > 0) {
|
|
||||||
lines.push(` │ ${gap} zile ${location}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const arrow = entry.direction === "intrat" ? "↓ INTRAT" : "↑ IEȘIT";
|
|
||||||
const status = entry.status === "inchis" ? "[ÎNCHIS]" : "[DESCHIS]";
|
|
||||||
lines.push("");
|
|
||||||
lines.push(` ${arrow} — ${formatDate(entry.date)} ${status}`);
|
|
||||||
lines.push(` Nr: ${entry.number}`);
|
|
||||||
lines.push(` Tip: ${getDocTypeLabel(entry.documentType)}`);
|
|
||||||
lines.push(` Subiect: ${entry.subject}`);
|
|
||||||
if (entry.sender) lines.push(` Expeditor: ${entry.sender}`);
|
|
||||||
if (entry.recipient) lines.push(` Destinatar: ${entry.recipient}`);
|
|
||||||
if (entry.assignee) lines.push(` Responsabil: ${entry.assignee}`);
|
|
||||||
if (entry.notes) lines.push(` Note: ${entry.notes}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push("");
|
|
||||||
lines.push("═══════════════════════════════════════════════════════");
|
|
||||||
lines.push(`Generat la: ${new Date().toLocaleString("ro-RO")}`);
|
|
||||||
lines.push("ArchiTools — Registratură");
|
|
||||||
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadReport(thread: Thread) {
|
|
||||||
const text = generateThreadReport(thread);
|
|
||||||
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `thread-${thread.root.number.replace(/[/\\]/g, "-")}.txt`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThreadExplorer({
|
export function ThreadExplorer({
|
||||||
entries,
|
entries,
|
||||||
onNavigateEntry,
|
onNavigateEntry,
|
||||||
}: ThreadExplorerProps) {
|
}: ThreadExplorerProps) {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "closed">(
|
const [activeOnly, setActiveOnly] = useState(false);
|
||||||
"all",
|
|
||||||
);
|
|
||||||
const [expandedThreads, setExpandedThreads] = useState<Set<string>>(
|
|
||||||
new Set(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const threads = useMemo(() => buildThreads(entries), [entries]);
|
const threads = useMemo(() => buildThreads(entries), [entries]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return threads.filter((t) => {
|
return threads.filter((t) => {
|
||||||
if (statusFilter === "active" && !t.isActive) return false;
|
if (activeOnly && !t.isActive) return false;
|
||||||
if (statusFilter === "closed" && t.isActive) return false;
|
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
return t.chain.some(
|
return t.chain.some(
|
||||||
@@ -295,317 +145,124 @@ export function ThreadExplorer({
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [threads, search, statusFilter]);
|
}, [threads, search, activeOnly]);
|
||||||
|
|
||||||
const toggleExpand = (id: string) => {
|
const activeCount = threads.filter((t) => t.isActive).length;
|
||||||
setExpandedThreads((prev) => {
|
const closedCount = threads.length - activeCount;
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(id)) next.delete(id);
|
|
||||||
else next.add(id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Stats
|
|
||||||
const totalThreads = threads.length;
|
|
||||||
const activeThreads = threads.filter((t) => t.isActive).length;
|
|
||||||
const avgDuration =
|
|
||||||
totalThreads > 0
|
|
||||||
? Math.round(
|
|
||||||
threads.reduce((sum, t) => sum + t.totalDays, 0) / totalThreads,
|
|
||||||
)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Stats */}
|
{/* Summary + filters */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="flex items-center gap-4">
|
||||||
<Card>
|
<p className="text-sm text-muted-foreground">
|
||||||
<CardContent className="p-4">
|
<span className="font-medium text-foreground">{activeCount}</span>{" "}
|
||||||
<p className="text-xs text-muted-foreground">Total fire</p>
|
{activeCount === 1 ? "dosar activ" : "dosare active"},{" "}
|
||||||
<p className="text-2xl font-bold">{totalThreads}</p>
|
<span className="font-medium text-foreground">{closedCount}</span>{" "}
|
||||||
</CardContent>
|
finalizate
|
||||||
</Card>
|
</p>
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-xs text-muted-foreground">Fire active</p>
|
|
||||||
<p className="text-2xl font-bold">{activeThreads}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-xs text-muted-foreground">Finalizate</p>
|
|
||||||
<p className="text-2xl font-bold">{totalThreads - activeThreads}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-xs text-muted-foreground">Durată medie</p>
|
|
||||||
<p className="text-2xl font-bold">
|
|
||||||
{avgDuration}{" "}
|
|
||||||
<span className="text-sm font-normal text-muted-foreground">
|
|
||||||
zile
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="relative">
|
||||||
<div className="relative min-w-[200px] flex-1">
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Input
|
||||||
<Input
|
placeholder="Cauta..."
|
||||||
placeholder="Caută după nr., subiect, expeditor..."
|
value={search}
|
||||||
value={search}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
className="h-9 w-[200px] pl-9"
|
||||||
className="pl-9"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
<Button
|
||||||
<div>
|
variant={activeOnly ? "default" : "outline"}
|
||||||
<Label className="text-xs">Status</Label>
|
size="sm"
|
||||||
<Select
|
onClick={() => setActiveOnly(!activeOnly)}
|
||||||
value={statusFilter}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setStatusFilter(v as "all" | "active" | "closed")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger className="mt-1 w-[140px]">
|
Doar active
|
||||||
<SelectValue />
|
</Button>
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">Toate</SelectItem>
|
|
||||||
<SelectItem value="active">Active</SelectItem>
|
|
||||||
<SelectItem value="closed">Finalizate</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Thread list */}
|
{/* Thread list with flow diagrams */}
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className="py-12 text-center">
|
<div className="py-12 text-center">
|
||||||
<GitBranch className="mx-auto h-10 w-10 text-muted-foreground/40" />
|
<GitBranch className="mx-auto h-10 w-10 text-muted-foreground/40" />
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
{totalThreads === 0
|
{threads.length === 0
|
||||||
? "Niciun fir de conversație. Creați legături între înregistrări (Răspuns la) pentru a forma fire."
|
? "Niciun dosar. Inchideti o inregistrare cu raspuns (Inchide) pentru a forma un dosar."
|
||||||
: "Niciun fir corespunde filtrelor."}
|
: "Niciun dosar corespunde filtrelor."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
{filtered.map((thread) => {
|
{filtered.map((thread) => (
|
||||||
const isExpanded = expandedThreads.has(thread.id);
|
<ThreadCard
|
||||||
return (
|
key={thread.id}
|
||||||
<Card key={thread.id} className="overflow-hidden">
|
thread={thread}
|
||||||
{/* Thread header */}
|
allEntries={entries}
|
||||||
<button
|
onNodeClick={onNavigateEntry}
|
||||||
type="button"
|
/>
|
||||||
onClick={() => toggleExpand(thread.id)}
|
))}
|
||||||
className="w-full text-left"
|
|
||||||
>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="mt-0.5 shrink-0">
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<GitBranch className="h-4 w-4 text-primary shrink-0" />
|
|
||||||
<span className="font-medium truncate">
|
|
||||||
{thread.root.subject}
|
|
||||||
</span>
|
|
||||||
<Badge
|
|
||||||
variant={thread.isActive ? "default" : "secondary"}
|
|
||||||
className="text-[10px]"
|
|
||||||
>
|
|
||||||
{thread.isActive ? "Activ" : "Finalizat"}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="text-[10px]">
|
|
||||||
{thread.chain.length} doc.
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Calendar className="h-3 w-3" />
|
|
||||||
{formatShortDate(thread.chain[0]!.date)} —{" "}
|
|
||||||
{formatShortDate(
|
|
||||||
thread.chain[thread.chain.length - 1]!.date,
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
{thread.totalDays} zile total
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Building2 className="h-3 w-3" />
|
|
||||||
{thread.daysAtUs}z la noi ·{" "}
|
|
||||||
{thread.daysAtInstitution}z la inst.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Expanded: Timeline */}
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="border-t bg-muted/20 px-4 pb-4 pt-3">
|
|
||||||
{/* Timeline */}
|
|
||||||
<div className="relative ml-6">
|
|
||||||
{/* Vertical line */}
|
|
||||||
<div className="absolute left-3 top-0 bottom-0 w-0.5 bg-border" />
|
|
||||||
|
|
||||||
{thread.chain.map((entry, i) => {
|
|
||||||
const prev = i > 0 ? thread.chain[i - 1] : null;
|
|
||||||
const gap = prev
|
|
||||||
? daysBetween(prev.date, entry.date)
|
|
||||||
: 0;
|
|
||||||
const isIncoming = entry.direction === "intrat";
|
|
||||||
const location = prev
|
|
||||||
? prev.direction === "iesit"
|
|
||||||
? "la instituție"
|
|
||||||
: "la noi"
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={entry.id}>
|
|
||||||
{/* Gap indicator */}
|
|
||||||
{prev && gap > 0 && (
|
|
||||||
<div className="relative flex items-center py-1.5 pl-8">
|
|
||||||
<div className="absolute left-[9px] h-5 w-3 border-l-2 border-dashed border-muted-foreground/30" />
|
|
||||||
<span className="text-[10px] text-muted-foreground italic">
|
|
||||||
{gap} zile {location}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Entry node */}
|
|
||||||
<div className="relative flex items-start gap-3 py-1.5">
|
|
||||||
{/* Node dot */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative z-10 mt-1.5 h-6 w-6 rounded-full border-2 flex items-center justify-center shrink-0",
|
|
||||||
isIncoming
|
|
||||||
? "border-blue-500 bg-blue-50 dark:bg-blue-950"
|
|
||||||
: "border-orange-500 bg-orange-50 dark:bg-orange-950",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isIncoming ? (
|
|
||||||
<ArrowDown className="h-3 w-3 text-blue-600 dark:text-blue-400" />
|
|
||||||
) : (
|
|
||||||
<ArrowUp className="h-3 w-3 text-orange-600 dark:text-orange-400" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Entry content */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onNavigateEntry?.(entry);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"flex-1 rounded-lg border p-3 text-left transition-colors hover:bg-accent/50",
|
|
||||||
entry.status === "inchis" && "opacity-70",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
isIncoming ? "default" : "secondary"
|
|
||||||
}
|
|
||||||
className="text-[10px]"
|
|
||||||
>
|
|
||||||
{isIncoming ? "↓ Intrat" : "↑ Ieșit"}
|
|
||||||
</Badge>
|
|
||||||
<span className="font-mono text-xs">
|
|
||||||
{entry.number}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatDate(entry.date)}
|
|
||||||
</span>
|
|
||||||
{entry.status === "inchis" && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-[10px]"
|
|
||||||
>
|
|
||||||
Închis
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-sm font-medium">
|
|
||||||
{entry.subject}
|
|
||||||
</p>
|
|
||||||
<div className="mt-1 flex flex-wrap gap-x-3 text-xs text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
Tip: {getDocTypeLabel(entry.documentType)}
|
|
||||||
</span>
|
|
||||||
{entry.sender && (
|
|
||||||
<span>De la: {entry.sender}</span>
|
|
||||||
)}
|
|
||||||
{entry.recipient && (
|
|
||||||
<span>Către: {entry.recipient}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Day summary + Export */}
|
|
||||||
<div className="mt-4 flex items-center justify-between border-t pt-3">
|
|
||||||
<div className="flex gap-4 text-xs">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<div className="h-2.5 w-2.5 rounded-full bg-blue-500" />
|
|
||||||
Intrate:{" "}
|
|
||||||
{
|
|
||||||
thread.chain.filter((e) => e.direction === "intrat")
|
|
||||||
.length
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<div className="h-2.5 w-2.5 rounded-full bg-orange-500" />
|
|
||||||
Ieșite:{" "}
|
|
||||||
{
|
|
||||||
thread.chain.filter((e) => e.direction === "iesit")
|
|
||||||
.length
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
La noi: {thread.daysAtUs}z · La instituție:{" "}
|
|
||||||
{thread.daysAtInstitution}z
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
downloadReport(thread);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FileDown className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
Export raport
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
);
|
||||||
{filtered.length} din {totalThreads} fire afișate
|
}
|
||||||
</p>
|
|
||||||
|
// ── Thread card with flow diagram ──
|
||||||
|
|
||||||
|
function ThreadCard({
|
||||||
|
thread,
|
||||||
|
allEntries,
|
||||||
|
onNodeClick,
|
||||||
|
}: {
|
||||||
|
thread: Thread;
|
||||||
|
allEntries: RegistryEntry[];
|
||||||
|
onNodeClick?: (entry: RegistryEntry) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card",
|
||||||
|
thread.isActive
|
||||||
|
? "border-border"
|
||||||
|
: "border-muted bg-muted/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 border-b px-4 py-3">
|
||||||
|
<GitBranch className="h-4 w-4 shrink-0 text-primary" />
|
||||||
|
<span className="truncate text-sm font-medium">
|
||||||
|
{thread.root.subject}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant={thread.isActive ? "default" : "secondary"}
|
||||||
|
className="shrink-0 text-[10px]"
|
||||||
|
>
|
||||||
|
{thread.isActive ? "Activ" : "Finalizat"}
|
||||||
|
</Badge>
|
||||||
|
<div className="ml-auto flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{formatShortDate(thread.chain[0]!.date)} —{" "}
|
||||||
|
{formatShortDate(thread.chain[thread.chain.length - 1]!.date)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{thread.totalDays}z
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{thread.chain.length} doc.
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flow diagram */}
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<FlowDiagram
|
||||||
|
chain={thread.chain}
|
||||||
|
allEntries={allEntries}
|
||||||
|
onNodeClick={onNodeClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export const DEADLINE_CATALOG: DeadlineTypeDef[] = [
|
|||||||
category: "certificat",
|
category: "certificat",
|
||||||
legalReference: "Legea 350/2001, art. 44 alin. (4)",
|
legalReference: "Legea 350/2001, art. 44 alin. (4)",
|
||||||
autoTrack: true,
|
autoTrack: true,
|
||||||
|
backgroundOnly: true,
|
||||||
directionFilter: ["iesit"],
|
directionFilter: ["iesit"],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
DeadlineAuditEntry,
|
DeadlineAuditEntry,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { getDeadlineType } from "./deadline-catalog";
|
import { getDeadlineType } from "./deadline-catalog";
|
||||||
import { computeDueDate } from "./working-days";
|
import { computeDueDate, addWorkingDays } from "./working-days";
|
||||||
|
|
||||||
export interface DeadlineDisplayStatus {
|
export interface DeadlineDisplayStatus {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -219,3 +219,163 @@ function formatDate(d: Date): string {
|
|||||||
const day = String(d.getDate()).padStart(2, "0");
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
return `${y}-${m}-${day}`;
|
return `${y}-${m}-${day}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Grouped deadlines for simplified dashboard ──
|
||||||
|
|
||||||
|
export interface DeadlineEntryGroup {
|
||||||
|
entry: RegistryEntry;
|
||||||
|
/** Main deadlines (user-created, non-background) */
|
||||||
|
mainDeadlines: Array<{
|
||||||
|
deadline: TrackedDeadline;
|
||||||
|
status: DeadlineDisplayStatus;
|
||||||
|
chainChildren: Array<{
|
||||||
|
deadline: TrackedDeadline;
|
||||||
|
status: DeadlineDisplayStatus;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group deadlines by entry for the simplified dashboard.
|
||||||
|
* Only includes entries with pending main deadlines.
|
||||||
|
* Auto-tracked / background deadlines are nested under their chain parent.
|
||||||
|
*/
|
||||||
|
export function groupDeadlinesByEntry(
|
||||||
|
entries: RegistryEntry[],
|
||||||
|
): DeadlineEntryGroup[] {
|
||||||
|
const groups: DeadlineEntryGroup[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const deadlines = entry.trackedDeadlines ?? [];
|
||||||
|
if (deadlines.length === 0) continue;
|
||||||
|
|
||||||
|
// Separate: main deadlines vs chain children vs background
|
||||||
|
const chainChildMap = new Map<
|
||||||
|
string,
|
||||||
|
Array<{ deadline: TrackedDeadline; status: DeadlineDisplayStatus }>
|
||||||
|
>();
|
||||||
|
const mainItems: Array<{
|
||||||
|
deadline: TrackedDeadline;
|
||||||
|
status: DeadlineDisplayStatus;
|
||||||
|
chainChildren: Array<{
|
||||||
|
deadline: TrackedDeadline;
|
||||||
|
status: DeadlineDisplayStatus;
|
||||||
|
}>;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// First pass: identify chain children
|
||||||
|
for (const dl of deadlines) {
|
||||||
|
const def = getDeadlineType(dl.typeId);
|
||||||
|
if (def?.backgroundOnly) continue; // skip background-only
|
||||||
|
|
||||||
|
if (dl.chainParentId) {
|
||||||
|
const children = chainChildMap.get(dl.chainParentId) ?? [];
|
||||||
|
children.push({ deadline: dl, status: getDeadlineDisplayStatus(dl) });
|
||||||
|
chainChildMap.set(dl.chainParentId, children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: build main deadline items
|
||||||
|
for (const dl of deadlines) {
|
||||||
|
const def = getDeadlineType(dl.typeId);
|
||||||
|
if (def?.backgroundOnly) continue;
|
||||||
|
if (dl.chainParentId) continue; // chain children are nested
|
||||||
|
|
||||||
|
// Auto-tracked without chain parent: treat as main if pending, skip if resolved
|
||||||
|
if (def?.autoTrack && dl.resolution !== "pending") continue;
|
||||||
|
|
||||||
|
mainItems.push({
|
||||||
|
deadline: dl,
|
||||||
|
status: getDeadlineDisplayStatus(dl),
|
||||||
|
chainChildren: chainChildMap.get(dl.id) ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only include entries that have pending main deadlines
|
||||||
|
const hasPending = mainItems.some((m) => m.deadline.resolution === "pending");
|
||||||
|
if (!hasPending) continue;
|
||||||
|
|
||||||
|
groups.push({ entry, mainDeadlines: mainItems });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: entries with overdue/urgent deadlines first
|
||||||
|
groups.sort((a, b) => {
|
||||||
|
const worstA = worstVariant(a.mainDeadlines.map((m) => m.status.variant));
|
||||||
|
const worstB = worstVariant(b.mainDeadlines.map((m) => m.status.variant));
|
||||||
|
return worstA - worstB;
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function worstVariant(variants: string[]): number {
|
||||||
|
const order: Record<string, number> = {
|
||||||
|
red: 0,
|
||||||
|
yellow: 1,
|
||||||
|
blue: 2,
|
||||||
|
green: 3,
|
||||||
|
gray: 4,
|
||||||
|
};
|
||||||
|
let worst = 4;
|
||||||
|
for (const v of variants) {
|
||||||
|
const o = order[v] ?? 4;
|
||||||
|
if (o < worst) worst = o;
|
||||||
|
}
|
||||||
|
return worst;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Transmission status (background check) ──
|
||||||
|
|
||||||
|
export type TransmissionStatus = "on-time" | "late" | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute whether a reply entry was transmitted within the legal 1-day window.
|
||||||
|
* Returns null if recipient registration date is not available.
|
||||||
|
*/
|
||||||
|
export function computeTransmissionStatus(
|
||||||
|
parentEntry: RegistryEntry,
|
||||||
|
replyEntry: RegistryEntry,
|
||||||
|
): TransmissionStatus {
|
||||||
|
if (!parentEntry.recipientRegDate) return null;
|
||||||
|
|
||||||
|
const regDate = new Date(parentEntry.recipientRegDate);
|
||||||
|
regDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// 1 working day after recipient registration date
|
||||||
|
const deadline = addWorkingDays(regDate, 1);
|
||||||
|
|
||||||
|
const replyDate = new Date(replyEntry.date);
|
||||||
|
replyDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
return replyDate.getTime() <= deadline.getTime() ? "on-time" : "late";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auto-resolution of deadlines ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find deadlines on a parent entry that should be auto-resolved when a reply arrives.
|
||||||
|
* Only returns pending deadlines that match the reply context.
|
||||||
|
*/
|
||||||
|
export function findAutoResolvableDeadlines(
|
||||||
|
parentEntry: RegistryEntry,
|
||||||
|
replyEntry: RegistryEntry,
|
||||||
|
): TrackedDeadline[] {
|
||||||
|
const pending = (parentEntry.trackedDeadlines ?? []).filter(
|
||||||
|
(dl) => dl.resolution === "pending",
|
||||||
|
);
|
||||||
|
if (pending.length === 0) return [];
|
||||||
|
|
||||||
|
// Only auto-resolve when reply is incoming (response to our outgoing request)
|
||||||
|
if (replyEntry.direction !== "intrat") return [];
|
||||||
|
if (parentEntry.direction !== "iesit") return [];
|
||||||
|
|
||||||
|
// Filter: skip background-only and auto-tracked chain children
|
||||||
|
return pending.filter((dl) => {
|
||||||
|
const def = getDeadlineType(dl.typeId);
|
||||||
|
if (!def) return false;
|
||||||
|
if (def.backgroundOnly) return false;
|
||||||
|
// Include main deadlines (no chain parent) — these are the ones the user cares about
|
||||||
|
if (!dl.chainParentId) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ export interface DeadlineTypeDef {
|
|||||||
autoTrack?: boolean;
|
autoTrack?: boolean;
|
||||||
/** Which directions this category/type applies to (undefined = both) */
|
/** Which directions this category/type applies to (undefined = both) */
|
||||||
directionFilter?: RegistryDirection[];
|
directionFilter?: RegistryDirection[];
|
||||||
|
/** If true, this deadline is purely background — never shown in dashboard */
|
||||||
|
backgroundOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Audit log entry for deadline changes */
|
/** Audit log entry for deadline changes */
|
||||||
|
|||||||
Reference in New Issue
Block a user