feat(f6.8): harden offline field flow and act documents
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m41s
DH V2 CI / API · typecheck, tests, build (push) Successful in 31s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m14s

This commit is contained in:
DH V2
2026-09-15 15:56:50 -03:00
parent 47cd985931
commit 079728aa6d
62 changed files with 2240 additions and 338 deletions
+2 -1
View File
@@ -1,7 +1,7 @@
export type IconName =
| 'home' | 'map' | 'layers' | 'calendar' | 'clipboard' | 'alert'
| 'history' | 'users' | 'shield' | 'audit' | 'logout' | 'menu'
| 'plus' | 'search' | 'edit' | 'chevron' | 'check' | 'key' | 'upload' | 'mail' | 'lock';
| 'plus' | 'search' | 'edit' | 'chevron' | 'check' | 'key' | 'upload' | 'mail' | 'lock' | 'camera';
export function Icon({ name, size = 18 }: { name: IconName; size?: number }) {
const paths: Record<IconName, React.ReactNode> = {
@@ -26,6 +26,7 @@ export function Icon({ name, size = 18 }: { name: IconName; size?: number }) {
upload: <><path d="M12 16V4"/><path d="m7 9 5-5 5 5"/><path d="M5 20h14"/></>,
mail: <><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></>,
lock: <><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></>,
camera: <><path d="M4 7h3l2-3h6l2 3h3a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2Z"/><circle cx="12" cy="13" r="4"/></>,
};
return (
<svg className="icon" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
+2 -2
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.23.0-4';
export const APP_PHASE = 'F6.7 · Cierre y firma por Acta';
export const APP_VERSION = '0.23.0-5';
export const APP_PHASE = 'F6.8 · Offline seguro y Acta documental';
@@ -38,7 +38,7 @@ export function AssetFindingCatalogPanel({ assetId, canManage }: { assetId: stri
.catch((requestError) => {
if (!active) return;
const message = errorMessage(requestError);
if (message.includes('no tiene clasificación técnica')) setUnsupported(true);
if (message.includes('no tiene tipo')) setUnsupported(true);
else setError(message);
})
.finally(() => active && setLoading(false));
@@ -52,10 +52,10 @@ export function AssetFindingCatalogPanel({ assetId, canManage }: { assetId: stri
.some((value) => value.toLocaleLowerCase().includes(needle)));
}, [items, search]);
if (loading) return <div className="panel"><LoadingBlock label="Cargando clasificación y Hallazgos aplicables…" /></div>;
if (loading) return <div className="panel"><LoadingBlock label="Cargando tipo y Hallazgos aplicables…" /></div>;
if (error) return <Alert>{error}</Alert>;
if (unsupported || !technical) return <section className="panel asset-finding-catalog-panel">
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS APLICABLES</span><h2>Sin clasificación técnica</h2><p className="section-copy">Departamento, Área y Yacimiento son niveles estructurales. Los Hallazgos se registran sobre Instalaciones y Subinstalaciones clasificadas.</p></div></div>
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS APLICABLES</span><h2>Sin tipo asignado</h2><p className="section-copy">Departamento, Área y Yacimiento son niveles estructurales. Los Hallazgos se registran sobre Instalaciones y Subinstalaciones con su tipo definido.</p></div></div>
</section>;
return <div className="asset-tab-stack">
@@ -65,14 +65,14 @@ export function AssetFindingCatalogPanel({ assetId, canManage }: { assetId: stri
<div>
<span className="eyebrow">HALLAZGOS APLICABLES</span>
<h2>{technical.family.name}</h2>
<p className="section-copy">Se muestran únicamente los Hallazgos asociados a esta clasificación técnica. Éste es el mismo catálogo que consume la APK para este objeto.</p>
<p className="section-copy">Se muestran únicamente los Hallazgos asociados a este tipo. Éste es el mismo catálogo que consume la APK para este objeto.</p>
</div>
<span className="count-pill">{items.length} asociados</span>
</div>
{canManage && <div className="temporal-notice">
<Icon name="layers" />
<p><strong>¿Falta un Hallazgo?</strong> Administrá la clasificación en Configuración de Inventarios y el cambio se aplicará a todos los objetos de este mismo tipo técnico.</p>
<p><strong>¿Falta un Hallazgo?</strong> Administrá el tipo en Configuración de Inventarios y el cambio se aplicará a todos los objetos de este mismo tipo.</p>
<Link className="button secondary" to="/admin/asset-types">Configurar Inventarios</Link>
</div>}
@@ -82,7 +82,7 @@ export function AssetFindingCatalogPanel({ assetId, canManage }: { assetId: stri
</label>}
{items.length === 0
? <div className="inline-empty">Esta clasificación todavía no tiene Hallazgos asociados. Desde Configuración de Inventarios podés vincular los que correspondan.</div>
? <div className="inline-empty">Este tipo todavía no tiene Hallazgos asociados. Desde Configuración de Inventarios podés vincular los que correspondan.</div>
: visible.length === 0
? <div className="inline-empty">No hay Hallazgos asociados que coincidan con la búsqueda.</div>
: <div className="finding-selection-list">{visible.map((item) => <div className="finding-selection-row" key={item.id}>
@@ -46,13 +46,13 @@ const structuredInventorySections = [
{
code: 'instalacion',
label: 'Instalaciones',
description: 'Elementos técnicos ubicados dentro de un Yacimiento y clasificados por su familia de Inventario.',
description: 'Elementos técnicos ubicados dentro de un Yacimiento y definidos por su tipo de instalación.',
icon: 'layers' as const,
},
{
code: 'subinstalacion',
label: 'Subinstalaciones',
description: 'Último nivel estructural. Dependen de una Instalación y conservan su clasificación técnica.',
description: 'Último nivel estructural. Dependen de una Instalación y conservan su tipo de subinstalación.',
icon: 'layers' as const,
},
] as const;
@@ -198,8 +198,8 @@ export function AssetHierarchyView({ filters, types }: { filters: InventoryQuery
<div><span>1</span><strong>Departamento</strong><small>raíz territorial</small></div><i></i>
<div><span>2</span><strong>Área</strong><small>operadoras vinculadas</small></div><i></i>
<div><span>3</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i></i>
<div><span>4</span><strong>Instalación</strong><small>clasificación técnica</small></div><i></i>
<div><span>5</span><strong>Subinstalación</strong><small>clasificación técnica</small></div>
<div><span>4</span><strong>Instalación</strong><small>tipo de instalación</small></div><i></i>
<div><span>5</span><strong>Subinstalación</strong><small>tipo de subinstalación</small></div>
</div>
<section className="asset-browser-group" style={{ marginTop: 20 }}>
@@ -238,7 +238,7 @@ export function AssetHierarchyView({ filters, types }: { filters: InventoryQuery
<section className="asset-browser-group">
<div className="asset-browser-group-heading"><div><h3>{nextLevelLabel(current?.type.code)}</h3><p>Jerarquía: Departamento Área Yacimiento Instalación Subinstalación.</p></div><span>{children.length}</span></div>
{children.length === 0
? <EmptyState title={current?.type.code.toLowerCase() === 'subinstalacion' ? 'Fin de la jerarquía' : 'No hay registros en este nivel'} text={current?.type.code.toLowerCase() === 'subinstalacion' ? 'Abrí la ficha para ver su clasificación y Hallazgos asociados.' : 'Agregá el primer registro de este nivel o revisá la búsqueda actual.'} />
? <EmptyState title={current?.type.code.toLowerCase() === 'subinstalacion' ? 'Fin de la jerarquía' : 'No hay registros en este nivel'} text={current?.type.code.toLowerCase() === 'subinstalacion' ? 'Abrí la ficha para ver su tipo y Hallazgos asociados.' : 'Agregá el primer registro de este nivel o revisá la búsqueda actual.'} />
: <div className="asset-browser-list">{children.map((item) => <InventoryCard key={item.id} item={item} href={navigationHref(searchParams,item.id)} />)}</div>}
</section>
</div>;
@@ -26,7 +26,7 @@ export function AssetTechnicalDataPanel({assetId,canEdit}:{assetId:string;canEdi
let active=true; setLoading(true); setUnsupported(false); setError('');
getInventoryTechnicalValues(assetId).then((loaded)=>{if(!active)return;setData(loaded);setValues(Object.fromEntries(loaded.definitions.map((definition)=>{
const raw=loaded.values[definition.id]; return [definition.id,definition.dataType==='DATETIME'?localDateTime(raw):raw??''];
})))}).catch((requestError)=>{if(!active)return;const message=errorMessage(requestError);if(message.includes('no tiene clasificación técnica'))setUnsupported(true);else setError(message);}).finally(()=>active&&setLoading(false));
})))}).catch((requestError)=>{if(!active)return;const message=errorMessage(requestError);if(message.includes('no tiene tipo'))setUnsupported(true);else setError(message);}).finally(()=>active&&setLoading(false));
return()=>{active=false};
},[assetId]);
if(loading)return <div className="form-section"><LoadingBlock label="Cargando datos técnicos…" /></div>;
@@ -48,9 +48,9 @@ export function AssetTechnicalDataPanel({assetId,canEdit}:{assetId:string;canEdi
}catch(requestError){setError(errorMessage(requestError));}finally{setSaving(false)}
};
return <div className="form-section">
<div><h2>Información técnica · {data.family.name}</h2><p className="section-copy">Estos campos pertenecen a la clasificación concreta del elemento, no al nivel genérico.</p></div>
<div><h2>Información técnica · {data.family.name}</h2><p className="section-copy">Estos campos pertenecen al tipo concreto del elemento, no al nivel genérico.</p></div>
{error&&<Alert>{error}</Alert>}{success&&<Alert type="success">{success}</Alert>}
{activeDefinitions.length===0?<div className="inline-empty">Esta clasificación todavía no tiene campos técnicos configurados.</div>:canEdit?<div className="dynamic-attributes">{activeDefinitions.map((definition)=>{
{activeDefinitions.length===0?<div className="inline-empty">Este tipo todavía no tiene campos técnicos configurados.</div>:canEdit?<div className="dynamic-attributes">{activeDefinitions.map((definition)=>{
const value=values[definition.id];const label=<span>{definition.name}{definition.unit?` (${definition.unit})`:''}{definition.isRequired?<em>obligatorio</em>:<em>opcional</em>}</span>;
if(definition.dataType==='BOOLEAN')return <label className="check-row attribute-check" key={definition.id}><input type="checkbox" checked={Boolean(value)} onChange={(event)=>setValues((current)=>({...current,[definition.id]:event.target.checked}))}/><span><strong>{definition.name}</strong><small>{definition.code}</small></span></label>;
if(definition.dataType==='SELECT')return <label className="field" key={definition.id}>{label}<SearchableSelect value={String(value??'')} onChange={(event)=>setValues((current)=>({...current,[definition.id]:event.target.value}))} required={definition.isRequired}><option value="">Seleccionar</option>{definition.options?.map((option)=><option key={option} value={option}>{option}</option>)}</SearchableSelect></label>;
@@ -27,7 +27,7 @@ export function FindingCatalogTypeApplicabilityPanel() {
const [catalog, setCatalog] = useState<FindingAdminCatalog>(EMPTY_CATALOG);
const [familyId, setFamilyId] = useState('');
const [enabled, setEnabled] = useState<Set<string>>(new Set());
const [reason, setReason] = useState('Actualización de Hallazgos asociados a la clasificación de Inventario');
const [reason, setReason] = useState('Actualización de Hallazgos asociados al tipo de Inventario');
const [search, setSearch] = useState('');
const [viewMode, setViewMode] = useState<ViewMode>('ASSOCIATED');
const [loading, setLoading] = useState(true);
@@ -77,15 +77,15 @@ export function FindingCatalogTypeApplicabilityPanel() {
};
if (loading) return <div className="panel"><LoadingBlock label="Cargando aplicabilidad…" /></div>;
if (families.length === 0) return <Alert>No hay clasificaciones de Instalación/Subinstalación disponibles. Crealas primero en Configuración de Inventarios.</Alert>;
if (families.length === 0) return <Alert>No hay tipos de Instalación/Subinstalación disponibles. Crealos primero en Configuración de Inventarios.</Alert>;
return <section className="panel finding-applicability-panel">
<div className="panel-heading"><div><span className="eyebrow">APLICABILIDAD POR CLASIFICACIÓN</span><h2>Qué Hallazgos verá el inspector</h2><p className="section-copy">Al elegir una clasificación se muestran sólo sus Hallazgos asociados. Todos para vincular se usa exclusivamente para modificar la relación.</p></div><span className="count-pill">{savedIds.size} vinculados</span></div>
<div className="panel-heading"><div><span className="eyebrow">APLICABILIDAD POR TIPO</span><h2>Qué Hallazgos verá el inspector</h2><p className="section-copy">Al elegir un tipo se muestran sólo sus Hallazgos asociados. Todos para vincular se usa exclusivamente para modificar la relación.</p></div><span className="count-pill">{savedIds.size} vinculados</span></div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<div className="form-grid finding-applicability-toolbar"><label className="field"><span>Clasificación de Inventario</span><SearchableSelect value={familyId} onChange={(event) => setFamilyId(event.target.value)}>{families.map((family) => <option value={family.id} key={family.id}>{familyContext(family)}</option>)}</SearchableSelect></label><label className="field"><span>Buscar dentro de {viewMode === 'ASSOCIATED' ? 'los asociados' : 'todo el Catálogo'}</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Título, código o categoría…" /></label></div>
<div className="form-grid finding-applicability-toolbar"><label className="field"><span>Tipo de instalación / subinstalación</span><SearchableSelect value={familyId} onChange={(event) => setFamilyId(event.target.value)}>{families.map((family) => <option value={family.id} key={family.id}>{familyContext(family)}</option>)}</SearchableSelect></label><label className="field"><span>Buscar dentro de {viewMode === 'ASSOCIATED' ? 'los asociados' : 'todo el Catálogo'}</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Título, código o categoría…" /></label></div>
{selectedFamily && <div className="temporal-notice"><Icon name="layers" /><p><strong>{selectedFamily.name}:</strong> {selectedFamily.level === 'SUBINSTALLATION' && selectedFamily.parentFamilies.length ? `compatible con ${selectedFamily.parentFamilies.map((parent) => parent.name).join(', ')}. ` : ''}Tiene {savedIds.size} Hallazgo{savedIds.size === 1 ? '' : 's'} asociado{savedIds.size === 1 ? '' : 's'}.</p></div>}
<div className="quick-view-row" style={{ marginBottom: 12 }}><button type="button" className={viewMode === 'ASSOCIATED' ? 'active' : ''} onClick={() => { setViewMode('ASSOCIATED'); setSearch(''); }}>Asociados ({savedIds.size})</button><button type="button" className={viewMode === 'ALL' ? 'active' : ''} onClick={() => { setViewMode('ALL'); setSearch(''); }}>Todos para vincular ({catalog.items.filter((item) => item.isActive && activeCategoryIds.has(item.categoryId)).length})</button></div>
{viewMode === 'ASSOCIATED' && visible.length === 0 ? <div className="inline-empty"><strong>{search.trim() ? 'Ningún Hallazgo asociado coincide con la búsqueda.' : 'Esta clasificación todavía no tiene Hallazgos asociados.'}</strong><br />{!search.trim() && 'Abrí “Todos para vincular” para elegirlos.'}</div> : <>{viewMode === 'ALL' && <div className="catalog-selection-actions"><button type="button" className="button secondary" onClick={selectVisible}>Seleccionar visibles</button><button type="button" className="button secondary" onClick={() => setEnabled(new Set())}>Quitar todos</button></div>}<div className="finding-selection-list">{visible.map((item) => viewMode === 'ALL' ? <label className="finding-selection-row" key={item.id}><input type="checkbox" checked={enabled.has(item.id)} onChange={() => toggle(item.id)} /><span><strong>{item.title}</strong><small>{categoryName.get(item.categoryId) ?? 'Catálogo'} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></label> : <div className="finding-selection-row" key={item.id}><span className="asset-symbol"><Icon name="check" size={14} /></span><span><strong>{item.title}</strong><small>{categoryName.get(item.categoryId) ?? 'Catálogo'} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></div>)}</div></>}
{viewMode === 'ASSOCIATED' && visible.length === 0 ? <div className="inline-empty"><strong>{search.trim() ? 'Ningún Hallazgo asociado coincide con la búsqueda.' : 'Este tipo todavía no tiene Hallazgos asociados.'}</strong><br />{!search.trim() && 'Abrí “Todos para vincular” para elegirlos.'}</div> : <>{viewMode === 'ALL' && <div className="catalog-selection-actions"><button type="button" className="button secondary" onClick={selectVisible}>Seleccionar visibles</button><button type="button" className="button secondary" onClick={() => setEnabled(new Set())}>Quitar todos</button></div>}<div className="finding-selection-list">{visible.map((item) => viewMode === 'ALL' ? <label className="finding-selection-row" key={item.id}><input type="checkbox" checked={enabled.has(item.id)} onChange={() => toggle(item.id)} /><span><strong>{item.title}</strong><small>{categoryName.get(item.categoryId) ?? 'Catálogo'} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></label> : <div className="finding-selection-row" key={item.id}><span className="asset-symbol"><Icon name="check" size={14} /></span><span><strong>{item.title}</strong><small>{categoryName.get(item.categoryId) ?? 'Catálogo'} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></div>)}</div></>}
{viewMode === 'ALL' && <><label className="field"><span>Motivo del cambio</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} /></label><div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5 || !selectedFamily} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : `Guardar ${enabled.size} asociados`}</button></div></>}
</section>;
}
@@ -0,0 +1,149 @@
import { useEffect, useState } from 'react';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
getAssetMediaBlob,
getInspectionFindingEvidenceBlob,
listInspectionActFieldMedia,
listInspectionFindingEvidence,
listInspectionFindings,
} from '../../lib/api';
import type { InspectionActFieldMedia, InspectionFindingEvidence } from '../../lib/api';
import { formatDate } from '../../lib/format';
type ActPhoto = {
findingId: string;
findingCode: string;
findingTitle: string;
evidence: InspectionFindingEvidence;
};
type AssetPhoto = InspectionActFieldMedia;
function PhotoThumb({ photo }: { photo: ActPhoto }) {
const [url, setUrl] = useState('');
useEffect(() => {
let active = true;
let objectUrl = '';
getInspectionFindingEvidenceBlob(photo.evidence.id)
.then((blob) => {
if (!active) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
})
.catch(() => undefined);
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [photo.evidence.id]);
return <article className="act-photo-card">
<button
type="button"
className="act-photo-preview"
onClick={() => url && window.open(url, '_blank', 'noopener,noreferrer')}
aria-label={`Ver foto ${photo.findingCode}`}
>
{url
? <img src={url} alt={photo.evidence.title || `${photo.findingCode} · ${photo.findingTitle}`} />
: <span className="media-preview-loading"><span className="spinner" /></span>}
</button>
<div className="act-photo-copy">
<span className="eyebrow">{photo.findingCode}</span>
<strong>{photo.findingTitle}</strong>
<small>{formatDate(photo.evidence.capturedAt || photo.evidence.createdAt)}</small>
{photo.evidence.latitude != null && photo.evidence.longitude != null && <small>
GPS {photo.evidence.latitude.toFixed(6)}, {photo.evidence.longitude.toFixed(6)}
</small>}
</div>
</article>;
}
function AssetPhotoThumb({ photo }: { photo: AssetPhoto }) {
const [url, setUrl] = useState('');
useEffect(() => {
let active = true;
let objectUrl = '';
getAssetMediaBlob(photo.id)
.then((blob) => {
if (!active) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
})
.catch(() => undefined);
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [photo.id]);
return <article className="act-photo-card">
<button type="button" className="act-photo-preview" onClick={() => url && window.open(url, '_blank', 'noopener,noreferrer')} aria-label={`Ver foto ${photo.assetCode}`}>
{url ? <img src={url} alt={photo.title || photo.assetName} /> : <span className="media-preview-loading"><span className="spinner" /></span>}
</button>
<div className="act-photo-copy">
<span className="eyebrow">INVENTARIO · {photo.assetCode}</span>
<strong>{photo.assetName}</strong>
<small>{formatDate(photo.fieldCapturedAt || photo.capturedAt || photo.createdAt)}</small>
{photo.latitude != null && photo.longitude != null && <small>GPS {photo.latitude.toFixed(6)}, {photo.longitude.toFixed(6)}</small>}
</div>
</article>;
}
export function InspectionActMediaPanel({ actId }: { actId: string }) {
const [photos, setPhotos] = useState<ActPhoto[]>([]);
const [assetPhotos, setAssetPhotos] = useState<AssetPhoto[]>([]);
const [findingCount, setFindingCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let active = true;
setLoading(true);
setError('');
Promise.all([
listInspectionFindings(actId),
listInspectionActFieldMedia(actId).catch(() => []),
])
.then(async ([findings, fieldMedia]) => {
const evidenceByFinding = await Promise.all(
findings.map(async (finding) => ({
finding,
evidence: await listInspectionFindingEvidence(finding.id),
})),
);
if (!active) return;
setFindingCount(findings.length);
setPhotos(evidenceByFinding.flatMap(({ finding, evidence }) =>
evidence.filter((item) => item.kind === 'PHOTO').map((item) => ({
findingId: finding.id, findingCode: finding.code, findingTitle: finding.title, evidence: item,
})),
));
setAssetPhotos(fieldMedia.filter((item) => item.kind === 'PHOTO'));
})
.catch((requestError) => active && setError(errorMessage(requestError)))
.finally(() => active && setLoading(false));
return () => { active = false; };
}, [actId]);
return <section className="panel act-media-panel">
<div className="panel-heading">
<div>
<span className="eyebrow">REGISTRO DE CAMPO</span>
<h2>Fotos y Hallazgos</h2>
<p className="section-copy">Las fotografías del Acta se muestran directamente, vinculadas al Hallazgo que documentan.</p>
</div>
<span className="count-pill">{photos.length + assetPhotos.length} foto{photos.length + assetPhotos.length === 1 ? '' : 's'} · {findingCount} hallazgo{findingCount === 1 ? '' : 's'}</span>
</div>
{error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Cargando registro fotográfico…" /> : <>
{photos.length === 0 && assetPhotos.length === 0 && <EmptyState title="Sin fotografías" text="Esta Acta todavía no tiene fotografías sincronizadas." />}
{photos.length > 0 && <div className="act-media-group"><div className="subsection-heading"><div><span className="eyebrow">HALLAZGOS</span><h4>Evidencia fotográfica</h4></div><span>{photos.length}</span></div><div className="act-photo-grid">{photos.map((photo) => <PhotoThumb key={photo.evidence.id} photo={photo} />)}</div></div>}
{assetPhotos.length > 0 && <div className="act-media-group"><div className="subsection-heading"><div><span className="eyebrow">INVENTARIO DE LA INSPECCIÓN</span><h4>Fotos tomadas durante esta inspección</h4></div><span>{assetPhotos.length}</span></div><div className="act-photo-grid">{assetPhotos.map((photo) => <AssetPhotoThumb key={photo.id} photo={photo} />)}</div></div>}
</>}
{(photos.length > 0 || assetPhotos.length > 0) && <div className="act-media-footnote"><Icon name="camera" /><span>Seleccioná una foto para verla a tamaño completo.</span></div>}
</section>;
}
+33 -2
View File
@@ -933,10 +933,12 @@ export interface InspectionVisit extends InspectionVisitListItem {
}
export type InspectionActStatus =
| 'DRAFT' | 'READY' | 'CLOSED' | 'CANCELLED' | 'RECTIFIED';
| 'DRAFT' | 'LOCKED' | 'SEALED' | 'CANCELLED'
| 'READY' | 'CLOSED' | 'RECTIFIED';
export type InspectionActVersionEvent =
| 'CREATED' | 'UPDATED' | 'READY' | 'REOPENED' | 'CLOSED' | 'CANCELLED';
| 'CREATED' | 'UPDATED' | 'LOCKED' | 'SEALED' | 'CANCELLED'
| 'READY' | 'REOPENED' | 'CLOSED';
export interface InspectionActListItem {
id: string;
@@ -992,6 +994,27 @@ export interface InspectionAct extends InspectionActListItem {
versions: InspectionActVersion[];
}
export interface InspectionActFieldMedia {
id: string;
assetId: string;
assetCode: string;
assetName: string;
kind: AssetMediaKind;
originalName: string;
mimeType: string;
sizeBytes: number;
sha256: string;
title: string | null;
description: string | null;
capturedAt: string | null;
latitude: number | null;
longitude: number | null;
accuracyM: number | null;
source: 'WEB' | 'ANDROID' | 'IMPORT';
fieldCapturedAt: string;
createdAt: string;
}
export type InspectionReportStatus = 'FROZEN' | 'CANCELLED';
export type InspectionReportPdfStatus = 'PENDING' | 'READY' | 'FAILED';
export type InspectionReportWordStatus = 'PENDING' | 'READY' | 'FAILED';
@@ -2684,6 +2707,14 @@ export function getInspectionAct(id: string) {
return apiRequest<InspectionAct>(`/inspection-acts/${id}`);
}
export function getInspectionActPdfBlob(actId: string) {
return apiBlobRequest(`/inspection-acts/${actId}/pdf`);
}
export async function listInspectionActFieldMedia(actId: string) {
return (await apiRequest<{ data: InspectionActFieldMedia[] }>(`/inspection-acts/${actId}/field-media`)).data;
}
export function getInspectionClosure(actId: string) {
return apiRequest<InspectionClosure>(`/inspection-acts/${actId}/closure`);
}
+14 -14
View File
@@ -52,8 +52,8 @@ const LEVELS: Array<{ kind: CanonicalKind; label: string; description: string }>
{ kind: 'DEPARTAMENTO', label: 'Departamento', description: 'Raíz territorial de la estructura física.' },
{ kind: 'AREA', label: 'Área', description: 'Pertenece obligatoriamente a un Departamento.' },
{ kind: 'YACIMIENTO', label: 'Yacimiento', description: 'Pertenece a un Área; su nombre puede repetirse en otra Área.' },
{ kind: 'INSTALACION', label: 'Instalación', description: 'Instancia dentro de un Yacimiento y con clasificación técnica.' },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', description: 'Instancia dentro de una Instalación y con clasificación técnica.' },
{ kind: 'INSTALACION', label: 'Instalación', description: 'Instancia dentro de un Yacimiento y con tipo de instalación.' },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', description: 'Instancia dentro de una Instalación y con tipo de subinstalación.' },
];
function canonicalType(types: AssetType[], kind: CanonicalKind): AssetType | null {
@@ -190,7 +190,7 @@ export function AssetTypesPage() {
if (canManageFindings) await replaceInventoryFamilyFindings(familyEditor.id, {
itemIds: [...familyFindingIds], reason: 'Actualización desde Configuración de Inventarios F6',
});
setSuccess('Clasificación actualizada.'); await load(); setFamilyEditor(null);
setSuccess('Tipo actualizado.'); await load(); setFamilyEditor(null);
}
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
@@ -258,35 +258,35 @@ export function AssetTypesPage() {
if (loading) return <LoadingBlock label="Cargando configuración de Inventarios…" />;
return <section className="inventory-config-page">
<div className="page-heading"><div><span className="eyebrow">ADMINISTRACIÓN</span><h1>Configuración de Inventarios</h1><p>Administrá clasificaciones, compatibilidades, Hallazgos y campos técnicos desde un único lugar.</p></div>{canManage && <Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Agregar registro</Link>}</div>
<div className="page-heading"><div><span className="eyebrow">ADMINISTRACIÓN</span><h1>Configuración de Inventarios</h1><p>Administrá tipos de instalación y subinstalación, compatibilidades, Hallazgos y campos técnicos desde un único lugar.</p></div>{canManage && <Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Agregar registro</Link>}</div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<div className="panel" style={{ marginBottom: 18 }}><div className="panel-heading"><div><span className="eyebrow">MODELO F6</span><h2>Estructura física fija</h2><p className="section-copy">La clasificación describe qué es cada Instalación/Subinstalación, pero nunca altera su nivel jerárquico.</p></div></div><div className="asset-browser-levels" aria-label="Estructura de Inventarios"><div><span>1</span><strong>Departamento</strong><small>raíz territorial</small></div><i></i><div><span>2</span><strong>Área</strong><small>dentro del Departamento</small></div><i></i><div><span>3</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i></i><div><span>4</span><strong>Instalación</strong><small>clasificación técnica</small></div><i></i><div><span>5</span><strong>Subinstalación</strong><small>clasificación técnica</small></div></div><div className="temporal-notice" style={{ marginTop: 14 }}><Icon name="users" /><p><strong>Empresa:</strong> maestro independiente; su relación con Área no reescribe el árbol físico.</p></div></div>
<div className="panel" style={{ marginBottom: 18 }}><div className="panel-heading"><div><span className="eyebrow">MODELO F6</span><h2>Estructura física fija</h2><p className="section-copy">El tipo describe qué es cada Instalación/Subinstalación, pero nunca altera su nivel jerárquico.</p></div></div><div className="asset-browser-levels" aria-label="Estructura de Inventarios"><div><span>1</span><strong>Departamento</strong><small>raíz territorial</small></div><i></i><div><span>2</span><strong>Área</strong><small>dentro del Departamento</small></div><i></i><div><span>3</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i></i><div><span>4</span><strong>Instalación</strong><small>tipo de instalación</small></div><i></i><div><span>5</span><strong>Subinstalación</strong><small>tipo de subinstalación</small></div></div><div className="temporal-notice" style={{ marginTop: 14 }}><Icon name="users" /><p><strong>Empresa:</strong> maestro independiente; su relación con Área no reescribe el árbol físico.</p></div></div>
<div className="dashboard-grid" style={{ alignItems: 'start' }}>
<article className="panel"><div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Instalación</h2><p className="section-copy">Tocá uno para editar Hallazgos, campos técnicos y ver sus Subinstalaciones compatibles.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('INSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div><div className="attribute-list">{installationFamilies.filter((family) => family.isActive !== false).map((family) => <button type="button" className={`attribute-card ${selectedInstallationFamilyId === family.id ? 'active' : ''}`} key={family.id} onClick={() => void openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.findingCount ?? 0} Hallazgos · {family.technicalAttributeCount ?? 0} campos técnicos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div></article>
<article className="panel"><div className="panel-heading"><div><span className="eyebrow">COMPATIBILIDAD</span><h2>Tipos de Subinstalación</h2><p className="section-copy">Una misma clasificación puede ser válida para varias Instalaciones.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('SUBINSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div><label className="field" style={{ marginBottom: 14 }}><span>Filtrar por tipo de Instalación</span><SearchableSelect value={selectedInstallationFamilyId} onChange={(event) => setSelectedInstallationFamilyId(event.target.value)}><option value="">Todas las Instalaciones</option>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <option key={family.id} value={family.id}>{family.name}</option>)}</SearchableSelect></label>{visibleSubinstallationFamilies.length === 0 ? <div className="inline-empty">{selectedInstallationFamilyId ? 'No hay tipos compatibles con esta Instalación.' : 'No hay tipos de Subinstalación configurados.'}</div> : <div className="attribute-list" style={{ maxHeight: 560, overflow: 'auto' }}>{visibleSubinstallationFamilies.map((family) => <button type="button" className="attribute-card" key={family.id} onClick={() => void openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.parentFamilies.map((parent) => parent.name).join(' · ') || 'Sin compatibilidades'} · {family.findingCount ?? 0} Hallazgos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>}</article>
<article className="panel"><div className="panel-heading"><div><span className="eyebrow">TIPOS DE INSTALACIÓN</span><h2>Tipos de Instalación</h2><p className="section-copy">Tocá uno para editar Hallazgos, campos técnicos y ver sus Subinstalaciones compatibles.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('INSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div><div className="attribute-list">{installationFamilies.filter((family) => family.isActive !== false).map((family) => <button type="button" className={`attribute-card ${selectedInstallationFamilyId === family.id ? 'active' : ''}`} key={family.id} onClick={() => void openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.findingCount ?? 0} Hallazgos · {family.technicalAttributeCount ?? 0} campos técnicos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div></article>
<article className="panel"><div className="panel-heading"><div><span className="eyebrow">COMPATIBILIDAD</span><h2>Tipos de Subinstalación</h2><p className="section-copy">Un mismo tipo de subinstalación puede ser válido para varias Instalaciones.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('SUBINSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div><label className="field" style={{ marginBottom: 14 }}><span>Filtrar por tipo de Instalación</span><SearchableSelect value={selectedInstallationFamilyId} onChange={(event) => setSelectedInstallationFamilyId(event.target.value)}><option value="">Todas las Instalaciones</option>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <option key={family.id} value={family.id}>{family.name}</option>)}</SearchableSelect></label>{visibleSubinstallationFamilies.length === 0 ? <div className="inline-empty">{selectedInstallationFamilyId ? 'No hay tipos compatibles con esta Instalación.' : 'No hay tipos de Subinstalación configurados.'}</div> : <div className="attribute-list" style={{ maxHeight: 560, overflow: 'auto' }}>{visibleSubinstallationFamilies.map((family) => <button type="button" className="attribute-card" key={family.id} onClick={() => void openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.parentFamilies.map((parent) => parent.name).join(' · ') || 'Sin compatibilidades'} · {family.findingCount ?? 0} Hallazgos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>}</article>
</div>
<article className="panel" style={{ marginTop: 18 }}><div className="panel-heading"><div><span className="eyebrow">CAMPOS GENERALES DEL NIVEL</span><h2>Información estructural</h2><p className="section-copy">Usá estos campos sólo cuando correspondan a todo el nivel. Marca, potencia, capacidad u otros datos técnicos deben configurarse dentro de cada clasificación.</p></div>{canManage && selectedType && <button className="button primary" onClick={() => openAttribute('new')}><Icon name="plus" />Nuevo campo general</button>}</div><div className="quick-view-row" style={{ marginBottom: 16 }}>{LEVELS.map((level) => <button type="button" key={level.kind} className={selectedKind === level.kind ? 'active' : ''} onClick={() => { setSelectedKind(level.kind); setAttributeEditor(null); }}>{level.label}</button>)}</div><p className="section-copy">{LEVELS.find((item) => item.kind === selectedKind)?.description}</p>{!selectedType ? <Alert>Este nivel todavía no tiene un tipo maestro activo.</Alert> : selectedType.attributes.length === 0 ? <div className="inline-empty">No hay campos generales para este nivel.</div> : <div className="attribute-list">{selectedType.attributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => openAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span><span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}{!attribute.isActive && <span className="tag">Inactivo</span>}</span><Icon name="chevron" /></button>)}</div>}</article>
<article className="panel" style={{ marginTop: 18 }}><div className="panel-heading"><div><span className="eyebrow">CAMPOS GENERALES DEL NIVEL</span><h2>Información estructural</h2><p className="section-copy">Usá estos campos sólo cuando correspondan a todo el nivel. Marca, potencia, capacidad u otros datos técnicos deben configurarse dentro de cada tipo.</p></div>{canManage && selectedType && <button className="button primary" onClick={() => openAttribute('new')}><Icon name="plus" />Nuevo campo general</button>}</div><div className="quick-view-row" style={{ marginBottom: 16 }}>{LEVELS.map((level) => <button type="button" key={level.kind} className={selectedKind === level.kind ? 'active' : ''} onClick={() => { setSelectedKind(level.kind); setAttributeEditor(null); }}>{level.label}</button>)}</div><p className="section-copy">{LEVELS.find((item) => item.kind === selectedKind)?.description}</p>{!selectedType ? <Alert>Este nivel todavía no tiene un tipo maestro activo.</Alert> : selectedType.attributes.length === 0 ? <div className="inline-empty">No hay campos generales para este nivel.</div> : <div className="attribute-list">{selectedType.attributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => openAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span><span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}{!attribute.isActive && <span className="tag">Inactivo</span>}</span><Icon name="chevron" /></button>)}</div>}</article>
{familyEditor && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveFamily}><div className="drawer-heading"><div><span className="eyebrow">CLASIFICACIÓN TÉCNICA</span><h2>{familyEditor === 'new' ? 'Nueva clasificación' : familyName}</h2></div><button type="button" className="icon-button" onClick={() => setFamilyEditor(null)}>×</button></div><div className="catalog-editor-fields">
{familyEditor && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveFamily}><div className="drawer-heading"><div><span className="eyebrow">TIPO DE INSTALACIÓN / SUBINSTALACIÓN</span><h2>{familyEditor === 'new' ? 'Nuevo tipo' : familyName}</h2></div><button type="button" className="icon-button" onClick={() => setFamilyEditor(null)}>×</button></div><div className="catalog-editor-fields">
{familyEditor === 'new' && <label className="field"><span>Nivel</span><SearchableSelect value={familyLevel} onChange={(event) => { setFamilyLevel(event.target.value as 'INSTALLATION' | 'SUBINSTALLATION'); setFamilyParentIds(new Set()); }}><option value="INSTALLATION">Instalación</option><option value="SUBINSTALLATION">Subinstalación</option></SearchableSelect></label>}
<label className="field"><span>Nombre</span><input value={familyName} onChange={(event) => setFamilyName(event.target.value)} maxLength={240} required /></label>
{familyLevel === 'SUBINSTALLATION' && <div className="field"><span>Tipos de Instalación compatibles <em>{familyParentIds.size} seleccionados</em></span><div className="finding-selection-list" style={{ maxHeight: 220, overflow: 'auto' }}>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label className="finding-selection-row" key={family.id}><input type="checkbox" checked={familyParentIds.has(family.id)} onChange={() => toggleParentCompatibility(family.id)} /><span><strong>{family.name}</strong><small>{family.code}</small></span></label>)}</div></div>}
{familyEditor !== 'new' && <>
<div className="temporal-notice"><Icon name="alert" /><p><strong>{familyFindingIds.size} Hallazgos asociados.</strong> La APK sólo verá estos Hallazgos para esta clasificación, además de OTROS.</p></div>
<div className="temporal-notice"><Icon name="alert" /><p><strong>{familyFindingIds.size} Hallazgos asociados.</strong> La APK sólo verá estos Hallazgos para este tipo, además de OTROS.</p></div>
<div className="quick-view-row"><button type="button" className={familyFindingMode === 'ASSOCIATED' ? 'active' : ''} onClick={() => setFamilyFindingMode('ASSOCIATED')}>Asociados ({familyFindingIds.size})</button>{canManageFindings && <button type="button" className={familyFindingMode === 'ALL' ? 'active' : ''} onClick={() => setFamilyFindingMode('ALL')}>Agregar o quitar</button>}</div>
<label className="field"><span>Buscar Hallazgo</span><input value={familyFindingSearch} onChange={(event) => setFamilyFindingSearch(event.target.value)} placeholder="Título, código o categoría…" /></label>
{visibleFamilyFindings.length === 0 ? <div className="inline-empty">{familyFindingMode === 'ASSOCIATED' ? 'No hay Hallazgos asociados.' : 'No hay coincidencias.'}</div> : <div className="finding-selection-list" style={{ maxHeight: 280, overflow: 'auto' }}>{visibleFamilyFindings.map((item) => familyFindingMode === 'ALL' ? <label className="finding-selection-row" key={item.id}><input type="checkbox" checked={familyFindingIds.has(item.id)} onChange={() => toggleFamilyFinding(item.id)} /><span><strong>{item.title}</strong><small>{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}</small></span></label> : <div className="finding-selection-row" key={item.id}><span className="asset-symbol"><Icon name="check" size={14} /></span><span><strong>{item.title}</strong><small>{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}</small></span></div>)}</div>}
<div className="panel-heading" style={{ marginTop: 10 }}><div><span className="eyebrow">DATOS DEL RUBRO</span><h3>Campos técnicos</h3><p className="section-copy">Sólo aparecen cuando un elemento usa esta clasificación.</p></div>{canManage && !technicalEditor && <button type="button" className="button secondary" onClick={() => openTechnicalAttribute('new')}><Icon name="plus" />Nuevo campo</button>}</div>
<div className="panel-heading" style={{ marginTop: 10 }}><div><span className="eyebrow">DATOS DEL RUBRO</span><h3>Campos técnicos</h3><p className="section-copy">Sólo aparecen cuando un elemento usa este tipo.</p></div>{canManage && !technicalEditor && <button type="button" className="button secondary" onClick={() => openTechnicalAttribute('new')}><Icon name="plus" />Nuevo campo</button>}</div>
{technicalLoading ? <LoadingBlock label="Cargando campos técnicos…" /> : technicalAttributes.length === 0 && !technicalEditor ? <div className="inline-empty">No hay campos técnicos definidos.</div> : !technicalEditor && <div className="attribute-list">{technicalAttributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => openTechnicalAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span>{attribute.isRequired && <span className="tag">Obligatorio</span>}<Icon name="chevron" /></button>)}</div>}
{technicalEditor && <div className="panel" style={{ padding: 14 }}><div className="panel-heading"><div><strong>{technicalEditor === 'new' ? 'Nuevo campo técnico' : 'Editar campo técnico'}</strong></div><button type="button" className="icon-button" onClick={() => setTechnicalEditor(null)}>×</button></div><label className="field"><span>Nombre</span><input value={technicalName} onChange={(event) => { setTechnicalName(event.target.value); if (technicalEditor === 'new') setTechnicalCode(attributeCodeFromName(event.target.value)); }} required /></label><label className="field"><span>Código interno</span><input value={technicalCode} onChange={(event) => setTechnicalCode(event.target.value.toLowerCase())} disabled={technicalEditor !== 'new'} pattern="[a-z][a-z0-9_]*" required /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={technicalType} onChange={(event) => setTechnicalType(event.target.value as InventoryFamilyAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>{technicalType === 'SELECT' && <label className="field"><span>Opciones</span><textarea rows={4} value={technicalOptions} onChange={(event) => setTechnicalOptions(event.target.value)} placeholder="Una por línea" required /></label>}<label className="field"><span>Unidad <em>opcional</em></span><input value={technicalUnit} onChange={(event) => setTechnicalUnit(event.target.value)} /></label><label className="field"><span>Orden</span><input type="number" min={0} max={10000} value={technicalOrder} onChange={(event) => setTechnicalOrder(Number(event.target.value))} /></label><label className="check-row"><input type="checkbox" checked={technicalRequired} onChange={(event) => setTechnicalRequired(event.target.checked)} /><span><strong>Obligatorio</strong><small>Debe completarse para esta clasificación.</small></span></label>{technicalEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={technicalActive} onChange={(event) => setTechnicalActive(event.target.checked)} /><span><strong>Campo activo</strong><small>Desactivarlo conserva datos existentes.</small></span></label>}<div className="form-actions"><button type="button" className="button secondary" onClick={() => setTechnicalEditor(null)}>Cancelar</button><button type="button" className="button primary" disabled={saving || !technicalName.trim() || !technicalCode || (technicalType === 'SELECT' && !technicalOptions.trim())} onClick={(event) => void saveTechnicalAttribute(event as unknown as FormEvent)}>{saving ? 'Guardando…' : 'Guardar campo'}</button></div></div>}
{technicalEditor && <div className="panel" style={{ padding: 14 }}><div className="panel-heading"><div><strong>{technicalEditor === 'new' ? 'Nuevo campo técnico' : 'Editar campo técnico'}</strong></div><button type="button" className="icon-button" onClick={() => setTechnicalEditor(null)}>×</button></div><label className="field"><span>Nombre</span><input value={technicalName} onChange={(event) => { setTechnicalName(event.target.value); if (technicalEditor === 'new') setTechnicalCode(attributeCodeFromName(event.target.value)); }} required /></label><label className="field"><span>Código interno</span><input value={technicalCode} onChange={(event) => setTechnicalCode(event.target.value.toLowerCase())} disabled={technicalEditor !== 'new'} pattern="[a-z][a-z0-9_]*" required /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={technicalType} onChange={(event) => setTechnicalType(event.target.value as InventoryFamilyAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>{technicalType === 'SELECT' && <label className="field"><span>Opciones</span><textarea rows={4} value={technicalOptions} onChange={(event) => setTechnicalOptions(event.target.value)} placeholder="Una por línea" required /></label>}<label className="field"><span>Unidad <em>opcional</em></span><input value={technicalUnit} onChange={(event) => setTechnicalUnit(event.target.value)} /></label><label className="field"><span>Orden</span><input type="number" min={0} max={10000} value={technicalOrder} onChange={(event) => setTechnicalOrder(Number(event.target.value))} /></label><label className="check-row"><input type="checkbox" checked={technicalRequired} onChange={(event) => setTechnicalRequired(event.target.checked)} /><span><strong>Obligatorio</strong><small>Debe completarse para este tipo.</small></span></label>{technicalEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={technicalActive} onChange={(event) => setTechnicalActive(event.target.checked)} /><span><strong>Campo activo</strong><small>Desactivarlo conserva datos existentes.</small></span></label>}<div className="form-actions"><button type="button" className="button secondary" onClick={() => setTechnicalEditor(null)}>Cancelar</button><button type="button" className="button primary" disabled={saving || !technicalName.trim() || !technicalCode || (technicalType === 'SELECT' && !technicalOptions.trim())} onClick={(event) => void saveTechnicalAttribute(event as unknown as FormEvent)}>{saving ? 'Guardando…' : 'Guardar campo'}</button></div></div>}
<Link className="button secondary" to={`/admin/finding-catalog?familyId=${familyEditor.id}`}>Abrir Catálogo completo <Icon name="chevron" /></Link>
<label className="check-row"><input type="checkbox" checked={familyActive} onChange={(event) => setFamilyActive(event.target.checked)} /><span><strong>Clasificación disponible</strong><small>Al desactivarla deja de ofrecerse en nuevas altas.</small></span></label>
<label className="check-row"><input type="checkbox" checked={familyActive} onChange={(event) => setFamilyActive(event.target.checked)} /><span><strong>Tipo disponible</strong><small>Al desactivarla deja de ofrecerse en nuevas altas.</small></span></label>
</>}
</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setFamilyEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !familyName.trim() || (familyLevel === 'SUBINSTALLATION' && familyParentIds.size === 0)}>{saving ? 'Guardando…' : familyEditor === 'new' ? 'Crear clasificación' : 'Guardar clasificación'}</button></div></form></div>}
</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setFamilyEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !familyName.trim() || (familyLevel === 'SUBINSTALLATION' && familyParentIds.size === 0)}>{saving ? 'Guardando…' : familyEditor === 'new' ? 'Crear tipo' : 'Guardar tipo'}</button></div></form></div>}
{attributeEditor && selectedType && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveAttribute}><div className="drawer-heading"><div><span className="eyebrow">CAMPO GENERAL DE {LEVELS.find((item) => item.kind === selectedKind)?.label.toUpperCase()}</span><h2>{attributeEditor === 'new' ? 'Nuevo campo' : 'Editar campo'}</h2></div><button type="button" className="icon-button" onClick={() => setAttributeEditor(null)}>×</button></div><div className="catalog-editor-fields"><label className="field"><span>Nombre visible</span><input value={attributeName} onChange={(event) => { setAttributeName(event.target.value); if (attributeEditor === 'new') setAttributeCode(attributeCodeFromName(event.target.value)); }} maxLength={160} required /></label><label className="field"><span>Código interno</span><input value={attributeCode} onChange={(event) => setAttributeCode(event.target.value.toLowerCase())} disabled={attributeEditor !== 'new'} maxLength={80} required pattern="[a-z][a-z0-9_]*" /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={attributeType} onChange={(event) => setAttributeType(event.target.value as AssetAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}</SearchableSelect></label>{attributeType === 'SELECT' && <label className="field"><span>Opciones</span><textarea rows={5} value={attributeOptions} onChange={(event) => setAttributeOptions(event.target.value)} placeholder="Una opción por línea" /></label>}<label className="field"><span>Unidad <em>opcional</em></span><input value={attributeUnit} onChange={(event) => setAttributeUnit(event.target.value)} maxLength={40} /></label><label className="field"><span>Orden</span><input type="number" value={attributeOrder} onChange={(event) => setAttributeOrder(Number(event.target.value))} min={0} max={10000} /></label><label className="check-row"><input type="checkbox" checked={attributeRequired} onChange={(event) => setAttributeRequired(event.target.checked)} /><span><strong>Campo obligatorio</strong><small>Debe completarse cuando se registra este nivel.</small></span></label>{attributeEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={attributeActive} onChange={(event) => setAttributeActive(event.target.checked)} /><span><strong>Campo activo</strong><small>Desactivarlo conserva los datos actuales.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setAttributeEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !attributeName.trim() || !attributeCode}>{saving ? 'Guardando…' : 'Guardar campo'}</button></div></form></div>}
</section>;
+1 -1
View File
@@ -48,7 +48,7 @@ const structuredTypeCopy: Record<string, { title: string; description: string }>
},
instalacion: {
title: 'Instalaciones',
description: 'Instalaciones dentro de un Yacimiento, con clasificación técnica y contexto operativo.',
description: 'Instalaciones dentro de un Yacimiento, con tipo de instalación y contexto operativo.',
},
subinstalacion: {
title: 'Subinstalaciones',
+37 -8
View File
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router';
import { DocumentPdfProjection } from '../components/DocumentPdfProjection';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import {
@@ -10,7 +9,9 @@ import {
} from '../features/inspections/inspectionActPresentation';
import { InspectionClosurePanel } from '../features/inspections/InspectionClosurePanel';
import { InspectionFindingsPanel } from '../features/inspections/InspectionFindingsPanel';
import { getInspectionVisit } from '../lib/api';
import { InspectionActMediaPanel } from '../features/inspections/InspectionActMediaPanel';
import { DocumentPdfProjection } from '../components/DocumentPdfProjection';
import { getInspectionActPdfBlob, getInspectionVisit } from '../lib/api';
import type { InspectionVisit } from '../lib/api';
import { getInspectionActF4, type InspectionActF4 } from '../lib/inspectionActF4Api';
import { formatDate } from '../lib/format';
@@ -21,6 +22,7 @@ export function InspectionActEditorPage() {
const [act, setAct] = useState<InspectionActF4 | null>(null);
const [loading, setLoading] = useState(Boolean(actId));
const [error, setError] = useState('');
const [pdfBusy, setPdfBusy] = useState(false);
useEffect(() => {
if (!actId) return;
@@ -48,21 +50,47 @@ export function InspectionActEditorPage() {
const isSealed = act?.status === 'SEALED' || act?.status === 'CLOSED';
const openActPdf = async (download: boolean) => {
if (!act) return;
setPdfBusy(true); setError('');
try {
const blob = await getInspectionActPdfBlob(act.id);
const url = URL.createObjectURL(blob);
if (download) {
const link = document.createElement('a');
link.href = url;
link.download = `${act.code}.pdf`;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1_000);
} else {
window.open(url, '_blank', 'noopener,noreferrer');
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setPdfBusy(false);
}
};
return <section className="survey-editor inspection-act-editor">
<div className="breadcrumb"><Link to="/inspecciones">Inspecciones</Link><span>/</span>{visit && <><Link to={`/inspecciones/${visit.id}`}>{visit.code}</Link><span>/</span></>}<span>{act?.code ?? 'Acta'}</span></div>
<div className="page-heading survey-editor-heading"><div><span className="eyebrow">ACTA DE INSPECCIÓN · SÓLO LECTURA</span><h1>{act?.title ?? 'Acta'}</h1><p>{act ? `${act.code} · versión ${act.currentVersion}` : 'Consulta del documento sincronizado desde la APK.'}</p></div>{act && <span className={`status-badge large ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span>}</div>
<div className="page-heading survey-editor-heading"><div><span className="eyebrow">ACTA DE INSPECCIÓN</span><h1>{act?.code ?? 'Acta'}</h1><p>{visit ? `${visit.code} · ${visit.scopeAsset?.name ?? visit.operationalArea?.name ?? 'Inspección'} · versión ${act?.currentVersion ?? ''}` : 'Consulta del documento sincronizado desde la APK.'}</p></div>{act && <span className={`status-badge large ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span>}</div>
{error && <Alert>{error}</Alert>}
{visit && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>{visit.code}</strong> El contenido constatado y los Hallazgos se registran desde la APK. Una vez bloqueada, el Acta ya no vuelve a borrador; la manifestación de empresa puede completarse posteriormente por enlace seguro.</p></div>}
{act?.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Informe relacionado: <Link to={`/informes/${act.report.id}`}>{act.report.code}</Link>.</strong> {act.report.pdfStatus === 'READY' ? 'El documento PDF está disponible.' : 'El contenido está congelado y el PDF continúa pendiente de composición.'}</p></div>}
{isSealed && act && !act.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Informe pendiente de emisión.</strong> El Acta ya está sellada y habilita el circuito documental del INF.</p></div>}
{act && <section className="panel act-document-primary">
<div className="act-document-primary-copy"><span className="asset-symbol"><Icon name="clipboard" /></span><div><span className="eyebrow">DOCUMENTO DEL ACTA</span><h2>{isSealed ? 'PDF del Acta disponible' : 'PDF pendiente de cierre'}</h2><p>{isSealed ? 'Abrí o descargá el Acta completa firmada y sellada.' : 'El PDF definitivo se genera cuando el Acta queda firmada y cerrada.'}</p></div></div>
<div className="act-primary-actions">{isSealed ? <><button className="button primary" type="button" disabled={pdfBusy} onClick={() => void openActPdf(false)}>{pdfBusy ? 'Preparando…' : 'Abrir PDF del Acta'}</button><button className="button secondary" type="button" disabled={pdfBusy} onClick={() => void openActPdf(true)}>Descargar PDF</button></> : <span className="status-badge pending">{inspectionActStatusLabel(act.status)}</span>}</div>
</section>}
{act?.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Informe relacionado: <Link to={`/informes/${act.report.id}`}>{act.report.code}</Link>.</strong> {act.report.pdfStatus === 'READY' ? ' El informe está disponible.' : ' El INF continúa en preparación.'}</p></div>}
{isSealed && act && !act.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>INF pendiente de emisión.</strong> El Acta ya está sellada y disponible como documento fuente.</p></div>}
{act?.status === 'CANCELLED' && <Alert>Cancelada: {act.cancellationReason}</Alert>}
{act && <section className="panel inspection-act-form">
<div className="panel-heading"><div><span className="eyebrow">CONTENIDO SINCRONIZADO</span><h2>{act.code}</h2></div><small className="muted">Actualizado {formatDate(act.updatedAt)}</small></div>
<div className="responsible-summary">
<div><small>Fecha y hora</small><strong>{formatDate(act.occurredAt)}</strong></div>
<div><small>Título</small><strong>{act.title}</strong></div>
<div><small>Estado documental</small><strong>{inspectionActStatusLabel(act.status)}</strong></div>
<div><small>Urgencia</small><strong>{act.urgency === 'URGENT' ? 'Urgente' : act.urgency === 'NON_URGENT' ? 'No urgente' : 'Pendiente de cierre'}</strong></div>
<div><small>Hallazgos</small><strong>{act.findingCount}</strong></div>
</div>
@@ -71,7 +99,8 @@ export function InspectionActEditorPage() {
<div className="inspection-act-asset-grid">{act.assets.map((asset) => <div className="inspection-member selected" key={asset.id}><span><strong>{asset.name}</strong><small>{asset.code} · {asset.typeName}</small></span></div>)}</div>
</section>}
{act && <DocumentPdfProjection kind="act" />}
{act && <InspectionActMediaPanel actId={act.id} />}
{act && <details className="legacy-projection-details"><summary>Ver estructura documental de referencia</summary><DocumentPdfProjection kind="act" /></details>}
{act && <InspectionFindingsPanel act={act} />}
{act && <InspectionClosurePanel act={act} />}
+1 -1
View File
@@ -250,7 +250,7 @@ export function InventoryCreatePage() {
{requiresFamily && <div className="form-section">
<div>
<h2>{kind === 'INSTALACION' ? 'Tipo de instalación' : 'Tipo / función'}</h2>
<p className="section-copy">{kind === 'SUBINSTALACION' ? 'Sólo aparecen tipos permitidos dentro de la Instalación seleccionada.' : 'Elegí la clasificación simple que corresponda.'}</p>
<p className="section-copy">{kind === 'SUBINSTALACION' ? 'Sólo aparecen tipos permitidos dentro de la Instalación seleccionada.' : 'Elegí el tipo que corresponda.'}</p>
</div>
{kind === 'SUBINSTALACION' && !selectedParent?.inventoryFamily ? <Alert>La Instalación elegida todavía no tiene un tipo configurado.</Alert> : <label className="field">
<span>{kind === 'INSTALACION' ? 'Tipo' : 'Tipo / función'} <em>obligatorio</em></span>
@@ -312,7 +312,7 @@ export function SimpleInventoryDetailPage({ initialAsset }: { initialAsset: Asse
{parentLabel && <div className="simple-inventory-context-card"><small>{parentLabel}</small>{asset.parent ? <Link to={`/inventarios/${asset.parent.id}`}><strong>{asset.parent.name}</strong><span>{asset.parent.code}</span></Link> : <strong>Sin asignar</strong>}</div>}
{kind === 'YACIMIENTO' && <div className="simple-inventory-context-card"><small>Empresa operadora</small>{asset.operatorCompany ? <Link to={`/inventarios/${asset.operatorCompany.id}`}><strong>{asset.operatorCompany.name}</strong><span>{asset.operatorCompany.code}</span></Link> : <strong>Sin asignar</strong>}</div>}
{(kind === 'INSTALACION' || kind === 'SUBINSTALACION') && <div className="simple-inventory-context-card"><small>Empresa del Yacimiento</small>{asset.operatorCompany ? <Link to={`/inventarios/${asset.operatorCompany.id}`}><strong>{asset.operatorCompany.name}</strong><span>{asset.operatorCompany.code}</span></Link> : <strong>Sin asignar</strong>}</div>}
{technicalLevel && <div className="simple-inventory-context-card"><small>Tipo técnico</small><strong>{technical?.family.name ?? 'Sin clasificación técnica'}</strong>{technical?.family.code && <span>{technical.family.code}</span>}</div>}
{technicalLevel && <div className="simple-inventory-context-card"><small>Tipo de instalación / subinstalación</small><strong>{technical?.family.name ?? 'Sin tipo asignado'}</strong>{technical?.family.code && <span>{technical.family.code}</span>}</div>}
</div>
</article>
+39
View File
@@ -1377,3 +1377,42 @@ code { color: #5e6677; font-family: ui-monospace, monospace; font-size: 9px; }
.signature-profile-preview { align-items: flex-start; flex-direction: column; }
.signature-profile-preview img { width: 100%; }
}
/* F6.8 · Acta: documento y registro fotográfico visibles como contenido principal. */
.act-document-primary { display: flex; align-items: center; justify-content: space-between; gap: 18px; border-color: #b8d4ce; background: #f5fbf9; }
.act-document-primary-copy { min-width: 0; display: flex; align-items: flex-start; gap: 13px; }
.act-document-primary-copy .asset-symbol { flex: 0 0 auto; color: #176e67; background: #d9f0ed; }
.act-document-primary-copy h2 { margin: 4px 0; font-size: 16px; }
.act-document-primary-copy p { margin: 0; color: var(--muted); font-size: 10px; }
.act-primary-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.act-media-panel { display: grid; gap: 14px; }
.act-media-panel > .panel-heading { margin-bottom: 0; }
.act-photo-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 12px; }
.act-photo-card { min-width: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 12px; background: white; }
.act-photo-preview { width: 100%; height: 190px; display: block; padding: 0; overflow: hidden; border: 0; cursor: zoom-in; background: #e9eef3; }
.act-photo-preview img { width: 100%; height: 100%; display: block; object-fit: cover; transition: transform .16s ease; }
.act-photo-preview:hover img { transform: scale(1.02); }
.act-photo-preview .media-preview-loading { width: 100%; height: 100%; display: grid; place-items: center; }
.act-photo-copy { min-width: 0; display: grid; gap: 4px; padding: 11px 12px 13px; }
.act-photo-copy strong { overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.act-photo-copy small { color: var(--muted); font-size: 8px; }
.act-media-footnote { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 9px; }
@media (max-width: 900px) {
.act-photo-grid { grid-template-columns: repeat(2,minmax(0,1fr)); }
.act-document-primary { align-items: flex-start; flex-direction: column; }
.act-primary-actions { width: 100%; justify-content: flex-start; }
}
@media (max-width: 620px) {
.act-photo-grid { grid-template-columns: 1fr; }
.act-photo-preview { height: 230px; }
.act-primary-actions .button { flex: 1 1 auto; }
}
.act-media-group { display: grid; gap: 10px; padding-top: 2px; }
.act-media-group + .act-media-group { margin-top: 5px; padding-top: 16px; border-top: 1px solid var(--line); }
.legacy-projection-details { overflow: hidden; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }
.legacy-projection-details > summary { cursor: pointer; list-style: none; padding: 12px 14px; color: var(--muted); font-size: 9px; font-weight: 700; letter-spacing: .04em; }
.legacy-projection-details > summary::-webkit-details-marker { display: none; }
.legacy-projection-details[open] > summary { border-bottom: 1px solid var(--line); }
.legacy-projection-details > .document-pdf-projection { margin: 12px; }