+ + + Registro fusionado. {mergeStatus.requested.code} · {mergeStatus.requested.name} conserva su historia, pero el registro vigente es {mergeStatus.canonical.code} · {mergeStatus.canonical.name}. + +
EXPEDIENTE TÉCNICO{dossier.asset.name}{dossier.asset.code}{dossier.asset.commonName ? ` · Nombre habitual: ${dossier.asset.commonName}` : ''} · Inspecciones, actas, hallazgos, respuestas, evidencias, documentos y cambios reunidos en un solo lugar. + {dossier.merge && dossier.merge.aliases.length > 0 && + + Expediente cronológico unificado: incluye {dossier.merge.aliases.length} registro{dossier.merge.aliases.length === 1 ? '' : 's'} fusionado{dossier.merge.aliases.length === 1 ? '' : 's'}. Cada evento mantiene visible la identidad de Inventario que tenía al momento de ocurrir. + } Inspecciones{dossier.counters.inspections} Actas{dossier.counters.acts} @@ -82,6 +172,20 @@ export function AssetDossierPanel({ assetId }: { assetId: string }) { {pendingVerification > 0 && {pendingVerification} hallazgo{pendingVerification === 1 ? '' : 's'} con verificación programada. Las fechas operativas pueden utilizarse para planificar próximos controles.} + {mergeable && + + CONCILIACIÓN¿Este registro está duplicado?Fusioná únicamente cuando ambas fichas representen la misma {asset?.type.name.toLowerCase()}. La historia anterior se conserva. + setShowMerge((current) => !current)}>{showMerge ? 'Cancelar fusión' : 'Fusionar duplicado'} + + {showMerge && + Buscar registro canónico { setMergeSearch(event.target.value); setCanonicalId(''); }} placeholder="Nombre o código…" /> + Conservar como registro oficial setCanonicalId(event.target.value)}>Seleccionar registro…{candidates.map((candidate) => {candidate.code} · {candidate.name})}Se muestran registros activos del mismo tipo, padre, Área y Operadora. + Motivo de la fusión obligatorio setMergeReason(event.target.value)} placeholder="Ej.: alta de campo duplicada; se confirmó que corresponde a la instalación existente…" /> + La fusión no cambia Actas ni Hallazgos históricos. Los hijos actuales se reubican al canónico con una nueva versión y el registro duplicado queda inactivo, nunca eliminado. + {merging ? 'Fusionando…' : 'Confirmar fusión cronológica'} + } + } + setView('timeline')}>Cronología setView('findings')}>Inspecciones y hallazgos @@ -91,14 +195,18 @@ export function AssetDossierPanel({ assetId }: { assetId: string }) { {view === 'timeline' && CRONOLOGÍALínea de tiempoCada evento conserva su origen y enlaza con el registro que lo generó cuando corresponde.{dossier.timeline.length} {dossier.timeline.length === 0 ? : - {dossier.timeline.map((event) => - - - {timelineLabel(event)}{formatDate(event.occurredAt)} - {event.href ? {event.title} : {event.title}} - {event.description && {event.description}} - - )} + {dossier.timeline.map((event) => { + const historicalInventory = (event.meta?.historicalInventory ?? null) as { id?: string; code?: string; name?: string; isCanonical?: boolean } | null; + return + + + {timelineLabel(event)}{formatDate(event.occurredAt)} + {event.href ? {event.title} : {event.title}} + {event.description && {event.description}} + {historicalInventory && !historicalInventory.isCanonical && Registrado originalmente en {historicalInventory.code} · {historicalInventory.name}} + + ; + })} } } diff --git a/web-v2/src/features/inspections/FindingCatalogProposalsPanel.tsx b/web-v2/src/features/inspections/FindingCatalogProposalsPanel.tsx index b203dc5..b9e86d6 100644 --- a/web-v2/src/features/inspections/FindingCatalogProposalsPanel.tsx +++ b/web-v2/src/features/inspections/FindingCatalogProposalsPanel.tsx @@ -1,10 +1,11 @@ import { SearchableSelect } from '../../components/SearchableSelect'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router'; import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback'; import { Icon } from '../../components/Icon'; import { listFindingCatalogProposals, reviewFindingCatalogProposal } from '../../lib/api'; import type { FindingAdminCatalog, FindingCatalogProposal } from '../../lib/api'; +import { mergeFindingCatalogItem } from '../../lib/findingCatalogMerge'; export function FindingCatalogProposalsPanel({ catalog }: { catalog: FindingAdminCatalog }) { const [items, setItems] = useState([]); @@ -14,6 +15,20 @@ export function FindingCatalogProposalsPanel({ catalog }: { catalog: FindingAdmi const [error, setError] = useState(''); const [success, setSuccess] = useState(''); + const [mergeSourceId, setMergeSourceId] = useState(''); + const [mergeCanonicalId, setMergeCanonicalId] = useState(''); + const [mergeReason, setMergeReason] = useState(''); + const [mergeBusy, setMergeBusy] = useState(false); + const [mergeError, setMergeError] = useState(''); + const [mergeSuccess, setMergeSuccess] = useState(''); + + const activeCatalogItems = useMemo( + () => catalog.items.filter((item) => item.isActive && item.categoryActive), + [catalog.items], + ); + const mergeSource = catalog.items.find((item) => item.id === mergeSourceId) ?? null; + const mergeCanonical = catalog.items.find((item) => item.id === mergeCanonicalId) ?? null; + const load = () => listFindingCatalogProposals({ status: 'PENDING' }).then(setItems); useEffect(() => { load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); }, []); @@ -38,11 +53,86 @@ export function FindingCatalogProposalsPanel({ catalog }: { catalog: FindingAdmi } }; - if (loading) return ; + const mergeCatalog = async () => { + setMergeError(''); setMergeSuccess(''); + if (!mergeSourceId || !mergeCanonicalId) { + setMergeError('Seleccioná el registro duplicado y el registro canónico.'); + return; + } + if (mergeSourceId === mergeCanonicalId) { + setMergeError('El duplicado y el canónico deben ser registros distintos.'); + return; + } + if (mergeReason.trim().length < 8) { + setMergeError('Indicá un motivo de al menos 8 caracteres para que la conciliación quede auditada.'); + return; + } + const sourceLabel = mergeSource ? `${mergeSource.title} (${mergeSource.code})` : 'el duplicado'; + const canonicalLabel = mergeCanonical ? `${mergeCanonical.title} (${mergeCanonical.code})` : 'el canónico'; + if (!window.confirm(`¿Fusionar ${sourceLabel} dentro de ${canonicalLabel}?\n\nLos Hallazgos históricos conservarán su referencia original. El duplicado dejará de ofrecerse para inspecciones futuras.`)) return; - return - PROPUESTAS DESDE CAMPOHallazgos cargados como OTROSEl hallazgo original queda intacto. Esta bandeja sirve para decidir si conviene sumar esa opción al catálogo para inspecciones futuras.{items.length} pendientes - {error && {error}}{success && {success}} - {items.length === 0 ? : {items.map((proposal) => {proposal.assetTypeName} · {proposal.assetCode}{proposal.proposedTitle}{proposal.description}{proposal.findingCode} · {proposal.assetName}{proposal.proposedSeverity ? ` · gravedad ${proposal.proposedSeverity}/10` : ''}Coincide con setMatches((current) => ({ ...current, [proposal.id]: event.target.value }))}>Seleccionar tipo existente…{catalog.items.filter((item) => item.isActive && item.categoryActive).map((item) => {item.title} · {item.code})} review(proposal, 'REJECT')}>Descartar review(proposal, 'MATCH')}>VincularSi todavía no existe una opción adecuada, creala en el catálogo y luego vinculá esta propuesta.)}} - ; + setMergeBusy(true); + try { + await mergeFindingCatalogItem(mergeSourceId, { + canonicalItemId: mergeCanonicalId, + reason: mergeReason.trim(), + }); + setMergeSuccess('Fusión registrada. El historial emitido quedó intacto y la aplicabilidad futura pasó al registro canónico.'); + setMergeSourceId(''); + setMergeCanonicalId(''); + setMergeReason(''); + window.setTimeout(() => window.location.reload(), 900); + } catch (requestError) { + setMergeError(errorMessage(requestError)); + } finally { + setMergeBusy(false); + } + }; + + return <> + + PROPUESTAS DESDE CAMPOHallazgos cargados como OTROSEl hallazgo original queda intacto. Esta bandeja sirve para decidir si conviene sumar esa opción al catálogo para inspecciones futuras.{items.length} pendientes + {error && {error}}{success && {success}} + {loading ? : items.length === 0 ? : {items.map((proposal) => {proposal.assetTypeName} · {proposal.assetCode}{proposal.proposedTitle}{proposal.description}{proposal.findingCode} · {proposal.assetName}{proposal.proposedSeverity ? ` · gravedad ${proposal.proposedSeverity}/10` : ''}Coincide con setMatches((current) => ({ ...current, [proposal.id]: event.target.value }))}>Seleccionar tipo existente…{activeCatalogItems.map((item) => {item.title} · {item.code})} review(proposal, 'REJECT')}>Descartar review(proposal, 'MATCH')}>VincularSi todavía no existe una opción adecuada, creala en el catálogo y luego vinculá esta propuesta.)}} + + + + + + CONCILIACIÓN DE CATÁLOGO + Fusionar Hallazgos duplicados + Usá esta operación cuando dos opciones del catálogo representan el mismo control. El duplicado se desactiva para el futuro, pero los Hallazgos y Actas ya emitidos conservan exactamente la referencia con la que nacieron. + + append-only + + {mergeError && {mergeError}}{mergeSuccess && {mergeSuccess}} + + + Duplicado a retirar + { setMergeSourceId(event.target.value); if (event.target.value === mergeCanonicalId) setMergeCanonicalId(''); }}> + Seleccionar duplicado… + {activeCatalogItems.map((item) => {item.categoryName} · {item.title} · {item.code})} + + {mergeSource && Uso histórico: {mergeSource.usageCount} hallazgo{mergeSource.usageCount === 1 ? '' : 's'} · revisión {mergeSource.revision}} + + + Registro canónico que continuará vigente + setMergeCanonicalId(event.target.value)} disabled={!mergeSourceId}> + Seleccionar canónico… + {activeCatalogItems.filter((item) => item.id !== mergeSourceId).map((item) => {item.categoryName} · {item.title} · {item.code})} + + {mergeCanonical && Uso histórico: {mergeCanonical.usageCount} hallazgo{mergeCanonical.usageCount === 1 ? '' : 's'} · revisión {mergeCanonical.revision}} + + + + Motivo de la fusión + setMergeReason(event.target.value)} placeholder="Ej.: duplicado detectado al normalizar el catálogo del Apéndice 26…" /> + Obligatorio. Queda guardado en el evento de conciliación y no puede modificarse después. + + Política histórica. Esta acción no reescribe inspection_findings ni propuestas ya resueltas. Sólo consolida la aplicabilidad para inspecciones futuras. + + {mergeBusy ? 'Fusionando…' : 'Fusionar y conservar historial'} + + + >; } diff --git a/web-v2/src/layout/AppLayout.tsx b/web-v2/src/layout/AppLayout.tsx index 72bdae1..dd5c1c7 100644 --- a/web-v2/src/layout/AppLayout.tsx +++ b/web-v2/src/layout/AppLayout.tsx @@ -33,7 +33,6 @@ const master: NavItem[] = [ { to: '/inventarios', label: 'Inventarios', icon: 'layers', permission: 'assets.read' }, { to: '/mapa', label: 'Mapa', icon: 'map', permission: 'assets.read' }, { to: '/importaciones', label: 'Importaciones', icon: 'upload', permission: 'asset_imports.read' }, - { to: '/relevamiento', label: 'Relevamientos', icon: 'clipboard', permission: 'surveys.read' }, ]; const administration: NavItem[] = [ diff --git a/web-v2/src/lib/findingCatalogMerge.ts b/web-v2/src/lib/findingCatalogMerge.ts new file mode 100644 index 0000000..28997b1 --- /dev/null +++ b/web-v2/src/lib/findingCatalogMerge.ts @@ -0,0 +1,62 @@ +import { apiRequest } from './api'; + +export interface FindingCatalogMergeItem { + id: string; + categoryId: string; + categoryCode: string; + categoryName: string; + code: string; + sourceNumber: number; + title: string; + legalBasis: string | null; + glossary: string | null; + importNote: string | null; + suggestedSeverity: number | null; + revision: number; + isActive: boolean; +} + +export interface FindingCatalogMergeAlias { + id: string; + code: string; + title: string; + reason: string; + mergedAt: string; + depth: number; +} + +export interface FindingCatalogMergeStatus { + requested: FindingCatalogMergeItem; + isMerged: boolean; + canonical: FindingCatalogMergeItem; + aliases: FindingCatalogMergeAlias[]; +} + +export interface FindingCatalogMergeResult { + merge: { + id: string; + sourceItemId: string; + canonicalItemId: string; + reason: string; + mergedAt: string; + mergedBy: string; + requestId: string; + }; + source: FindingCatalogMergeItem; + canonical: FindingCatalogMergeItem; + historyPolicy: 'EMITTED_FINDINGS_PRESERVED'; +} + +export function getFindingCatalogMergeStatus(itemId: string) { + return apiRequest(`/finding-catalog/items/${itemId}/merge-status`); +} + +export function mergeFindingCatalogItem( + sourceItemId: string, + input: { canonicalItemId: string; reason: string }, +) { + return apiRequest(`/finding-catalog/items/${sourceItemId}/merge`, { + method: 'POST', + body: JSON.stringify(input), + }); +} diff --git a/web-v2/src/lib/inventoryMergeApi.ts b/web-v2/src/lib/inventoryMergeApi.ts new file mode 100644 index 0000000..cbab709 --- /dev/null +++ b/web-v2/src/lib/inventoryMergeApi.ts @@ -0,0 +1,86 @@ +import { apiRequest, listAssets } from './api'; +import type { AssetDetail, AssetListItem } from './api'; + +export interface InventoryMergeAlias { + id: string; + code: string; + name: string; + reason: string; + mergedAt: string; + depth: number; +} + +export interface InventoryMergeStatus { + requested: { + id: string; + code: string; + name: string; + typeCode: string; + }; + isMerged: boolean; + canonical: { + id: string; + code: string; + name: string; + typeCode: string; + }; + chain: Array<{ + id: string; + sourceAssetId: string; + canonicalAssetId: string; + reason: string; + mergedAt: string; + mergedBy: string | null; + source: string; + }>; + aliases: InventoryMergeAlias[]; +} + +export interface InventoryMergeResult { + merge: { + id: string; + sourceAssetId: string; + canonicalAssetId: string; + reason: string; + mergedAt: string; + }; + source: { id: string; code: string; name: string }; + canonical: { id: string; code: string; name: string }; + sourceVersionNumber: number; + reparentedChildIds: string[]; + historyPolicy: 'HISTORICAL_REFERENCES_PRESERVED'; +} + +export function getInventoryMergeStatus(assetId: string) { + return apiRequest(`/assets/${assetId}/merge-status`); +} + +export function mergeInventoryAsset( + sourceAssetId: string, + canonicalAssetId: string, + reason: string, +) { + return apiRequest(`/assets/${sourceAssetId}/merge`, { + method: 'POST', + body: JSON.stringify({ canonicalAssetId, reason }), + }); +} + +export async function searchInventoryMergeCandidates( + source: AssetDetail, + search = '', +): Promise { + if (!source.parent?.id || !source.operationalArea?.id || !source.operatorCompany?.id) return []; + const page = await listAssets({ + page: 1, + pageSize: 80, + search: search.trim() || undefined, + typeId: source.type.id, + parentId: source.parent.id, + operationalAreaId: source.operationalArea.id, + operatorCompanyId: source.operatorCompany.id, + }); + return page.data.filter((candidate) => + candidate.id !== source.id && candidate.informationStatus !== 'INACTIVE', + ); +} diff --git a/web-v2/src/lib/inventoryStructureApi.ts b/web-v2/src/lib/inventoryStructureApi.ts new file mode 100644 index 0000000..36ffcd2 --- /dev/null +++ b/web-v2/src/lib/inventoryStructureApi.ts @@ -0,0 +1,121 @@ +import { apiRequest } from './api'; + +export type InventoryStructureKind = 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION'; + +export interface InventoryFamily { + id: string; + code: string; + name: string; + level: 'INSTALLATION' | 'SUBINSTALLATION'; + legacyTypeCode: string | null; + informationLabels: string[]; + parentFamilyId: string | null; + parentFamilyCode: string | null; + parentFamilyName: string | null; +} + +export interface InventoryStructureLevel { + kind: InventoryStructureKind; + label: string; + type: { id: string; code: string; name: string }; + parentKind: InventoryStructureKind | null; + requiresFamily: boolean; +} + +export interface InventoryStructureOptions { + levels: InventoryStructureLevel[]; + installationFamilies: InventoryFamily[]; + subinstallationFamilies: InventoryFamily[]; +} + +export interface InventoryStructureParent { + id: string; + code: string; + name: string; + commonName: string | null; + type: { id: string; code: string; name: string }; + inventoryFamily: null | { + id: string; + code: string; + name: string; + level: 'INSTALLATION' | 'SUBINSTALLATION'; + informationLabels: string[]; + }; + parent: null | { id: string; code: string; name: string }; +} + +export interface InventoryFamilyFinding { + id: string; + code: string; + sourceNumber: number; + title: string; + legalBasis: string | null; + glossary: string | null; + suggestedSeverity: number | null; + categoryId: string; + categoryCode: string; + categoryName: string; +} + +export interface InventoryFamilyFindings { + family: { + id: string; + code: string; + name: string; + level: string; + informationLabels: string[]; + }; + items: InventoryFamilyFinding[]; + count: number; +} + +export interface CreatedInventoryStructure { + id: string; + code: string; + name: string; + commonName: string | null; + description: string | null; + informationStatus: string; + operationalStatus: string; + type: { id: string; code: string; name: string }; + parent: null | { id: string; code: string; name: string }; + inventoryFamily: null | { + id: string; + code: string; + name: string; + level: string; + informationLabels: string[]; + }; +} + +export function getInventoryStructureOptions() { + return apiRequest('/inventory-structure'); +} + +export async function listInventoryStructureParents(kind: InventoryStructureKind, search = '') { + const query = new URLSearchParams(); + if (search.trim()) query.set('search', search.trim()); + const suffix = query.size ? `?${query}` : ''; + return (await apiRequest<{ data: InventoryStructureParent[] }>( + `/inventory-structure/parents/${kind}${suffix}`, + )).data; +} + +export function getInventoryFamilyFindings(familyId: string) { + return apiRequest(`/inventory-families/${familyId}/findings`); +} + +export function createInventoryStructure(input: { + kind: InventoryStructureKind; + code?: string | null; + name: string; + commonName?: string | null; + parentId?: string | null; + familyId?: string | null; + description?: string | null; +}) { + return apiRequest('/inventory-structure', { + method: 'POST', + body: JSON.stringify(input), + }); +} diff --git a/web-v2/src/lib/userProfileApi.ts b/web-v2/src/lib/userProfileApi.ts new file mode 100644 index 0000000..6b6b365 --- /dev/null +++ b/web-v2/src/lib/userProfileApi.ts @@ -0,0 +1,47 @@ +import { apiRequest } from './api'; +import type { AdministrativeUser } from './api'; + +export interface AdministrativeUserProfile extends AdministrativeUser { + dni: string | null; + phone: string | null; + jobTitle: string | null; + employeeNumber: string | null; +} + +export interface UserProfileInput { + username?: string; + email?: string | null; + dni?: string | null; + phone?: string | null; + jobTitle?: string | null; + employeeNumber?: string | null; + firstName?: string; + lastName?: string; +} + +export interface CreateUserProfileInput extends UserProfileInput { + username: string; + firstName: string; + lastName: string; + password: string; + mustChangePassword: boolean; + roleIds: string[]; +} + +export function getUserProfile(id: string) { + return apiRequest(`/users/${id}`); +} + +export function createUserProfile(input: CreateUserProfileInput) { + return apiRequest('/users', { + method: 'POST', + body: JSON.stringify(input), + }); +} + +export function updateUserProfile(id: string, input: UserProfileInput) { + return apiRequest(`/users/${id}`, { + method: 'PATCH', + body: JSON.stringify(input), + }); +} diff --git a/web-v2/src/pages/DocumentDeliveryPage.tsx b/web-v2/src/pages/DocumentDeliveryPage.tsx index 7eb2683..baeaa3c 100644 --- a/web-v2/src/pages/DocumentDeliveryPage.tsx +++ b/web-v2/src/pages/DocumentDeliveryPage.tsx @@ -1,16 +1,144 @@ import { FormEvent, useEffect, useState } from 'react'; +import { useAuth } from '../auth/AuthContext'; import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; import { Icon } from '../components/Icon'; -import { getDocumentDeliverySettings, listDocumentDeliveries, retryDocumentDelivery, retryPendingDocumentDeliveries, updateDocumentDeliverySettings } from '../lib/api'; +import { + getDocumentDeliverySettings, + listDocumentDeliveries, + retryDocumentDelivery, + retryPendingDocumentDeliveries, + updateDocumentDeliverySettings, +} from '../lib/api'; import type { DocumentDeliveryItem, DocumentDeliverySettings } from '../lib/api'; -import { useAuth } from '../auth/AuthContext'; -const statusLabel:Record={PENDING:'Pendiente',WAITING_RECIPIENT:'Falta destinatario',WAITING_TRANSPORT:'SMTP sin configurar',WAITING_ARTIFACT:'Documento pendiente',SENT:'Enviado',FAILED:'Error'}; -export function DocumentDeliveryPage(){ const {hasPermission}=useAuth(); const canManage=hasPermission('document_delivery.manage'); const [settings,setSettings]=useState(null); const [items,setItems]=useState([]); const [officeEmail,setOfficeEmail]=useState(''); const [directorEmail,setDirectorEmail]=useState(''); const [loading,setLoading]=useState(true); const [saving,setSaving]=useState(false); const [error,setError]=useState(''); const [success,setSuccess]=useState(''); - const load=async()=>{const [s,o]=await Promise.all([getDocumentDeliverySettings(),listDocumentDeliveries()]);setSettings(s);setOfficeEmail(s.officeEmail??'');setDirectorEmail(s.directorEmail??'');setItems(o.data);}; useEffect(()=>{load().catch(e=>setError(errorMessage(e))).finally(()=>setLoading(false));},[]); - const save=async(e:FormEvent)=>{e.preventDefault();setSaving(true);setError('');setSuccess('');try{const s=await updateDocumentDeliverySettings({officeEmail:officeEmail||null,directorEmail:directorEmail||null});setSettings(s);setSuccess('Destinatarios institucionales actualizados');}catch(err){setError(errorMessage(err));}finally{setSaving(false);}}; - const retryAll=async()=>{setSaving(true);setError('');setSuccess('');try{const r=await retryPendingDocumentDeliveries();await load();setSuccess(`Se procesaron ${r.processed} entrega/s pendientes.`);}catch(err){setError(errorMessage(err));}finally{setSaving(false);}}; - if(loading)return ; return DOCUMENTOSEntrega documentalConfigurá los destinatarios institucionales y controlá el envío automático de Actas e Informes.{canManage&&Reintentar pendientes}{error&&{error}}{success&&{success}} - Destinatarios institucionalesEl email de cada empresa se configura dentro de su ficha de Inventario. Las credenciales SMTP permanecen fuera de la base de datos.Email de oficinasetOfficeEmail(e.target.value)} disabled={!canManage}/>Email del Director de HidrocarburossetDirectorEmail(e.target.value)} disabled={!canManage}/>{settings?.smtpConfigured?'Servidor de correo configurado':'Servidor de correo pendiente'}{settings?.mailFrom?` · Remitente ${settings.mailFrom}`:' · Falta configurar SMTP_HOST y MAIL_FROM en el servidor.'}{canManage&&{saving?'Guardando…':'Guardar destinatarios'}} - TRAZABILIDADÚltimas entregas{items.length}{items.length===0?Todavía no existen entregas documentales.:DocumentoDestinoEstadoIntentos{items.map(item=>{item.documentKind==='ACT_PDF'?item.actCode:item.reportCode}{item.documentKind==='ACT_PDF'?'Acta PDF':'Informe Word'}{item.recipientAssetName??(item.recipientKind==='COMPANY'?'Empresa sin identificar':item.recipientKind==='OFFICE'?'Oficina':'Director')}{item.recipientEmail??'Sin email configurado'}{statusLabel[item.status]}{item.lastError&&{item.lastError}}{item.attempts}{canManage&&item.status!=='SENT'&&{setSaving(true);try{await retryDocumentDelivery(item.id);await load();}catch(err){setError(errorMessage(err));}finally{setSaving(false);}}}>Reintentar})}} - ; } +const statusLabel: Record = { + PENDING: 'Pendiente', + WAITING_RECIPIENT: 'Falta destinatario', + WAITING_TRANSPORT: 'SMTP sin configurar', + WAITING_ARTIFACT: 'Documento pendiente', + SENT: 'Enviado', + FAILED: 'Error', +}; + +type DeliveryWithInspector = DocumentDeliveryItem & { + recipientUserId?: string | null; + recipientUserName?: string | null; +}; + +function recipientLabel(item: DeliveryWithInspector): string { + if (item.recipientAssetName) return item.recipientAssetName; + if (item.recipientUserName) return item.recipientUserName; + const kind = String(item.recipientKind); + if (kind === 'COMPANY') return 'Empresa sin identificar'; + if (kind === 'OFFICE') return 'Oficina'; + if (kind === 'INSPECTOR') return 'Inspector responsable'; + return 'Director'; +} + +export function DocumentDeliveryPage() { + const { hasPermission } = useAuth(); + const canManage = hasPermission('document_delivery.manage'); + const [settings, setSettings] = useState(null); + const [items, setItems] = useState([]); + const [officeEmail, setOfficeEmail] = useState(''); + const [directorEmail, setDirectorEmail] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const load = async () => { + const [currentSettings, outbox] = await Promise.all([ + getDocumentDeliverySettings(), + listDocumentDeliveries(), + ]); + setSettings(currentSettings); + setOfficeEmail(currentSettings.officeEmail ?? ''); + setDirectorEmail(currentSettings.directorEmail ?? ''); + setItems(outbox.data); + }; + + useEffect(() => { + load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); + }, []); + + const save = async (event: FormEvent) => { + event.preventDefault(); + setSaving(true); + setError(''); + setSuccess(''); + try { + const currentSettings = await updateDocumentDeliverySettings({ + officeEmail: officeEmail || null, + directorEmail: directorEmail || null, + }); + setSettings(currentSettings); + setSuccess('Destinatarios institucionales actualizados'); + } catch (requestError) { + setError(errorMessage(requestError)); + } finally { + setSaving(false); + } + }; + + const retryAll = async () => { + setSaving(true); + setError(''); + setSuccess(''); + try { + const result = await retryPendingDocumentDeliveries(); + await load(); + setSuccess(`Se procesaron ${result.processed} entrega/s pendientes.`); + } catch (requestError) { + setError(errorMessage(requestError)); + } finally { + setSaving(false); + } + }; + + if (loading) return ; + + return + + + DOCUMENTOS + Entrega documental + Configurá los destinatarios institucionales y controlá el envío automático de Actas e Informes. + + {canManage && Reintentar pendientes} + + {error && {error}} + {success && {success}} + + + + + Destinatarios institucionales + El email de cada empresa se configura dentro de su ficha de Inventario. El Inspector responsable usa automáticamente el email declarado en su Usuario. Las credenciales SMTP permanecen fuera de la base de datos. + + + Email de oficina setOfficeEmail(event.target.value)} disabled={!canManage} /> + Email del Director de Hidrocarburos setDirectorEmail(event.target.value)} disabled={!canManage} /> + + {settings?.smtpConfigured ? 'Servidor de correo configurado' : 'Servidor de correo pendiente'}{settings?.mailFrom ? ` · Remitente ${settings.mailFrom}` : ' · Falta configurar SMTP_HOST y MAIL_FROM en el servidor.'} + {canManage && {saving ? 'Guardando…' : 'Guardar destinatarios'}} + + + + + TRAZABILIDADÚltimas entregas{items.length} + {items.length === 0 + ? Todavía no existen entregas documentales. + : DocumentoDestinoEstadoIntentos{items.map((baseItem) => { + const item = baseItem as DeliveryWithInspector; + return + {item.documentKind === 'ACT_PDF' ? item.actCode : item.reportCode}{item.documentKind === 'ACT_PDF' ? 'Acta PDF' : 'Informe Word'} + {recipientLabel(item)}{item.recipientEmail ?? 'Sin email configurado'} + {statusLabel[item.status]}{item.lastError && {item.lastError}} + {item.attempts} + {canManage && item.status !== 'SENT' && { setSaving(true); try { await retryDocumentDelivery(item.id); await load(); } catch (requestError) { setError(errorMessage(requestError)); } finally { setSaving(false); } }}>Reintentar} + ; + })}} + + ; +} diff --git a/web-v2/src/pages/InventoryCreatePage.tsx b/web-v2/src/pages/InventoryCreatePage.tsx new file mode 100644 index 0000000..bf72910 --- /dev/null +++ b/web-v2/src/pages/InventoryCreatePage.tsx @@ -0,0 +1,269 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { FormEvent } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router'; +import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; +import { Icon } from '../components/Icon'; +import { getAsset } from '../lib/api'; +import { + createInventoryStructure, + getInventoryFamilyFindings, + getInventoryStructureOptions, + listInventoryStructureParents, +} from '../lib/inventoryStructureApi'; +import type { + InventoryFamily, + InventoryFamilyFindings, + InventoryStructureKind, + InventoryStructureOptions, + InventoryStructureParent, +} from '../lib/inventoryStructureApi'; + +const KINDS: Array<{ kind: InventoryStructureKind; label: string; help: string; step: number }> = [ + { kind: 'AREA', label: 'Área', help: 'Nivel territorial raíz.', step: 1 }, + { kind: 'YACIMIENTO', label: 'Yacimiento', help: 'Debe pertenecer a un Área.', step: 2 }, + { kind: 'INSTALACION', label: 'Instalación', help: 'Debe pertenecer a un Yacimiento.', step: 3 }, + { kind: 'SUBINSTALACION', label: 'Subinstalación', help: 'Debe pertenecer a una Instalación.', step: 4 }, +]; + +const childKindByParentType: Record = { + area: 'YACIMIENTO', + yacimiento: 'INSTALACION', + instalacion: 'SUBINSTALACION', +}; + +function kindLabel(kind: InventoryStructureKind): string { + return KINDS.find((item) => item.kind === kind)?.label ?? kind; +} + +export function InventoryCreatePage() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const contextParentId = searchParams.get('parentId'); + const [options, setOptions] = useState(null); + const [kind, setKind] = useState('AREA'); + const [parents, setParents] = useState([]); + const [parentSearch, setParentSearch] = useState(''); + const [parentId, setParentId] = useState(''); + const [familyId, setFamilyId] = useState(''); + const [familyFindings, setFamilyFindings] = useState(null); + const [code, setCode] = useState(''); + const [name, setName] = useState(''); + const [commonName, setCommonName] = useState(''); + const [description, setDescription] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + getInventoryStructureOptions() + .then(async (loaded) => { + setOptions(loaded); + if (contextParentId) { + const parent = await getAsset(contextParentId); + const inferred = childKindByParentType[parent.type.code.toLowerCase()]; + if (inferred) { + setKind(inferred); + setParentId(parent.id); + } + } + }) + .catch((requestError) => setError(errorMessage(requestError))) + .finally(() => setLoading(false)); + }, [contextParentId]); + + useEffect(() => { + if (kind === 'AREA') { + setParents([]); + setParentId(''); + return; + } + const timer = window.setTimeout(() => { + listInventoryStructureParents(kind, parentSearch) + .then((loaded) => { + setParents(loaded); + if (contextParentId && loaded.some((item) => item.id === contextParentId)) { + setParentId(contextParentId); + } + }) + .catch((requestError) => setError(errorMessage(requestError))); + }, 180); + return () => window.clearTimeout(timer); + }, [kind, parentSearch, contextParentId]); + + const selectedParent = parents.find((item) => item.id === parentId) ?? null; + const families = useMemo(() => { + if (!options) return [] as InventoryFamily[]; + if (kind === 'INSTALACION') return options.installationFamilies; + if (kind === 'SUBINSTALACION') { + const parentFamilyId = selectedParent?.inventoryFamily?.id; + return parentFamilyId + ? options.subinstallationFamilies.filter((item) => item.parentFamilyId === parentFamilyId) + : []; + } + return []; + }, [options, kind, selectedParent]); + const selectedFamily = families.find((item) => item.id === familyId) ?? null; + + useEffect(() => { + if (!familyId) { + setFamilyFindings(null); + return; + } + getInventoryFamilyFindings(familyId) + .then(setFamilyFindings) + .catch((requestError) => setError(errorMessage(requestError))); + }, [familyId]); + + useEffect(() => { + if (kind !== 'INSTALACION' && kind !== 'SUBINSTALACION') setFamilyId(''); + if (kind === 'SUBINSTALACION' && familyId && !families.some((item) => item.id === familyId)) setFamilyId(''); + }, [kind, familyId, families]); + + const chooseKind = (next: InventoryStructureKind) => { + setKind(next); + setParentId(''); + setParentSearch(''); + setFamilyId(''); + setFamilyFindings(null); + setError(''); + }; + + const save = async (event: FormEvent) => { + event.preventDefault(); + const requiresParent = kind !== 'AREA'; + const requiresFamily = kind === 'INSTALACION' || kind === 'SUBINSTALACION'; + if (requiresParent && !parentId) { + setError(`Seleccioná el ${kind === 'YACIMIENTO' ? 'Área' : kind === 'INSTALACION' ? 'Yacimiento' : 'Instalación'} padre.`); + return; + } + if (requiresFamily && !familyId) { + setError(`Seleccioná la familia de ${kindLabel(kind).toLowerCase()}.`); + return; + } + setSaving(true); + setError(''); + try { + const created = await createInventoryStructure({ + kind, + code: code.trim() || null, + name: name.trim(), + commonName: commonName.trim() || null, + parentId: parentId || null, + familyId: familyId || null, + description: description.trim() || null, + }); + navigate(`/inventarios/${created.id}`, { replace: true }); + } catch (requestError) { + setError(errorMessage(requestError)); + } finally { + setSaving(false); + } + }; + + if (loading) return ; + + const parentLabel = kind === 'YACIMIENTO' ? 'Área' : kind === 'INSTALACION' ? 'Yacimiento' : 'Instalación'; + const currentStep = KINDS.find((item) => item.kind === kind)?.step ?? 1; + + return + + Inventarios›Nuevo registro + + + + + INVENTARIO + Agregar a la estructura + La estructura oficial es Área → Yacimiento → Instalación → Subinstalación. Elegí el nivel y el sistema te guía con los vínculos válidos. + + + + {error && {error}} + + + + 1. ¿Qué querés crear?Sólo se pueden crear los cuatro niveles estructurales definidos para DH. + + {KINDS.map((item) => chooseKind(item.kind)} + style={{ minHeight: 76, display: 'flex', flexDirection: 'column', alignItems: 'flex-start', justifyContent: 'center', gap: 3 }} + > + {item.step}. {item.label} + {item.help} + )} + + + + Ruta: {KINDS.slice(0, currentStep).map((item) => item.label).join(' → ')} + + + + + + {kind !== 'AREA' && + 2. Ubicación en la estructuraPrimero elegí el {parentLabel} al que pertenece este registro. + + Buscar {parentLabel.toLowerCase()} + setParentSearch(event.target.value)} placeholder={`Buscar por nombre o código de ${parentLabel.toLowerCase()}…`} /> + + + {parentLabel} padre obligatorio + { setParentId(event.target.value); setFamilyId(''); }} required> + Seleccionar {parentLabel.toLowerCase()}… + {parents.map((parent) => {parent.name} · {parent.code}{parent.inventoryFamily ? ` · ${parent.inventoryFamily.name}` : ''})} + + No se permiten saltos de nivel ni padres incompatibles. + + } + + {(kind === 'INSTALACION' || kind === 'SUBINSTALACION') && + 3. Familia técnicaLa familia no crea otro nivel. Sirve para aplicar exactamente los Hallazgos del Excel que corresponden. + {kind === 'SUBINSTALACION' && !selectedParent?.inventoryFamily ? La Instalación seleccionada todavía no tiene una familia técnica F3.1. Revisala antes de crear una Subinstalación. : + Familia de {kindLabel(kind).toLowerCase()} obligatorio + setFamilyId(event.target.value)} required> + Seleccionar familia… + {families.map((family) => {family.name})} + + {kind === 'SUBINSTALACION' && selectedParent?.inventoryFamily && Se muestran sólo las subinstalaciones válidas para {selectedParent.inventoryFamily.name}.} + } + + {selectedFamily && + + + Hallazgos asociados automáticamente + {familyFindings ? `${familyFindings.count} controles del Excel para ${selectedFamily.name}` : 'Cargando catálogo asociado…'} + {familyFindings && familyFindings.items.length > 0 && + {familyFindings.items.slice(0, 7).map((item) => {item.title})} + {familyFindings.items.length > 7 && + {familyFindings.items.length - 7} hallazgos más} + } + + } + + {selectedFamily && selectedFamily.informationLabels.length > 0 && + + Información técnica esperada: {selectedFamily.informationLabels.join(' · ')} + } + } + + + {kind === 'AREA' ? '2' : kind === 'YACIMIENTO' ? '3' : '4'}. IdentificaciónUsá el nombre real de campo. El código DH puede generarse automáticamente. + + Nombre obligatorio setName(event.target.value)} required maxLength={200} placeholder={`Nombre de ${kindLabel(kind).toLowerCase()}`} /> + Código DH opcional setCode(event.target.value.toUpperCase())} maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" placeholder="Dejar vacío para generar automáticamente" />Si no lo informás, DH genera un código único. + Nombre habitual / sobrenombre opcional setCommonName(event.target.value)} maxLength={200} placeholder="Nombre usado por los inspectores en campo" /> + + Descripción opcional setDescription(event.target.value)} rows={2} maxLength={4000} /> + + + + Cancelar + + {saving ? 'Creando…' : `Crear ${kindLabel(kind)}`} + + + + ; +} diff --git a/web-v2/src/pages/NewUserPage.tsx b/web-v2/src/pages/NewUserPage.tsx index 1ee775f..69a918a 100644 --- a/web-v2/src/pages/NewUserPage.tsx +++ b/web-v2/src/pages/NewUserPage.tsx @@ -1,10 +1,11 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import type { FormEvent } from 'react'; import { Link, useNavigate } from 'react-router'; import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; import { Icon } from '../components/Icon'; -import { createUser, listRoles } from '../lib/api'; +import { listRoles } from '../lib/api'; import type { AdministrativeRole } from '../lib/api'; +import { createUserProfile } from '../lib/userProfileApi'; export function NewUserPage() { const navigate = useNavigate(); @@ -18,17 +19,30 @@ export function NewUserPage() { listRoles().then(setRoles).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); }, []); + const inspectorSelected = useMemo( + () => roles.some((role) => role.code === 'inspector' && roleIds.includes(role.id)), + [roles, roleIds], + ); const toggleRole = (id: string) => setRoleIds((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id]); const submit = async (event: FormEvent) => { event.preventDefault(); setError(''); - setSubmitting(true); const data = new FormData(event.currentTarget); + const email = String(data.get('email') ?? '').trim(); + if (inspectorSelected && !email) { + setError('El email es obligatorio para usuarios con rol Inspector porque allí recibirán la documentación de sus inspecciones.'); + return; + } + setSubmitting(true); try { - const created = await createUser({ + const created = await createUserProfile({ username: String(data.get('username')), - email: String(data.get('email') ?? '') || undefined, + email: email || null, + dni: String(data.get('dni') ?? '').trim() || null, + phone: String(data.get('phone') ?? '').trim() || null, + jobTitle: String(data.get('jobTitle') ?? '').trim() || null, + employeeNumber: String(data.get('employeeNumber') ?? '').trim() || null, firstName: String(data.get('firstName')), lastName: String(data.get('lastName')), password: String(data.get('password')), @@ -45,10 +59,30 @@ export function NewUserPage() { return Usuarios/Nuevo usuario - NUEVO ACCESOCrear usuarioLa contraseña inicial puede obligarse a cambiar en el primer ingreso. + NUEVO ACCESOCrear usuarioAdemás del acceso, registrá los datos personales que identifican al agente y permiten las comunicaciones oficiales. {error && {error}} {loading ? : - Datos personalesNombreApellidoUsuarioEmail opcional + + Datos personalesNombre y apellido identifican al agente. DNI, contacto y función quedan asociados a su historial operativo. + + Nombre obligatorio + Apellido obligatorio + DNI + Teléfono + Cargo / función + Legajo / matrícula + + + + + Acceso y contactoEl email del Inspector se utiliza también como destinatario de la documentación al cerrar la inspección. + + Usuario obligatorio + Email {inspectorSelected ? obligatorio para Inspector : recomendado} + + {inspectorSelected && Inspector: este email recibirá copia de las Actas/Informe correspondientes al cierre de la inspección.} + + SeguridadContraseña temporalMínimo 12 caracteres.Exigir cambio de contraseñaEl usuario no podrá acceder a otros módulos hasta actualizarla. Roles{roles.map((role) => toggleRole(role.id)} />{role.name}{role.description})} Cancelar{submitting ? 'Creando…' : 'Crear usuario'} diff --git a/web-v2/src/pages/UserDetailPage.tsx b/web-v2/src/pages/UserDetailPage.tsx index e0be2a7..9ad3f38 100644 --- a/web-v2/src/pages/UserDetailPage.tsx +++ b/web-v2/src/pages/UserDetailPage.tsx @@ -1,25 +1,28 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import type { FormEvent } from 'react'; import { Link, useLocation, useParams } from 'react-router'; import { useAuth } from '../auth/AuthContext'; import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; import { Icon } from '../components/Icon'; import { - getUser, listRoles, replaceUserRoles, resetUserPassword, - updateUser, updateUserStatus, } from '../lib/api'; -import type { AdministrativeRole, AdministrativeUser } from '../lib/api'; +import type { AdministrativeRole } from '../lib/api'; +import { + getUserProfile, + updateUserProfile, +} from '../lib/userProfileApi'; +import type { AdministrativeUserProfile } from '../lib/userProfileApi'; import { formatDate, initials } from '../lib/format'; export function UserDetailPage() { const { id = '' } = useParams(); const location = useLocation(); const { user: currentUser, hasPermission } = useAuth(); - const [user, setUser] = useState(null); + const [user, setUser] = useState(null); const [roles, setRoles] = useState([]); const [roleIds, setRoleIds] = useState([]); const [loading, setLoading] = useState(true); @@ -33,7 +36,7 @@ export function UserDetailPage() { const load = async () => { setLoading(true); try { - const [loadedUser, loadedRoles] = await Promise.all([getUser(id), listRoles()]); + const [loadedUser, loadedRoles] = await Promise.all([getUserProfile(id), listRoles()]); setUser(loadedUser); setRoles(loadedRoles); setRoleIds(loadedUser.roles.map((role) => role.id)); @@ -46,27 +49,45 @@ export function UserDetailPage() { useEffect(() => { void load(); }, [id]); + const inspectorRoleId = useMemo(() => roles.find((role) => role.code === 'inspector')?.id ?? null, [roles]); + const inspectorSelected = inspectorRoleId ? roleIds.includes(inspectorRoleId) : false; + const saveProfile = async (event: FormEvent) => { event.preventDefault(); - setError(''); setSuccess(''); setSaving('profile'); + setError(''); setSuccess(''); const data = new FormData(event.currentTarget); + const email = String(data.get('email') ?? '').trim(); + if (inspectorSelected && !email) { + setError('El email es obligatorio para un Inspector porque allí recibe la documentación de sus inspecciones.'); + return; + } + setSaving('profile'); try { - const updated = await updateUser(id, { + const updated = await updateUserProfile(id, { firstName: String(data.get('firstName')), lastName: String(data.get('lastName')), username: String(data.get('username')), - email: String(data.get('email') ?? '') || null, + email: email || null, + dni: String(data.get('dni') ?? '').trim() || null, + phone: String(data.get('phone') ?? '').trim() || null, + jobTitle: String(data.get('jobTitle') ?? '').trim() || null, + employeeNumber: String(data.get('employeeNumber') ?? '').trim() || null, }); - setUser(updated); setSuccess('Datos del usuario actualizados'); + setUser(updated); setSuccess('Datos personales y de contacto actualizados'); } catch (requestError) { setError(errorMessage(requestError)); } finally { setSaving(''); } }; const saveRoles = async () => { + if (inspectorSelected && !user?.email) { + setError('Antes de asignar el rol Inspector, cargá y guardá un email válido.'); + return; + } setError(''); setSuccess(''); setSaving('roles'); try { - const updated = await replaceUserRoles(id, roleIds); - setUser(updated); setSuccess('Roles actualizados correctamente'); + await replaceUserRoles(id, roleIds); + const refreshed = await getUserProfile(id); + setUser(refreshed); setSuccess('Roles actualizados correctamente'); } catch (requestError) { setError(errorMessage(requestError)); } finally { setSaving(''); } }; @@ -78,8 +99,9 @@ export function UserDetailPage() { if (!window.confirm(`¿Confirmás que querés ${verb} a ${user.username}?`)) return; setError(''); setSuccess(''); setSaving('status'); try { - const updated = await updateUserStatus(id, next); - setUser(updated); setSuccess(`Usuario ${next === 'ACTIVE' ? 'activado' : 'desactivado'}`); + await updateUserStatus(id, next); + const refreshed = await getUserProfile(id); + setUser(refreshed); setSuccess(`Usuario ${next === 'ACTIVE' ? 'activado' : 'desactivado'}`); } catch (requestError) { setError(errorMessage(requestError)); } finally { setSaving(''); } }; @@ -91,8 +113,9 @@ export function UserDetailPage() { if (!window.confirm(`¿Restablecer la contraseña de ${user?.username ?? 'este usuario'}? Se cerrarán todas sus sesiones activas.`)) return; setError(''); setSuccess(''); setSaving('password'); try { - const updated = await resetUserPassword(id, resetPasswordValue, forcePasswordChange); - setUser(updated); + await resetUserPassword(id, resetPasswordValue, forcePasswordChange); + const refreshed = await getUserProfile(id); + setUser(refreshed); setResetPasswordValue(''); setResetPasswordConfirm(''); setSuccess('Contraseña restablecida. Las sesiones anteriores fueron revocadas.'); } catch (requestError) { setError(errorMessage(requestError)); } @@ -110,13 +133,23 @@ export function UserDetailPage() { return Usuarios/{user.username} - {initials(user.firstName, user.lastName)}DETALLE DE USUARIO{user.firstName} {user.lastName}@{user.username} · Creado {formatDate(user.createdAt)}{canChangeStatus && {user.status === 'ACTIVE' ? 'Desactivar acceso' : 'Activar acceso'}} + {initials(user.firstName, user.lastName)}DETALLE DE USUARIO{user.firstName} {user.lastName}@{user.username} · {user.jobTitle || 'Función sin informar'} · Creado {formatDate(user.createdAt)}{canChangeStatus && {user.status === 'ACTIVE' ? 'Desactivar acceso' : 'Activar acceso'}} {error && {error}}{success && {success}} - CUENTADatos personales{user.status === 'ACTIVE' ? 'Activo' : 'Inactivo'} - NombreApellidoUsuarioEmail + PERFILDatos personales y contacto{user.status === 'ACTIVE' ? 'Activo' : 'Inactivo'} + + Nombre + Apellido + DNI + Teléfono + Cargo / función + Legajo / matrícula + Usuario + Email {inspectorSelected && obligatorio para Inspector} + + {inspectorSelected && Destinatario del Inspector: al finalizar una inspección, la documentación se enviará también a {user.email || 'este email cuando lo completes'}.} Último acceso{formatDate(user.lastLoginAt)}Último cambio de clave{formatDate(user.passwordChangedAt)}Intentos fallidos{user.failedLoginAttempts}Bloqueado hasta{formatDate(user.lockedUntil)} {user.mustChangePassword && Este usuario debe cambiar su contraseña temporal en el próximo ingreso.} {canUpdate && {saving === 'profile' ? 'Guardando…' : 'Guardar datos'}} @@ -134,7 +167,8 @@ export function UserDetailPage() { AUTORIZACIÓNRoles asignados{roleIds.length} {roles.map((role) => toggleRole(role.id)} disabled={!canAssign} />{role.name}{role.description})} - {canAssign && {saving === 'roles' ? 'Guardando…' : 'Guardar roles'}} + {inspectorSelected && !user.email && Para guardar el rol Inspector, primero cargá y guardá un email válido.} + {canAssign && {saving === 'roles' ? 'Guardando…' : 'Guardar roles'}} ;
+ + CONCILIACIÓN¿Este registro está duplicado?Fusioná únicamente cuando ambas fichas representen la misma {asset?.type.name.toLowerCase()}. La historia anterior se conserva. + setShowMerge((current) => !current)}>{showMerge ? 'Cancelar fusión' : 'Fusionar duplicado'} + + {showMerge && + Buscar registro canónico { setMergeSearch(event.target.value); setCanonicalId(''); }} placeholder="Nombre o código…" /> + Conservar como registro oficial setCanonicalId(event.target.value)}>Seleccionar registro…{candidates.map((candidate) => {candidate.code} · {candidate.name})}Se muestran registros activos del mismo tipo, padre, Área y Operadora. + Motivo de la fusión obligatorio setMergeReason(event.target.value)} placeholder="Ej.: alta de campo duplicada; se confirmó que corresponde a la instalación existente…" /> + La fusión no cambia Actas ni Hallazgos históricos. Los hijos actuales se reubican al canónico con una nueva versión y el registro duplicado queda inactivo, nunca eliminado. + {merging ? 'Fusionando…' : 'Confirmar fusión cronológica'} + } +
CRONOLOGÍALínea de tiempoCada evento conserva su origen y enlaza con el registro que lo generó cuando corresponde.{dossier.timeline.length} {dossier.timeline.length === 0 ? : - {dossier.timeline.map((event) => - - - {timelineLabel(event)}{formatDate(event.occurredAt)} - {event.href ? {event.title} : {event.title}} - {event.description && {event.description}} - - )} + {dossier.timeline.map((event) => { + const historicalInventory = (event.meta?.historicalInventory ?? null) as { id?: string; code?: string; name?: string; isCanonical?: boolean } | null; + return + + + {timelineLabel(event)}{formatDate(event.occurredAt)} + {event.href ? {event.title} : {event.title}} + {event.description && {event.description}} + {historicalInventory && !historicalInventory.isCanonical && Registrado originalmente en {historicalInventory.code} · {historicalInventory.name}} + + ; + })} }
{proposal.assetTypeName} · {proposal.assetCode}{proposal.proposedTitle}{proposal.description}{proposal.findingCode} · {proposal.assetName}{proposal.proposedSeverity ? ` · gravedad ${proposal.proposedSeverity}/10` : ''}Coincide con setMatches((current) => ({ ...current, [proposal.id]: event.target.value }))}>Seleccionar tipo existente…{catalog.items.filter((item) => item.isActive && item.categoryActive).map((item) => {item.title} · {item.code})} review(proposal, 'REJECT')}>Descartar review(proposal, 'MATCH')}>VincularSi todavía no existe una opción adecuada, creala en el catálogo y luego vinculá esta propuesta.
{proposal.assetTypeName} · {proposal.assetCode}{proposal.proposedTitle}{proposal.description}{proposal.findingCode} · {proposal.assetName}{proposal.proposedSeverity ? ` · gravedad ${proposal.proposedSeverity}/10` : ''}Coincide con setMatches((current) => ({ ...current, [proposal.id]: event.target.value }))}>Seleccionar tipo existente…{activeCatalogItems.map((item) => {item.title} · {item.code})} review(proposal, 'REJECT')}>Descartar review(proposal, 'MATCH')}>VincularSi todavía no existe una opción adecuada, creala en el catálogo y luego vinculá esta propuesta.
AUTORIZACIÓNRoles asignados{roleIds.length} {roles.map((role) => toggleRole(role.id)} disabled={!canAssign} />{role.name}{role.description})} - {canAssign && {saving === 'roles' ? 'Guardando…' : 'Guardar roles'}} + {inspectorSelected && !user.email && Para guardar el rol Inspector, primero cargá y guardá un email válido.} + {canAssign && {saving === 'roles' ? 'Guardando…' : 'Guardar roles'}}