a2b9ff75b5
- ac-verificare (5z lucr.) now auto-track, created automatically with any AC emitere type. Informational: authority notifies if incomplete. - ac-emitere (30z cal.) now chains to ac-emitere-dupa-completari when interrupted — term recalculates from completion submission date. - ac-emitere-urgenta (7z lucr.) and ac-emitere-anexe (15z cal.) kept. - New: ac-prelungire-emitere (15z lucr.) — authority communicates decision on AC extension within 15 working days. - Info box in DeadlineAddDialog for autorizare category explaining auto-tracked verification + interruption mechanism. - None of the autorizare deadlines have tacit approval. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1817 lines
66 KiB
TypeScript
1817 lines
66 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo, useRef, useCallback } from "react";
|
|
import {
|
|
Paperclip,
|
|
X,
|
|
Clock,
|
|
Plus,
|
|
UserPlus,
|
|
Info,
|
|
GitBranch,
|
|
Loader2,
|
|
AlertTriangle,
|
|
Calendar,
|
|
Globe,
|
|
ArrowDownToLine,
|
|
ArrowUpFromLine,
|
|
HardDrive,
|
|
FolderOpen,
|
|
Link2,
|
|
} from "lucide-react";
|
|
import type { CompanyId } from "@/core/auth/types";
|
|
import {
|
|
extractTemplates,
|
|
assembleSubject,
|
|
filterTemplates,
|
|
filterSubjects,
|
|
getRecommendedTemplates,
|
|
getSubjectPlaceholder,
|
|
createDynamicTemplate,
|
|
} from "../services/subject-template-service";
|
|
import type { SubjectTemplate } from "../services/subject-template-service";
|
|
import { SubjectTemplateInput } from "./subject-template-input";
|
|
import { SubjectAutocompleteDropdown } from "./subject-autocomplete-dropdown";
|
|
import type {
|
|
RegistryEntry,
|
|
RegistryDirection,
|
|
DocumentType,
|
|
RegistryAttachment,
|
|
TrackedDeadline,
|
|
DeadlineResolution,
|
|
ACValidityTracking,
|
|
} from "../types";
|
|
import { DEFAULT_DOC_TYPE_LABELS } from "../types";
|
|
import {
|
|
isNetworkPath,
|
|
toUncPath,
|
|
pathFileName,
|
|
shortDisplayPath,
|
|
shareLabelFor,
|
|
} from "@/config/nas-paths";
|
|
import { Input } from "@/shared/components/ui/input";
|
|
import { Label } from "@/shared/components/ui/label";
|
|
import { Textarea } from "@/shared/components/ui/textarea";
|
|
import { Button } from "@/shared/components/ui/button";
|
|
import { Badge } from "@/shared/components/ui/badge";
|
|
import { Switch } from "@/shared/components/ui/switch";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/shared/components/ui/select";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/shared/components/ui/tooltip";
|
|
import { useContacts } from "@/modules/address-book/hooks/use-contacts";
|
|
import { useTags } from "@/core/tagging";
|
|
import type { AddressContact } from "@/modules/address-book/types";
|
|
import { v4 as uuid } from "uuid";
|
|
import { DeadlineCard } from "./deadline-card";
|
|
import { DeadlineAddDialog } from "./deadline-add-dialog";
|
|
import { DeadlineResolveDialog } from "./deadline-resolve-dialog";
|
|
import { QuickContactDialog } from "./quick-contact-dialog";
|
|
import { ThreadView } from "./thread-view";
|
|
import { ClosureBanner } from "./closure-banner";
|
|
import { ACValidityTracker } from "./ac-validity-tracker";
|
|
import { cn } from "@/shared/lib/utils";
|
|
import {
|
|
createTrackedDeadline,
|
|
resolveDeadline as resolveDeadlineFn,
|
|
} from "../services/deadline-service";
|
|
import { getDeadlineType } from "../services/deadline-catalog";
|
|
|
|
interface RegistryEntryFormProps {
|
|
initial?: RegistryEntry;
|
|
/** Pre-fill as reply to this entry — sets threadParentId, flips direction (used by "Inchide") */
|
|
replyTo?: RegistryEntry;
|
|
/** Pre-fill as related/conex entry — adds to linkedEntryIds, keeps direction (used by "Conex") */
|
|
conexTo?: RegistryEntry;
|
|
allEntries?: RegistryEntry[];
|
|
onSubmit: (
|
|
data: Omit<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
|
|
) => void | Promise<void>;
|
|
onCancel: () => void;
|
|
/** Callback to create a new Address Book contact */
|
|
onCreateContact?: (data: {
|
|
name: string;
|
|
phone: string;
|
|
email: string;
|
|
}) => Promise<AddressContact | undefined>;
|
|
/** Callback to create a new document type tag in Tag Manager */
|
|
onCreateDocType?: (label: string) => Promise<void>;
|
|
/** Navigate to an entry (for thread clicks) */
|
|
onNavigateEntry?: (entry: RegistryEntry) => void;
|
|
}
|
|
|
|
export function RegistryEntryForm({
|
|
initial,
|
|
replyTo,
|
|
conexTo,
|
|
allEntries,
|
|
onSubmit,
|
|
onCancel,
|
|
onCreateContact,
|
|
onCreateDocType,
|
|
onNavigateEntry,
|
|
}: RegistryEntryFormProps) {
|
|
const { allContacts, refresh: refreshContacts } = useContacts();
|
|
const { tags: docTypeTags } = useTags("document-type");
|
|
const { tags: projectTags } = useTags("project");
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Track locally-added custom types that may not yet be in Tag Manager
|
|
const [localCustomTypes, setLocalCustomTypes] = useState<Map<string, string>>(
|
|
new Map(),
|
|
);
|
|
|
|
// ── Build dynamic doc type list from defaults + Tag Manager + local ──
|
|
const allDocTypes = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
// Add defaults
|
|
for (const [key, label] of Object.entries(DEFAULT_DOC_TYPE_LABELS)) {
|
|
map.set(key, label);
|
|
}
|
|
// Add from Tag Manager (document-type category)
|
|
for (const tag of docTypeTags) {
|
|
const key = tag.label.toLowerCase().replace(/\s+/g, "-");
|
|
if (!map.has(key)) {
|
|
map.set(key, tag.label);
|
|
}
|
|
}
|
|
// Add locally-created types (before Tag Manager syncs)
|
|
for (const [key, label] of localCustomTypes) {
|
|
if (!map.has(key)) {
|
|
map.set(key, label);
|
|
}
|
|
}
|
|
// Preserve initial doc type even if not in defaults or Tag Manager
|
|
// (e.g., custom type whose tag was deleted, or tag not yet loaded)
|
|
if (initial?.documentType && !map.has(initial.documentType)) {
|
|
const fallbackLabel =
|
|
initial.documentType.charAt(0).toUpperCase() +
|
|
initial.documentType.slice(1).replace(/-/g, " ");
|
|
map.set(initial.documentType, fallbackLabel);
|
|
}
|
|
// Sort alphabetically by label
|
|
const sorted = new Map(
|
|
[...map.entries()].sort((a, b) => a[1].localeCompare(b[1], "ro")),
|
|
);
|
|
return sorted;
|
|
}, [docTypeTags, localCustomTypes, initial?.documentType]);
|
|
|
|
// When replyTo is provided, flip direction (intrat→iesit, iesit→intrat)
|
|
const replyDirection: RegistryDirection | undefined = replyTo
|
|
? replyTo.direction === "intrat" ? "iesit" : "intrat"
|
|
: undefined;
|
|
const [direction, setDirection] = useState<RegistryDirection>(
|
|
initial?.direction ?? replyDirection ?? "intrat",
|
|
);
|
|
const defaultDocType = initial?.documentType ?? (direction === "intrat" ? "aviz" : "cerere");
|
|
const [documentType, setDocumentType] = useState<DocumentType>(defaultDocType);
|
|
const [customDocType, setCustomDocType] = useState("");
|
|
const [subject, setSubject] = useState(initial?.subject ?? "");
|
|
const [subjectQuery, setSubjectQuery] = useState(initial?.subject ?? "");
|
|
const [subjectFocused, setSubjectFocused] = useState(false);
|
|
const [activeTemplate, setActiveTemplate] = useState<SubjectTemplate | null>(null);
|
|
const [templateFieldValues, setTemplateFieldValues] = useState<Record<string, string>>({});
|
|
const [date, setDate] = useState(
|
|
initial?.date ?? new Date().toISOString().slice(0, 10),
|
|
);
|
|
const [sender, setSender] = useState(initial?.sender ?? "");
|
|
const [senderContactId, setSenderContactId] = useState(
|
|
initial?.senderContactId ?? "",
|
|
);
|
|
const [recipient, setRecipient] = useState(initial?.recipient ?? "");
|
|
const [recipientContactId, setRecipientContactId] = useState(
|
|
initial?.recipientContactId ?? "",
|
|
);
|
|
const [company, setCompany] = useState<CompanyId>(
|
|
initial?.company ?? "beletage",
|
|
);
|
|
const [isClosed, setIsClosed] = useState(initial?.status === "inchis");
|
|
const [deadline, setDeadline] = useState(initial?.deadline ?? "");
|
|
const [assignee, setAssignee] = useState(initial?.assignee ?? "");
|
|
const [assigneeContactId, setAssigneeContactId] = useState(
|
|
initial?.assigneeContactId ?? "",
|
|
);
|
|
const [threadParentId, setThreadParentId] = useState(
|
|
initial?.threadParentId ?? replyTo?.id ?? "",
|
|
);
|
|
const [notes, setNotes] = useState(initial?.notes ?? "");
|
|
const [linkedEntryIds, setLinkedEntryIds] = useState<string[]>(
|
|
initial?.linkedEntryIds ?? (conexTo ? [conexTo.id] : []),
|
|
);
|
|
const [attachments, setAttachments] = useState<RegistryAttachment[]>(
|
|
initial?.attachments ?? [],
|
|
);
|
|
const [trackedDeadlines, setTrackedDeadlines] = useState<TrackedDeadline[]>(
|
|
initial?.trackedDeadlines ?? [],
|
|
);
|
|
const [linkedSearch, setLinkedSearch] = useState("");
|
|
const [threadSearch, setThreadSearch] = useState("");
|
|
|
|
// ── 3.03 new fields ──
|
|
const [recipientRegNumber, setRecipientRegNumber] = useState(
|
|
initial?.recipientRegNumber ?? "",
|
|
);
|
|
const [recipientRegDate, setRecipientRegDate] = useState(
|
|
initial?.recipientRegDate ?? "",
|
|
);
|
|
const [expiryDate, setExpiryDate] = useState(initial?.expiryDate ?? "");
|
|
const [expiryAlertDays, setExpiryAlertDays] = useState(
|
|
initial?.expiryAlertDays ?? 30,
|
|
);
|
|
const [externalStatusUrl, setExternalStatusUrl] = useState(
|
|
initial?.externalStatusUrl ?? "",
|
|
);
|
|
const [externalTrackingId, setExternalTrackingId] = useState(
|
|
initial?.externalTrackingId ?? "",
|
|
);
|
|
const [acValidity, setAcValidity] = useState<ACValidityTracking | undefined>(
|
|
initial?.acValidity,
|
|
);
|
|
|
|
// ── Submission lock + file upload tracking ──
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [uploadingCount, setUploadingCount] = useState(0);
|
|
const isUploading = uploadingCount > 0;
|
|
|
|
// ── Deadline dialogs ──
|
|
const [deadlineAddOpen, setDeadlineAddOpen] = useState(false);
|
|
const [resolvingDeadline, setResolvingDeadline] =
|
|
useState<TrackedDeadline | null>(null);
|
|
|
|
// ── Quick contact creation ──
|
|
const [quickContactOpen, setQuickContactOpen] = useState(false);
|
|
const [quickContactField, setQuickContactField] = useState<
|
|
"sender" | "recipient" | "assignee"
|
|
>("sender");
|
|
const [quickContactName, setQuickContactName] = useState("");
|
|
|
|
const handleAddDeadline = (
|
|
typeId: string,
|
|
startDate: string,
|
|
options?: { isCJ?: boolean; isComisie?: boolean; chainParentId?: string },
|
|
) => {
|
|
const tracked = createTrackedDeadline(
|
|
typeId,
|
|
startDate,
|
|
options?.chainParentId,
|
|
);
|
|
if (tracked) {
|
|
const newDeadlines = [tracked];
|
|
|
|
// Auto-create verification deadline for CU emitere types
|
|
const isCUEmitere =
|
|
typeId === "cu-emitere-l50" || typeId === "cu-emitere-l350";
|
|
if (isCUEmitere) {
|
|
const verification = createTrackedDeadline(
|
|
"cu-verificare",
|
|
startDate,
|
|
);
|
|
if (verification) newDeadlines.push(verification);
|
|
}
|
|
|
|
// Auto-create CJ sub-deadlines if CJ toggle is on
|
|
if (isCUEmitere && options?.isCJ) {
|
|
const cjRequest = createTrackedDeadline(
|
|
"cu-cj-solicitare-aviz",
|
|
startDate,
|
|
);
|
|
if (cjRequest) {
|
|
newDeadlines.push(cjRequest);
|
|
// Chain: primar responds 5 days from when arhitect-sef requests (3 days from start)
|
|
const requestDue = new Date(startDate);
|
|
requestDue.setDate(requestDue.getDate() + 3);
|
|
const y = requestDue.getFullYear();
|
|
const m = String(requestDue.getMonth() + 1).padStart(2, "0");
|
|
const d = String(requestDue.getDate()).padStart(2, "0");
|
|
const cjResponse = createTrackedDeadline(
|
|
"cu-cj-aviz-primar",
|
|
`${y}-${m}-${d}`,
|
|
cjRequest.id,
|
|
);
|
|
if (cjResponse) newDeadlines.push(cjResponse);
|
|
}
|
|
}
|
|
|
|
// Auto-create verification deadline for AC emitere types
|
|
const isACEmitere =
|
|
typeId === "ac-emitere" ||
|
|
typeId === "ac-emitere-urgenta" ||
|
|
typeId === "ac-emitere-anexe";
|
|
if (isACEmitere) {
|
|
const acVerification = createTrackedDeadline(
|
|
"ac-verificare",
|
|
startDate,
|
|
);
|
|
if (acVerification) newDeadlines.push(acVerification);
|
|
}
|
|
|
|
// Auto-create completari limit for avize (when Comisie toggle is OFF)
|
|
const addedDef = getDeadlineType(typeId);
|
|
if (addedDef?.category === "avize" && !options?.isComisie) {
|
|
const completariLimit = createTrackedDeadline(
|
|
"aviz-completari-limit",
|
|
startDate,
|
|
);
|
|
if (completariLimit) newDeadlines.push(completariLimit);
|
|
}
|
|
|
|
// Auto-create Cultura Phase 1 (depunere la comisie) when adding cultura-comisie
|
|
if (typeId === "aviz-cultura-comisie") {
|
|
const culturaPhase1 = createTrackedDeadline(
|
|
"aviz-cultura-depunere-comisie",
|
|
startDate,
|
|
);
|
|
if (culturaPhase1) newDeadlines.push(culturaPhase1);
|
|
}
|
|
|
|
// Auto-create comunicare catre beneficiar for all iesit deadlines
|
|
// (legal obligation: institution must communicate on the day of issuance)
|
|
if (direction === "iesit") {
|
|
const comunicare = createTrackedDeadline(
|
|
"comunicare-aviz-beneficiar",
|
|
startDate,
|
|
);
|
|
if (comunicare) newDeadlines.push(comunicare);
|
|
}
|
|
|
|
setTrackedDeadlines((prev) => [...prev, ...newDeadlines]);
|
|
}
|
|
};
|
|
|
|
const handleResolveDeadline = (
|
|
resolution: DeadlineResolution,
|
|
note: string,
|
|
chainNext: boolean,
|
|
) => {
|
|
if (!resolvingDeadline) return;
|
|
const resolved = resolveDeadlineFn(resolvingDeadline, resolution, note);
|
|
setTrackedDeadlines((prev) =>
|
|
prev.map((d) => (d.id === resolved.id ? resolved : d)),
|
|
);
|
|
|
|
// Standard chain (completed / aprobat-tacit)
|
|
if (chainNext && resolution !== "intrerupt") {
|
|
const def = getDeadlineType(resolvingDeadline.typeId);
|
|
if (def?.chainNextTypeId) {
|
|
const resolvedDate = new Date().toISOString().slice(0, 10);
|
|
const chained = createTrackedDeadline(
|
|
def.chainNextTypeId,
|
|
resolvedDate,
|
|
resolvingDeadline.id,
|
|
);
|
|
if (chained) setTrackedDeadlines((prev) => [...prev, chained]);
|
|
}
|
|
}
|
|
|
|
// Interruption chain — institution requested completions, term interrupted
|
|
// Creates chained deadline with today as placeholder start date
|
|
// User must update start date when completions are actually submitted
|
|
if (resolution === "intrerupt") {
|
|
const def = getDeadlineType(resolvingDeadline.typeId);
|
|
if (def?.chainNextTypeId) {
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const chained = createTrackedDeadline(
|
|
def.chainNextTypeId,
|
|
today,
|
|
resolvingDeadline.id,
|
|
);
|
|
if (chained) {
|
|
chained.auditLog = [
|
|
...(chained.auditLog ?? []),
|
|
{
|
|
action: "modified",
|
|
timestamp: new Date().toISOString(),
|
|
detail:
|
|
"ATENTIE: Actualizati data start cand se depun completarile/clarificarile",
|
|
},
|
|
];
|
|
setTrackedDeadlines((prev) => [...prev, chained]);
|
|
}
|
|
}
|
|
}
|
|
|
|
setResolvingDeadline(null);
|
|
};
|
|
|
|
const handleRemoveDeadline = (deadlineId: string) => {
|
|
setTrackedDeadlines((prev) => prev.filter((d) => d.id !== deadlineId));
|
|
};
|
|
|
|
// ── Contact autocomplete ──
|
|
const [senderFocused, setSenderFocused] = useState(false);
|
|
const [recipientFocused, setRecipientFocused] = useState(false);
|
|
const [assigneeFocused, setAssigneeFocused] = useState(false);
|
|
|
|
const filterContacts = useCallback(
|
|
(query: string) => {
|
|
if (!query || query.length < 2) return [];
|
|
const q = query.toLowerCase();
|
|
return allContacts
|
|
.filter(
|
|
(c) =>
|
|
c.name.toLowerCase().includes(q) ||
|
|
c.company.toLowerCase().includes(q),
|
|
)
|
|
.slice(0, 5);
|
|
},
|
|
[allContacts],
|
|
);
|
|
|
|
const senderSuggestions = useMemo(
|
|
() => filterContacts(sender),
|
|
[filterContacts, sender],
|
|
);
|
|
const recipientSuggestions = useMemo(
|
|
() => filterContacts(recipient),
|
|
[filterContacts, recipient],
|
|
);
|
|
const assigneeSuggestions = useMemo(
|
|
() => filterContacts(assignee),
|
|
[filterContacts, assignee],
|
|
);
|
|
|
|
// ── Subject autocomplete ──
|
|
const allSubjects = useMemo(() => {
|
|
if (!allEntries) return [];
|
|
const unique = new Set<string>();
|
|
for (const e of allEntries) {
|
|
if (e.subject && !e.isReserved) unique.add(e.subject);
|
|
}
|
|
return Array.from(unique);
|
|
}, [allEntries]);
|
|
|
|
const allTemplates = useMemo(
|
|
() => extractTemplates(allSubjects),
|
|
[allSubjects],
|
|
);
|
|
|
|
const matchingTemplates = useMemo(
|
|
() =>
|
|
subjectQuery.length >= 2
|
|
? filterTemplates(allTemplates, subjectQuery).slice(0, 5)
|
|
: [],
|
|
[allTemplates, subjectQuery],
|
|
);
|
|
|
|
const matchingSuggestions = useMemo(
|
|
() =>
|
|
subjectQuery.length >= 2
|
|
? filterSubjects(allSubjects, subjectQuery, 5)
|
|
: [],
|
|
[allSubjects, subjectQuery],
|
|
);
|
|
|
|
// Recommended templates for current doc type (seeds + DB-learned)
|
|
const recommendedTemplates = useMemo(
|
|
() => getRecommendedTemplates(documentType, allTemplates),
|
|
[documentType, allTemplates],
|
|
);
|
|
|
|
// Recent subjects from DB (last 5 unique, sorted by newest first)
|
|
const recentSubjects = useMemo(() => {
|
|
if (!allEntries || allEntries.length === 0) return [];
|
|
const sorted = [...allEntries]
|
|
.filter((e) => e.subject && !e.isReserved)
|
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
const seen = new Set<string>();
|
|
const result: string[] = [];
|
|
for (const e of sorted) {
|
|
if (result.length >= 5) break;
|
|
const lower = e.subject.toLowerCase();
|
|
if (!seen.has(lower)) {
|
|
seen.add(lower);
|
|
result.push(e.subject);
|
|
}
|
|
}
|
|
return result;
|
|
}, [allEntries]);
|
|
|
|
// Dynamic placeholder based on document type + direction
|
|
const subjectPlaceholder = useMemo(
|
|
() => getSubjectPlaceholder(documentType, direction),
|
|
[documentType, direction],
|
|
);
|
|
|
|
// ── Quick contact creation handler ──
|
|
const openQuickContact = (
|
|
field: "sender" | "recipient" | "assignee",
|
|
name: string,
|
|
) => {
|
|
setQuickContactField(field);
|
|
setQuickContactName(name);
|
|
setQuickContactOpen(true);
|
|
};
|
|
|
|
const handleQuickContactConfirm = async (data: {
|
|
name: string;
|
|
phone: string;
|
|
email: string;
|
|
}) => {
|
|
if (!onCreateContact) return;
|
|
const contact = await onCreateContact(data);
|
|
if (contact) {
|
|
const displayName = contact.company
|
|
? `${contact.name} (${contact.company})`
|
|
: contact.name;
|
|
if (quickContactField === "sender") {
|
|
setSender(displayName);
|
|
setSenderContactId(contact.id);
|
|
} else if (quickContactField === "recipient") {
|
|
setRecipient(displayName);
|
|
setRecipientContactId(contact.id);
|
|
} else {
|
|
setAssignee(displayName);
|
|
setAssigneeContactId(contact.id);
|
|
}
|
|
await refreshContacts();
|
|
}
|
|
setQuickContactOpen(false);
|
|
};
|
|
|
|
// ── Custom doc type creation — adds to local state immediately + Tag Manager ──
|
|
const handleAddCustomDocType = async () => {
|
|
const label = customDocType.trim();
|
|
if (!label) return;
|
|
const key = label.toLowerCase().replace(/\s+/g, "-");
|
|
// Add to local types immediately so it appears in the select
|
|
setLocalCustomTypes((prev) => new Map(prev).set(key, label));
|
|
// Select the newly created type
|
|
setDocumentType(key);
|
|
setCustomDocType("");
|
|
if (onCreateDocType) {
|
|
await onCreateDocType(label);
|
|
}
|
|
};
|
|
|
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files;
|
|
if (!files) return;
|
|
const fileArr = Array.from(files);
|
|
setUploadingCount((prev) => prev + fileArr.length);
|
|
for (const file of fileArr) {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const base64 = reader.result as string;
|
|
setAttachments((prev) => [
|
|
...prev,
|
|
{
|
|
id: uuid(),
|
|
name: file.name,
|
|
data: base64,
|
|
type: file.type,
|
|
size: file.size,
|
|
addedAt: new Date().toISOString(),
|
|
},
|
|
]);
|
|
setUploadingCount((prev) => Math.max(0, prev - 1));
|
|
};
|
|
reader.onerror = () => {
|
|
setUploadingCount((prev) => Math.max(0, prev - 1));
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
};
|
|
|
|
const removeAttachment = (id: string) => {
|
|
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
|
};
|
|
|
|
// Network path support
|
|
const [networkPathInput, setNetworkPathInput] = useState("");
|
|
const [showNetworkInput, setShowNetworkInput] = useState(false);
|
|
|
|
const handleAddNetworkPath = () => {
|
|
const raw = networkPathInput.trim();
|
|
if (!raw) return;
|
|
const unc = toUncPath(raw);
|
|
const fileName = pathFileName(raw);
|
|
setAttachments((prev) => [
|
|
...prev,
|
|
{
|
|
id: uuid(),
|
|
name: fileName,
|
|
data: "__network__",
|
|
type: "network/path",
|
|
size: 0,
|
|
addedAt: new Date().toISOString(),
|
|
networkPath: unc,
|
|
},
|
|
]);
|
|
setNetworkPathInput("");
|
|
setShowNetworkInput(false);
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (isSubmitting || isUploading) return;
|
|
setIsSubmitting(true);
|
|
try {
|
|
await onSubmit({
|
|
direction,
|
|
documentType,
|
|
subject,
|
|
date,
|
|
sender,
|
|
senderContactId: senderContactId || undefined,
|
|
recipient,
|
|
recipientContactId: recipientContactId || undefined,
|
|
company,
|
|
status: isClosed ? "inchis" : "deschis",
|
|
deadline: deadline || undefined,
|
|
assignee: assignee || undefined,
|
|
assigneeContactId: assigneeContactId || undefined,
|
|
threadParentId: threadParentId || "",
|
|
recipientRegNumber: recipientRegNumber || undefined,
|
|
recipientRegDate: recipientRegDate || undefined,
|
|
expiryDate: expiryDate || undefined,
|
|
expiryAlertDays: expiryDate ? expiryAlertDays : undefined,
|
|
externalStatusUrl: externalStatusUrl || undefined,
|
|
externalTrackingId: externalTrackingId || undefined,
|
|
acValidity: acValidity,
|
|
linkedEntryIds,
|
|
attachments,
|
|
trackedDeadlines:
|
|
trackedDeadlines.length > 0 ? trackedDeadlines : undefined,
|
|
notes,
|
|
tags: initial?.tags ?? [],
|
|
visibility: initial?.visibility ?? "all",
|
|
});
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
// ── Contact autocomplete dropdown renderer ──
|
|
const renderContactDropdown = (
|
|
suggestions: AddressContact[],
|
|
focused: boolean,
|
|
fieldName: "sender" | "recipient" | "assignee",
|
|
currentValue: string,
|
|
onSelect: (c: AddressContact) => void,
|
|
) => {
|
|
if (!focused) return null;
|
|
|
|
const hasExactMatch = suggestions.some(
|
|
(c) =>
|
|
c.name.toLowerCase() === currentValue.toLowerCase() ||
|
|
`${c.name} (${c.company})`.toLowerCase() === currentValue.toLowerCase(),
|
|
);
|
|
|
|
const showCreateButton =
|
|
currentValue.length >= 2 && !hasExactMatch && onCreateContact;
|
|
|
|
if (suggestions.length === 0 && !showCreateButton) return null;
|
|
|
|
return (
|
|
<div className="absolute z-10 mt-1 w-full rounded-md border bg-popover p-1 shadow-md">
|
|
{suggestions.map((c) => (
|
|
<button
|
|
key={c.id}
|
|
type="button"
|
|
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
|
|
onMouseDown={() => onSelect(c)}
|
|
>
|
|
<span className="font-medium">{c.name}</span>
|
|
{c.company && (
|
|
<span className="ml-1 text-muted-foreground text-xs">
|
|
{c.company}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
{showCreateButton && (
|
|
<button
|
|
type="button"
|
|
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent text-primary flex items-center gap-1.5 border-t mt-1 pt-1.5"
|
|
onMouseDown={() => openQuickContact(fieldName, currentValue)}
|
|
>
|
|
<UserPlus className="h-3.5 w-3.5" />
|
|
Creează contact "{currentValue}"
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Thread parent entry for display
|
|
const threadParent =
|
|
threadParentId && allEntries
|
|
? allEntries.find((e) => e.id === threadParentId)
|
|
: null;
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
{/* Closure info banner (if editing a closed entry) */}
|
|
{initial?.closureInfo && (
|
|
<ClosureBanner
|
|
closureInfo={initial.closureInfo}
|
|
allEntries={allEntries}
|
|
onNavigateLinked={onNavigateEntry}
|
|
/>
|
|
)}
|
|
|
|
{/* Thread view (if editing an entry that's in a thread) */}
|
|
{initial &&
|
|
allEntries &&
|
|
(initial.threadParentId ||
|
|
allEntries.some((e) => e.threadParentId === initial.id)) && (
|
|
<ThreadView
|
|
entry={initial}
|
|
allEntries={allEntries}
|
|
onNavigate={onNavigateEntry}
|
|
/>
|
|
)}
|
|
|
|
{/* Row 1: Direction + Document type + Date */}
|
|
<div className="grid gap-4 sm:grid-cols-3">
|
|
<div>
|
|
<Label className="flex items-center gap-1.5">
|
|
Direcție
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" className="max-w-xs">
|
|
<p className="text-xs">
|
|
Intrat = document primit de la terți. Ieșit = document
|
|
emis de noi către terți.
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</Label>
|
|
<div className="mt-1 flex rounded-lg border bg-muted/30 p-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDirection("intrat");
|
|
// Auto-switch doc type if user hasn't customized it
|
|
if (documentType === "cerere") setDocumentType("aviz");
|
|
}}
|
|
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 === "intrat"
|
|
? "bg-blue-500 text-white shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-background",
|
|
)}
|
|
>
|
|
<ArrowDownToLine className="h-4 w-4" />
|
|
Intrat
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDirection("iesit");
|
|
// Auto-switch doc type if user hasn't customized it
|
|
if (documentType === "aviz") setDocumentType("cerere");
|
|
}}
|
|
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 === "iesit"
|
|
? "bg-orange-500 text-white shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-background",
|
|
)}
|
|
>
|
|
<ArrowUpFromLine className="h-4 w-4" />
|
|
Ieșit
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label className="flex items-center gap-1.5">
|
|
Tip document
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" className="max-w-xs">
|
|
<p className="text-xs">
|
|
Alege tipul care descrie cel mai bine documentul. Poți
|
|
adăuga tipuri noi cu câmpul de mai jos.
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</Label>
|
|
<div className="mt-1">
|
|
<Select
|
|
value={allDocTypes.has(documentType) ? documentType : "altele"}
|
|
onValueChange={(v) => setDocumentType(v as DocumentType)}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Array.from(allDocTypes.entries()).map(([key, label]) => (
|
|
<SelectItem key={key} value={key}>
|
|
{label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{/* Add custom type inline */}
|
|
<div className="mt-1.5 flex gap-1">
|
|
<Input
|
|
value={customDocType}
|
|
onChange={(e) => setCustomDocType(e.target.value)}
|
|
placeholder="Tip nou..."
|
|
className="text-xs h-7"
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
handleAddCustomDocType();
|
|
}
|
|
}}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 px-2"
|
|
onClick={handleAddCustomDocType}
|
|
disabled={!customDocType.trim()}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label className="flex items-center gap-1.5">
|
|
Data document
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" className="max-w-xs">
|
|
<p className="text-xs">
|
|
Data reală de pe document. Poate fi în trecut dacă
|
|
înregistrezi retroactiv.
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</Label>
|
|
<Input
|
|
type="date"
|
|
value={date}
|
|
onChange={(e) => setDate(e.target.value)}
|
|
className="mt-1"
|
|
/>
|
|
{!initial && date !== new Date().toISOString().slice(0, 10) && (
|
|
<p className="text-[10px] text-amber-600 dark:text-amber-400 mt-0.5">
|
|
Data diferă de azi — înregistrarea retroactivă va primi următorul
|
|
nr. secvențial.
|
|
</p>
|
|
)}
|
|
{/* Late receipt indicator for incoming admin acts */}
|
|
{direction === "intrat" &&
|
|
documentType === "aviz" &&
|
|
date &&
|
|
(() => {
|
|
const docDate = new Date(date);
|
|
const today = new Date();
|
|
docDate.setHours(0, 0, 0, 0);
|
|
today.setHours(0, 0, 0, 0);
|
|
const diffDays = Math.floor(
|
|
(today.getTime() - docDate.getTime()) / (1000 * 60 * 60 * 24),
|
|
);
|
|
if (diffDays > 0) {
|
|
return (
|
|
<p className="text-[10px] text-orange-600 dark:text-orange-400 mt-0.5 flex items-center gap-1">
|
|
<span>
|
|
Actul are data emiterii cu {diffDays}{" "}
|
|
{diffDays === 1 ? "zi" : "zile"} in urma — primit cu
|
|
intarziere fata de obligatia de comunicare in ziua
|
|
emiterii (L350 art. 44).
|
|
</span>
|
|
</p>
|
|
);
|
|
}
|
|
return null;
|
|
})()}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Subject with autocomplete + template mode */}
|
|
<div className="relative">
|
|
<Label className="flex items-center gap-1.5">
|
|
Subiect *
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" className="max-w-xs">
|
|
{direction === "intrat" ? (
|
|
<div className="text-xs space-y-1">
|
|
<p className="font-medium">Acte primite:</p>
|
|
<ul className="list-disc pl-3 space-y-0.5">
|
|
<li>CU nr. 312/2026 — Proiect X</li>
|
|
<li>AC nr. 89/2026 — Proiect X</li>
|
|
<li>Aviz ISU nr. 55/2026 — Proiect X</li>
|
|
<li>Contract nr. 15/2026 — Proiect X</li>
|
|
</ul>
|
|
<p className="text-muted-foreground">Selecteaza un sablon sau tasteaza liber.</p>
|
|
<p className="text-muted-foreground mt-1">Tip: scrie <code className="font-mono text-amber-300 bg-white/10 px-1 rounded">{"{proiect}"}</code> pentru dropdown de proiecte.</p>
|
|
</div>
|
|
) : (
|
|
<div className="text-xs space-y-1">
|
|
<p className="font-medium">Documente trimise:</p>
|
|
<ul className="list-disc pl-3 space-y-0.5">
|
|
<li>Cerere CU nr. 123/2026 — Proiect X</li>
|
|
<li>Solicitare aviz DSP — Proiect X</li>
|
|
<li>Oferta proiectare DTAC — Proiect X</li>
|
|
<li>Notificare incepere lucrari — Proiect X</li>
|
|
</ul>
|
|
<p className="text-muted-foreground">Selecteaza un sablon sau tasteaza liber.</p>
|
|
<p className="text-muted-foreground mt-1">Tip: scrie <code className="font-mono text-amber-300 bg-white/10 px-1 rounded">{"{proiect}"}</code> pentru dropdown de proiecte.</p>
|
|
</div>
|
|
)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</Label>
|
|
|
|
{activeTemplate ? (
|
|
<SubjectTemplateInput
|
|
template={activeTemplate}
|
|
fieldValues={templateFieldValues}
|
|
onFieldChange={(fieldId, value) => {
|
|
const next = { ...templateFieldValues, [fieldId]: value };
|
|
setTemplateFieldValues(next);
|
|
setSubject(assembleSubject(activeTemplate, next));
|
|
}}
|
|
onClear={() => {
|
|
setActiveTemplate(null);
|
|
setSubjectQuery(subject);
|
|
}}
|
|
projectTags={projectTags}
|
|
/>
|
|
) : (
|
|
<Input
|
|
value={subjectQuery}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
setSubjectQuery(val);
|
|
setSubject(val);
|
|
|
|
// Auto-detect {proiect}, {nr}, {an}, {detalii}, {text} placeholders
|
|
// and switch to template mode when a closing } is typed
|
|
if (val.includes("{") && val.includes("}")) {
|
|
const dynTemplate = createDynamicTemplate(val);
|
|
if (dynTemplate) {
|
|
// Pre-fill default values (e.g. current year for {an})
|
|
const prefill: Record<string, string> = {};
|
|
for (const field of dynTemplate.fields) {
|
|
if (field.defaultValue) {
|
|
prefill[field.id] = field.defaultValue;
|
|
}
|
|
}
|
|
setActiveTemplate(dynTemplate);
|
|
setTemplateFieldValues(prefill);
|
|
setSubject(assembleSubject(dynTemplate, prefill));
|
|
setSubjectFocused(false);
|
|
return;
|
|
}
|
|
}
|
|
}}
|
|
onFocus={() => setSubjectFocused(true)}
|
|
onBlur={() => setTimeout(() => setSubjectFocused(false), 200)}
|
|
className="mt-1"
|
|
required
|
|
placeholder={subjectPlaceholder}
|
|
/>
|
|
)}
|
|
|
|
<SubjectAutocompleteDropdown
|
|
templates={matchingTemplates}
|
|
suggestions={matchingSuggestions}
|
|
recommendedTemplates={recommendedTemplates}
|
|
recentSubjects={recentSubjects}
|
|
visible={
|
|
subjectFocused &&
|
|
!activeTemplate
|
|
}
|
|
onSelectTemplate={(t) => {
|
|
// Pre-fill year fields with current year
|
|
const prefill: Record<string, string> = {};
|
|
for (const field of t.fields) {
|
|
if (field.defaultValue) {
|
|
prefill[field.id] = field.defaultValue;
|
|
}
|
|
}
|
|
setActiveTemplate(t);
|
|
setTemplateFieldValues(prefill);
|
|
setSubject(assembleSubject(t, prefill));
|
|
setSubjectFocused(false);
|
|
}}
|
|
onSelectSuggestion={(s) => {
|
|
setSubject(s);
|
|
setSubjectQuery(s);
|
|
setSubjectFocused(false);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Sender / Recipient with autocomplete + quick create */}
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
{/* Sender */}
|
|
<div className="relative">
|
|
<Label className="flex items-center gap-1.5">
|
|
Expeditor
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" className="max-w-xs">
|
|
<p className="text-xs">
|
|
Cine a trimis documentul. Caută în Agenda de contacte sau
|
|
creează rapid un contact nou.
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</Label>
|
|
<Input
|
|
value={sender}
|
|
onChange={(e) => {
|
|
setSender(e.target.value);
|
|
setSenderContactId("");
|
|
}}
|
|
onFocus={() => setSenderFocused(true)}
|
|
onBlur={() => setTimeout(() => setSenderFocused(false), 200)}
|
|
className="mt-1"
|
|
placeholder="Nume sau companie..."
|
|
/>
|
|
{renderContactDropdown(
|
|
senderSuggestions,
|
|
senderFocused,
|
|
"sender",
|
|
sender,
|
|
(c) => {
|
|
setSender(c.company ? `${c.name} (${c.company})` : c.name);
|
|
setSenderContactId(c.id);
|
|
setSenderFocused(false);
|
|
},
|
|
)}
|
|
</div>
|
|
{/* Recipient */}
|
|
<div className="relative">
|
|
<Label className="flex items-center gap-1.5">
|
|
Destinatar
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" className="max-w-xs">
|
|
<p className="text-xs">
|
|
Cui trimitem documentul. Caută în Agenda de contacte sau
|
|
creează rapid un contact nou.
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</Label>
|
|
<Input
|
|
value={recipient}
|
|
onChange={(e) => {
|
|
setRecipient(e.target.value);
|
|
setRecipientContactId("");
|
|
}}
|
|
onFocus={() => setRecipientFocused(true)}
|
|
onBlur={() => setTimeout(() => setRecipientFocused(false), 200)}
|
|
className="mt-1"
|
|
placeholder="Nume sau companie..."
|
|
/>
|
|
{renderContactDropdown(
|
|
recipientSuggestions,
|
|
recipientFocused,
|
|
"recipient",
|
|
recipient,
|
|
(c) => {
|
|
setRecipient(c.company ? `${c.name} (${c.company})` : c.name);
|
|
setRecipientContactId(c.id);
|
|
setRecipientFocused(false);
|
|
},
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recipient registration fields — only for outgoing (iesit) documents */}
|
|
{direction === "iesit" && (
|
|
<div className="rounded-md border border-blue-500/30 bg-blue-500/5 p-3 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<AlertTriangle className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
|
<Label className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
|
Înregistrare la destinatar
|
|
</Label>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" className="max-w-xs">
|
|
<p className="text-xs">
|
|
Termenul legal pentru ieșiri curge DOAR de la data
|
|
înregistrării la destinatar, nu de la data trimiterii.
|
|
Completează aceste câmpuri când primești confirmarea.
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div>
|
|
<Label className="text-xs">Nr. înregistrare destinatar</Label>
|
|
<Input
|
|
value={recipientRegNumber}
|
|
onChange={(e) => setRecipientRegNumber(e.target.value)}
|
|
className="mt-1"
|
|
placeholder="Ex: 12345/2026"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">Data înregistrare destinatar</Label>
|
|
<Input
|
|
type="date"
|
|
value={recipientRegDate}
|
|
onChange={(e) => setRecipientRegDate(e.target.value)}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
{!recipientRegNumber && !recipientRegDate && (
|
|
<p className="text-[10px] text-amber-600 dark:text-amber-400">
|
|
Atenție: Datele de la destinatar nu sunt completate. Termenele
|
|
legale atașate nu vor porni până la completarea lor.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Assignee (Responsabil) — kept below */}
|
|
<div className="relative">
|
|
<Label className="flex items-center gap-1.5">
|
|
Responsabil
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" className="max-w-xs">
|
|
<p className="text-xs">
|
|
Persoana din echipă responsabilă cu gestionarea acestei
|
|
înregistrări. Câmp pregătit pentru integrare viitoare cu ERP.
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</Label>
|
|
<Input
|
|
value={assignee}
|
|
onChange={(e) => {
|
|
setAssignee(e.target.value);
|
|
setAssigneeContactId("");
|
|
}}
|
|
onFocus={() => setAssigneeFocused(true)}
|
|
onBlur={() => setTimeout(() => setAssigneeFocused(false), 200)}
|
|
className="mt-1"
|
|
placeholder="Persoana responsabilă..."
|
|
/>
|
|
{renderContactDropdown(
|
|
assigneeSuggestions,
|
|
assigneeFocused,
|
|
"assignee",
|
|
assignee,
|
|
(c) => {
|
|
setAssignee(c.company ? `${c.name} (${c.company})` : c.name);
|
|
setAssigneeContactId(c.id);
|
|
setAssigneeFocused(false);
|
|
},
|
|
)}
|
|
</div>
|
|
|
|
{/* Company + Closed switch + Deadline */}
|
|
<div className="grid gap-4 sm:grid-cols-3">
|
|
<div>
|
|
<Label>Companie</Label>
|
|
<Select
|
|
value={company}
|
|
onValueChange={(v) => setCompany(v as CompanyId)}
|
|
>
|
|
<SelectTrigger className="mt-1">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="beletage">Beletage</SelectItem>
|
|
<SelectItem value="urban-switch">Urban Switch</SelectItem>
|
|
<SelectItem value="studii-de-teren">Studii de Teren</SelectItem>
|
|
<SelectItem value="group">Grup</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label className="flex items-center gap-1.5">
|
|
Status
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" className="max-w-xs">
|
|
<p className="text-xs">
|
|
Implicit, toate înregistrările sunt deschise. Bifează doar
|
|
când dosarul este finalizat.
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</Label>
|
|
<div className="mt-2.5 flex items-center gap-2">
|
|
<Switch checked={isClosed} onCheckedChange={setIsClosed} />
|
|
<span className="text-sm text-muted-foreground">
|
|
{isClosed ? "Închis" : "Deschis"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label className="flex items-center gap-1.5">
|
|
Termen limită intern
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" className="max-w-xs">
|
|
<p className="text-xs">
|
|
Termen intern pentru a răspunde sau a acționa pe această
|
|
înregistrare. Nu este termen legal — termenele legale se
|
|
adaugă separat mai jos.
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</Label>
|
|
<Input
|
|
type="date"
|
|
value={deadline}
|
|
onChange={(e) => setDeadline(e.target.value)}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Document Expiry — CU/AC validity tracking (only for act administrativ) */}
|
|
{documentType === "aviz" && (
|
|
<>
|
|
<div className="rounded-md border border-muted p-3 space-y-3">
|
|
<Label className="flex items-center gap-1.5 text-sm font-medium">
|
|
<Calendar className="h-3.5 w-3.5" />
|
|
Valabilitate act administrativ
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" className="max-w-xs">
|
|
<p className="text-xs">
|
|
Pentru acte administrative cu termen de valabilitate (CU,
|
|
AC, avize). Sistemul genereaza alerte inainte de expirare.
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</Label>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div>
|
|
<Label className="text-xs">Data expirare</Label>
|
|
<Input
|
|
type="date"
|
|
value={expiryDate}
|
|
onChange={(e) => setExpiryDate(e.target.value)}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
{expiryDate && (
|
|
<div>
|
|
<Label className="text-xs">Alertă cu X zile înainte</Label>
|
|
<Input
|
|
type="number"
|
|
value={expiryAlertDays}
|
|
onChange={(e) =>
|
|
setExpiryAlertDays(parseInt(e.target.value, 10) || 30)
|
|
}
|
|
className="mt-1"
|
|
min={1}
|
|
max={365}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{expiryDate &&
|
|
(() => {
|
|
const expiry = new Date(expiryDate);
|
|
const now = new Date();
|
|
now.setHours(0, 0, 0, 0);
|
|
expiry.setHours(0, 0, 0, 0);
|
|
const daysLeft = Math.ceil(
|
|
(expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
|
);
|
|
if (daysLeft < 0) {
|
|
return (
|
|
<p className="text-[10px] text-red-600 dark:text-red-400 font-medium">
|
|
Document expirat de {Math.abs(daysLeft)} zile!
|
|
</p>
|
|
);
|
|
}
|
|
if (daysLeft <= expiryAlertDays) {
|
|
return (
|
|
<p className="text-[10px] text-amber-600 dark:text-amber-400">
|
|
Expiră în {daysLeft} zile — inițiați procedurile de
|
|
prelungire.
|
|
</p>
|
|
);
|
|
}
|
|
return null;
|
|
})()}
|
|
</div>
|
|
|
|
{/* AC Validity Tracker */}
|
|
<ACValidityTracker
|
|
value={acValidity}
|
|
onChange={setAcValidity}
|
|
entryDate={date}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* Web scraping prep — external tracking */}
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<Label className="flex items-center gap-1.5 text-xs">
|
|
<Globe className="h-3 w-3" />
|
|
URL verificare status (opțional)
|
|
</Label>
|
|
<Input
|
|
value={externalStatusUrl}
|
|
onChange={(e) => setExternalStatusUrl(e.target.value)}
|
|
className="mt-1 text-xs"
|
|
placeholder="https://portal.primaria.ro/..."
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">ID urmărire extern (opțional)</Label>
|
|
<Input
|
|
value={externalTrackingId}
|
|
onChange={(e) => setExternalTrackingId(e.target.value)}
|
|
className="mt-1 text-xs"
|
|
placeholder="Ex: REF-2026-001"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Thread parent — reply to another entry */}
|
|
{allEntries && allEntries.length > 0 && (
|
|
<div>
|
|
<Label className="flex items-center gap-1.5">
|
|
<GitBranch className="h-3.5 w-3.5" />
|
|
Răspuns la (Thread)
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" className="max-w-xs">
|
|
<p className="text-xs">
|
|
Leagă această înregistrare ca răspuns la alta, creând un fir
|
|
de conversație instituțională. O intrare poate genera mai
|
|
multe ieșiri (branching).
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</Label>
|
|
{threadParentId && (
|
|
<div className="mt-1.5 flex items-center gap-2 rounded border border-primary/30 bg-primary/5 px-2 py-1.5 text-sm">
|
|
{threadParent ? (
|
|
<>
|
|
<Badge
|
|
variant={
|
|
threadParent.direction === "intrat" ? "default" : "secondary"
|
|
}
|
|
className="text-[10px]"
|
|
>
|
|
{threadParent.direction === "intrat" ? "\u2193" : "\u2191"}
|
|
</Badge>
|
|
<span className="font-mono text-xs">{threadParent.number}</span>
|
|
<span className="truncate text-muted-foreground text-xs">
|
|
{threadParent.subject}
|
|
</span>
|
|
</>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground italic">
|
|
Legat de o inregistrare (ID: {threadParentId.slice(0, 8)}...)
|
|
</span>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="ml-auto h-7 px-2 text-xs text-destructive hover:text-destructive"
|
|
onClick={() => setThreadParentId("")}
|
|
title="Sterge legatura"
|
|
>
|
|
<X className="h-3.5 w-3.5 mr-0.5" />
|
|
Sterge
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{!threadParentId && (
|
|
<>
|
|
<Input
|
|
className="mt-1.5"
|
|
placeholder="Caută după număr sau subiect..."
|
|
value={threadSearch}
|
|
onChange={(e) => setThreadSearch(e.target.value)}
|
|
/>
|
|
{threadSearch.trim().length >= 2 && (
|
|
<div className="mt-1.5 max-h-32 overflow-y-auto space-y-1">
|
|
{allEntries
|
|
.filter((e) => {
|
|
if (e.id === initial?.id) return false;
|
|
const q = threadSearch.toLowerCase();
|
|
return (
|
|
e.number.toLowerCase().includes(q) ||
|
|
e.subject.toLowerCase().includes(q)
|
|
);
|
|
})
|
|
.slice(0, 8)
|
|
.map((e) => (
|
|
<button
|
|
key={e.id}
|
|
type="button"
|
|
onClick={() => {
|
|
setThreadParentId(e.id);
|
|
setThreadSearch("");
|
|
}}
|
|
className="flex w-full items-center gap-2 rounded border px-2 py-1 text-xs transition-colors hover:bg-accent"
|
|
>
|
|
<Badge
|
|
variant={
|
|
e.direction === "intrat" ? "default" : "secondary"
|
|
}
|
|
className="text-[9px] px-1 py-0"
|
|
>
|
|
{e.direction === "intrat" ? "↓" : "↑"}
|
|
</Badge>
|
|
<span className="font-mono">{e.number}</span>
|
|
<span className="truncate text-muted-foreground">
|
|
{e.subject.length > 40
|
|
? e.subject.slice(0, 40) + "…"
|
|
: e.subject}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Linked entries */}
|
|
{allEntries && allEntries.length > 0 && (
|
|
<div>
|
|
<Label>Înregistrări legate</Label>
|
|
<Input
|
|
className="mt-1.5"
|
|
placeholder="Caută după număr, subiect sau expeditor…"
|
|
value={linkedSearch}
|
|
onChange={(e) => setLinkedSearch(e.target.value)}
|
|
/>
|
|
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
|
{allEntries
|
|
.filter((e) => {
|
|
if (e.id === initial?.id) return false;
|
|
if (!linkedSearch.trim()) return true;
|
|
const q = linkedSearch.toLowerCase();
|
|
return (
|
|
e.number.toLowerCase().includes(q) ||
|
|
e.subject.toLowerCase().includes(q) ||
|
|
(e.sender ?? "").toLowerCase().includes(q)
|
|
);
|
|
})
|
|
.map((e) => (
|
|
<button
|
|
key={e.id}
|
|
type="button"
|
|
onClick={() => {
|
|
setLinkedEntryIds((prev) =>
|
|
prev.includes(e.id)
|
|
? prev.filter((id) => id !== e.id)
|
|
: [...prev, e.id],
|
|
);
|
|
}}
|
|
className={`rounded border px-2 py-0.5 text-xs transition-colors ${
|
|
linkedEntryIds.includes(e.id)
|
|
? "border-primary bg-primary/10 text-primary"
|
|
: "border-muted-foreground/30 text-muted-foreground hover:border-primary/50"
|
|
}`}
|
|
>
|
|
{e.number}
|
|
{e.subject && (
|
|
<span className="ml-1 opacity-60">
|
|
·{" "}
|
|
{e.subject.length > 30
|
|
? e.subject.slice(0, 30) + "…"
|
|
: e.subject}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tracked Deadlines */}
|
|
<div>
|
|
<div className="flex items-center justify-between">
|
|
<Label className="flex items-center gap-1.5">
|
|
<Clock className="h-3.5 w-3.5" />
|
|
Termene legale
|
|
</Label>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setDeadlineAddOpen(true)}
|
|
>
|
|
<Plus className="mr-1 h-3.5 w-3.5" /> Adaugă termen
|
|
</Button>
|
|
</div>
|
|
{trackedDeadlines.length > 0 && (
|
|
<div className="mt-2 space-y-2">
|
|
{trackedDeadlines.map((dl) => (
|
|
<DeadlineCard
|
|
key={dl.id}
|
|
deadline={dl}
|
|
onResolve={setResolvingDeadline}
|
|
onRemove={handleRemoveDeadline}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
{trackedDeadlines.length === 0 && (
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|
Niciun termen legal. Apăsați "Adaugă termen" pentru a
|
|
urmări un termen.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<DeadlineAddDialog
|
|
open={deadlineAddOpen}
|
|
onOpenChange={setDeadlineAddOpen}
|
|
entryDate={date}
|
|
direction={direction}
|
|
documentType={documentType}
|
|
onAdd={handleAddDeadline}
|
|
/>
|
|
|
|
<DeadlineResolveDialog
|
|
open={resolvingDeadline !== null}
|
|
deadline={resolvingDeadline}
|
|
onOpenChange={(open) => {
|
|
if (!open) setResolvingDeadline(null);
|
|
}}
|
|
onResolve={handleResolveDeadline}
|
|
/>
|
|
|
|
{/* Attachments */}
|
|
<div>
|
|
<div className="flex items-center justify-between">
|
|
<Label className="flex items-center gap-1.5">
|
|
Atașamente
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" className="max-w-xs">
|
|
<p className="text-xs">
|
|
Atașează fișiere sau introdu căi de rețea NAS
|
|
(\\newamun\...). Preferă NAS pentru fișiere mari (>5MB).
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
{isUploading && (
|
|
<span className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400 font-normal">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
Se încarcă {uploadingCount} fișier
|
|
{uploadingCount > 1 ? "e" : ""}…
|
|
</span>
|
|
)}
|
|
</Label>
|
|
<div className="flex gap-1.5">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowNetworkInput((v) => !v)}
|
|
disabled={isSubmitting}
|
|
title="Link către fișier pe NAS"
|
|
>
|
|
<HardDrive className="mr-1 h-3.5 w-3.5" /> Link NAS
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={isSubmitting}
|
|
>
|
|
<Paperclip className="mr-1 h-3.5 w-3.5" /> Adaugă fișier
|
|
</Button>
|
|
</div>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.xls,.xlsx"
|
|
onChange={handleFileUpload}
|
|
className="hidden"
|
|
/>
|
|
</div>
|
|
|
|
{/* Network path input */}
|
|
{showNetworkInput && (
|
|
<div className="mt-2 rounded-md border border-blue-200 bg-blue-50/50 dark:border-blue-800 dark:bg-blue-950/30 p-3 space-y-2">
|
|
<Label className="text-xs flex items-center gap-1">
|
|
<HardDrive className="h-3 w-3" />
|
|
Cale fișier pe NAS (A:\ O:\ P:\ T:\ sau \\newamun\\...)
|
|
</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={networkPathInput}
|
|
onChange={(e) => setNetworkPathInput(e.target.value)}
|
|
placeholder="P:\\095 - 2020 - Duplex\\99_DOC\\CU 1348-2024.pdf"
|
|
className="flex-1 font-mono text-xs"
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
handleAddNetworkPath();
|
|
}
|
|
}}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={handleAddNetworkPath}
|
|
disabled={!networkPathInput.trim()}
|
|
>
|
|
<Link2 className="mr-1 h-3.5 w-3.5" /> Adaugă
|
|
</Button>
|
|
</div>
|
|
{networkPathInput.trim() && isNetworkPath(networkPathInput) && (
|
|
<p className="text-[10px] text-muted-foreground">
|
|
→ {shortDisplayPath(networkPathInput)}
|
|
</p>
|
|
)}
|
|
{networkPathInput.trim() && !isNetworkPath(networkPathInput) && (
|
|
<p className="text-[10px] text-amber-600 dark:text-amber-400">
|
|
Calea nu pare a fi pe NAS. Introdu o cale de tip A:\ O:\ P:\ T:\
|
|
sau \\newamun\\...
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{attachments.length > 0 && (
|
|
<div className="mt-2 space-y-1">
|
|
{attachments.map((att) =>
|
|
att.networkPath ? (
|
|
// Network path attachment — distinct visual
|
|
<div
|
|
key={att.id}
|
|
className="flex items-center gap-2 rounded border border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-950/20 px-2 py-1.5 text-sm group"
|
|
>
|
|
<HardDrive className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
|
|
<button
|
|
type="button"
|
|
className="flex-1 min-w-0 flex items-center gap-1 text-blue-700 dark:text-blue-300 hover:underline cursor-pointer text-left"
|
|
title="Click copiază calea — lipește în Explorer"
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(att.networkPath!);
|
|
}}
|
|
>
|
|
<FolderOpen className="h-3 w-3 shrink-0" />
|
|
<span className="truncate font-mono text-xs">
|
|
{shortDisplayPath(att.networkPath)}
|
|
</span>
|
|
</button>
|
|
<Badge
|
|
variant="outline"
|
|
className="text-[10px] border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400 shrink-0"
|
|
>
|
|
{shareLabelFor(att.networkPath) ?? "NAS"}
|
|
</Badge>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeAttachment(att.id)}
|
|
className="text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
// Normal uploaded file attachment
|
|
<div
|
|
key={att.id}
|
|
className="flex items-center gap-2 rounded border px-2 py-1 text-sm"
|
|
>
|
|
<Paperclip className="h-3 w-3 text-muted-foreground" />
|
|
<span className="flex-1 truncate">{att.name}</span>
|
|
<Badge variant="outline" className="text-[10px]">
|
|
{(att.size / 1024).toFixed(0)} KB
|
|
</Badge>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeAttachment(att.id)}
|
|
className="text-destructive"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
),
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Notes */}
|
|
<div>
|
|
<Label>Note</Label>
|
|
<Textarea
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
rows={3}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={onCancel}
|
|
disabled={isSubmitting}
|
|
>
|
|
Anulează
|
|
</Button>
|
|
<Button type="submit" disabled={isSubmitting || isUploading}>
|
|
{isSubmitting ? (
|
|
<>
|
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
|
Se salvează…
|
|
</>
|
|
) : isUploading ? (
|
|
<>
|
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
|
Se încarcă fișiere…
|
|
</>
|
|
) : initial ? (
|
|
"Actualizează"
|
|
) : (
|
|
"Adaugă"
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Quick contact creation dialog */}
|
|
<QuickContactDialog
|
|
open={quickContactOpen}
|
|
onOpenChange={setQuickContactOpen}
|
|
initialName={quickContactName}
|
|
onConfirm={handleQuickContactConfirm}
|
|
/>
|
|
</form>
|
|
);
|
|
}
|