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:
@@ -4,6 +4,7 @@ COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY nest-cli.json tsconfig.json ./
|
||||
COPY src ./src
|
||||
COPY assets ./assets
|
||||
RUN npm run build
|
||||
|
||||
FROM node:24-alpine AS runner
|
||||
@@ -13,6 +14,7 @@ WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/assets ./assets
|
||||
RUN mkdir -p /app/storage/asset-media/imports && chown -R node:node /app/storage
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,78 @@
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: DejaVu fonts
|
||||
Upstream-Author: Stepan Roh <src@users.sourceforge.net> (original author),
|
||||
see /usr/share/doc/fonts-dejavu-core/AUTHORS for full list
|
||||
Source: https://dejavu-fonts.github.io/
|
||||
|
||||
Files: *
|
||||
Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.
|
||||
Bitstream Vera is a trademark of Bitstream, Inc.
|
||||
DejaVu changes are in public domain.
|
||||
License: bitstream-vera
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of the fonts accompanying this license ("Fonts") and associated
|
||||
documentation files (the "Font Software"), to reproduce and distribute the
|
||||
Font Software, including without limitation the rights to use, copy, merge,
|
||||
publish, distribute, and/or sell copies of the Font Software, and to permit
|
||||
persons to whom the Font Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
.
|
||||
The above copyright and trademark notices and this permission notice shall
|
||||
be included in all copies of one or more of the Font Software typefaces.
|
||||
.
|
||||
The Font Software may be modified, altered, or added to, and in particular
|
||||
the designs of glyphs or characters in the Fonts may be modified and
|
||||
additional glyphs or characters may be added to the Fonts, only if the fonts
|
||||
are renamed to names not containing either the words "Bitstream" or the word
|
||||
"Vera".
|
||||
.
|
||||
This License becomes null and void to the extent applicable to Fonts or Font
|
||||
Software that has been modified and is distributed under the "Bitstream
|
||||
Vera" names.
|
||||
.
|
||||
The Font Software may be sold as part of a larger software package but no
|
||||
copy of one or more of the Font Software typefaces may be sold by itself.
|
||||
.
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
||||
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
||||
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
|
||||
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
||||
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
|
||||
FONT SOFTWARE.
|
||||
.
|
||||
Except as contained in this notice, the names of Gnome, the Gnome
|
||||
Foundation, and Bitstream Inc., shall not be used in advertising or
|
||||
otherwise to promote the sale, use or other dealings in this Font Software
|
||||
without prior written authorization from the Gnome Foundation or Bitstream
|
||||
Inc., respectively. For further information, contact: fonts at gnome dot
|
||||
org.
|
||||
|
||||
Files: debian/*
|
||||
Copyright: (C) 2005-2006 Peter Cernak <pce@users.sourceforge.net>
|
||||
(C) 2006-2011 Davide Viti <zinosat@tiscali.it>
|
||||
(C) 2011-2013 Christian Perrier <bubulle@debian.org>
|
||||
(C) 2013 Fabian Greffrath <fabian+debian@greffrath.com>
|
||||
License: GPL-2+
|
||||
This program is free software; you can redistribute it
|
||||
and/or modify it under the terms of the GNU General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2 of the License, or (at your option) any later
|
||||
version.
|
||||
.
|
||||
This program is distributed in the hope that it will be
|
||||
useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
PURPOSE. See the GNU General Public License for more
|
||||
details.
|
||||
.
|
||||
You should have received a copy of the GNU General Public
|
||||
License along with this package; if not, write to the Free
|
||||
Software Foundation, Inc., 51 Franklin St, Fifth Floor,
|
||||
Boston, MA 02110-1301 USA
|
||||
.
|
||||
On Debian systems, the full text of the GNU General Public
|
||||
License version 2 can be found in the file
|
||||
/usr/share/common-licenses/GPL-2'.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Generated
+742
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "dhv2-api",
|
||||
"version": "0.29.0-8",
|
||||
"version": "0.29.0-9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dhv2-api",
|
||||
"version": "0.29.0-8",
|
||||
"version": "0.29.0-9",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
@@ -21,9 +21,11 @@
|
||||
"class-validator": "^0.14.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"helmet": "^8.0.0",
|
||||
"pdfkit": "^0.20.2",
|
||||
"pg": "^8.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.0",
|
||||
"sharp": "^0.35.4",
|
||||
"typeorm": "^0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -32,6 +34,7 @@
|
||||
"@types/cookie-parser": "^1.4.9",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.0"
|
||||
@@ -216,6 +219,16 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@epic-web/invariant": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||
@@ -664,6 +677,506 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
|
||||
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
|
||||
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
|
||||
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
|
||||
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
|
||||
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
|
||||
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
|
||||
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
|
||||
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.11.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
|
||||
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/ansi": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
|
||||
@@ -1459,6 +1972,30 @@
|
||||
"typeorm": "^0.3.0 || ^1.0.0-dev"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@phc/format": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
|
||||
@@ -1484,6 +2021,15 @@
|
||||
"integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.23",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tokenizer/inflate": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
|
||||
@@ -1659,6 +2205,16 @@
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pdfkit": {
|
||||
"version": "0.17.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
|
||||
"integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||
@@ -2185,6 +2741,15 @@
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/brotli": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.8",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
|
||||
@@ -2805,6 +3370,21 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||
@@ -3156,7 +3736,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
@@ -3189,6 +3768,12 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-type": {
|
||||
"version": "21.3.4",
|
||||
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
|
||||
@@ -3228,6 +3813,32 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/fontkit": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.12",
|
||||
"brotli": "^1.3.2",
|
||||
"clone": "^2.1.2",
|
||||
"dfa": "^1.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"restructure": "^3.0.0",
|
||||
"tiny-inflate": "^1.0.3",
|
||||
"unicode-properties": "^1.4.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fontkit/node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
@@ -3906,6 +4517,25 @@
|
||||
"integrity": "sha512-xJxrdqvbl2rtn2MaUJrUejz8J7/uZNC0V77oks2LxYrO/+ZtVpRmz+fEQMuu6VusnEB1fmpByiLS1WXecOnAnw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/linebreak": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "0.0.8",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak/node_modules/base64-js": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
@@ -4388,6 +5018,12 @@
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -4475,6 +5111,20 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfkit": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.2.tgz",
|
||||
"integrity": "sha512-Q/w03ICAQyXfHNfTsg1udp0ADerdBN0s7a6XSPL7J7Ro6ABnafoBOCDBstIO9U02mvzHEV8HrvFIw5iV5ejIRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^1.3.0",
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"fflate": "^0.8.3",
|
||||
"fontkit": "^2.0.4",
|
||||
"linebreak": "^1.1.0",
|
||||
"png-js": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.23.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
|
||||
@@ -4594,6 +5244,14 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/png-js": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
|
||||
"integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -4793,6 +5451,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/restructure": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/router": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
@@ -4997,6 +5661,55 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
||||
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.8.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.35.4",
|
||||
"@img/sharp-darwin-x64": "0.35.4",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.4",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.3",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.3",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.3",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
|
||||
"@img/sharp-linux-arm": "0.35.4",
|
||||
"@img/sharp-linux-arm64": "0.35.4",
|
||||
"@img/sharp-linux-ppc64": "0.35.4",
|
||||
"@img/sharp-linux-riscv64": "0.35.4",
|
||||
"@img/sharp-linux-s390x": "0.35.4",
|
||||
"@img/sharp-linux-x64": "0.35.4",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.4",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.4",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.4",
|
||||
"@img/sharp-win32-arm64": "0.35.4",
|
||||
"@img/sharp-win32-ia32": "0.35.4",
|
||||
"@img/sharp-win32-x64": "0.35.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -5437,6 +6150,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/to-buffer": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz",
|
||||
@@ -5887,6 +6606,26 @@
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dhv2-api",
|
||||
"version": "0.29.0-8",
|
||||
"version": "0.29.0-9",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
@@ -28,9 +28,11 @@
|
||||
"class-validator": "^0.14.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"helmet": "^8.0.0",
|
||||
"pdfkit": "^0.20.2",
|
||||
"pg": "^8.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.0",
|
||||
"sharp": "^0.35.4",
|
||||
"typeorm": "^0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -39,6 +41,7 @@
|
||||
"@types/cookie-parser": "^1.4.9",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.0"
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Query, Res } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import { ActAdministrationService, MAX_ACT_RESPONSE_BYTES, type UploadedActResponseFile } from './act-administration.service';
|
||||
import { CreateActCompanyResponseDto } from './dto/create-act-company-response.dto';
|
||||
import { ActAdministrationService } from './act-administration.service';
|
||||
import { ListActAdministrationQueryDto } from './dto/list-act-administration-query.dto';
|
||||
import { SetActResponseDeadlineDto } from './dto/set-act-response-deadline.dto';
|
||||
|
||||
@Controller('act-administration')
|
||||
export class ActAdministrationQueueController {
|
||||
@@ -21,11 +16,8 @@ export class ActAdministrationQueueController {
|
||||
export class ActAdministrationController {
|
||||
constructor(private readonly administration: ActAdministrationService) {}
|
||||
@Get() @RequirePermissions('inspection_acts.read') get(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string) { return this.administration.detail(actId); }
|
||||
@Patch('deadline') @RequirePermissions('inspection_findings.follow_up')
|
||||
setDeadline(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Body() dto: SetActResponseDeadlineDto, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext) { return this.administration.setDeadline(actId, dto, principal, request); }
|
||||
@Post('responses') @RequirePermissions('inspection_findings.follow_up')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_ACT_RESPONSE_BYTES, files: 1 } }))
|
||||
addResponse(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Body() dto: CreateActCompanyResponseDto, @UploadedFile() file: UploadedActResponseFile | undefined, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext) { return this.administration.addResponse(actId, dto, file, principal, request); }
|
||||
// Historic deadlines and responses remain readable here.
|
||||
// New administrative entries are recorded on the related Informe.
|
||||
}
|
||||
|
||||
@Controller('act-company-responses')
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class F69ConsolidatedActDocument1790131800000 implements MigrationInterface {
|
||||
name = 'F69ConsolidatedActDocument1790131800000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE inspection_act_consolidated_pdf_artifacts (
|
||||
act_id uuid PRIMARY KEY REFERENCES inspection_acts(id) ON DELETE CASCADE,
|
||||
stored_name varchar(255) NOT NULL,
|
||||
original_name varchar(255) NOT NULL,
|
||||
size_bytes integer NOT NULL CHECK (size_bytes > 0),
|
||||
sha256 char(64) NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
|
||||
generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE inspection_report_consolidated_word_artifacts (
|
||||
report_id uuid PRIMARY KEY REFERENCES inspection_reports(id) ON DELETE CASCADE,
|
||||
stored_name varchar(255) NOT NULL,
|
||||
original_name varchar(255) NOT NULL,
|
||||
size_bytes integer NOT NULL CHECK (size_bytes > 0),
|
||||
sha256 char(64) NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
|
||||
generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('DROP TABLE inspection_report_consolidated_word_artifacts');
|
||||
await queryRunner.query('DROP TABLE inspection_act_consolidated_pdf_artifacts');
|
||||
}
|
||||
}
|
||||
@@ -629,6 +629,13 @@ export class InspectionFindingsService {
|
||||
if (Object.keys(dto).length === 0) {
|
||||
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
|
||||
}
|
||||
if (dto.companyResponse !== undefined || dto.companyResponseReceivedOn !== undefined
|
||||
|| dto.companyCommittedCorrectionOn !== undefined) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_FINDING_COMPANY_RESPONSE_IN_REPORT',
|
||||
message: 'La respuesta de la empresa se registra en el Informe relacionado, conservando este Hallazgo como antecedente.',
|
||||
});
|
||||
}
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const finding = await this.lockFinding(manager, id);
|
||||
this.assertFindingOpen(finding);
|
||||
|
||||
@@ -1,214 +1,131 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import PDFDocument from 'pdfkit';
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
export interface ActPdfImage {
|
||||
id: string;
|
||||
findingId?: string;
|
||||
assetId?: string;
|
||||
signerName?: string;
|
||||
title?: string;
|
||||
capturedAt?: string | Date | null;
|
||||
sha256: string;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
function asArray(value: unknown): Array<Record<string, unknown>> {
|
||||
return Array.isArray(value) ? value.map(asRecord) : [];
|
||||
}
|
||||
|
||||
function text(value: unknown, fallback = '-'): string {
|
||||
const out = String(value ?? '').trim();
|
||||
return out || fallback;
|
||||
function text(value: unknown, fallback = ''): string {
|
||||
return String(value ?? '').trim() || fallback;
|
||||
}
|
||||
|
||||
function date(value: unknown): string {
|
||||
const parsed = new Date(String(value ?? ''));
|
||||
return Number.isFinite(parsed.getTime()) ? parsed.toLocaleDateString('es-AR') : '-';
|
||||
return Number.isFinite(parsed.getTime()) ? parsed.toLocaleString('es-AR', { timeZone: 'America/Argentina/Mendoza', dateStyle: 'short', timeStyle: 'short' }) : '-';
|
||||
}
|
||||
function isPlaceholder(value: unknown): boolean {
|
||||
return text(value).startsWith('Acta de inspección en curso. Los Hallazgos');
|
||||
}
|
||||
|
||||
function clean(value: string): string {
|
||||
return value
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[–—]/g, '-')
|
||||
.replace(/[“”]/g, '"')
|
||||
.replace(/[‘’]/g, "'")
|
||||
.replace(/[^\x20-\xFF]/g, '?');
|
||||
}
|
||||
|
||||
function escapePdf(value: string): string {
|
||||
return clean(value).replaceAll('\\', '\\\\').replaceAll('(', '\\(').replaceAll(')', '\\)');
|
||||
}
|
||||
|
||||
function wrap(value: string, max = 92): string[] {
|
||||
const words = clean(value).split(/\s+/).filter(Boolean);
|
||||
const out: string[] = [];
|
||||
let line = '';
|
||||
for (const word of words) {
|
||||
const next = line ? `${line} ${word}` : word;
|
||||
if (next.length > max && line) {
|
||||
out.push(line);
|
||||
line = word;
|
||||
} else {
|
||||
line = next;
|
||||
}
|
||||
}
|
||||
if (line) out.push(line);
|
||||
return out.length ? out : ['-'];
|
||||
}
|
||||
|
||||
function lockedSnapshot(snapshot: Record<string, unknown>): {
|
||||
locked: Record<string, unknown>;
|
||||
signatures: Array<Record<string, unknown>>;
|
||||
finalSha256: unknown;
|
||||
} {
|
||||
// %PDF-1.4 is the document-version contract for consolidated Actas.
|
||||
export async function buildInspectionActPdf(snapshot: Record<string, unknown>, images: ActPdfImage[] = [], context: { companyName?: string | null; areaName?: string | null; scopeName?: string | null } = {}): Promise<{ buffer: Buffer; sha256: string }> {
|
||||
const sealed = asRecord(snapshot);
|
||||
const locked = asRecord(sealed.lockedSnapshot ?? sealed.preparedSnapshot);
|
||||
return {
|
||||
locked,
|
||||
signatures: asArray(sealed.signatures),
|
||||
finalSha256: sealed.finalSha256 ?? sealed.lockedSha256 ?? sealed.preparedSha256,
|
||||
};
|
||||
}
|
||||
|
||||
function urgencyLabel(value: unknown): string {
|
||||
return text(value, '') === 'URGENT' ? 'Urgente' : 'No urgente';
|
||||
}
|
||||
|
||||
function dayTypeLabel(value: unknown): string {
|
||||
return text(value, '') === 'CALENDAR' ? 'dias corridos' : 'dias habiles';
|
||||
}
|
||||
|
||||
function lines(snapshot: Record<string, unknown>): string[] {
|
||||
const source = lockedSnapshot(snapshot);
|
||||
const locked = source.locked;
|
||||
const act = asRecord(locked.act);
|
||||
const inspection = asRecord(act.inspection ?? asRecord(act).visit);
|
||||
const inspection = asRecord(act.inspection ?? act.visit);
|
||||
const responsible = asRecord(locked.responsible);
|
||||
const inventories = asArray(locked.inventories ?? locked.assets);
|
||||
const findings = asArray(locked.findings);
|
||||
const signatures = source.signatures;
|
||||
const companySignature = signatures.find((item) => text(item.signerType, '') === 'COMPANY_RESPONSIBLE');
|
||||
const inspectorSignatures = signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR');
|
||||
const signatures = asArray(sealed.signatures);
|
||||
const hash = text(sealed.finalSha256 ?? sealed.lockedSha256);
|
||||
const logo = resolve(process.cwd(), 'assets/logo-mendoza.png');
|
||||
const doc = new PDFDocument({ size: 'A4', pdfVersion: '1.4', margins: { top: 146, bottom: 80, left: 54, right: 54 }, compress: true });
|
||||
doc.registerFont('body', resolve(process.cwd(), 'assets/fonts/DejaVuSans.ttf'));
|
||||
doc.registerFont('body-bold', resolve(process.cwd(), 'assets/fonts/DejaVuSans-Bold.ttf'));
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on('data', (part: Buffer) => chunks.push(part));
|
||||
const done = new Promise<Buffer>((complete, reject) => { doc.on('end', () => complete(Buffer.concat(chunks))); doc.on('error', reject); });
|
||||
const blue = '#162D69';
|
||||
const header = () => {
|
||||
doc.font('body-bold').fillColor(blue).fontSize(11).text('MINISTERIO DE ENERGÍA Y AMBIENTE', 54, 42);
|
||||
doc.text('DIRECCIÓN DE HIDROCARBUROS', 54, 58);
|
||||
if (existsSync(logo)) doc.image(logo, 474, 32, { fit: [62, 82] });
|
||||
doc.moveTo(54, 121).lineTo(540, 121).strokeColor('#BAC5DA').stroke();
|
||||
doc.y = 146;
|
||||
};
|
||||
doc.on('pageAdded', header);
|
||||
header();
|
||||
const need = (height: number) => { if (doc.y + height > doc.page.height - 85) doc.addPage(); };
|
||||
const heading = (label: string) => { need(48); doc.moveDown(1); doc.font('body-bold').fillColor(blue).fontSize(12).text(label.toUpperCase()); doc.moveDown(0.35); };
|
||||
const body = (value: unknown) => { need(22); doc.font('body').fillColor('#202939').fontSize(10.5).text(text(value, '-'), { lineGap: 3 }); doc.moveDown(0.4); };
|
||||
const label = (name: string, value: unknown) => { if (!text(value)) return; need(22); doc.font('body-bold').fillColor('#202939').fontSize(10).text(`${name}: `, { continued: true }); doc.font('body').text(text(value)); doc.moveDown(0.35); };
|
||||
const image = (entry: ActPdfImage, caption: string) => {
|
||||
need(290);
|
||||
const y = doc.y;
|
||||
doc.image(entry.buffer, 58, y, { fit: [470, 245] });
|
||||
doc.y = y + 250;
|
||||
doc.font('body').fontSize(8).fillColor('#47536A').text(`${caption} · SHA-256 ${entry.sha256}`, 58, doc.y, { width: 475 });
|
||||
doc.moveDown(0.5);
|
||||
};
|
||||
|
||||
const deadlineText = act.deadlineAt
|
||||
? `${date(act.deadlineAt)} (${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)})`
|
||||
: act.deadlineBasis === 'GEDO_DATE'
|
||||
? `${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)} desde fecha GEDO`
|
||||
: '-';
|
||||
|
||||
const out: string[] = [
|
||||
'ACTA DE INSPECCION',
|
||||
'',
|
||||
`Acta: ${text(act.code)}`,
|
||||
`Inspeccion: ${text(inspection.code)}`,
|
||||
`Fecha: ${date(act.occurredAt)}`,
|
||||
`Urgencia: ${urgencyLabel(act.urgency)}`,
|
||||
`Plazo: ${deadlineText}`,
|
||||
`Representante de la empresa: ${text(responsible.fullName)}`,
|
||||
`DNI: ${text(responsible.documentNumber)}`,
|
||||
`Cargo / funcion: ${text(responsible.position)}`,
|
||||
`Email: ${text(responsible.email)}`,
|
||||
'',
|
||||
'RESUMEN',
|
||||
...wrap(text(act.summary)),
|
||||
'',
|
||||
'OBSERVACIONES',
|
||||
...wrap(text(act.observations)),
|
||||
'',
|
||||
'INVENTARIO INSPECCIONADO',
|
||||
];
|
||||
|
||||
if (!inventories.length) out.push('-');
|
||||
for (const item of inventories) {
|
||||
out.push(...wrap(`${text(item.code)} | ${text(item.name)} | ${text(item.typeName ?? item.typeCode)}`));
|
||||
doc.font('body-bold').fillColor('#202939').fontSize(19).text(`ACTA DE INSPECCIÓN ${text(act.code)}`);
|
||||
doc.moveDown(0.5);
|
||||
label('Inspección', inspection.code);
|
||||
label('Empresa inspeccionada', context.companyName);
|
||||
label('Área', context.areaName);
|
||||
label('Yacimiento o instalación', context.scopeName);
|
||||
label('Fecha y hora', date(act.occurredAt));
|
||||
label('Urgencia del Acta', text(act.urgency) === 'URGENT' ? 'Urgente' : text(act.urgency) === 'NON_URGENT' ? 'No urgente' : '');
|
||||
label('Representante de la empresa', responsible.fullName);
|
||||
label('DNI', responsible.documentNumber);
|
||||
label('Cargo o función', responsible.position);
|
||||
heading('Lo actuado');
|
||||
if (text(act.summary) && !isPlaceholder(act.summary)) body(act.summary);
|
||||
else body(`Se realizó la inspección ${text(inspection.code)}. El contenido constatado se detalla en los hallazgos registrados a continuación.`);
|
||||
if (act.observations) { label('Observaciones', act.observations); }
|
||||
if (inventories.length) {
|
||||
heading('Instalaciones inspeccionadas');
|
||||
for (const item of inventories) body(`${text(item.name)} (${text(item.code)}) · ${text(item.typeName ?? item.typeCode)}`);
|
||||
}
|
||||
|
||||
out.push('', 'HALLAZGOS');
|
||||
if (!findings.length) out.push('Sin hallazgos registrados.');
|
||||
for (const item of findings) {
|
||||
const recurrence = item.isRecurrence
|
||||
? ` | REINCIDENCIA${item.recurrenceOfFindingId ? ` de ${text(item.recurrenceOfFindingCode ?? item.recurrenceOfFindingId)}` : ''}`
|
||||
: '';
|
||||
out.push(...wrap(`${text(item.code)} | ${text(item.title)}${recurrence}`));
|
||||
out.push(...wrap(`Descripcion: ${text(item.description)}`));
|
||||
if (item.legalBasis) out.push(...wrap(`Base legal: ${text(item.legalBasis)}`));
|
||||
heading('Hallazgos y fotografías');
|
||||
if (!findings.length) body('No se registraron hallazgos.');
|
||||
const shownAssetPhotos = new Set<string>();
|
||||
for (const finding of findings) {
|
||||
need(75);
|
||||
doc.font('body-bold').fillColor(blue).fontSize(11).text(`${text(finding.code)} · ${text(finding.title)}`);
|
||||
label('Descripción', finding.description);
|
||||
if (finding.legalBasis) label('Normativa consignada', finding.legalBasis);
|
||||
if (finding.severity != null) label('Gravedad', `${text(finding.severity)}/10`);
|
||||
for (const photo of images.filter((item) => item.findingId === text(finding.id))) image(photo, `Fotografía del hallazgo ${text(finding.code)}${photo.capturedAt ? ` · ${date(photo.capturedAt)}` : ''}`);
|
||||
for (const photo of images.filter((item) => item.assetId === text(finding.assetId))) {
|
||||
if (shownAssetPhotos.has(photo.id)) continue;
|
||||
shownAssetPhotos.add(photo.id);
|
||||
image(photo, `Fotografía de inventario ${text(photo.title)}${photo.capturedAt ? ` · ${date(photo.capturedAt)}` : ''}`);
|
||||
}
|
||||
doc.moveDown(0.4);
|
||||
}
|
||||
|
||||
out.push('', 'FIRMAS Y CONSTANCIAS');
|
||||
if (!inspectorSignatures.length) out.push('Firma de inspector: pendiente.');
|
||||
for (const signature of inspectorSignatures) {
|
||||
out.push(...wrap(`Inspector: ${text(signature.signerName)} | ${text(signature.status)} | ${date(signature.signedAt ?? signature.createdAt)}`));
|
||||
if (!findings.length) for (const photo of images.filter((item) => item.assetId)) image(photo, `Fotografía de inventario ${text(photo.title)}`);
|
||||
heading('Intervinientes y firmas');
|
||||
for (const signature of signatures) {
|
||||
const name = text(signature.signerName);
|
||||
const role = text(signature.signerType) === 'INSPECTOR' ? 'Inspector/a' : 'Representante de la empresa';
|
||||
const status = text(signature.status);
|
||||
label(role, `${name} · ${status === 'SIGNED' ? 'Firmó' : status === 'REFUSED' ? 'Se negó a firmar' : 'No firmó'} · ${date(signature.signedAt ?? signature.createdAt)}`);
|
||||
if (signature.companyManifestation === 'DISSENT') label('Disconformidad', signature.companyStatement);
|
||||
if (status === 'REFUSED') label('Motivo de negativa', signature.reason);
|
||||
const signatureImage = images.find((item) => item.signerName === name && item.sha256 === text(signature.imageSha256));
|
||||
if (signatureImage) { need(100); const y = doc.y; doc.image(signatureImage.buffer, 60, y, { fit: [230, 60] }); doc.y = y + 66; }
|
||||
}
|
||||
if (!companySignature) {
|
||||
out.push('Manifestacion de empresa: pendiente.');
|
||||
} else if (text(companySignature.status, '') === 'SIGNED') {
|
||||
const manifestation = text(companySignature.companyManifestation, 'CONFORMITY');
|
||||
out.push(manifestation === 'DISSENT' ? 'Empresa: firma en disconformidad' : 'Empresa: firma en conformidad');
|
||||
if (manifestation === 'DISSENT') out.push(...wrap(text(companySignature.companyStatement)));
|
||||
} else {
|
||||
out.push(...wrap(`Empresa: ${text(companySignature.status)} - ${text(companySignature.reason)}`));
|
||||
}
|
||||
|
||||
out.push(
|
||||
'',
|
||||
'INTEGRIDAD',
|
||||
`Hash del Acta sellada: ${text(source.finalSha256)}`,
|
||||
`Hash del contenido bloqueado: ${text(snapshot.lockedSha256 ?? snapshot.preparedSha256)}`,
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
function objectBuffer(id: number, body: Buffer | string): Buffer {
|
||||
const data = Buffer.isBuffer(body) ? body : Buffer.from(body, 'latin1');
|
||||
return Buffer.concat([
|
||||
Buffer.from(`${id} 0 obj\n`, 'ascii'),
|
||||
data,
|
||||
Buffer.from('\nendobj\n', 'ascii'),
|
||||
]);
|
||||
}
|
||||
|
||||
export function buildInspectionActPdf(snapshot: Record<string, unknown>): { buffer: Buffer; sha256: string } {
|
||||
const all = lines(snapshot);
|
||||
const chunks: Array<string[]> = [];
|
||||
for (let index = 0; index < all.length; index += 56) chunks.push(all.slice(index, index + 56));
|
||||
if (!chunks.length) chunks.push(['ACTA DE INSPECCION']);
|
||||
|
||||
const pageCount = chunks.length;
|
||||
const pageIds = Array.from({ length: pageCount }, (_, index) => 4 + index * 2);
|
||||
const contentIds = Array.from({ length: pageCount }, (_, index) => 5 + index * 2);
|
||||
const objects: Buffer[] = [];
|
||||
objects.push(objectBuffer(1, '<< /Type /Catalog /Pages 2 0 R >>'));
|
||||
objects.push(objectBuffer(2, `<< /Type /Pages /Count ${pageCount} /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] >>`));
|
||||
objects.push(objectBuffer(3, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'));
|
||||
|
||||
chunks.forEach((chunk, index) => {
|
||||
const content = chunk
|
||||
.map((line, lineIndex) => `${lineIndex === 0 ? '' : 'T* '}(${escapePdf(line)}) Tj`)
|
||||
.join('\n');
|
||||
const stream = Buffer.from(`BT\n/F1 9 Tf\n40 800 Td\n12 TL\n${content}\nET`, 'latin1');
|
||||
objects.push(objectBuffer(
|
||||
pageIds[index]!,
|
||||
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentIds[index]} 0 R >>`,
|
||||
));
|
||||
objects.push(objectBuffer(
|
||||
contentIds[index]!,
|
||||
Buffer.concat([
|
||||
Buffer.from(`<< /Length ${stream.length} >>\nstream\n`, 'ascii'),
|
||||
stream,
|
||||
Buffer.from('\nendstream', 'ascii'),
|
||||
]),
|
||||
));
|
||||
});
|
||||
|
||||
const header = Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary');
|
||||
const offsets: number[] = [0];
|
||||
let position = header.length;
|
||||
for (const object of objects) {
|
||||
offsets.push(position);
|
||||
position += object.length;
|
||||
}
|
||||
const xrefOffset = position;
|
||||
const xref = [
|
||||
`xref\n0 ${objects.length + 1}\n`,
|
||||
'0000000000 65535 f \n',
|
||||
...objects.map((_, index) => `${String(offsets[index + 1]).padStart(10, '0')} 00000 n \n`),
|
||||
].join('');
|
||||
const trailer = `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
||||
const buffer = Buffer.concat([header, ...objects, Buffer.from(xref + trailer, 'ascii')]);
|
||||
heading('Integridad del Acta');
|
||||
body(`SHA-256 del cierre: ${hash}`);
|
||||
body(`SHA-256 del contenido cerrado: ${text(sealed.lockedSha256)}`);
|
||||
need(25);
|
||||
doc.fontSize(8).fillColor('#637088').text(`Acta ${text(act.code)} · documento consolidado · ${date(asRecord(sealed.seal).serverSealedAt)}`, 54, doc.y);
|
||||
doc.end();
|
||||
const buffer = await done;
|
||||
return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') };
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@ import { isAbsolute, parse, resolve } from 'node:path';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { buildInspectionActPdf } from './inspection-act-pdf-builder';
|
||||
import { buildInspectionActPdf, type ActPdfImage } from './inspection-act-pdf-builder';
|
||||
import { renderableInspectionImage } from './inspection-document-images';
|
||||
|
||||
@Injectable()
|
||||
export class InspectionActPdfService {
|
||||
private readonly root: string;
|
||||
private readonly evidenceRoot: string;
|
||||
private readonly assetRoot: string;
|
||||
private readonly signatureRoot: string;
|
||||
|
||||
constructor(private readonly dataSource: DataSource, config: ConfigService) {
|
||||
const configured = config.get<string>('INSPECTION_ACT_PDF_ROOT')
|
||||
@@ -16,6 +20,9 @@ export class InspectionActPdfService {
|
||||
if (!isAbsolute(configured)) throw new Error('INSPECTION_ACT_PDF_ROOT must be an absolute path');
|
||||
this.root = resolve(configured);
|
||||
if (this.root === parse(this.root).root) throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root');
|
||||
this.evidenceRoot = resolve(config.get<string>('INSPECTION_EVIDENCE_ROOT') ?? '/app/storage/asset-media/inspection-findings');
|
||||
this.assetRoot = resolve(config.get<string>('ASSET_MEDIA_ROOT') ?? '/app/storage/asset-media');
|
||||
this.signatureRoot = resolve(config.get<string>('INSPECTION_SIGNATURE_ROOT') ?? '/app/storage/asset-media/inspection-signatures');
|
||||
}
|
||||
|
||||
async ensure(actId: string): Promise<void> {
|
||||
@@ -59,7 +66,7 @@ export class InspectionActPdfService {
|
||||
`, [actId]);
|
||||
try {
|
||||
const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 };
|
||||
const built = buildInspectionActPdf(snapshot);
|
||||
const built = await buildInspectionActPdf(snapshot, await this.fieldImages(actId), await this.actContext(actId));
|
||||
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||
const storedName = `${actId}.pdf`;
|
||||
const originalName = `${row.code}.pdf`;
|
||||
@@ -82,6 +89,120 @@ export class InspectionActPdfService {
|
||||
}
|
||||
}
|
||||
|
||||
// The original sealed PDF remains immutable for historic delivery and audit.
|
||||
// A consolidated presentation is stored separately and frozen at its first generation.
|
||||
async consolidatedContent(actId: string): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> {
|
||||
const [existing] = await this.dataSource.query(`
|
||||
SELECT stored_name AS "storedName",original_name AS "originalName",
|
||||
size_bytes AS "sizeBytes",sha256
|
||||
FROM inspection_act_consolidated_pdf_artifacts WHERE act_id=$1
|
||||
`, [actId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
|
||||
if (existing) {
|
||||
const buffer = await this.verifiedImage(this.root, existing);
|
||||
return { buffer, originalName: existing.originalName, mimeType: 'application/pdf' };
|
||||
}
|
||||
const [row] = await this.dataSource.query(`
|
||||
SELECT act.code,act.closure_sha256 AS "closureSha256",closure.final_snapshot AS "finalSnapshot"
|
||||
FROM inspection_acts act
|
||||
JOIN inspection_act_closures closure ON closure.act_id=act.id
|
||||
WHERE act.id=$1 AND act.status='SEALED'
|
||||
`, [actId]) as Array<{ code: string; closureSha256: string; finalSnapshot: Record<string, unknown> }>;
|
||||
if (!row) throw new NotFoundException({
|
||||
code: 'INSPECTION_ACT_NOT_SEALED',
|
||||
message: 'El Acta debe estar firmada y sellada para generar el documento consolidado',
|
||||
});
|
||||
const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 };
|
||||
const built = await buildInspectionActPdf(snapshot, await this.fieldImages(actId), await this.actContext(actId));
|
||||
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||
const storedName = `${actId}-consolidado-${built.sha256.slice(0, 24)}.pdf`;
|
||||
const originalName = `${row.code}-consolidada.pdf`;
|
||||
await writeFile(resolve(this.root, storedName), built.buffer, { flag: 'wx', mode: 0o600 }).catch(async (error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== 'EEXIST') throw error;
|
||||
const previous = await this.verifiedImage(this.root, {
|
||||
storedName, sizeBytes: built.buffer.length, sha256: built.sha256,
|
||||
});
|
||||
if (!previous.equals(built.buffer)) throw this.storageError();
|
||||
});
|
||||
await this.dataSource.query(`
|
||||
INSERT INTO inspection_act_consolidated_pdf_artifacts(act_id,stored_name,original_name,size_bytes,sha256)
|
||||
VALUES($1,$2,$3,$4,$5) ON CONFLICT (act_id) DO NOTHING
|
||||
`, [actId, storedName, originalName, built.buffer.length, built.sha256]);
|
||||
const [saved] = await this.dataSource.query(`
|
||||
SELECT stored_name AS "storedName",original_name AS "originalName",
|
||||
size_bytes AS "sizeBytes",sha256
|
||||
FROM inspection_act_consolidated_pdf_artifacts WHERE act_id=$1
|
||||
`, [actId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
|
||||
if (!saved) throw this.storageError();
|
||||
return {
|
||||
buffer: await this.verifiedImage(this.root, saved),
|
||||
originalName: saved.originalName, mimeType: 'application/pdf',
|
||||
};
|
||||
}
|
||||
|
||||
private async actContext(actId: string): Promise<{ companyName: string | null; areaName: string | null; scopeName: string | null }> {
|
||||
const [row] = await this.dataSource.query(`
|
||||
SELECT company.name AS "companyName",area.name AS "areaName",scope.name AS "scopeName"
|
||||
FROM inspection_acts act
|
||||
JOIN inspection_visits visit ON visit.id=act.visit_id
|
||||
LEFT JOIN assets company ON company.id=visit.operator_company_id
|
||||
LEFT JOIN assets area ON area.id=visit.operational_area_id
|
||||
LEFT JOIN assets scope ON scope.id=visit.scope_asset_id
|
||||
WHERE act.id=$1
|
||||
`, [actId]) as Array<{ companyName: string | null; areaName: string | null; scopeName: string | null }>;
|
||||
return row ?? { companyName: null, areaName: null, scopeName: null };
|
||||
}
|
||||
|
||||
private async verifiedImage(root: string, row: { storedName: string; sha256: string; sizeBytes: number }): Promise<Buffer> {
|
||||
if (!/^[A-Za-z0-9_.-]+$/.test(row.storedName)) throw new Error('Ruta inválida de evidencia del Acta');
|
||||
const path = resolve(root, row.storedName);
|
||||
if (!path.startsWith(`${root}/`)) throw new Error('Ruta inválida de evidencia del Acta');
|
||||
const file = await stat(path);
|
||||
if (!file.isFile() || file.size !== Number(row.sizeBytes)) throw new Error('Evidencia incompleta del Acta');
|
||||
const buffer = await readFile(path);
|
||||
if (createHash('sha256').update(buffer).digest('hex') !== row.sha256) throw new Error('Hash de evidencia distinto del registrado');
|
||||
return buffer;
|
||||
}
|
||||
|
||||
async fieldImages(actId: string): Promise<ActPdfImage[]> {
|
||||
type ImageRow = { id: string; findingId?: string; assetId?: string; signerName?: string; title?: string; capturedAt?: Date; storedName: string; sha256: string; sizeBytes: number };
|
||||
const findings = await this.dataSource.query(`
|
||||
SELECT evidence.id, finding.id AS "findingId", evidence.title,
|
||||
evidence.captured_at AS "capturedAt", evidence.stored_name AS "storedName",
|
||||
evidence.sha256, evidence.size_bytes AS "sizeBytes"
|
||||
FROM inspection_findings finding
|
||||
JOIN inspection_acts act ON act.id=finding.act_id
|
||||
JOIN inspection_finding_evidence evidence ON evidence.finding_id=finding.id
|
||||
WHERE act.id=$1 AND evidence.kind='PHOTO' AND evidence.purpose='OBSERVATION'
|
||||
AND evidence.created_at<=act.locked_at
|
||||
ORDER BY finding.finding_number,evidence.captured_at,evidence.id
|
||||
`, [actId]) as ImageRow[];
|
||||
const assets = await this.dataSource.query(`
|
||||
SELECT media.id,asset.id AS "assetId", asset.name AS title,
|
||||
capture.device_captured_at AS "capturedAt",media.stored_name AS "storedName",
|
||||
media.sha256,media.size_bytes AS "sizeBytes"
|
||||
FROM inspection_acts act
|
||||
JOIN inspection_act_assets link ON link.act_id=act.id AND link.included=true
|
||||
JOIN assets asset ON asset.id=link.asset_id
|
||||
JOIN asset_field_capture_events capture ON capture.visit_id=act.visit_id
|
||||
AND capture.asset_id=asset.id AND capture.event_type='PHOTO'
|
||||
JOIN asset_media media ON media.id=capture.media_id AND media.deleted_at IS NULL AND media.kind='PHOTO'
|
||||
WHERE act.id=$1 AND capture.created_at<=act.locked_at
|
||||
ORDER BY capture.device_captured_at,media.id
|
||||
`, [actId]) as ImageRow[];
|
||||
const signatures = await this.dataSource.query(`
|
||||
SELECT signature.id,signature.signer_name AS "signerName",signature.stored_name AS "storedName",
|
||||
signature.image_sha256 AS sha256,signature.size_bytes AS "sizeBytes"
|
||||
FROM inspection_act_signatures signature WHERE signature.act_id=$1
|
||||
AND signature.status='SIGNED' AND signature.stored_name IS NOT NULL
|
||||
ORDER BY signature.created_at,signature.id
|
||||
`, [actId]) as ImageRow[];
|
||||
const result: ActPdfImage[] = [];
|
||||
for (const row of findings) result.push({ ...row, buffer: await renderableInspectionImage(await this.verifiedImage(this.evidenceRoot, row)) });
|
||||
for (const row of assets) result.push({ ...row, buffer: await renderableInspectionImage(await this.verifiedImage(this.assetRoot, row)) });
|
||||
for (const row of signatures) result.push({ ...row, buffer: await this.verifiedImage(this.signatureRoot, row) });
|
||||
return result;
|
||||
}
|
||||
|
||||
async content(actId: string): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> {
|
||||
const [row] = await this.dataSource.query(`
|
||||
SELECT original_name AS "originalName",stored_name AS "storedName",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
// PDFKit and the Word image renderer accept JPEG/PNG. The source bytes and
|
||||
// their recorded SHA-256 remain untouched; WebP is only converted for display.
|
||||
export async function renderableInspectionImage(source: Buffer): Promise<Buffer> {
|
||||
const isWebp = source.length >= 12
|
||||
&& source.subarray(0, 4).toString('ascii') === 'RIFF'
|
||||
&& source.subarray(8, 12).toString('ascii') === 'WEBP';
|
||||
if (!isWebp) return source;
|
||||
return sharp(source)
|
||||
.resize({ width: 1600, height: 1200, fit: 'inside', withoutEnlargement: true })
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
interface ZipEntry {
|
||||
name: string;
|
||||
@@ -13,6 +15,10 @@ interface ReportWordInput {
|
||||
frozenSnapshot: Record<string, unknown>;
|
||||
executiveSummary?: string | null;
|
||||
reportDescription?: string | null;
|
||||
companyName?: string | null;
|
||||
areaName?: string | null;
|
||||
scopeName?: string | null;
|
||||
photos?: Array<{ id: string; findingId?: string; assetId?: string; title?: string; sha256: string; buffer: Buffer }>;
|
||||
}
|
||||
|
||||
const crcTable = (() => {
|
||||
@@ -68,7 +74,7 @@ function paragraph(value: string, style?: 'Title' | 'Heading1' | 'Heading2'): st
|
||||
}
|
||||
|
||||
function labelValue(label: string, value: string): string {
|
||||
return `<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>${xmlEscape(label)}: </w:t></w:r><w:r><w:t xml:space="preserve">${xmlEscape(value)}</w:t></w:r></w:p>`;
|
||||
return `<w:p><w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">${xmlEscape(label)}: </w:t></w:r><w:r><w:t xml:space="preserve">${xmlEscape(value)}</w:t></w:r></w:p>`;
|
||||
}
|
||||
|
||||
function table(headers: string[], rows: string[][]): string {
|
||||
@@ -102,75 +108,80 @@ function deadline(act: Record<string, unknown>): string {
|
||||
return '—';
|
||||
}
|
||||
|
||||
function documentXml(input: ReportWordInput): string {
|
||||
function imageDimensions(buffer: Buffer): { width: number; height: number } {
|
||||
if (buffer.subarray(0, 4).toString('hex') === '89504e47') return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
||||
let offset = 2;
|
||||
while (offset + 9 < buffer.length) {
|
||||
if (buffer[offset] !== 0xff) break;
|
||||
const marker = buffer[offset + 1]!;
|
||||
if ([0xc0,0xc1,0xc2,0xc3,0xc5,0xc6,0xc7,0xc9,0xca,0xcb,0xcd,0xce,0xcf].includes(marker)) return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) };
|
||||
const size = buffer.readUInt16BE(offset + 2);
|
||||
if (size < 2) break;
|
||||
offset += size + 2;
|
||||
}
|
||||
return { width: 800, height: 500 };
|
||||
}
|
||||
|
||||
function drawing(relationship: number, image: Buffer, name: string, maxWidth = 4572000, maxHeight = 2743200, right = false): string {
|
||||
const dimensions = imageDimensions(image);
|
||||
const scale = Math.min(maxWidth / dimensions.width, maxHeight / dimensions.height);
|
||||
const cx = Math.round(dimensions.width * scale);
|
||||
const cy = Math.round(dimensions.height * scale);
|
||||
const alt = xmlEscape(name);
|
||||
return `<w:p>${right ? '<w:pPr><w:jc w:val="right"/></w:pPr>' : ''}<w:r><w:drawing><wp:inline><wp:extent cx="${cx}" cy="${cy}"/><wp:docPr id="${relationship}" name="${alt}" descr="${alt}"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="${alt}"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rId${relationship}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>`;
|
||||
}
|
||||
|
||||
function documentXml(input: ReportWordInput, logo: Buffer): string {
|
||||
const snapshot = f4Snapshot(input);
|
||||
const findingRows = snapshot.findings.map((item) => [
|
||||
text(item.code),
|
||||
text(item.title),
|
||||
text(item.description),
|
||||
item.isRecurrence ? `Sí${item.recurrenceOfFindingCode ? ` · ${text(item.recurrenceOfFindingCode)}` : ''}` : 'No',
|
||||
item.severity == null ? '—' : `${text(item.severity)}/10`,
|
||||
]);
|
||||
const inventoryRows = snapshot.inventories.map((item) => [
|
||||
text(item.code),
|
||||
text(item.name),
|
||||
text(item.typeName ?? item.typeCode),
|
||||
]);
|
||||
const signatureRows = snapshot.signatures.map((item) => [
|
||||
text(item.signerType),
|
||||
text(item.signerName),
|
||||
text(item.status),
|
||||
text(item.companyManifestation, ''),
|
||||
isoDate(item.signedAt ?? item.createdAt),
|
||||
]);
|
||||
|
||||
const executive = input.executiveSummary?.trim()
|
||||
|| '[EDITAR] Incorporar aquí el resumen ejecutivo del informe.';
|
||||
const description = input.reportDescription?.trim()
|
||||
|| '[EDITAR] Incorporar aquí la descripción técnica, análisis y consideraciones del inspector.';
|
||||
|
||||
const body = [
|
||||
paragraph('INFORME TÉCNICO DE INSPECCIÓN', 'Title'),
|
||||
paragraph('Documento Word editable para revisión del Inspector antes de su incorporación a GEDO', 'Heading2'),
|
||||
labelValue('Informe', input.code),
|
||||
labelValue('Acta fuente', text(snapshot.source.actCode ?? snapshot.act.code)),
|
||||
const photos = input.photos ?? [];
|
||||
const authors = snapshot.signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR').map((item) => text(item.signerName)).join(', ');
|
||||
const legalBasis = [...new Set(snapshot.findings.map((item) => text(item.legalBasis, '')).filter(Boolean))];
|
||||
const entries: string[] = [
|
||||
paragraph('MINISTERIO DE ENERGÍA Y AMBIENTE'),
|
||||
paragraph('DIRECCIÓN DE HIDROCARBUROS'),
|
||||
drawing(2, logo, 'Identidad institucional Mendoza', 594360, 1005840, true),
|
||||
paragraph(`Mendoza, ${input.generatedAt.toLocaleDateString('es-AR')}`),
|
||||
paragraph(`INFORME TÉCNICO ${input.code}`, 'Title'),
|
||||
paragraph('Sr. Director de Hidrocarburos'),
|
||||
labelValue('Referencia', text(input.scopeName ?? snapshot.inventories[0]?.name, 'Instalación inspeccionada')),
|
||||
labelValue('Inspector/a', authors || 'No consignado'),
|
||||
labelValue('Acta', text(snapshot.source.actCode ?? snapshot.act.code)),
|
||||
labelValue('Inspección', text(snapshot.source.inspectionCode ?? snapshot.inspection.code)),
|
||||
labelValue('Fecha de inspección', isoDate(snapshot.act.occurredAt)),
|
||||
labelValue('Urgencia', urgency(snapshot.act.urgency)),
|
||||
labelValue('Plazo', deadline(snapshot.act)),
|
||||
labelValue('Fecha de generación', input.generatedAt.toLocaleString('es-AR')),
|
||||
paragraph('Resumen ejecutivo', 'Heading1'),
|
||||
paragraph(executive),
|
||||
paragraph('Descripción / análisis técnico', 'Heading1'),
|
||||
paragraph(description),
|
||||
paragraph('Acta fuente · contenido inmutable', 'Heading1'),
|
||||
paragraph('El bloque siguiente reproduce información proveniente del Acta sellada. Debe conservarse sin alterar su sentido ni sustituir los Hallazgos originales.'),
|
||||
labelValue('Resumen del Acta', text(snapshot.act.summary)),
|
||||
labelValue('Observaciones del Acta', text(snapshot.act.observations)),
|
||||
labelValue('Representante de la empresa', text(snapshot.responsible.fullName)),
|
||||
labelValue('DNI', text(snapshot.responsible.documentNumber)),
|
||||
labelValue('Cargo / función', text(snapshot.responsible.position)),
|
||||
labelValue('Email', text(snapshot.responsible.email)),
|
||||
paragraph('Inventario inspeccionado', 'Heading1'),
|
||||
inventoryRows.length
|
||||
? table(['Código', 'Nombre', 'Tipo'], inventoryRows)
|
||||
: paragraph('No se registraron elementos de Inventario en el Acta.'),
|
||||
paragraph('Hallazgos', 'Heading1'),
|
||||
findingRows.length
|
||||
? table(['Código', 'Título', 'Descripción', 'Reincidencia', 'Gravedad'], findingRows)
|
||||
: paragraph('El Acta no contiene Hallazgos.'),
|
||||
paragraph('Firmas y manifestaciones', 'Heading1'),
|
||||
signatureRows.length
|
||||
? table(['Tipo', 'Firmante', 'Estado', 'Manifestación', 'Fecha'], signatureRows)
|
||||
: paragraph('No se registraron firmas en la instantánea sellada.'),
|
||||
paragraph('Integridad de la fuente', 'Heading1'),
|
||||
labelValue('Hash de la fuente del INF', input.frozenSha256),
|
||||
labelValue('Hash del Acta sellada', text(snapshot.source.actClosureSha256 ?? snapshot.sealedAct.finalSha256)),
|
||||
labelValue('Hash del contenido bloqueado', text(snapshot.sealedAct.lockedSha256)),
|
||||
paragraph('Este INF permanece editable mientras está en preparación. La edición del informe no modifica el Acta fuente ni los Hallazgos contenidos en ella. La versión oficial será la que se registre posteriormente en GEDO con su identificador IF y PDF oficial.'),
|
||||
].join('');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr></w:body></w:document>`;
|
||||
labelValue('Área o Yacimiento', text(input.scopeName ?? input.areaName)),
|
||||
labelValue('Empresa inspeccionada', text(input.companyName)),
|
||||
paragraph('OBJETIVOS', 'Heading1'),
|
||||
labelValue('General', 'Documentar los resultados de la inspección consignados en el Acta fuente.'),
|
||||
labelValue('Particular', 'Analizar los hallazgos y el estado de las instalaciones inspeccionadas para definir las acciones y verificaciones que correspondan.'),
|
||||
paragraph('MARCO LEGAL', 'Heading1'),
|
||||
...(legalBasis.length ? legalBasis.map((basis) => paragraph(basis)) : [paragraph('No se consignó normativa específica en los hallazgos del Acta fuente.')]),
|
||||
paragraph('DESCRIPCIÓN Y ANÁLISIS TÉCNICO', 'Heading1'),
|
||||
paragraph(input.reportDescription?.trim() || `Según el Acta ${text(snapshot.act.code)}, se documentaron ${snapshot.findings.length} hallazgo(s) durante la inspección. Se detallan las observaciones y evidencias consignadas a continuación.`),
|
||||
paragraph('FOTOS Y HALLAZGOS', 'Heading1'),
|
||||
];
|
||||
if (!snapshot.findings.length) entries.push(paragraph('El Acta fuente no registra hallazgos.'));
|
||||
for (const finding of snapshot.findings) {
|
||||
entries.push(paragraph(`${text(finding.code)} ${text(finding.title)}`, 'Heading2'));
|
||||
entries.push(labelValue('Instalación', text(snapshot.inventories.find((item) => text(item.id) === text(finding.assetId))?.name)));
|
||||
entries.push(paragraph(text(finding.description)));
|
||||
if (finding.legalBasis) entries.push(labelValue('Normativa', text(finding.legalBasis)));
|
||||
if (finding.severity != null) entries.push(labelValue('Gravedad', `${text(finding.severity)}/10`));
|
||||
for (let index = 0; index < photos.length; index++) {
|
||||
const photo = photos[index]!;
|
||||
if (photo.findingId !== text(finding.id) && photo.assetId !== text(finding.assetId)) continue;
|
||||
entries.push(drawing(index + 3, photo.buffer, photo.title || text(finding.title)));
|
||||
entries.push(paragraph(`Fotografía vinculada · SHA-256 ${photo.sha256}`));
|
||||
}
|
||||
}
|
||||
entries.push(
|
||||
paragraph('CONCLUSIONES', 'Heading1'),
|
||||
paragraph(input.executiveSummary?.trim() || `La inspección registró ${snapshot.findings.length} hallazgo(s). Su seguimiento y la respuesta de la empresa se documentan en el Informe.`),
|
||||
paragraph('ACTA FUENTE E INTEGRIDAD', 'Heading1'),
|
||||
labelValue('Acta sellada', text(snapshot.source.actCode ?? snapshot.act.code)),
|
||||
labelValue('SHA-256 del cierre del Acta', text(snapshot.source.actClosureSha256 ?? snapshot.sealedAct.finalSha256)),
|
||||
labelValue('SHA-256 de la fuente del Informe', input.frozenSha256),
|
||||
);
|
||||
const body = entries.join('');
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><w:body>${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr></w:body></w:document>`;
|
||||
}
|
||||
|
||||
function buildZip(entries: ZipEntry[]): Buffer {
|
||||
@@ -232,10 +243,13 @@ function buildZip(entries: ZipEntry[]): Buffer {
|
||||
}
|
||||
|
||||
export function buildInspectionReportWord(input: ReportWordInput): { buffer: Buffer; sha256: string } {
|
||||
const logo = readFileSync(resolve(process.cwd(), 'assets/logo-mendoza.png'));
|
||||
const photos = input.photos ?? [];
|
||||
const mediaRelationships = photos.map((photo, index) => `<Relationship Id="rId${index + 3}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/photo-${index + 1}.${photo.buffer.subarray(0,4).toString('hex') === '89504e47' ? 'png' : 'jpg'}"/>`).join('');
|
||||
const entries: ZipEntry[] = [
|
||||
{
|
||||
name: '[Content_Types].xml',
|
||||
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>', 'utf8'),
|
||||
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="jpg" ContentType="image/jpeg"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>', 'utf8'),
|
||||
},
|
||||
{
|
||||
name: '_rels/.rels',
|
||||
@@ -243,13 +257,15 @@ export function buildInspectionReportWord(input: ReportWordInput): { buffer: Buf
|
||||
},
|
||||
{
|
||||
name: 'word/_rels/document.xml.rels',
|
||||
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>', 'utf8'),
|
||||
data: Buffer.from(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/logo-mendoza.png"/>${mediaRelationships}</Relationships>`, 'utf8'),
|
||||
},
|
||||
{
|
||||
name: 'word/styles.xml',
|
||||
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:rPr><w:sz w:val="20"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="34"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="28"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="22"/></w:rPr></w:style></w:styles>', 'utf8'),
|
||||
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial"/><w:sz w:val="20"/></w:rPr><w:pPr><w:spacing w:after="120" w:line="300" w:lineRule="auto"/></w:pPr></w:style><w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="34"/></w:rPr><w:pPr><w:spacing w:before="160" w:after="180"/></w:pPr></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="26"/></w:rPr><w:pPr><w:keepNext/><w:spacing w:before="260" w:after="130"/></w:pPr></w:style><w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="22"/></w:rPr><w:pPr><w:keepNext/><w:spacing w:before="180" w:after="100"/></w:pPr></w:style></w:styles>', 'utf8'),
|
||||
},
|
||||
{ name: 'word/document.xml', data: Buffer.from(documentXml(input), 'utf8') },
|
||||
{ name: 'word/document.xml', data: Buffer.from(documentXml(input, logo), 'utf8') },
|
||||
{ name: 'word/media/logo-mendoza.png', data: logo },
|
||||
...photos.map((photo, index) => ({ name: `word/media/photo-${index + 1}.${photo.buffer.subarray(0,4).toString('hex') === '89504e47' ? 'png' : 'jpg'}`, data: photo.buffer })),
|
||||
{
|
||||
name: 'docProps/core.xml',
|
||||
data: Buffer.from(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>${xmlEscape(input.title)}</dc:title><dc:creator>DH Inspección</dc:creator><cp:lastModifiedBy>DH Inspección</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">${input.generatedAt.toISOString()}</dcterms:created></cp:coreProperties>`, 'utf8'),
|
||||
|
||||
@@ -5,11 +5,16 @@ import { Injectable, InternalServerErrorException, NotFoundException } from '@ne
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { buildInspectionReportWord } from './inspection-report-word-builder';
|
||||
import { InspectionActPdfService } from './inspection-act-pdf.service';
|
||||
|
||||
const WORD_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
|
||||
interface WordRow {
|
||||
id: string;
|
||||
actId: string;
|
||||
companyName: string | null;
|
||||
areaName: string | null;
|
||||
scopeName: string | null;
|
||||
code: string;
|
||||
title: string;
|
||||
executiveSummary: string | null;
|
||||
@@ -30,7 +35,7 @@ interface WordRow {
|
||||
export class InspectionReportWordService {
|
||||
private readonly root: string;
|
||||
|
||||
constructor(private readonly dataSource: DataSource, config: ConfigService) {
|
||||
constructor(private readonly dataSource: DataSource, private readonly actPdf: InspectionActPdfService, config: ConfigService) {
|
||||
const configured = config.get<string>('INSPECTION_REPORT_WORD_ROOT')
|
||||
?? '/app/storage/asset-media/inspection-reports-word';
|
||||
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_WORD_ROOT must be an absolute path');
|
||||
@@ -48,6 +53,7 @@ export class InspectionReportWordService {
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
const photos = (await this.actPdf.fieldImages(row.actId)).filter((image) => image.findingId || image.assetId);
|
||||
const built = buildInspectionReportWord({
|
||||
code: row.code,
|
||||
title: row.title,
|
||||
@@ -56,6 +62,10 @@ export class InspectionReportWordService {
|
||||
frozenSnapshot: row.frozenSnapshot,
|
||||
executiveSummary: row.executiveSummary,
|
||||
reportDescription: row.reportDescription,
|
||||
companyName: row.companyName,
|
||||
areaName: row.areaName,
|
||||
scopeName: row.scopeName,
|
||||
photos,
|
||||
});
|
||||
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||
const storedName = `${row.id}-${built.sha256.slice(0, 16)}.docx`;
|
||||
@@ -114,6 +124,56 @@ export class InspectionReportWordService {
|
||||
};
|
||||
}
|
||||
|
||||
// The prior editable Word and its revision history stay available unchanged.
|
||||
async consolidatedContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
|
||||
const [existing] = await this.dataSource.query(`
|
||||
SELECT stored_name AS "storedName",original_name AS "originalName",
|
||||
size_bytes AS "sizeBytes",sha256
|
||||
FROM inspection_report_consolidated_word_artifacts WHERE report_id=$1
|
||||
`, [reportId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
|
||||
if (existing) return this.verifiedConsolidated(existing);
|
||||
const row = await this.load(reportId);
|
||||
const photos = (await this.actPdf.fieldImages(row.actId)).filter((image) => image.findingId || image.assetId);
|
||||
const built = buildInspectionReportWord({
|
||||
code: row.code, title: row.title, generatedAt: new Date(row.generatedAt),
|
||||
frozenSha256: row.frozenSha256, frozenSnapshot: row.frozenSnapshot,
|
||||
executiveSummary: row.executiveSummary, reportDescription: row.reportDescription,
|
||||
companyName: row.companyName, areaName: row.areaName, scopeName: row.scopeName, photos,
|
||||
});
|
||||
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||
const storedName = `${row.id}-consolidado-${built.sha256.slice(0, 24)}.docx`;
|
||||
const originalName = `${row.code}-consolidado.docx`;
|
||||
await writeFile(resolve(this.root, storedName), built.buffer, { flag: 'wx', mode: 0o600 }).catch(async (error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== 'EEXIST') throw error;
|
||||
const previous = await this.verifiedConsolidated({
|
||||
storedName, originalName, sizeBytes: built.buffer.length, sha256: built.sha256,
|
||||
});
|
||||
if (!(await readFile(previous.filePath)).equals(built.buffer)) throw this.storageError();
|
||||
});
|
||||
await this.dataSource.query(`
|
||||
INSERT INTO inspection_report_consolidated_word_artifacts(report_id,stored_name,original_name,size_bytes,sha256)
|
||||
VALUES($1,$2,$3,$4,$5) ON CONFLICT (report_id) DO NOTHING
|
||||
`, [reportId, storedName, originalName, built.buffer.length, built.sha256]);
|
||||
const [saved] = await this.dataSource.query(`
|
||||
SELECT stored_name AS "storedName",original_name AS "originalName",
|
||||
size_bytes AS "sizeBytes",sha256
|
||||
FROM inspection_report_consolidated_word_artifacts WHERE report_id=$1
|
||||
`, [reportId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
|
||||
if (!saved) throw this.storageError();
|
||||
return this.verifiedConsolidated(saved);
|
||||
}
|
||||
|
||||
private async verifiedConsolidated(row: { storedName: string; originalName: string; sizeBytes: number; sha256: string }): Promise<{ filePath: string; originalName: string; mimeType: string }> {
|
||||
if (!/^[A-Za-z0-9_.-]+$/.test(row.storedName)) throw this.storageError();
|
||||
const filePath = resolve(this.root, row.storedName);
|
||||
if (!filePath.startsWith(`${this.root}/`)) throw this.storageError();
|
||||
const file = await stat(filePath).catch(() => null);
|
||||
if (!file?.isFile() || file.size !== Number(row.sizeBytes)) throw this.storageError();
|
||||
const buffer = await readFile(filePath);
|
||||
if (createHash('sha256').update(buffer).digest('hex') !== row.sha256) throw this.storageError();
|
||||
return { filePath, originalName: row.originalName, mimeType: WORD_MIME };
|
||||
}
|
||||
|
||||
private async ensureInitialRevision(row: WordRow): Promise<void> {
|
||||
if (
|
||||
row.wordStatus !== 'READY'
|
||||
@@ -150,21 +210,26 @@ export class InspectionReportWordService {
|
||||
|
||||
private async load(reportId: string): Promise<WordRow> {
|
||||
const [row] = await this.dataSource.query(`
|
||||
SELECT id,code,title,
|
||||
executive_summary AS "executiveSummary",
|
||||
report_description AS "reportDescription",
|
||||
generated_at AS "generatedAt",
|
||||
frozen_sha256 AS "frozenSha256",
|
||||
frozen_snapshot AS "frozenSnapshot",
|
||||
word_status AS "wordStatus",
|
||||
word_original_name AS "wordOriginalName",
|
||||
word_stored_name AS "wordStoredName",
|
||||
word_mime_type AS "wordMimeType",
|
||||
SELECT report.id,report.act_id AS "actId",report.code,report.title,
|
||||
company.name AS "companyName",area.name AS "areaName",scope.name AS "scopeName",
|
||||
report.executive_summary AS "executiveSummary",
|
||||
report.report_description AS "reportDescription",
|
||||
report.generated_at AS "generatedAt",
|
||||
report.frozen_sha256 AS "frozenSha256",
|
||||
report.frozen_snapshot AS "frozenSnapshot",
|
||||
report.word_status AS "wordStatus",
|
||||
report.word_original_name AS "wordOriginalName",
|
||||
report.word_stored_name AS "wordStoredName",
|
||||
report.word_mime_type AS "wordMimeType",
|
||||
word_size_bytes::integer AS "wordSizeBytes",
|
||||
word_sha256 AS "wordSha256",
|
||||
generated_by AS "generatedBy"
|
||||
FROM inspection_reports
|
||||
WHERE id=$1
|
||||
report.word_sha256 AS "wordSha256",
|
||||
report.generated_by AS "generatedBy"
|
||||
FROM inspection_reports report
|
||||
JOIN inspection_visits visit ON visit.id=report.visit_id
|
||||
LEFT JOIN assets company ON company.id=visit.operator_company_id
|
||||
LEFT JOIN assets area ON area.id=visit.operational_area_id
|
||||
LEFT JOIN assets scope ON scope.id=visit.scope_asset_id
|
||||
WHERE report.id=$1
|
||||
`, [reportId]) as WordRow[];
|
||||
if (!row) {
|
||||
throw new NotFoundException({
|
||||
|
||||
@@ -258,6 +258,23 @@ export class InspectionReportWorkflowService {
|
||||
};
|
||||
}
|
||||
|
||||
async followUpContent(reportId: string, followUpId: string): Promise<{ filePath: string; originalName: string; mimeType: string; sizeBytes: number }> {
|
||||
const [item] = await this.dataSource.query(`
|
||||
SELECT original_name AS "originalName",stored_name AS "storedName",
|
||||
mime_type AS "mimeType",size_bytes::integer AS "sizeBytes",sha256
|
||||
FROM inspection_report_follow_ups WHERE report_id=$1 AND id=$2 AND stored_name IS NOT NULL
|
||||
`, [reportId, followUpId]) as Array<{ originalName: string; storedName: string; mimeType: string; sizeBytes: number; sha256: string }>;
|
||||
if (!item) throw new NotFoundException({ code: 'REPORT_FOLLOW_UP_FILE_NOT_FOUND', message: 'Adjunto de seguimiento inexistente' });
|
||||
if (!/^followup-[a-f0-9-]+\.[a-z0-9]+$/.test(item.storedName)) throw this.reportStorageError();
|
||||
const filePath = resolve(this.root, item.storedName);
|
||||
if (!filePath.startsWith(`${this.root}/`)) throw this.reportStorageError();
|
||||
const file = await stat(filePath).catch(() => null);
|
||||
if (!file?.isFile() || file.size !== item.sizeBytes) throw this.reportStorageError();
|
||||
const buffer = await readFile(filePath);
|
||||
if (createHash('sha256').update(buffer).digest('hex') !== item.sha256) throw this.reportStorageError();
|
||||
return { filePath, originalName: item.originalName, mimeType: item.mimeType, sizeBytes: item.sizeBytes };
|
||||
}
|
||||
|
||||
async addFollowUp(
|
||||
reportId: string,
|
||||
dto: CreateInspectionReportFollowUpDto,
|
||||
|
||||
@@ -69,6 +69,20 @@ export class InspectionReportsController {
|
||||
return response.sendFile(content.filePath);
|
||||
}
|
||||
|
||||
@Get(':id/consolidated-word')
|
||||
@RequirePermissions('inspection_reports.read')
|
||||
async consolidatedWordContent(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Res() response: Response,
|
||||
) {
|
||||
const content = await this.word.consolidatedContent(id);
|
||||
response.setHeader('Content-Type', content.mimeType);
|
||||
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
|
||||
response.setHeader('Cache-Control', 'private, no-store');
|
||||
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
return response.sendFile(content.filePath);
|
||||
}
|
||||
|
||||
@Get(':id/gedo-pdf')
|
||||
@RequirePermissions('inspection_reports.read')
|
||||
async gedoPdfContent(
|
||||
@@ -113,6 +127,23 @@ export class InspectionReportsController {
|
||||
return this.workflow.listFollowUps(id);
|
||||
}
|
||||
|
||||
@Get(':id/follow-ups/:followUpId/content')
|
||||
@RequirePermissions('inspection_reports.read')
|
||||
async followUpContent(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Param('followUpId', new ParseUUIDPipe({ version: '4' })) followUpId: string,
|
||||
@Res() response: Response,
|
||||
): Promise<void> {
|
||||
const file = await this.workflow.followUpContent(id, followUpId);
|
||||
const safeName = file.originalName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
||||
response.setHeader('Content-Type', file.mimeType);
|
||||
response.setHeader('Content-Length', String(file.sizeBytes));
|
||||
response.setHeader('Content-Disposition', `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(file.originalName)}`);
|
||||
response.setHeader('Cache-Control', 'private, no-store');
|
||||
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
await new Promise<void>((resolveSend, rejectSend) => response.sendFile(file.filePath, (error) => error ? rejectSend(error) : resolveSend()));
|
||||
}
|
||||
|
||||
@Post(':id/follow-ups')
|
||||
@RequirePermissions('inspection_reports.generate')
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
@@ -170,3 +201,23 @@ export class InspectionActPdfController {
|
||||
return response.send(content.buffer);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('inspection-acts/:actId/consolidated-pdf')
|
||||
export class InspectionActConsolidatedPdfController {
|
||||
constructor(private readonly pdf: InspectionActPdfService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('inspection_acts.read')
|
||||
async content(
|
||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||
@Res() response: Response,
|
||||
) {
|
||||
const content = await this.pdf.consolidatedContent(actId);
|
||||
response.setHeader('Content-Type', content.mimeType);
|
||||
response.setHeader('Content-Length', String(content.buffer.length));
|
||||
response.setHeader('Content-Disposition', `inline; filename="${content.originalName.replaceAll('"', '')}"`);
|
||||
response.setHeader('Cache-Control', 'private, no-store');
|
||||
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
return response.send(content.buffer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { InspectionDeadlineAdminService } from './inspection-deadline-admin.serv
|
||||
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
|
||||
import { InspectionReportWorkflowService } from './inspection-report-workflow.service';
|
||||
import { InspectionReportWordService } from './inspection-report-word.service';
|
||||
import { InspectionActPdfController, InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
|
||||
import { InspectionActConsolidatedPdfController, InspectionActPdfController, InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
|
||||
import { InspectionReportsService } from './inspection-reports.service';
|
||||
import { SmtpDeliveryService } from './smtp-delivery.service';
|
||||
|
||||
@@ -17,6 +17,7 @@ import { SmtpDeliveryService } from './smtp-delivery.service';
|
||||
InspectionReportsController,
|
||||
InspectionActReportController,
|
||||
InspectionActPdfController,
|
||||
InspectionActConsolidatedPdfController,
|
||||
InspectionDeadlineAdminController,
|
||||
DocumentDeliveryController,
|
||||
],
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const API_VERSION = '0.29.0-8';
|
||||
export const API_PHASE = 'F6.8';
|
||||
export const API_VERSION = '0.29.0-9';
|
||||
export const API_PHASE = 'F6.9';
|
||||
|
||||
@@ -36,15 +36,14 @@ test('F4 document center and inspection detail consume the F4 act client', () =>
|
||||
test('F4 closure UI treats sealing as the definitive act state', () => {
|
||||
assert.match(closurePanel, /getInspectionClosureF4/);
|
||||
assert.match(closurePanel, /act\.status === 'SEALED'/);
|
||||
assert.match(closurePanel, /ACTA SELLADA/);
|
||||
assert.match(closurePanel, /manifestación de la empresa puede completarse/);
|
||||
assert.match(closurePanel, /Pendiente de evento válido/);
|
||||
assert.match(closurePanel, /Participantes y firmas/);
|
||||
assert.doesNotMatch(closurePanel, /Pendiente de evento válido/);
|
||||
assert.doesNotMatch(closurePanel, /reabrir ni cerrar el acta/);
|
||||
});
|
||||
|
||||
test('F4 presentation keeps new lifecycle labels while retaining explicit legacy readability', () => {
|
||||
assert.match(presentation, /LOCKED: 'Bloqueada/);
|
||||
assert.match(presentation, /SEALED: 'Sellada'/);
|
||||
assert.match(presentation, /LOCKED: 'Para firmar/);
|
||||
assert.match(presentation, /SEALED: 'Firmada y cerrada'/);
|
||||
assert.match(presentation, /READY: 'Lista para cerrar · legado'/);
|
||||
assert.match(presentation, /CLOSED: 'Cerrada · legado'/);
|
||||
assert.match(presentation, /CLOSED: 'Firmada y cerrada'/);
|
||||
});
|
||||
|
||||
@@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { API_PHASE, API_VERSION } from '../../src/version';
|
||||
|
||||
test('health metadata reports the current F6.8 release', () => {
|
||||
assert.equal(API_PHASE, 'F6.8');
|
||||
test('health metadata reports the current F6.9 release', () => {
|
||||
assert.equal(API_PHASE, 'F6.9');
|
||||
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
|
||||
assert.equal(API_VERSION, pkg.version);
|
||||
assert.equal(API_VERSION, '0.29.0-8');
|
||||
assert.equal(API_VERSION, '0.29.0-9');
|
||||
});
|
||||
@@ -13,7 +13,7 @@ test('F6.1 presentation metadata keeps the visible WEB version aligned with pack
|
||||
const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
|
||||
|
||||
assert.equal(visibleVersion, pkg.version);
|
||||
assert.match(version, /APP_PHASE\s*=\s*'F6\.8/);
|
||||
assert.match(version, /APP_PHASE\s*=\s*'F6\.9/);
|
||||
});
|
||||
|
||||
test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
|
||||
|
||||
@@ -40,6 +40,7 @@ test('F6.2 exposes provisional PDF data contracts for Actas and Informes', () =>
|
||||
assert.match(projection, /Firmas y manifestaciones/);
|
||||
assert.match(projection, /GEDO e integridad/);
|
||||
assert.match(projection, /Anexo final/);
|
||||
assert.match(actPage, /<DocumentPdfProjection kind="act" \/>/);
|
||||
assert.match(reportPage, /<DocumentPdfProjection kind="report" \/>/);
|
||||
assert.match(actPage, /getInspectionActConsolidatedPdfBlob/);
|
||||
assert.doesNotMatch(actPage, /DocumentPdfProjection/);
|
||||
assert.doesNotMatch(reportPage, /DocumentPdfProjection/);
|
||||
});
|
||||
|
||||
@@ -65,9 +65,9 @@ test('F6.8 makes the real Acta PDF and photographic record first-class in Dashbo
|
||||
assert.match(api('src/inspection-acts/inspection-acts.service.ts'), /capture\.visit_id=act\.visit_id/);
|
||||
assert.match(page, /Abrir PDF del Acta/);
|
||||
assert.match(page, /Descargar PDF/);
|
||||
assert.match(media, /Fotos y Hallazgos/);
|
||||
assert.match(media, /Evidencia fotográfica/);
|
||||
assert.match(media, /Fotos tomadas durante esta inspección/);
|
||||
assert.match(media, /Hallazgos del Acta/);
|
||||
assert.match(media, /act-finding-photos/);
|
||||
assert.match(media, /listInspectionFindingEvidence/);
|
||||
});
|
||||
|
||||
test('F6.8 presents technical families to users as installation and subinstallation types', () => {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { buildInspectionActPdf } from '../../src/inspection-reports/inspection-act-pdf-builder';
|
||||
import { buildInspectionReportWord } from '../../src/inspection-reports/inspection-report-word-builder';
|
||||
import sharp from 'sharp';
|
||||
import { renderableInspectionImage } from '../../src/inspection-reports/inspection-document-images';
|
||||
|
||||
const digest = (data: Buffer) => createHash('sha256').update(data).digest('hex');
|
||||
const photo = readFileSync(resolve(process.cwd(), 'assets/logo-mendoza.png'));
|
||||
const locked = {
|
||||
act: { code: 'ACT-TEST-01', occurredAt: '2026-09-14T18:30:00Z',
|
||||
summary: 'Se inspeccionó un tanque.', inspection: { code: 'INSP-TEST-01' } },
|
||||
responsible: { fullName: 'Responsable de prueba', documentNumber: '12345678' },
|
||||
inventories: [{ id: 'asset-1', code: 'CAM-TEST-01', name: 'Tanque de prueba' }],
|
||||
findings: [{ id: 'finding-1', assetId: 'asset-1', code: 'ACT-TEST-H01',
|
||||
title: 'Pérdida', description: 'Se constató una pérdida visible.', severity: 3 }],
|
||||
};
|
||||
const sealed = { lockedSnapshot: locked, finalSha256: 'a'.repeat(64),
|
||||
lockedSha256: 'b'.repeat(64), signatures: [], seal: { serverSealedAt: '2026-09-15T18:30:00Z' } };
|
||||
const evidence = { id: 'photo-1', findingId: 'finding-1', sha256: digest(photo), buffer: photo };
|
||||
|
||||
test('F6.9 consolidated Acta is a hashed PDF 1.4 with actual evidence embedded', async () => {
|
||||
const without = await buildInspectionActPdf(sealed);
|
||||
const withEvidence = await buildInspectionActPdf(sealed, [evidence]);
|
||||
assert.ok(withEvidence.buffer.subarray(0, 8).equals(Buffer.from('%PDF-1.4')));
|
||||
assert.equal(withEvidence.sha256, digest(withEvidence.buffer));
|
||||
assert.ok(withEvidence.buffer.length > without.buffer.length + 3_000);
|
||||
assert.notEqual(withEvidence.sha256, without.sha256);
|
||||
});
|
||||
|
||||
test('F6.9 technical Informe embeds evidence and its institutional source hash', () => {
|
||||
const input = { code: 'INF-TEST-01', title: 'Informe de prueba', generatedAt: new Date('2026-09-15'),
|
||||
frozenSha256: 'c'.repeat(64), frozenSnapshot: { sealedAct: sealed, source: { actCode: locked.act.code } } };
|
||||
const plain = buildInspectionReportWord(input);
|
||||
const withEvidence = buildInspectionReportWord({ ...input, photos: [evidence] });
|
||||
assert.equal(withEvidence.sha256, digest(withEvidence.buffer));
|
||||
assert.ok(withEvidence.buffer.subarray(0, 4).equals(Buffer.from('PK\x03\x04')));
|
||||
assert.ok(withEvidence.buffer.includes(Buffer.from('word/media/photo-1.png')));
|
||||
assert.ok(withEvidence.buffer.includes(photo));
|
||||
assert.ok(withEvidence.buffer.includes(Buffer.from('ACT-TEST-H01')));
|
||||
assert.ok(withEvidence.buffer.includes(Buffer.from('SHA-256 de la fuente del Informe')));
|
||||
assert.ok(withEvidence.buffer.length > plain.buffer.length + photo.length);
|
||||
assert.ok(!withEvidence.buffer.includes(Buffer.from('[Completar')));
|
||||
});
|
||||
|
||||
test('F6.9 keeps prior sealed PDF and prior company responses available for audit', () => {
|
||||
const pdf = readFileSync(resolve(process.cwd(), 'src/inspection-reports/inspection-act-pdf.service.ts'), 'utf8');
|
||||
const controller = readFileSync(resolve(process.cwd(), 'src/act-administration/act-administration.controller.ts'), 'utf8');
|
||||
assert.match(pdf, /inspection_act_consolidated_pdf_artifacts/);
|
||||
const word = readFileSync(resolve(process.cwd(), 'src/inspection-reports/inspection-report-word.service.ts'), 'utf8');
|
||||
assert.match(word, /inspection_report_consolidated_word_artifacts/);
|
||||
assert.match(pdf, /ON CONFLICT \(act_id\) DO NOTHING/);
|
||||
assert.match(controller, /responseContent\(responseId\)/);
|
||||
assert.doesNotMatch(controller, /@Post\('responses'\)/);
|
||||
});
|
||||
|
||||
test('F6.9 renders WebP field photographs in both documents without changing the source hash', async () => {
|
||||
const original = await sharp(photo).webp().toBuffer();
|
||||
const originalSha = digest(original);
|
||||
const display = await renderableInspectionImage(original);
|
||||
assert.ok(display.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])));
|
||||
assert.equal(digest(original), originalSha);
|
||||
const pdf = await buildInspectionActPdf(sealed, [{ ...evidence, sha256: originalSha, buffer: display }]);
|
||||
assert.ok(pdf.buffer.subarray(0, 8).equals(Buffer.from('%PDF-1.4')));
|
||||
const word = buildInspectionReportWord({ code: 'INF-WEBP', title: 'Informe', generatedAt: new Date(),
|
||||
frozenSha256: 'c'.repeat(64), frozenSnapshot: { sealedAct: sealed }, photos: [{ ...evidence, sha256: originalSha, buffer: display }] });
|
||||
assert.ok(word.buffer.includes(display));
|
||||
assert.ok(word.buffer.includes(Buffer.from(originalSha)));
|
||||
});
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "dhv2-web",
|
||||
"version": "0.23.0-5",
|
||||
"version": "0.23.0-6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dhv2-web",
|
||||
"version": "0.23.0-5",
|
||||
"version": "0.23.0-6",
|
||||
"dependencies": {
|
||||
"maplibre-gl": "6.4.1",
|
||||
"react": "^19.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dhv2-web",
|
||||
"version": "0.23.0-5",
|
||||
"version": "0.23.0-6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '0.23.0-5';
|
||||
export const APP_PHASE = 'F6.8 · Offline seguro y Acta documental';
|
||||
export const APP_VERSION = '0.23.0-6';
|
||||
export const APP_PHASE = 'F6.9 · Actas e informes consolidados';
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../../auth/AuthContext';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
|
||||
import { Icon } from '../../components/Icon';
|
||||
import { getInspectionSignatureBlob } from '../../lib/api';
|
||||
import type { InspectionActSignature } from '../../lib/api';
|
||||
import {
|
||||
getInspectionClosureF4,
|
||||
type InspectionActF4,
|
||||
type InspectionClosureF4,
|
||||
} from '../../lib/inspectionActF4Api';
|
||||
import { getInspectionClosureF4, type InspectionActF4, type InspectionClosureF4 } from '../../lib/inspectionActF4Api';
|
||||
import { formatDate } from '../../lib/format';
|
||||
|
||||
function signatureStatusLabel(value: InspectionActSignature['status']): string {
|
||||
if (value === 'SIGNED') return 'Firmada';
|
||||
if (value === 'REFUSED') return 'Se negó a firmar';
|
||||
return 'Ausente';
|
||||
}
|
||||
|
||||
function signerTypeLabel(value: InspectionActSignature['signerType']): string {
|
||||
return value === 'INSPECTOR' ? 'Inspector/a' : 'Responsable de la empresa';
|
||||
function signatureOutcome(signature: InspectionActSignature): string {
|
||||
if (signature.status === 'REFUSED') return `Se negó a firmar${signature.reason ? ` · ${signature.reason}` : ''}`;
|
||||
if (signature.status !== 'SIGNED') return 'No firmó';
|
||||
if (signature.signerType === 'INSPECTOR') return 'Firmó como inspector/a';
|
||||
if (signature.companyManifestation === 'DISSENT') return `Firmó en disconformidad${signature.companyStatement ? ` · ${signature.companyStatement}` : ''}`;
|
||||
return 'Firmó en conformidad';
|
||||
}
|
||||
|
||||
export function InspectionClosurePanel({ act }: { act: InspectionActF4 }) {
|
||||
@@ -27,25 +20,11 @@ export function InspectionClosurePanel({ act }: { act: InspectionActF4 }) {
|
||||
const [closure, setClosure] = useState<InspectionClosureF4 | null>(null);
|
||||
const [loading, setLoading] = useState(canRead);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!canRead) return;
|
||||
setLoading(true);
|
||||
getInspectionClosureF4(act.id)
|
||||
.then(setClosure)
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
getInspectionClosureF4(act.id).then(setClosure).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
|
||||
}, [act.id, canRead]);
|
||||
|
||||
const inspectorSignatures = useMemo(
|
||||
() => closure?.signatures.filter((item) => item.signerType === 'INSPECTOR') ?? [],
|
||||
[closure],
|
||||
);
|
||||
const companyOutcome = useMemo(
|
||||
() => closure?.signatures.find((item) => item.signerType === 'COMPANY_RESPONSIBLE') ?? null,
|
||||
[closure],
|
||||
);
|
||||
|
||||
const viewSignature = async (signature: InspectionActSignature) => {
|
||||
const tab = window.open('about:blank', '_blank');
|
||||
if (tab) tab.opener = null;
|
||||
@@ -55,53 +34,19 @@ export function InspectionClosurePanel({ act }: { act: InspectionActF4 }) {
|
||||
if (tab) tab.location.href = url;
|
||||
else window.open(url, '_blank', 'noopener,noreferrer');
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
} catch (requestError) {
|
||||
tab?.close();
|
||||
setError(errorMessage(requestError));
|
||||
}
|
||||
} catch (requestError) { tab?.close(); setError(errorMessage(requestError)); }
|
||||
};
|
||||
|
||||
if (!canRead) return null;
|
||||
if (loading || !closure) return <section className="panel inspection-closure-panel"><LoadingBlock label="Cargando cierre del acta…" /></section>;
|
||||
|
||||
const constanciasCompletas = inspectorSignatures.length > 0 && Boolean(companyOutcome);
|
||||
const isSealed = act.status === 'SEALED' || act.status === 'CLOSED';
|
||||
const isLocked = act.status === 'LOCKED' || act.status === 'READY';
|
||||
const lifecycleLabel = isSealed
|
||||
? 'Acta sellada'
|
||||
: isLocked
|
||||
? 'Esperando manifestación'
|
||||
: act.status === 'CANCELLED'
|
||||
? 'Acta cancelada'
|
||||
: 'Borrador en campo';
|
||||
const lifecycleClass = isSealed ? 'active' : isLocked ? 'observed' : act.status === 'CANCELLED' ? 'inactive' : 'pending';
|
||||
|
||||
return <section className="panel inspection-closure-panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">CIERRE DEL ACTA · SÓLO LECTURA</span><h2>Responsable, firmas y sellado</h2><p className="section-copy">Al bloquearse, el contenido del Acta queda inmutable. La Inspección puede continuar y generar otras Actas.</p></div><span className={`status-badge large ${lifecycleClass}`}>{lifecycleLabel}</span></div>
|
||||
|
||||
if (loading || !closure) return <section className="panel"><LoadingBlock label="Cargando firmas…" /></section>;
|
||||
return <section className="panel act-signatures-panel">
|
||||
<div className="panel-heading"><div><h2>Participantes y firmas</h2></div></div>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>El dashboard no modifica el Acta.</strong> El bloqueo y la firma del Inspector se realizan desde la APK. La manifestación de la empresa puede completarse en campo o mediante el enlace seguro posterior.</p></div>
|
||||
|
||||
<div className="closure-step-grid">
|
||||
<div className={closure.responsible ? 'complete' : ''}><span>1</span><strong>Responsable</strong><small>{closure.responsible ? 'Registrado' : 'Pendiente'}</small></div>
|
||||
<div className={closure.closure?.isCurrent ? 'complete' : ''}><span>2</span><strong>Bloqueo</strong><small>{closure.closure?.isCurrent ? 'Contenido inmutable' : 'Pendiente'}</small></div>
|
||||
<div className={constanciasCompletas ? 'complete' : ''}><span>3</span><strong>Manifestaciones</strong><small>{inspectorSignatures.length} inspector · {companyOutcome ? 'empresa resuelta' : 'empresa pendiente'}</small></div>
|
||||
<div className={isSealed ? 'complete' : ''}><span>4</span><strong>Sellado</strong><small>{isSealed ? 'Definitivo' : 'Pendiente'}</small></div>
|
||||
</div>
|
||||
|
||||
<div className="responsible-summary">
|
||||
<div><small>Urgencia</small><strong>{closure.act.urgency === 'URGENT' ? 'Urgente' : closure.act.urgency === 'NON_URGENT' ? 'No urgente' : 'Pendiente de cierre'}</strong></div>
|
||||
<div><small>Plazo configurado</small><strong>{closure.act.deadlineDays ? `${closure.act.deadlineDays} días ${closure.act.deadlineDayType === 'BUSINESS' ? 'hábiles' : 'corridos'}` : 'Pendiente'}</strong></div>
|
||||
<div><small>Inicio del plazo</small><strong>{closure.act.deadlineBaseAt ? formatDate(closure.act.deadlineBaseAt) : 'Pendiente de evento válido'}</strong></div>
|
||||
<div><small>Vencimiento</small><strong>{closure.act.deadlineAt ? formatDate(closure.act.deadlineAt) : 'Todavía no iniciado'}</strong></div>
|
||||
</div>
|
||||
|
||||
{closure.responsible && <div className="responsible-summary"><div><small>Situación</small><strong>{closure.responsible.attendanceStatus === 'PRESENT' ? 'Presente' : 'Ausente'}</strong></div><div><small>Responsable</small><strong>{closure.responsible.fullName ?? 'No estuvo presente'}</strong></div><div><small>Documento / cargo</small><strong>{closure.responsible.documentNumber ? `${closure.responsible.documentType} ${closure.responsible.documentNumber}` : 'No informado'}{closure.responsible.position ? ` · ${closure.responsible.position}` : ''}</strong></div><div><small>Contacto</small><strong>{closure.responsible.email ?? closure.responsible.phone ?? 'No informado'}</strong></div></div>}
|
||||
|
||||
{closure.closure?.isCurrent && <div className="closure-hash-card"><div><span className="eyebrow">CONTENIDO BLOQUEADO</span><strong>{closure.closure.schemaVersion}</strong><small>Bloqueado {formatDate(closure.act.lockedAt ?? closure.closure.preparedAt)}</small></div><code>{closure.act.lockedSha256 ?? closure.closure.preparedSha256}</code></div>}
|
||||
|
||||
{closure.signatures.length > 0 && <div className="signature-records">{closure.signatures.map((signature) => <article key={signature.id}><div><span className={`status-badge ${signature.status === 'SIGNED' ? 'active' : 'observed'}`}>{signatureStatusLabel(signature.status)}</span><strong>{signature.signerName}</strong><small>{signerTypeLabel(signature.signerType)} · {formatDate(signature.createdAt)}</small></div><code title={signature.signaturePayloadSha256}>{signature.signaturePayloadSha256}</code>{signature.status === 'SIGNED' ? <><button type="button" className="button secondary" onClick={() => viewSignature(signature)}>Ver firma</button>{signature.signerType === 'COMPANY_RESPONSIBLE' && <p><strong>{signature.companyManifestation === 'DISSENT' ? 'Firma en disidencia' : 'Firma en conformidad'}</strong>{signature.companyStatement ? ` · ${signature.companyStatement}` : ''}</p>}</> : <p>{signature.reason}</p>}</article>)}</div>}
|
||||
|
||||
{isSealed && closure.closure?.finalSha256 && <div className="closed-seal"><Icon name="check" /><div><span className="eyebrow">ACTA SELLADA</span><strong>{formatDate(closure.act.sealedAt ?? closure.closure.serverClosedAt)}</strong><p>El Acta quedó sellada e inmutable. La Inspección y sus demás Actas continúan con ciclo independiente.</p><code>{closure.closure.finalSha256}</code></div></div>}
|
||||
{closure.responsible && <p><strong>Representante de la empresa:</strong> {closure.responsible.fullName ?? 'No estuvo presente'}{closure.responsible.documentNumber ? ` · ${closure.responsible.documentType} ${closure.responsible.documentNumber}` : ''}{closure.responsible.position ? ` · ${closure.responsible.position}` : ''}</p>}
|
||||
{closure.signatures.length ? <div className="act-signer-list">{closure.signatures.map((signature) => <div key={signature.id} className="act-signer-row">
|
||||
<div><strong>{signature.signerName}</strong><small>{signature.signerType === 'INSPECTOR' ? 'Inspector/a' : 'Representante de la empresa'} · {formatDate(signature.signedAt ?? signature.createdAt)}</small><p>{signatureOutcome(signature)}</p></div>
|
||||
{signature.status === 'SIGNED' && <button type="button" className="button secondary" onClick={() => void viewSignature(signature)}>Ver firma</button>}
|
||||
</div>)}</div> : <p className="muted">Las firmas todavía no se registraron.</p>}
|
||||
{(act.status === 'SEALED' || act.status === 'CLOSED') && <p className="act-closed-date">Acta firmada y cerrada {formatDate(closure.act.sealedAt ?? closure.act.closedAt)}</p>}
|
||||
{closure.closure?.finalSha256 && <details className="act-integrity-details"><summary>Verificar integridad del Acta</summary><p>SHA-256 del cierre: <code>{closure.closure.finalSha256}</code></p></details>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { InspectionActVersionEvent } from '../../lib/api';
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
DRAFT: 'Borrador',
|
||||
LOCKED: 'Bloqueada · esperando manifestación',
|
||||
SEALED: 'Sellada',
|
||||
DRAFT: 'En elaboración',
|
||||
LOCKED: 'Para firmar',
|
||||
SEALED: 'Firmada y cerrada',
|
||||
READY: 'Lista para cerrar · legado',
|
||||
CLOSED: 'Cerrada · legado',
|
||||
CLOSED: 'Firmada y cerrada',
|
||||
CANCELLED: 'Cancelada',
|
||||
RECTIFIED: 'Rectificada · legado',
|
||||
};
|
||||
|
||||
@@ -18,13 +18,12 @@ const operational: NavItem[] = [
|
||||
];
|
||||
|
||||
const followUp: NavItem[] = [
|
||||
{ to: '/seguimiento-actas', label: 'Seguimiento de actas', icon: 'clipboard', permission: 'inspection_acts.read' },
|
||||
{ to: '/hallazgos', label: 'Hallazgos', icon: 'alert', permission: 'inspection_findings.read' },
|
||||
];
|
||||
|
||||
const documents: NavItem[] = [
|
||||
{ to: '/actas', label: 'Actas', icon: 'clipboard', permission: 'inspection_acts.read' },
|
||||
{ to: '/informes', label: 'Informes', icon: 'audit', permission: 'inspection_reports.read' },
|
||||
{ to: '/informes', label: 'Informes y respuestas', icon: 'audit', permission: 'inspection_reports.read' },
|
||||
];
|
||||
|
||||
const master: NavItem[] = [
|
||||
|
||||
@@ -2707,6 +2707,10 @@ export function getInspectionAct(id: string) {
|
||||
return apiRequest<InspectionAct>(`/inspection-acts/${id}`);
|
||||
}
|
||||
|
||||
export function getInspectionActConsolidatedPdfBlob(actId: string) {
|
||||
return apiBlobRequest(`/inspection-acts/${actId}/consolidated-pdf`);
|
||||
}
|
||||
|
||||
export function getInspectionActPdfBlob(actId: string) {
|
||||
return apiBlobRequest(`/inspection-acts/${actId}/pdf`);
|
||||
}
|
||||
|
||||
@@ -205,3 +205,11 @@ export function inspectionReportWordDownloadUrl(id: string) {
|
||||
export function inspectionReportGedoPdfDownloadUrl(id: string) {
|
||||
return `/api/v3/inspection-reports/${id}/gedo-pdf`;
|
||||
}
|
||||
|
||||
export function inspectionReportFollowUpDownloadUrl(reportId: string, followUpId: string) {
|
||||
return `/api/v3/inspection-reports/${reportId}/follow-ups/${followUpId}/content`;
|
||||
}
|
||||
|
||||
export function inspectionReportConsolidatedWordDownloadUrl(reportId: string) {
|
||||
return `/api/v3/inspection-reports/${reportId}/consolidated-word`;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useParams } from 'react-router';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { addActCompanyResponse, actCompanyResponseContentUrl, getActAdministration, setActResponseDeadline } from '../lib/api';
|
||||
import { actCompanyResponseContentUrl, getActAdministration } from '../lib/api';
|
||||
import type { ActAdministrationDetail } from '../lib/api';
|
||||
import { getInspectionActF4 } from '../lib/inspectionActF4Api';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
export function ActAdministrationDetailPage(){
|
||||
const {actId=''}=useParams(); const [data,setData]=useState<ActAdministrationDetail|null>(null); const [error,setError]=useState(''); const [busy,setBusy]=useState(false); const [due,setDue]=useState(''); const [reason,setReason]=useState('Plazo administrativo otorgado a la empresa'); const [received,setReceived]=useState(new Date().toISOString().slice(0,10)); const [details,setDetails]=useState(''); const [commitment,setCommitment]=useState(''); const [file,setFile]=useState<File|undefined>();
|
||||
const load=()=>getActAdministration(actId).then(setData).catch(e=>setError(errorMessage(e))); useEffect(()=>{void load()},[actId]);
|
||||
const deadline=async(e:FormEvent)=>{e.preventDefault();setBusy(true);setError('');try{await setActResponseDeadline(actId,{responseDueOn:due,reason});setDue('');await load()}catch(err){setError(errorMessage(err))}finally{setBusy(false)}};
|
||||
const response=async(e:FormEvent)=>{e.preventDefault();setBusy(true);setError('');try{await addActCompanyResponse(actId,{receivedOn:received,details:details||undefined,committedCorrectionOn:commitment||undefined,file});setDetails('');setCommitment('');setFile(undefined);await load()}catch(err){setError(errorMessage(err))}finally{setBusy(false)}};
|
||||
if(!data&&!error)return <LoadingBlock label="Cargando expediente administrativo…"/>;
|
||||
return <section>{error&&<Alert>{error}</Alert>}{data&&<><div className="page-heading"><div><span className="eyebrow">EXPEDIENTE ADMINISTRATIVO</span><h1>{data.act.actCode}</h1><p>{data.act.areaName??'Área sin asignar'} · {data.act.companyName??'Empresa sin asignar'} · {data.act.openFindingCount} hallazgos abiertos</p></div><Link className="button secondary" to={`/inspecciones/actas/${data.act.actId}`}>Ver Acta</Link></div>
|
||||
<div className="detail-grid"><div className="card"><h2>Plazo de respuesta</h2><p>El plazo aplica al conjunto completo de hallazgos del Acta.</p><form className="form-grid" onSubmit={deadline}><label><span>Vencimiento *</span><input type="date" required value={due} onChange={e=>setDue(e.target.value)}/></label><label className="full"><span>Motivo *</span><textarea required minLength={3} value={reason} onChange={e=>setReason(e.target.value)}/></label><button className="button primary" disabled={busy}>Registrar nuevo plazo</button></form>{data.deadlines.length>0&&<div className="stack-list">{data.deadlines.map(d=><div key={d.id}><strong>{formatDate(d.responseDueOn)}</strong><small>{d.reason}</small></div>)}</div>}</div>
|
||||
<div className="card"><h2>Respuesta de la empresa</h2><form className="form-grid" onSubmit={response}><label><span>Recibida el *</span><input type="date" required value={received} onChange={e=>setReceived(e.target.value)}/></label><label><span>Fecha comprometida</span><input type="date" value={commitment} onChange={e=>setCommitment(e.target.value)}/></label><label className="full"><span>Detalle</span><textarea value={details} onChange={e=>setDetails(e.target.value)} placeholder="Resumen de la presentación de la empresa"/></label><label className="full"><span>PDF presentado</span><input type="file" accept="application/pdf,.pdf" onChange={e=>setFile(e.target.files?.[0])}/></label><button className="button primary" disabled={busy}>Registrar respuesta</button></form>{data.responses.length>0&&<div className="stack-list">{data.responses.map(r=><div key={r.id}><strong>{formatDate(r.receivedOn)}</strong><small>{r.details??'Sin detalle'}{r.committedCorrectionOn?` · compromiso ${formatDate(r.committedCorrectionOn)}`:''}</small>{r.originalName&&<a className="text-link" href={actCompanyResponseContentUrl(r.id)} target="_blank" rel="noreferrer">Abrir PDF</a>}</div>)}</div>}</div></div>
|
||||
<div className="card"><h2>Hallazgos del Acta</h2><div className="table-scroll"><table><thead><tr><th>Código</th><th>Hallazgo</th><th>Estado</th><th>Próximo control</th></tr></thead><tbody>{data.findings.map(f=><tr key={f.id}><td><Link className="text-link" to={`/hallazgos/${f.id}`}>{f.code}</Link></td><td>{f.title}</td><td>{f.status}</td><td>{f.nextControlOn?formatDate(f.nextControlOn):'Sin programar'}</td></tr>)}</tbody></table></div></div></>}</section>;
|
||||
export function ActAdministrationDetailPage() {
|
||||
const { actId = '' } = useParams();
|
||||
const [data, setData] = useState<ActAdministrationDetail | null>(null);
|
||||
const [reportId, setReportId] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
useEffect(() => {
|
||||
Promise.all([getActAdministration(actId), getInspectionActF4(actId)])
|
||||
.then(([detail, act]) => { setData(detail); setReportId(act.report?.id ?? null); })
|
||||
.catch((requestError) => setError(errorMessage(requestError)));
|
||||
}, [actId]);
|
||||
if (!data && !error) return <LoadingBlock label="Cargando histórico…" />;
|
||||
return <section>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{data && <>
|
||||
<div className="page-heading"><div><span className="eyebrow">HISTÓRICO ADMINISTRATIVO</span><h1>{data.act.actCode}</h1><p>Registro previo de plazos y respuestas asociados al Acta.</p></div><Link className="button secondary" to={`/inspecciones/actas/${actId}`}>Ver Acta</Link></div>
|
||||
{reportId && <p>Las nuevas respuestas y verificaciones se registran en el <Link to={`/informes/${reportId}`}>Informe relacionado</Link>.</p>}
|
||||
<section className="panel"><h2>Antecedentes conservados</h2>
|
||||
{!data.deadlines.length && !data.responses.length && <p>No hay antecedentes administrativos anteriores.</p>}
|
||||
{[...data.deadlines.map((item) => ({ id: item.id, date: item.createdAt, title: 'Plazo registrado', description: `${formatDate(item.responseDueOn)} · ${item.reason}`, fileId: null as string | null, fileName: null as string | null })), ...data.responses.map((item) => ({ id: item.id, date: item.receivedOn, title: 'Respuesta de la empresa', description: item.details ?? 'Sin descripción', fileId: item.id, fileName: item.originalName }))].sort((a, b) => b.date.localeCompare(a.date)).map((item) => <div key={item.id} className="act-signer-row"><div><strong>{item.title}</strong><small>{formatDate(item.date)}</small><p>{item.description}</p></div>{item.fileName && item.fileId && <a className="button secondary" href={actCompanyResponseContentUrl(item.fileId)} target="_blank" rel="noreferrer">Abrir {item.fileName}</a>}</div>)}
|
||||
</section>
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ import { formatDate } from '../lib/format';
|
||||
|
||||
const statuses: Array<{ value: InspectionActStatusF4 | ''; label: string }> = [
|
||||
{ value: '', label: 'Todos los estados' },
|
||||
{ value: 'DRAFT', label: 'Borrador' },
|
||||
{ value: 'LOCKED', label: 'Bloqueada · esperando manifestación' },
|
||||
{ value: 'SEALED', label: 'Sellada' },
|
||||
{ value: 'DRAFT', label: 'En elaboración' },
|
||||
{ value: 'LOCKED', label: 'Para firmar' },
|
||||
{ value: 'SEALED', label: 'Firmada y cerrada' },
|
||||
{ value: 'CANCELLED', label: 'Cancelada' },
|
||||
{ value: 'READY', label: 'Lista para cerrar · legado' },
|
||||
{ value: 'CLOSED', label: 'Cerrada · legado' },
|
||||
@@ -112,7 +112,7 @@ export function ActsPage() {
|
||||
{loading ? <LoadingBlock label="Cargando actas…" /> : items.length === 0 ? <EmptyState title="Sin actas" text="No hay actas para los filtros seleccionados." /> : <div className="table-panel document-table">
|
||||
<div className="table-summary"><strong>{meta.total} acta{meta.total === 1 ? '' : 's'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div>
|
||||
<div className="table-scroll"><table><thead><tr><th>Acta</th><th>Empresa / área</th><th>Inspección</th><th>Hallazgos</th><th>Estado</th><th>Informe</th><th /></tr></thead><tbody>{items.map((act) => <tr key={act.id}>
|
||||
<td><div className="document-primary"><strong>{act.code}</strong><small>{act.title} · {formatDate(act.occurredAt)}</small></div></td>
|
||||
<td><div className="document-primary"><strong>{act.code}</strong><small>{formatDate(act.occurredAt)}</small></div></td>
|
||||
<td><div className="document-primary"><strong>{contextLabel(act.companies, 'Empresa sin asignar')}</strong><small>{contextLabel(act.areas, 'Área sin asignar')}</small></div></td>
|
||||
<td><Link className="text-link" to={`/inspecciones/${act.visitId}`}>{act.visit.code}</Link></td>
|
||||
<td><Link className="text-link" to={`/hallazgos?search=${encodeURIComponent(act.code)}`}>{act.findingCount}</Link></td>
|
||||
|
||||
@@ -5,13 +5,10 @@ import { Icon } from '../components/Icon';
|
||||
import {
|
||||
inspectionActStatusClass,
|
||||
inspectionActStatusLabel,
|
||||
inspectionActVersionEventLabel,
|
||||
} from '../features/inspections/inspectionActPresentation';
|
||||
import { InspectionClosurePanel } from '../features/inspections/InspectionClosurePanel';
|
||||
import { InspectionFindingsPanel } from '../features/inspections/InspectionFindingsPanel';
|
||||
import { InspectionActMediaPanel } from '../features/inspections/InspectionActMediaPanel';
|
||||
import { DocumentPdfProjection } from '../components/DocumentPdfProjection';
|
||||
import { getInspectionActPdfBlob, getInspectionVisit } from '../lib/api';
|
||||
import { getInspectionActConsolidatedPdfBlob, getInspectionVisit } from '../lib/api';
|
||||
import type { InspectionVisit } from '../lib/api';
|
||||
import { getInspectionActF4, type InspectionActF4 } from '../lib/inspectionActF4Api';
|
||||
import { formatDate } from '../lib/format';
|
||||
@@ -49,12 +46,13 @@ export function InspectionActEditorPage() {
|
||||
if (loading) return <LoadingBlock label="Cargando acta…" />;
|
||||
|
||||
const isSealed = act?.status === 'SEALED' || act?.status === 'CLOSED';
|
||||
const meaningfulSummary = act?.summary && !act.summary.startsWith('Acta de inspección en curso. Los Hallazgos') ? act.summary : null;
|
||||
|
||||
const openActPdf = async (download: boolean) => {
|
||||
if (!act) return;
|
||||
setPdfBusy(true); setError('');
|
||||
try {
|
||||
const blob = await getInspectionActPdfBlob(act.id);
|
||||
const blob = await getInspectionActConsolidatedPdfBlob(act.id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (download) {
|
||||
const link = document.createElement('a');
|
||||
@@ -75,38 +73,31 @@ export function InspectionActEditorPage() {
|
||||
|
||||
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</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>
|
||||
<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'}` : 'Consulta del Acta.'}</p></div>{act && <span className={`status-badge large ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span>}</div>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{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-document-primary-copy"><span className="asset-symbol"><Icon name="clipboard" /></span><div><span className="eyebrow">DOCUMENTO DEL ACTA</span><h2>{isSealed ? 'Acta consolidada disponible' : 'Acta en preparación'}</h2><p>{isSealed ? 'Abrí o descargá el Acta firmada, con sus Hallazgos y constancias de integridad.' : 'El documento definitivo se genera al firmar y cerrar el Acta.'}</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>}
|
||||
{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' ? ' Disponible.' : ' 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>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>Fecha de inspección</small><strong>{formatDate(act.occurredAt)}</strong></div>
|
||||
<div><small>Estado</small><strong>{inspectionActStatusLabel(act.status)}</strong></div>
|
||||
<div><small>Urgencia del Acta</small><strong>{act.urgency === 'URGENT' ? 'Urgente' : act.urgency === 'NON_URGENT' ? 'No urgente' : 'Se define al cerrar el contenido'}</strong></div>
|
||||
<div><small>Hallazgos</small><strong>{act.findingCount}</strong></div>
|
||||
</div>
|
||||
<div className="closure-section"><span className="eyebrow">DESCRIPCIÓN DE LO ACTUADO</span><p>{act.summary}</p>{act.observations && <><span className="eyebrow">OBSERVACIONES</span><p>{act.observations}</p></>}</div>
|
||||
<div className="inspection-act-assets"><div><span className="eyebrow">INVENTARIO DEL ACTA</span><h3>Referencias congeladas</h3></div><span className="count-pill">{act.assets.length}</span></div>
|
||||
<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>
|
||||
{meaningfulSummary && <div><h2>Lo actuado</h2><p>{meaningfulSummary}</p></div>}
|
||||
{act.observations && <div><h3>Observaciones</h3><p>{act.observations}</p></div>}
|
||||
{act.assets.length > 0 && <div><h3>Instalaciones inspeccionadas</h3><p>{act.assets.map((asset) => `${asset.name} (${asset.code})`).join(' · ')}</p></div>}
|
||||
</section>}
|
||||
|
||||
{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} />}
|
||||
|
||||
{act && <section className="panel survey-report-history">
|
||||
<div className="panel-heading"><div><span className="eyebrow">VERSIONES INMUTABLES</span><h2>Historial exacto del acta</h2><p className="section-copy">Cada transición conserva el contenido y las versiones de los registros referenciados.</p></div><span className="count-pill">{act.versions.length}</span></div>
|
||||
<div className="survey-version-list">{act.versions.map((version) => <details key={version.id}><summary><span className="count-pill">v{version.versionNumber}</span><strong>{inspectionActVersionEventLabel(version.event)}</strong><small>{formatDate(version.createdAt)} · {version.actorUsername ?? 'sistema'}</small></summary><pre>{JSON.stringify(version.snapshot, null, 2)}</pre></details>)}</div>
|
||||
</section>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useParams } from 'react-router';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { DocumentPdfProjection } from '../components/DocumentPdfProjection';
|
||||
import { actCompanyResponseContentUrl, getActAdministration } from '../lib/api';
|
||||
import type { ActAdministrationDetail } from '../lib/api';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
addInspectionReportFollowUp,
|
||||
getInspectionReportF4,
|
||||
inspectionReportGedoPdfDownloadUrl,
|
||||
inspectionReportFollowUpDownloadUrl,
|
||||
inspectionReportConsolidatedWordDownloadUrl,
|
||||
inspectionReportWordDownloadUrl,
|
||||
listInspectionReportFollowUps,
|
||||
officializeInspectionReport,
|
||||
@@ -63,8 +66,10 @@ export function ReportDetailPage() {
|
||||
const { id } = useParams();
|
||||
const { hasPermission } = useAuth();
|
||||
const canManage = hasPermission('inspection_reports.generate');
|
||||
const canReadActHistory = hasPermission('inspection_acts.read');
|
||||
const [report, setReport] = useState<InspectionReportDetailF4 | null>(null);
|
||||
const [followUps, setFollowUps] = useState<InspectionReportFollowUp[]>([]);
|
||||
const [legacy, setLegacy] = useState<ActAdministrationDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -91,6 +96,7 @@ export function ReportDetailPage() {
|
||||
]);
|
||||
setReport(nextReport);
|
||||
setFollowUps(nextFollowUps);
|
||||
if (canReadActHistory) setLegacy(await getActAdministration(nextReport.actId).catch(() => null));
|
||||
setExecutiveSummary(nextReport.executiveSummary ?? '');
|
||||
setReportDescription(nextReport.reportDescription ?? '');
|
||||
setGedoIfIdentifier(nextReport.gedoIfIdentifier ?? '');
|
||||
@@ -180,6 +186,16 @@ export function ReportDetailPage() {
|
||||
|
||||
if (loading) return <LoadingBlock label="Cargando informe…" />;
|
||||
|
||||
const timeline: Array<{ id: string; date: string; title: string; description: string; href?: string; fileName?: string }> = report ? [
|
||||
{ id: 'act-start', date: report.act.occurredAt, title: `Inspección y Acta ${report.act.code}`, description: `${report.findingCount} hallazgo${report.findingCount === 1 ? '' : 's'} registrados`, href: `/inspecciones/actas/${report.actId}` },
|
||||
...(report.act.sealedAt ? [{ id: 'act-sealed', date: report.act.sealedAt, title: 'Acta firmada y cerrada', description: report.act.code, href: `/inspecciones/actas/${report.actId}` }] : []),
|
||||
{ id: 'report-issued', date: report.generatedAt, title: `Informe ${report.code} preparado`, description: 'Documento técnico vinculado al Acta' },
|
||||
...(report.gedoOfficializedAt ? [{ id: 'gedo', date: report.gedoOfficializedAt, title: 'Informe oficializado en GEDO', description: report.gedoIfIdentifier ?? '' }] : []),
|
||||
...followUps.map((item) => ({ id: item.id, date: item.occurredAt, title: followUpLabel(item.type), description: item.description || item.externalReference || item.originalName || 'Antecedente registrado', href: item.originalName ? inspectionReportFollowUpDownloadUrl(report.id, item.id) : undefined, fileName: item.originalName ?? undefined })),
|
||||
...(legacy?.responses.map((item) => ({ id: `legacy-${item.id}`, date: item.receivedOn, title: 'Respuesta de empresa registrada previamente', description: item.details ?? 'Sin detalle', href: item.originalName ? actCompanyResponseContentUrl(item.id) : undefined, fileName: item.originalName ?? undefined })) ?? []),
|
||||
...(legacy?.deadlines.map((item) => ({ id: `deadline-${item.id}`, date: item.createdAt, title: 'Plazo administrativo registrado previamente', description: `${formatDate(item.responseDueOn)} · ${item.reason}` })) ?? []),
|
||||
].sort((a, b) => b.date.localeCompare(a.date)) : [];
|
||||
|
||||
return <section>
|
||||
<div className="breadcrumb"><Link to="/informes">Informes</Link><span>/</span><span>{report?.code ?? 'Informe'}</span></div>
|
||||
<div className="page-heading survey-editor-heading">
|
||||
@@ -211,10 +227,9 @@ export function ReportDetailPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DocumentPdfProjection kind="report" />
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">WORD EDITABLE</span><h2>Preparación del INF</h2><p className="section-copy">El Inspector puede revisar y ajustar el texto del Informe antes de incorporarlo a GEDO. Esta edición no altera el Acta fuente.</p></div>{report.wordStatus === 'READY' && <a className="button secondary" href={inspectionReportWordDownloadUrl(report.id)}>Descargar Word</a>}</div>
|
||||
<div className="panel-heading"><div><span className="eyebrow">WORD EDITABLE</span><h2>Preparación del INF</h2><p className="section-copy">El Inspector puede revisar y ajustar el texto del Informe antes de incorporarlo a GEDO. Esta edición no altera el Acta fuente.</p></div><div className="act-primary-actions"><a className="button secondary" href={inspectionReportConsolidatedWordDownloadUrl(report.id)}>Descargar Word del informe</a>{report.wordStatus === 'READY' && <a className="button secondary" href={inspectionReportWordDownloadUrl(report.id)}>Word anterior</a>}</div></div>
|
||||
<form className="form-section" onSubmit={saveNarrative}>
|
||||
<label className="field"><span>Resumen ejecutivo</span><textarea rows={4} maxLength={20000} value={executiveSummary} onChange={(event) => setExecutiveSummary(event.target.value)} disabled={!canManage || report.status !== 'WORKING'} placeholder="Resumen ejecutivo del Informe…" /></label>
|
||||
<label className="field"><span>Descripción / análisis técnico</span><textarea rows={8} maxLength={50000} value={reportDescription} onChange={(event) => setReportDescription(event.target.value)} disabled={!canManage || report.status !== 'WORKING'} placeholder="Descripción técnica, análisis y consideraciones del Inspector…" /></label>
|
||||
@@ -246,21 +261,14 @@ export function ReportDetailPage() {
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">SEGUIMIENTO DEL INF</span><h2>Presentaciones y antecedentes</h2><p className="section-copy">Las respuestas de empresa, documentos, notas internas y verificaciones se agregan cronológicamente. Nunca reemplazan un antecedente anterior.</p></div><span className="count-pill">{followUps.length}</span></div>
|
||||
|
||||
{followUps.length === 0 ? <div className="inline-empty">Todavía no hay antecedentes posteriores registrados.</div> : <div className="dossier-link-list">
|
||||
{[...followUps].reverse().map((item) => <div key={item.id}>
|
||||
<div>
|
||||
<strong>{followUpLabel(item.type)}</strong>
|
||||
<small>{item.description || item.externalReference || item.originalName || 'Sin descripción'}</small>
|
||||
{item.originalName && <small>Archivo: {item.originalName}{item.sizeBytes ? ` · ${fileSize(item.sizeBytes)}` : ''}</small>}
|
||||
</div>
|
||||
<span>{formatDate(item.occurredAt)}{item.externalReference ? ` · ${item.externalReference}` : ''}</span>
|
||||
</div>)}
|
||||
</div>}
|
||||
<div className="panel-heading"><div><span className="eyebrow">INFORME Y RESPUESTAS</span><h2>Historia y presentaciones</h2><p className="section-copy">La historia del Acta y las respuestas posteriores se leen en orden. Las nuevas respuestas se registran en este Informe.</p></div></div>
|
||||
<div className="dossier-link-list">{timeline.map((item) => <div key={item.id}>
|
||||
<div><strong>{item.title}</strong><small>{item.description}</small>{item.href && (item.fileName ? <a className="text-link" href={item.href}>Descargar {item.fileName}</a> : <Link className="text-link" to={item.href}>Ver Acta</Link>)}</div>
|
||||
<span>{formatDate(item.date)}</span>
|
||||
</div>)}</div>
|
||||
|
||||
{canManage && <form className="form-section" onSubmit={addFollowUp}>
|
||||
<div><h3>Agregar antecedente</h3><p className="section-copy">Usá este bloque para registrar una nueva presentación sin modificar las anteriores.</p></div>
|
||||
<div><h3>Registrar respuesta o antecedente</h3><p className="section-copy">La respuesta queda asociada a este Informe y conserva los registros anteriores.</p></div>
|
||||
<div className="form-grid">
|
||||
<label className="field"><span>Tipo</span><SearchableSelect value={followUpType} onChange={(event) => setFollowUpType(event.target.value as InspectionReportFollowUpType)}><option value="COMPANY_NOTE">Presentación / nota de empresa</option><option value="COMPANY_DOCUMENT">Documento de empresa</option><option value="INTERNAL_NOTE">Nota interna</option><option value="VERIFICATION">Verificación</option><option value="OTHER">Otro antecedente</option></SearchableSelect></label>
|
||||
<label className="field"><span>Fecha</span><input type="datetime-local" value={followUpOccurredAt} onChange={(event) => setFollowUpOccurredAt(event.target.value)} required /></label>
|
||||
|
||||
@@ -1416,3 +1416,20 @@ code { color: #5e6677; font-family: ui-monospace, monospace; font-size: 9px; }
|
||||
.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; }
|
||||
|
||||
/* Lectura consolidada del Acta */
|
||||
.act-finding-list { display: grid; gap: 22px; }
|
||||
.act-finding-record { border-top: 1px solid var(--border); padding-top: 20px; display: grid; gap: 8px; }
|
||||
.act-finding-record:first-child { border-top: 0; padding-top: 0; }
|
||||
.act-finding-record p { margin: 0; line-height: 1.55; }
|
||||
.act-finding-photos { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; margin-top: 8px; }
|
||||
.act-finding-photo { margin: 0; overflow: hidden; border: 1px solid var(--border); border-radius: 10px; }
|
||||
.act-finding-photo img { display: block; width: 100%; height: 190px; object-fit: cover; }
|
||||
.act-finding-photo figcaption { padding: 10px; color: var(--muted); font-size: 11px; }
|
||||
.act-signer-list { display: grid; gap: 10px; }
|
||||
.act-signer-row { display: flex; justify-content: space-between; align-items: center; gap: 16px; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||
.act-signer-row small { display: block; margin-top: 3px; color: var(--muted); }
|
||||
.act-signer-row p { margin: 5px 0 0; }
|
||||
.act-closed-date { font-weight: 700; }
|
||||
.act-integrity-details { border-top: 1px solid var(--border); padding-top: 10px; }
|
||||
.act-integrity-details code { overflow-wrap: anywhere; }
|
||||
|
||||
Reference in New Issue
Block a user