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
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:
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user