feat(f6.9): consolidate act and report documents and simplify follow-up
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m24s
DH V2 CI / API · typecheck, tests, build (push) Successful in 40s
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m22s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m24s
DH V2 CI / API · typecheck, tests, build (push) Successful in 40s
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m22s
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
|
||||
import { Icon } from '../../components/Icon';
|
||||
import {
|
||||
getAssetMediaBlob,
|
||||
getInspectionFindingEvidenceBlob,
|
||||
@@ -8,142 +7,70 @@ import {
|
||||
listInspectionFindingEvidence,
|
||||
listInspectionFindings,
|
||||
} from '../../lib/api';
|
||||
import type { InspectionActFieldMedia, InspectionFindingEvidence } from '../../lib/api';
|
||||
import type { InspectionActFieldMedia, InspectionFinding, InspectionFindingEvidence } from '../../lib/api';
|
||||
import { formatDate } from '../../lib/format';
|
||||
|
||||
type ActPhoto = {
|
||||
findingId: string;
|
||||
findingCode: string;
|
||||
findingTitle: string;
|
||||
evidence: InspectionFindingEvidence;
|
||||
};
|
||||
type FindingWithPhotos = { finding: InspectionFinding; photos: InspectionFindingEvidence[] };
|
||||
|
||||
type AssetPhoto = InspectionActFieldMedia;
|
||||
|
||||
function PhotoThumb({ photo }: { photo: ActPhoto }) {
|
||||
function Photo({ id, title, caption, load }: { id: string; title: string; caption: string; load: (id: string) => Promise<Blob> }) {
|
||||
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>;
|
||||
load(id).then((blob) => {
|
||||
if (!active) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setUrl(objectUrl);
|
||||
}).catch(() => undefined);
|
||||
return () => { active = false; if (objectUrl) URL.revokeObjectURL(objectUrl); };
|
||||
}, [id, load]);
|
||||
return <figure className="act-finding-photo">
|
||||
{url ? <a href={url} target="_blank" rel="noopener noreferrer" aria-label={`Ver foto ${title}`}><img src={url} alt={title} /></a> : <span className="media-preview-loading">Cargando foto…</span>}
|
||||
<figcaption>{caption}</figcaption>
|
||||
</figure>;
|
||||
}
|
||||
|
||||
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>
|
||||
function Finding({ item, assetPhotos }: { item: FindingWithPhotos; assetPhotos: InspectionActFieldMedia[] }) {
|
||||
const { finding, photos } = item;
|
||||
return <article className="act-finding-record">
|
||||
<div className="inspection-finding-heading"><div><span className="eyebrow">{finding.code}</span><h3>{finding.title}</h3><p>{finding.asset.name} · {finding.asset.code}</p></div></div>
|
||||
<p className="inspection-finding-description">{finding.description}</p>
|
||||
{finding.legalBasis && <p><strong>Normativa:</strong> {finding.legalBasis}</p>}
|
||||
{finding.severity != null && <p>Gravedad {finding.severity}/10</p>}
|
||||
{(photos.length > 0 || assetPhotos.length > 0) && <div className="act-finding-photos">
|
||||
{photos.map((photo) => <Photo key={photo.id} id={photo.id} title={photo.title || finding.title} caption={`Hallazgo · ${formatDate(photo.capturedAt || photo.createdAt)}${photo.latitude != null && photo.longitude != null ? ` · GPS ${photo.latitude.toFixed(6)}, ${photo.longitude.toFixed(6)}` : ''}`} load={getInspectionFindingEvidenceBlob} />)}
|
||||
{assetPhotos.map((photo) => <Photo key={photo.id} id={photo.id} title={photo.title || finding.asset.name} caption={`Inventario · ${formatDate(photo.fieldCapturedAt || photo.capturedAt || photo.createdAt)}`} load={getAssetMediaBlob} />)}
|
||||
</div>}
|
||||
{photos.length === 0 && assetPhotos.length === 0 && <small className="muted">Sin fotografías vinculadas.</small>}
|
||||
</article>;
|
||||
}
|
||||
|
||||
export function InspectionActMediaPanel({ actId }: { actId: string }) {
|
||||
const [photos, setPhotos] = useState<ActPhoto[]>([]);
|
||||
const [assetPhotos, setAssetPhotos] = useState<AssetPhoto[]>([]);
|
||||
const [findingCount, setFindingCount] = useState(0);
|
||||
const [items, setItems] = useState<FindingWithPhotos[]>([]);
|
||||
const [assetPhotos, setAssetPhotos] = useState<InspectionActFieldMedia[]>([]);
|
||||
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),
|
||||
})),
|
||||
);
|
||||
setLoading(true); setError('');
|
||||
Promise.all([listInspectionFindings(actId), listInspectionActFieldMedia(actId).catch(() => [])])
|
||||
.then(async ([findings, media]) => {
|
||||
const records = await Promise.all(findings.map(async (finding) => ({
|
||||
finding, photos: (await listInspectionFindingEvidence(finding.id)).filter((evidence) => evidence.kind === 'PHOTO' && evidence.purpose === 'OBSERVATION'),
|
||||
})));
|
||||
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'));
|
||||
setItems(records);
|
||||
setAssetPhotos(media.filter((photo) => photo.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>
|
||||
<div className="panel-heading"><div><h2>Hallazgos del Acta</h2><p className="section-copy">Cada hallazgo reúne su descripción y las fotos tomadas en campo.</p></div><span className="count-pill">{items.length}</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>}
|
||||
{loading ? <LoadingBlock label="Cargando hallazgos y fotografías…" /> : items.length ?
|
||||
<div className="act-finding-list">{items.map((item) => <Finding key={item.finding.id} item={item} assetPhotos={assetPhotos.filter((photo) => photo.assetId === item.finding.asset.id)} />)}</div> :
|
||||
<EmptyState title="Sin hallazgos" text="Esta Acta no contiene hallazgos sincronizados." />}
|
||||
</section>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user