v-aguatech
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
# Editor configuration, see https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.ts]
|
||||||
|
quote_type = single
|
||||||
|
ij_typescript_use_double_quotes = false
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||||
|
|
||||||
|
# Compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# Node
|
||||||
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/mcp.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/.angular/cache
|
||||||
|
.sass-cache/
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
__screenshots__/
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.html",
|
||||||
|
"options": {
|
||||||
|
"parser": "angular"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||||
|
"recommendations": ["angular.ng-template"]
|
||||||
|
}
|
||||||
Vendored
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "ng serve",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "npm: start",
|
||||||
|
"url": "http://localhost:4200/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ng test",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "npm: test",
|
||||||
|
"url": "http://localhost:9876/debug.html"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://angular.dev/ai/mcp
|
||||||
|
"servers": {
|
||||||
|
"angular-cli": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@angular/cli", "mcp"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+42
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "start",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": {
|
||||||
|
"owner": "typescript",
|
||||||
|
"pattern": "$tsc",
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": {
|
||||||
|
"regexp": "Changes detected"
|
||||||
|
},
|
||||||
|
"endsPattern": {
|
||||||
|
"regexp": "bundle generation (complete|failed)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "test",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": {
|
||||||
|
"owner": "typescript",
|
||||||
|
"pattern": "$tsc",
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": {
|
||||||
|
"regexp": "Changes detected"
|
||||||
|
},
|
||||||
|
"endsPattern": {
|
||||||
|
"regexp": "bundle generation (complete|failed)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# VAguatechAngular
|
||||||
|
|
||||||
|
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.9.
|
||||||
|
|
||||||
|
## Development server
|
||||||
|
|
||||||
|
To start a local development server, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||||
|
|
||||||
|
## Code scaffolding
|
||||||
|
|
||||||
|
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate component component-name
|
||||||
|
```
|
||||||
|
|
||||||
|
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To build the project run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng build
|
||||||
|
```
|
||||||
|
|
||||||
|
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running end-to-end tests
|
||||||
|
|
||||||
|
For end-to-end (e2e) testing, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"cli": {
|
||||||
|
"packageManager": "npm",
|
||||||
|
"analytics": "9e434f1a-9bba-4713-a4e8-c59e9943824f"
|
||||||
|
},
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"v-aguatech-angular": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular/build:application",
|
||||||
|
"options": {
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.css"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "500kB",
|
||||||
|
"maximumError": "1MB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "4kB",
|
||||||
|
"maximumError": "8kB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular/build:dev-server",
|
||||||
|
"options": {
|
||||||
|
"port": 4201
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "v-aguatech-angular:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "v-aguatech-angular:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular/build:unit-test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+8795
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "v-aguatech-angular",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "npm@10.9.2",
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/common": "^21.2.0",
|
||||||
|
"@angular/compiler": "^21.2.0",
|
||||||
|
"@angular/core": "^21.2.0",
|
||||||
|
"@angular/forms": "^21.2.0",
|
||||||
|
"@angular/platform-browser": "^21.2.0",
|
||||||
|
"@angular/router": "^21.2.0",
|
||||||
|
"docx": "^9.6.1",
|
||||||
|
"docx-preview": "^0.3.7",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular/build": "^21.2.9",
|
||||||
|
"@angular/cli": "^21.2.9",
|
||||||
|
"@angular/compiler-cli": "^21.2.0",
|
||||||
|
"jsdom": "^28.0.0",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"typescript": "~5.9.2",
|
||||||
|
"vitest": "^4.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://v-aguatech.com" TargetMode="External"/><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image2.jpeg"/></Relationships>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideRouter(routes)
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
<main class="workspace">
|
||||||
|
<aside class="workspace__controls" (input)="queuePreviewRender()" (change)="queuePreviewRender()">
|
||||||
|
<div class="panel-head">
|
||||||
|
<p class="eyebrow">V-aguatech Report Creator</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-row">
|
||||||
|
<button
|
||||||
|
class="button button--primary"
|
||||||
|
type="button"
|
||||||
|
(click)="downloadWord()"
|
||||||
|
[disabled]="isExporting"
|
||||||
|
>
|
||||||
|
{{ isExporting ? 'A gerar Word...' : 'Gerar Word' }}
|
||||||
|
</button>
|
||||||
|
<button class="button button--ghost" type="button" (click)="resetForm()">Limpar</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
class="status"
|
||||||
|
[class.is-success]="statusTone === 'success'"
|
||||||
|
[class.is-error]="statusTone === 'error'"
|
||||||
|
>
|
||||||
|
{{ statusMessage }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section class="form-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>Dados base</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span>N do processo</span>
|
||||||
|
<input [(ngModel)]="form.nproc" placeholder="Ex. 2026/12345" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Segurado</span>
|
||||||
|
<input [(ngModel)]="form.segurado" placeholder="Nome do segurado" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Companhia</span>
|
||||||
|
<input [(ngModel)]="form.companhia" placeholder="Companhia seguradora" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Terceiro</span>
|
||||||
|
<input [(ngModel)]="form.terceiro" placeholder="Entidade terceira" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Data da visita</span>
|
||||||
|
<input [(ngModel)]="form.dataVisita" type="date" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Local da visita</span>
|
||||||
|
<input [(ngModel)]="form.localVisita" placeholder="Morada ou local" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field field--full">
|
||||||
|
<span>Descricao do imovel</span>
|
||||||
|
<textarea
|
||||||
|
[(ngModel)]="form.descricaoImovel"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Caracterizacao do imovel e enquadramento."
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Ano de construcão</span>
|
||||||
|
<input [(ngModel)]="form.anoConstrucao" placeholder="Ex. 1998" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Área do espaço (m2)</span>
|
||||||
|
<input [(ngModel)]="form.area" placeholder="Ex. 82" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field field--full">
|
||||||
|
<span>Descrição da pesquisa</span>
|
||||||
|
<textarea
|
||||||
|
[(ngModel)]="form.descricaoPesquisa"
|
||||||
|
rows="5"
|
||||||
|
placeholder="Descrição do levantamento efetuado, constatações e enquadramento."
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="form-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>Fotografias</h2>
|
||||||
|
<p>Imagem principal do imovel e das pesquisas.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-block">
|
||||||
|
<div class="upload-row">
|
||||||
|
<div>
|
||||||
|
<strong>Imagem do imovel</strong>
|
||||||
|
<p>Substitui o bloco principal do documento.</p>
|
||||||
|
</div>
|
||||||
|
<label class="button button--soft">
|
||||||
|
Carregar imagem
|
||||||
|
<input
|
||||||
|
class="sr-only"
|
||||||
|
type="file"
|
||||||
|
accept=".png,.jpg,.jpeg,.gif,.bmp"
|
||||||
|
(change)="onMainPhotoSelected($event)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (form.fotoImovel; as photo) {
|
||||||
|
<div class="thumb-list thumb-list--single">
|
||||||
|
<article class="thumb-card">
|
||||||
|
<img [src]="photo.objectUrl" [alt]="photo.name" />
|
||||||
|
<div class="thumb-card__meta">
|
||||||
|
<strong>{{ photo.name }}</strong>
|
||||||
|
<button class="link-button" type="button" (click)="clearMainPhoto()">Remover</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-block">
|
||||||
|
<div class="upload-row">
|
||||||
|
<div>
|
||||||
|
<strong>Imagens da pesquisa</strong>
|
||||||
|
<p>Usadas na seccão descritiva do relatório.</p>
|
||||||
|
</div>
|
||||||
|
<label class="button button--soft">
|
||||||
|
Adicionar imagens
|
||||||
|
<input
|
||||||
|
class="sr-only"
|
||||||
|
type="file"
|
||||||
|
accept=".png,.jpg,.jpeg,.gif,.bmp"
|
||||||
|
multiple
|
||||||
|
(change)="onResearchPhotosSelected($event)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (form.fotosPesquisa.length) {
|
||||||
|
<div class="thumb-list">
|
||||||
|
@for (photo of form.fotosPesquisa; track photo.id) {
|
||||||
|
<article class="thumb-card">
|
||||||
|
<img [src]="photo.objectUrl" [alt]="photo.name" />
|
||||||
|
<div class="thumb-card__meta">
|
||||||
|
<strong>{{ photo.name }}</strong>
|
||||||
|
<button class="link-button" type="button" (click)="removeResearchPhoto(photo.id)">
|
||||||
|
Remover
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="form-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>Presenças</h2>
|
||||||
|
<p>Lista de pessoas presentes durante a pesquisa, adicionar tantas quanto necessárias</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@for (section of presenceSections; track section.type) {
|
||||||
|
<div class="presence-group">
|
||||||
|
<div class="group-head">
|
||||||
|
<div>
|
||||||
|
<h3>{{ section.title }}</h3>
|
||||||
|
<p>Adicionar ou remover {{ section.singularLabel }}s conforme necessário.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="button button--ghost button--small"
|
||||||
|
type="button"
|
||||||
|
(click)="addPresence(section.type)"
|
||||||
|
>
|
||||||
|
{{ section.buttonLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!getPresenceEntries(section.type).length) {
|
||||||
|
<p class="empty-state">Sem entradas neste grupo.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@for (presence of getPresenceEntries(section.type); track presence.id) {
|
||||||
|
<article class="presence-card">
|
||||||
|
<label class="field">
|
||||||
|
<span>Nome</span>
|
||||||
|
<input [(ngModel)]="presence.name" placeholder="Nome da pessoa presente" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field field--full">
|
||||||
|
<span>Declaracões</span>
|
||||||
|
<textarea
|
||||||
|
[(ngModel)]="presence.info"
|
||||||
|
rows="3"
|
||||||
|
placeholder="O que esta pessoa informou durante a pesquisa."
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="link-button link-button--danger"
|
||||||
|
type="button"
|
||||||
|
(click)="removePresence(presence.id)"
|
||||||
|
>
|
||||||
|
Remover entrada
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="form-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>Inicio da pesquisa</h2>
|
||||||
|
<p>Linhas adicionaveis com combobox para escolher o metodo.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group-head group-head--section">
|
||||||
|
<div>
|
||||||
|
<h3>Métodos do inicio</h3>
|
||||||
|
<p>Escolhe uma opcao da lista e completa a descrição se necessario.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="button button--ghost button--small"
|
||||||
|
type="button"
|
||||||
|
(click)="addTechniqueBlock('inicioPesquisa')"
|
||||||
|
>
|
||||||
|
Adicionar linha
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tech-grid">
|
||||||
|
@for (item of form.inicioPesquisa; track item.id) {
|
||||||
|
<article class="tech-card" [class.is-selected]="!!item.name">
|
||||||
|
<div class="tech-card__top">
|
||||||
|
<label class="field field--full">
|
||||||
|
<span>Metodo</span>
|
||||||
|
<select [(ngModel)]="item.name">
|
||||||
|
<option value="">Escolher metodo...</option>
|
||||||
|
@for (option of techniqueOptions; track option) {
|
||||||
|
<option [value]="option">{{ option }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="link-button link-button--danger"
|
||||||
|
type="button"
|
||||||
|
(click)="removeTechniqueBlock('inicioPesquisa', item.id)"
|
||||||
|
>
|
||||||
|
Remover linha
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="field field--full">
|
||||||
|
<span>Descricao</span>
|
||||||
|
<textarea
|
||||||
|
[(ngModel)]="item.description"
|
||||||
|
rows="3"
|
||||||
|
[disabled]="!item.name"
|
||||||
|
placeholder="Descricao desta etapa inicial."
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="upload-row">
|
||||||
|
<div>
|
||||||
|
<strong>Fotografias</strong>
|
||||||
|
<p>Galeria para este metodo.</p>
|
||||||
|
</div>
|
||||||
|
<label class="button button--soft" [class.is-disabled]="!item.name">
|
||||||
|
Adicionar
|
||||||
|
<input
|
||||||
|
class="sr-only"
|
||||||
|
type="file"
|
||||||
|
accept=".png,.jpg,.jpeg,.gif,.bmp"
|
||||||
|
multiple
|
||||||
|
[disabled]="!item.name"
|
||||||
|
(change)="onTechniquePhotosSelected($event, item)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (item.photos.length) {
|
||||||
|
<div class="thumb-list">
|
||||||
|
@for (photo of item.photos; track photo.id) {
|
||||||
|
<article class="thumb-card">
|
||||||
|
<img [src]="photo.objectUrl" [alt]="photo.name" />
|
||||||
|
<div class="thumb-card__meta">
|
||||||
|
<strong>{{ photo.name }}</strong>
|
||||||
|
<button
|
||||||
|
class="link-button"
|
||||||
|
type="button"
|
||||||
|
(click)="removeTechniquePhoto(item, photo.id)"
|
||||||
|
>
|
||||||
|
Remover
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="form-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>Levantamento de danos</h2>
|
||||||
|
<p>Linhas adicionaveis com combobox para escolher o metodo.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group-head group-head--section">
|
||||||
|
<div>
|
||||||
|
<h3>Metodos do levantamento</h3>
|
||||||
|
<p>Escolhe uma opcao da lista e completa a descricao se necessario.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="button button--ghost button--small"
|
||||||
|
type="button"
|
||||||
|
(click)="addTechniqueBlock('tecnicas')"
|
||||||
|
>
|
||||||
|
Adicionar linha
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tech-grid">
|
||||||
|
@for (item of form.tecnicas; track item.id) {
|
||||||
|
<article class="tech-card" [class.is-selected]="!!item.name">
|
||||||
|
<div class="tech-card__top">
|
||||||
|
<label class="field field--full">
|
||||||
|
<span>Metodo</span>
|
||||||
|
<select [(ngModel)]="item.name">
|
||||||
|
<option value="">Escolher metodo...</option>
|
||||||
|
@for (option of techniqueOptions; track option) {
|
||||||
|
<option [value]="option">{{ option }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="link-button link-button--danger"
|
||||||
|
type="button"
|
||||||
|
(click)="removeTechniqueBlock('tecnicas', item.id)"
|
||||||
|
>
|
||||||
|
Remover linha
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="field field--full">
|
||||||
|
<span>Descricao</span>
|
||||||
|
<textarea
|
||||||
|
[(ngModel)]="item.description"
|
||||||
|
rows="3"
|
||||||
|
[disabled]="!item.name"
|
||||||
|
placeholder="Descricao deste metodo usado no levantamento."
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="upload-row">
|
||||||
|
<div>
|
||||||
|
<strong>Fotografias</strong>
|
||||||
|
<p>Galeria para este metodo.</p>
|
||||||
|
</div>
|
||||||
|
<label class="button button--soft" [class.is-disabled]="!item.name">
|
||||||
|
Adicionar
|
||||||
|
<input
|
||||||
|
class="sr-only"
|
||||||
|
type="file"
|
||||||
|
accept=".png,.jpg,.jpeg,.gif,.bmp"
|
||||||
|
multiple
|
||||||
|
[disabled]="!item.name"
|
||||||
|
(change)="onTechniquePhotosSelected($event, item)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (item.photos.length) {
|
||||||
|
<div class="thumb-list">
|
||||||
|
@for (photo of item.photos; track photo.id) {
|
||||||
|
<article class="thumb-card">
|
||||||
|
<img [src]="photo.objectUrl" [alt]="photo.name" />
|
||||||
|
<div class="thumb-card__meta">
|
||||||
|
<strong>{{ photo.name }}</strong>
|
||||||
|
<button
|
||||||
|
class="link-button"
|
||||||
|
type="button"
|
||||||
|
(click)="removeTechniquePhoto(item, photo.id)"
|
||||||
|
>
|
||||||
|
Remover
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="workspace__preview">
|
||||||
|
<div class="preview-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Preview</p>
|
||||||
|
<h2>Relatório em tempo real</h2>
|
||||||
|
</div>
|
||||||
|
<p class="preview-note"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="word-preview-shell">
|
||||||
|
<div #wordPreviewStyles class="word-preview-styles" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
@if (isPreviewRendering) {
|
||||||
|
<div class="preview-banner">
|
||||||
|
<span class="preview-dot"></span>
|
||||||
|
A atualizar o documento Word...
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (previewErrorMessage) {
|
||||||
|
<div class="preview-banner preview-banner--error">
|
||||||
|
{{ previewErrorMessage }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div #wordPreview class="word-preview-host"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
export const routes: Routes = [];
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { App } from './app';
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [App],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create the app', () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
expect(app).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render workspace title and primary action', async () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(compiled.querySelector('.eyebrow')?.textContent).toContain('V-aguatech Report Creator');
|
||||||
|
expect(compiled.querySelector('.button--primary')?.textContent).toContain('Gerar Word');
|
||||||
|
});
|
||||||
|
});
|
||||||
+376
@@ -0,0 +1,376 @@
|
|||||||
|
import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createInitialReportForm,
|
||||||
|
createPresenceEntry,
|
||||||
|
createTechniqueBlock,
|
||||||
|
PresenceEntry,
|
||||||
|
PresenceType,
|
||||||
|
PRESENCE_SECTIONS,
|
||||||
|
ReportFormModel,
|
||||||
|
TECHNIQUE_OPTIONS,
|
||||||
|
TechniqueBlock,
|
||||||
|
UploadedImage
|
||||||
|
} from './report.models';
|
||||||
|
|
||||||
|
type TechniqueCollectionKey = 'inicioPesquisa' | 'tecnicas';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
imports: [FormsModule],
|
||||||
|
templateUrl: './app.html',
|
||||||
|
styleUrl: './app.css'
|
||||||
|
})
|
||||||
|
export class App implements AfterViewInit, OnDestroy {
|
||||||
|
readonly presenceSections = PRESENCE_SECTIONS;
|
||||||
|
readonly techniqueOptions = TECHNIQUE_OPTIONS;
|
||||||
|
|
||||||
|
@ViewChild('wordPreview') private readonly wordPreviewRef?: ElementRef<HTMLDivElement>;
|
||||||
|
@ViewChild('wordPreviewStyles') private readonly wordPreviewStylesRef?: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
|
form: ReportFormModel = createInitialReportForm();
|
||||||
|
statusMessage = 'Word interativo a medida que preenche o formulario.';
|
||||||
|
statusTone: 'info' | 'success' | 'error' = 'info';
|
||||||
|
isExporting = false;
|
||||||
|
isPreviewRendering = false;
|
||||||
|
previewErrorMessage = '';
|
||||||
|
|
||||||
|
private readonly supportedExtensions = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp']);
|
||||||
|
private readonly supportedMimeTypes = new Set([
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/jpg',
|
||||||
|
'image/gif',
|
||||||
|
'image/bmp'
|
||||||
|
]);
|
||||||
|
|
||||||
|
private previewRenderTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private previewRenderRequestId = 0;
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.queuePreviewRender(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.previewRenderTimer) {
|
||||||
|
clearTimeout(this.previewRenderTimer);
|
||||||
|
this.previewRenderTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.previewRenderRequestId += 1;
|
||||||
|
this.revokeFormImages(this.form);
|
||||||
|
}
|
||||||
|
|
||||||
|
queuePreviewRender(delay = 250): void {
|
||||||
|
if (this.previewRenderTimer) {
|
||||||
|
clearTimeout(this.previewRenderTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.previewRenderTimer = setTimeout(() => {
|
||||||
|
this.previewRenderTimer = null;
|
||||||
|
void this.renderWordPreview();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPresenceEntries(type: PresenceType): PresenceEntry[] {
|
||||||
|
return this.form.presencas.filter((item) => item.type === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
addPresence(type: PresenceType): void {
|
||||||
|
this.form.presencas = [...this.form.presencas, createPresenceEntry(type)];
|
||||||
|
this.queuePreviewRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
removePresence(id: string): void {
|
||||||
|
this.form.presencas = this.form.presencas.filter((item) => item.id !== id);
|
||||||
|
this.queuePreviewRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
addTechniqueBlock(section: TechniqueCollectionKey): void {
|
||||||
|
this.form[section] = [...this.form[section], createTechniqueBlock()];
|
||||||
|
this.queuePreviewRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeTechniqueBlock(section: TechniqueCollectionKey, id: string): void {
|
||||||
|
const block = this.form[section].find((item) => item.id === id);
|
||||||
|
if (!block) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.revokeImages(block.photos);
|
||||||
|
this.form[section] = this.form[section].filter((item) => item.id !== id);
|
||||||
|
this.queuePreviewRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onMainPhotoSelected(event: Event): Promise<void> {
|
||||||
|
try {
|
||||||
|
const files = this.extractFiles(event);
|
||||||
|
if (!files.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [image] = await this.readImages(files);
|
||||||
|
if (!image) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.revokeImage(this.form.fotoImovel);
|
||||||
|
this.form.fotoImovel = image;
|
||||||
|
this.setStatus(`Fotografia principal carregada: ${image.name}`, 'success');
|
||||||
|
this.queuePreviewRender();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
this.setStatus('Nao foi possivel carregar a fotografia principal.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onResearchPhotosSelected(event: Event): Promise<void> {
|
||||||
|
try {
|
||||||
|
const files = this.extractFiles(event);
|
||||||
|
if (!files.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const images = await this.readImages(files);
|
||||||
|
this.form.fotosPesquisa = [...this.form.fotosPesquisa, ...images];
|
||||||
|
this.setStatus(`${images.length} fotografia(s) adicionadas a pesquisa.`, 'success');
|
||||||
|
this.queuePreviewRender();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
this.setStatus('Nao foi possivel carregar as fotografias da pesquisa.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onTechniquePhotosSelected(event: Event, block: TechniqueBlock): Promise<void> {
|
||||||
|
try {
|
||||||
|
const files = this.extractFiles(event);
|
||||||
|
if (!files.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const images = await this.readImages(files);
|
||||||
|
block.photos = [...block.photos, ...images];
|
||||||
|
this.setStatus(`${images.length} fotografia(s) adicionadas a "${block.name}".`, 'success');
|
||||||
|
this.queuePreviewRender();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
this.setStatus(`Nao foi possivel carregar as fotografias de "${block.name}".`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearMainPhoto(): void {
|
||||||
|
this.revokeImage(this.form.fotoImovel);
|
||||||
|
this.form.fotoImovel = null;
|
||||||
|
this.setStatus('Fotografia principal removida.', 'info');
|
||||||
|
this.queuePreviewRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeResearchPhoto(photoId: string): void {
|
||||||
|
const photo = this.form.fotosPesquisa.find((item) => item.id === photoId);
|
||||||
|
if (!photo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.revokeImage(photo);
|
||||||
|
this.form.fotosPesquisa = this.form.fotosPesquisa.filter((item) => item.id !== photoId);
|
||||||
|
this.setStatus('Fotografia removida da descricao da pesquisa.', 'info');
|
||||||
|
this.queuePreviewRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeTechniquePhoto(block: TechniqueBlock, photoId: string): void {
|
||||||
|
const photo = block.photos.find((item) => item.id === photoId);
|
||||||
|
if (!photo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.revokeImage(photo);
|
||||||
|
block.photos = block.photos.filter((item) => item.id !== photoId);
|
||||||
|
this.setStatus(`Fotografia removida de "${block.name}".`, 'info');
|
||||||
|
this.queuePreviewRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm(): void {
|
||||||
|
this.revokeFormImages(this.form);
|
||||||
|
this.form = createInitialReportForm();
|
||||||
|
this.setStatus('Formulario limpo. A pre-visualizacao foi reiniciada.', 'info');
|
||||||
|
this.queuePreviewRender(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadWord(): Promise<void> {
|
||||||
|
this.isExporting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { buildOutputFilename, buildReportBlob } = await import('./report-docx');
|
||||||
|
const blob = await buildReportBlob(this.form, 'download');
|
||||||
|
const filename = buildOutputFilename();
|
||||||
|
this.saveBlob(blob, filename);
|
||||||
|
this.setStatus(`Documento Word gerado: ${filename}`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
this.setStatus('Nao foi possivel gerar o ficheiro Word.', 'error');
|
||||||
|
} finally {
|
||||||
|
this.isExporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractFiles(event: Event): File[] {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const files = Array.from(input.files ?? []);
|
||||||
|
input.value = '';
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readImages(files: File[]): Promise<UploadedImage[]> {
|
||||||
|
return Promise.all(files.map((file) => this.readImage(file)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readImage(file: File): Promise<UploadedImage> {
|
||||||
|
this.assertSupportedImage(file);
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dimensions = await this.measureImage(objectUrl);
|
||||||
|
return {
|
||||||
|
id: this.createUiId(file.name),
|
||||||
|
file,
|
||||||
|
name: file.name,
|
||||||
|
objectUrl,
|
||||||
|
width: dimensions.width,
|
||||||
|
height: dimensions.height
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private measureImage(objectUrl: string): Promise<{ width: number; height: number }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
|
||||||
|
image.onload = () => {
|
||||||
|
resolve({
|
||||||
|
width: image.naturalWidth || 1200,
|
||||||
|
height: image.naturalHeight || 900
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
image.onerror = () => {
|
||||||
|
reject(new Error('Nao foi possivel ler uma das imagens selecionadas.'));
|
||||||
|
};
|
||||||
|
|
||||||
|
image.src = objectUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async renderWordPreview(): Promise<void> {
|
||||||
|
const bodyContainer = this.wordPreviewRef?.nativeElement;
|
||||||
|
const styleContainer = this.wordPreviewStylesRef?.nativeElement;
|
||||||
|
if (!bodyContainer || !styleContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = ++this.previewRenderRequestId;
|
||||||
|
this.isPreviewRendering = true;
|
||||||
|
this.previewErrorMessage = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [{ buildReportBlob }, { renderAsync }] = await Promise.all([
|
||||||
|
import('./report-docx'),
|
||||||
|
import('docx-preview')
|
||||||
|
]);
|
||||||
|
const blob = await buildReportBlob(this.form, 'preview');
|
||||||
|
|
||||||
|
if (requestId !== this.previewRenderRequestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyContainer.replaceChildren();
|
||||||
|
styleContainer.replaceChildren();
|
||||||
|
|
||||||
|
await renderAsync(blob, bodyContainer, styleContainer, {
|
||||||
|
className: 'docx',
|
||||||
|
inWrapper: true,
|
||||||
|
ignoreWidth: false,
|
||||||
|
ignoreHeight: false,
|
||||||
|
breakPages: true,
|
||||||
|
useBase64URL: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requestId !== this.previewRenderRequestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isPreviewRendering = false;
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId !== this.previewRenderRequestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(error);
|
||||||
|
bodyContainer.replaceChildren();
|
||||||
|
styleContainer.replaceChildren();
|
||||||
|
this.previewErrorMessage = 'Nao foi possivel renderizar o documento Word.';
|
||||||
|
this.isPreviewRendering = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private revokeFormImages(form: ReportFormModel): void {
|
||||||
|
this.revokeImage(form.fotoImovel);
|
||||||
|
this.revokeImages(form.fotosPesquisa);
|
||||||
|
|
||||||
|
for (const block of [...form.inicioPesquisa, ...form.tecnicas]) {
|
||||||
|
this.revokeImages(block.photos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private revokeImage(image: UploadedImage | null): void {
|
||||||
|
if (!image) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
URL.revokeObjectURL(image.objectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private revokeImages(images: UploadedImage[]): void {
|
||||||
|
for (const image of images) {
|
||||||
|
this.revokeImage(image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveBlob(blob: Blob, filename: string): void {
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = objectUrl;
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createUiId(prefix: string): string {
|
||||||
|
const base = prefix.toLowerCase().replace(/\s+/g, '-');
|
||||||
|
const generated =
|
||||||
|
globalThis.crypto?.randomUUID?.() ??
|
||||||
|
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
|
||||||
|
return `${base}-${generated}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setStatus(message: string, tone: 'info' | 'success' | 'error'): void {
|
||||||
|
this.statusMessage = message;
|
||||||
|
this.statusTone = tone;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertSupportedImage(file: File): void {
|
||||||
|
const mimeType = file.type.toLowerCase();
|
||||||
|
const extension = file.name.toLowerCase().split('.').pop() ?? '';
|
||||||
|
|
||||||
|
if (this.supportedMimeTypes.has(mimeType) || this.supportedExtensions.has(extension)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Formato de imagem nao suportado. Use PNG, JPG, GIF ou BMP.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,760 @@
|
|||||||
|
import {
|
||||||
|
AlignmentType,
|
||||||
|
BorderStyle,
|
||||||
|
Document,
|
||||||
|
ExternalHyperlink,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
ImageRun,
|
||||||
|
Packer,
|
||||||
|
PageNumber,
|
||||||
|
Paragraph,
|
||||||
|
Table,
|
||||||
|
TableCell,
|
||||||
|
TableLayoutType,
|
||||||
|
TableRow,
|
||||||
|
TextRun,
|
||||||
|
WidthType
|
||||||
|
} from 'docx';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildPresenceNarrative,
|
||||||
|
collectSelectedMethods,
|
||||||
|
CONCLUSION_PARAGRAPHS,
|
||||||
|
ReportFormModel,
|
||||||
|
splitParagraphs,
|
||||||
|
TECHNICIAN_NAME,
|
||||||
|
TECHNICIAN_NOTE,
|
||||||
|
TechniqueBlock,
|
||||||
|
UploadedImage
|
||||||
|
} from './report.models';
|
||||||
|
|
||||||
|
type SupportedImageType = 'jpg' | 'png' | 'gif' | 'bmp';
|
||||||
|
export type ReportBuildMode = 'preview' | 'download';
|
||||||
|
|
||||||
|
const HEADER_LOGO_PATH = 'brand-header.png';
|
||||||
|
const HEADER_LOGO_SIZE = { width: 176, height: 33 };
|
||||||
|
const FOOTER_PAGE_DROP_PATH = 'footer-page-drop-cropped.png';
|
||||||
|
const FOOTER_PAGE_DROP_SIZE = { width: 31, height: 46 };
|
||||||
|
const TEMPLATE_FOOTER_XML_PATH = 'template-footer1.xml';
|
||||||
|
const TEMPLATE_FOOTER_RELS_PATH = 'template-footer1.xml.rels';
|
||||||
|
const TEMPLATE_FOOTER_IMAGE_PATH = 'footer-page-drop.jpeg';
|
||||||
|
const FOOTER_SITE_URL = 'https://v-aguatech.com';
|
||||||
|
const FOOTER_LINK_CELL_WIDTH = 91;
|
||||||
|
const FOOTER_PAGE_CELL_WIDTH = 9;
|
||||||
|
const FOOTER_PAGE_NUMBER_OFFSET = -560;
|
||||||
|
|
||||||
|
let headerLogoDataPromise: Promise<ArrayBuffer> | null = null;
|
||||||
|
let footerPageDropDataPromise: Promise<ArrayBuffer> | null = null;
|
||||||
|
let templateFooterXmlPromise: Promise<string> | null = null;
|
||||||
|
let templateFooterRelsPromise: Promise<string> | null = null;
|
||||||
|
|
||||||
|
export async function buildReportBlob(
|
||||||
|
form: ReportFormModel,
|
||||||
|
mode: ReportBuildMode = 'preview'
|
||||||
|
): Promise<Blob> {
|
||||||
|
const [header, footer] = await Promise.all([
|
||||||
|
createDocumentHeader(),
|
||||||
|
createDocumentFooter()
|
||||||
|
]);
|
||||||
|
const children: Array<Paragraph | Table> = [];
|
||||||
|
const presenceNarrative = buildPresenceNarrative(form.presencas);
|
||||||
|
|
||||||
|
children.push(createSummaryTable(form));
|
||||||
|
children.push(createSpacer(240));
|
||||||
|
children.push(createTitle('Relatório da pesquisa não destrutiva de fugas'));
|
||||||
|
|
||||||
|
children.push(createSectionHeading('Descrição do imóvel'));
|
||||||
|
children.push(
|
||||||
|
createBodyParagraph(form.descricaoImovel || 'Sem descrição do imóvel preenchida.')
|
||||||
|
);
|
||||||
|
children.push(
|
||||||
|
createLeadParagraph('Ano de construção: ', form.anoConstrucao || 'Por preencher')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (form.fotoImovel) {
|
||||||
|
children.push(await createMainImageParagraph(form.fotoImovel));
|
||||||
|
}
|
||||||
|
|
||||||
|
children.push(createSectionHeading('Presentes aquando da pesquisa'));
|
||||||
|
|
||||||
|
if (presenceNarrative.header) {
|
||||||
|
children.push(createBodyParagraph(presenceNarrative.header));
|
||||||
|
} else {
|
||||||
|
children.push(createBodyParagraph('Sem presenças registadas.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const detail of presenceNarrative.details) {
|
||||||
|
children.push(createBodyParagraph(detail));
|
||||||
|
}
|
||||||
|
|
||||||
|
children.push(createSectionHeading('Descrição da pesquisa'));
|
||||||
|
|
||||||
|
const descriptionParagraphs = splitParagraphs(form.descricaoPesquisa);
|
||||||
|
if (descriptionParagraphs.length) {
|
||||||
|
for (const paragraph of descriptionParagraphs) {
|
||||||
|
children.push(createBodyParagraph(paragraph));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
children.push(createBodyParagraph('Sem descrição da pesquisa preenchida.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.fotosPesquisa.length) {
|
||||||
|
children.push(...(await createImageGalleryParagraphs(form.fotosPesquisa, 260, 180)));
|
||||||
|
}
|
||||||
|
|
||||||
|
children.push(createLeadParagraph('Área do espaço: ', `${form.area || 'Por preencher'} m²`));
|
||||||
|
|
||||||
|
children.push(createSectionHeading('Início da pesquisa'));
|
||||||
|
const introBlocks = form.inicioPesquisa.filter((item) => !!item.name.trim());
|
||||||
|
if (introBlocks.length) {
|
||||||
|
children.push(...(await createTechniqueParagraphs(introBlocks)));
|
||||||
|
} else {
|
||||||
|
children.push(createBodyParagraph('Nenhum item selecionado.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
children.push(createSectionHeading('Levantamento de danos'));
|
||||||
|
const damageBlocks = form.tecnicas.filter((item) => !!item.name.trim());
|
||||||
|
if (damageBlocks.length) {
|
||||||
|
children.push(...(await createTechniqueParagraphs(damageBlocks)));
|
||||||
|
} else {
|
||||||
|
children.push(createBodyParagraph('Nenhum item selecionado.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
children.push(createSectionHeading('Conclusão'));
|
||||||
|
for (const paragraph of CONCLUSION_PARAGRAPHS) {
|
||||||
|
children.push(createBodyParagraph(paragraph));
|
||||||
|
}
|
||||||
|
|
||||||
|
children.push(createSectionHeading('Informações gerais do técnico'));
|
||||||
|
children.push(createBodyParagraph(TECHNICIAN_NOTE));
|
||||||
|
|
||||||
|
children.push(createSectionHeading('Métodos utilizados'));
|
||||||
|
const methods = collectSelectedMethods(form);
|
||||||
|
if (methods.length) {
|
||||||
|
for (const method of methods) {
|
||||||
|
children.push(createMethodParagraph(method));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
children.push(createBodyParagraph('Sem métodos assinalados.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
children.push(createSectionHeading('Técnico'));
|
||||||
|
children.push(createLeadParagraph('', TECHNICIAN_NAME));
|
||||||
|
|
||||||
|
const wordDocument = new Document({
|
||||||
|
creator: 'Codex',
|
||||||
|
title: 'Relatório V-aguatech',
|
||||||
|
description: 'Relatório da pesquisa não destrutiva de fugas',
|
||||||
|
styles: {
|
||||||
|
default: {
|
||||||
|
document: {
|
||||||
|
run: {
|
||||||
|
font: 'Verdana',
|
||||||
|
size: 20,
|
||||||
|
color: '666666'
|
||||||
|
},
|
||||||
|
paragraph: {
|
||||||
|
spacing: {
|
||||||
|
line: 276
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
properties: {
|
||||||
|
page: {
|
||||||
|
margin: {
|
||||||
|
top: 1417,
|
||||||
|
right: 1701,
|
||||||
|
bottom: 1417,
|
||||||
|
left: 1701,
|
||||||
|
header: 708,
|
||||||
|
footer: 708
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
default: header
|
||||||
|
},
|
||||||
|
footers: {
|
||||||
|
default: footer
|
||||||
|
},
|
||||||
|
children
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = await Packer.toBlob(wordDocument);
|
||||||
|
if (mode === 'download') {
|
||||||
|
return applyTemplateFooter(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOutputFilename(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const date = [
|
||||||
|
now.getFullYear(),
|
||||||
|
pad(now.getMonth() + 1),
|
||||||
|
pad(now.getDate())
|
||||||
|
].join('-');
|
||||||
|
const time = [pad(now.getHours()), pad(now.getMinutes()), pad(now.getSeconds())].join('-');
|
||||||
|
|
||||||
|
return `${date}_${time}.docx`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSummaryTable(form: ReportFormModel): Table {
|
||||||
|
return new Table({
|
||||||
|
width: {
|
||||||
|
size: 100,
|
||||||
|
type: WidthType.PERCENTAGE
|
||||||
|
},
|
||||||
|
layout: TableLayoutType.FIXED,
|
||||||
|
borders: createTableBorders(),
|
||||||
|
rows: [
|
||||||
|
createSummaryRow('Nº do Processo', form.nproc, 'Segurado', form.segurado),
|
||||||
|
createSummaryRow('Companhia', form.companhia, 'Terceiro', form.terceiro),
|
||||||
|
createSummaryRow('Data da visita', form.dataVisita, 'Local da visita', form.localVisita)
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSummaryRow(
|
||||||
|
leftLabel: string,
|
||||||
|
leftValue: string,
|
||||||
|
rightLabel: string,
|
||||||
|
rightValue: string
|
||||||
|
): TableRow {
|
||||||
|
return new TableRow({
|
||||||
|
children: [
|
||||||
|
createSummaryCell(leftLabel, leftValue),
|
||||||
|
createSummaryCell(rightLabel, rightValue)
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSummaryCell(label: string, value: string): TableCell {
|
||||||
|
return new TableCell({
|
||||||
|
width: {
|
||||||
|
size: 50,
|
||||||
|
type: WidthType.PERCENTAGE
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
new Paragraph({
|
||||||
|
spacing: {
|
||||||
|
before: 120,
|
||||||
|
after: 120
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
new TextRun({
|
||||||
|
text: `${label}: `,
|
||||||
|
bold: true,
|
||||||
|
color: '808080'
|
||||||
|
}),
|
||||||
|
new TextRun({
|
||||||
|
text: value || 'Por preencher',
|
||||||
|
color: value ? '666666' : '9A9A9A'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTitle(text: string): Paragraph {
|
||||||
|
return new Paragraph({
|
||||||
|
spacing: {
|
||||||
|
after: 240
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
new TextRun({
|
||||||
|
text,
|
||||||
|
bold: true,
|
||||||
|
color: '4A66AC',
|
||||||
|
size: 28
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSectionHeading(text: string): Paragraph {
|
||||||
|
return new Paragraph({
|
||||||
|
spacing: {
|
||||||
|
before: 220,
|
||||||
|
after: 120
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
new TextRun({
|
||||||
|
text,
|
||||||
|
bold: true,
|
||||||
|
italics: true,
|
||||||
|
color: '4A66AC'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLeadParagraph(label: string, value: string): Paragraph {
|
||||||
|
return new Paragraph({
|
||||||
|
alignment: AlignmentType.JUSTIFIED,
|
||||||
|
children: [
|
||||||
|
new TextRun({
|
||||||
|
text: label,
|
||||||
|
bold: !!label,
|
||||||
|
color: label ? '4A66AC' : '666666'
|
||||||
|
}),
|
||||||
|
new TextRun({
|
||||||
|
text: value,
|
||||||
|
color: value === 'Por preencher' ? '9A9A9A' : '666666'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBodyParagraph(text: string): Paragraph {
|
||||||
|
return new Paragraph({
|
||||||
|
alignment: AlignmentType.JUSTIFIED,
|
||||||
|
spacing: {
|
||||||
|
after: 80
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
new TextRun({
|
||||||
|
text,
|
||||||
|
color: '666666'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMethodParagraph(text: string): Paragraph {
|
||||||
|
return new Paragraph({
|
||||||
|
bullet: {
|
||||||
|
level: 0
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
after: 50
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
new TextRun({
|
||||||
|
text,
|
||||||
|
color: '666666'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDocumentHeader(): Promise<Header> {
|
||||||
|
const logoData = await loadHeaderLogoData();
|
||||||
|
|
||||||
|
return new Header({
|
||||||
|
children: [
|
||||||
|
new Paragraph({
|
||||||
|
alignment: AlignmentType.RIGHT,
|
||||||
|
spacing: {
|
||||||
|
after: 0
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
new ImageRun({
|
||||||
|
type: 'png',
|
||||||
|
data: logoData,
|
||||||
|
transformation: HEADER_LOGO_SIZE
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDocumentFooter(): Promise<Footer> {
|
||||||
|
const pageDropData = await loadFooterPageDropData();
|
||||||
|
|
||||||
|
return new Footer({
|
||||||
|
children: [
|
||||||
|
new Table({
|
||||||
|
width: {
|
||||||
|
size: 100,
|
||||||
|
type: WidthType.PERCENTAGE
|
||||||
|
},
|
||||||
|
layout: TableLayoutType.FIXED,
|
||||||
|
borders: createBorderlessTableBorders(),
|
||||||
|
rows: [
|
||||||
|
new TableRow({
|
||||||
|
children: [
|
||||||
|
new TableCell({
|
||||||
|
width: {
|
||||||
|
size: FOOTER_LINK_CELL_WIDTH,
|
||||||
|
type: WidthType.PERCENTAGE
|
||||||
|
},
|
||||||
|
borders: createBorderlessTableBorders(),
|
||||||
|
children: [
|
||||||
|
new Paragraph({
|
||||||
|
spacing: {
|
||||||
|
before: 0,
|
||||||
|
after: 0
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
new ExternalHyperlink({
|
||||||
|
link: FOOTER_SITE_URL,
|
||||||
|
children: [
|
||||||
|
new TextRun({
|
||||||
|
text: FOOTER_SITE_URL,
|
||||||
|
color: '4A66AC',
|
||||||
|
size: 18
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
new TableCell({
|
||||||
|
width: {
|
||||||
|
size: FOOTER_PAGE_CELL_WIDTH,
|
||||||
|
type: WidthType.PERCENTAGE
|
||||||
|
},
|
||||||
|
borders: createBorderlessTableBorders(),
|
||||||
|
children: [
|
||||||
|
new Paragraph({
|
||||||
|
alignment: AlignmentType.CENTER,
|
||||||
|
spacing: {
|
||||||
|
before: 0,
|
||||||
|
after: 0
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
new ImageRun({
|
||||||
|
type: 'png',
|
||||||
|
data: pageDropData,
|
||||||
|
transformation: FOOTER_PAGE_DROP_SIZE
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
new Paragraph({
|
||||||
|
alignment: AlignmentType.CENTER,
|
||||||
|
spacing: {
|
||||||
|
before: FOOTER_PAGE_NUMBER_OFFSET,
|
||||||
|
after: 0
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
new TextRun({
|
||||||
|
color: 'FFFFFF',
|
||||||
|
bold: true,
|
||||||
|
size: 20,
|
||||||
|
children: [PageNumber.CURRENT]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMainImageParagraph(image: UploadedImage): Promise<Paragraph> {
|
||||||
|
const imageRun = await createImageRun(image, 620, 360);
|
||||||
|
return new Paragraph({
|
||||||
|
alignment: AlignmentType.CENTER,
|
||||||
|
spacing: {
|
||||||
|
before: 120,
|
||||||
|
after: 160
|
||||||
|
},
|
||||||
|
children: [imageRun]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTechniqueParagraphs(blocks: TechniqueBlock[]): Promise<Paragraph[]> {
|
||||||
|
const paragraphs: Paragraph[] = [];
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
paragraphs.push(
|
||||||
|
new Paragraph({
|
||||||
|
spacing: {
|
||||||
|
before: 140,
|
||||||
|
after: 60
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
new TextRun({
|
||||||
|
text: block.name,
|
||||||
|
bold: true,
|
||||||
|
color: '4A66AC',
|
||||||
|
size: 22
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (block.description.trim()) {
|
||||||
|
for (const paragraph of splitParagraphs(block.description)) {
|
||||||
|
paragraphs.push(createBodyParagraph(paragraph));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.photos.length) {
|
||||||
|
paragraphs.push(...(await createImageGalleryParagraphs(block.photos, 240, 180)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return paragraphs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createImageGalleryParagraphs(
|
||||||
|
images: UploadedImage[],
|
||||||
|
maxWidth: number,
|
||||||
|
maxHeight: number
|
||||||
|
): Promise<Paragraph[]> {
|
||||||
|
const paragraphs: Paragraph[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < images.length; index += 2) {
|
||||||
|
const currentRow = images.slice(index, index + 2);
|
||||||
|
const children: Array<TextRun | ImageRun> = [];
|
||||||
|
|
||||||
|
for (let imageIndex = 0; imageIndex < currentRow.length; imageIndex += 1) {
|
||||||
|
const image = currentRow[imageIndex];
|
||||||
|
if (!image) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
children.push(await createImageRun(image, maxWidth, maxHeight));
|
||||||
|
|
||||||
|
if (imageIndex < currentRow.length - 1) {
|
||||||
|
children.push(
|
||||||
|
new TextRun({
|
||||||
|
text: ' '
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
paragraphs.push(
|
||||||
|
new Paragraph({
|
||||||
|
alignment: AlignmentType.CENTER,
|
||||||
|
spacing: {
|
||||||
|
before: 80,
|
||||||
|
after: 120
|
||||||
|
},
|
||||||
|
children
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paragraphs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createImageRun(
|
||||||
|
image: UploadedImage,
|
||||||
|
maxWidth: number,
|
||||||
|
maxHeight: number
|
||||||
|
): Promise<ImageRun> {
|
||||||
|
const data = await image.file.arrayBuffer();
|
||||||
|
const size = scaleDimensions(image.width, image.height, maxWidth, maxHeight);
|
||||||
|
const type = getImageType(image.file);
|
||||||
|
|
||||||
|
return new ImageRun({
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
transformation: size
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scaleDimensions(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
maxWidth: number,
|
||||||
|
maxHeight: number
|
||||||
|
): { width: number; height: number } {
|
||||||
|
if (width <= maxWidth && height <= maxHeight) {
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
||||||
|
return {
|
||||||
|
width: Math.max(1, Math.round(width * ratio)),
|
||||||
|
height: Math.max(1, Math.round(height * ratio))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSpacer(after: number): Paragraph {
|
||||||
|
return new Paragraph({
|
||||||
|
spacing: {
|
||||||
|
after
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTableBorders() {
|
||||||
|
return {
|
||||||
|
top: { style: BorderStyle.SINGLE, size: 4, color: '4A66AC' },
|
||||||
|
bottom: { style: BorderStyle.SINGLE, size: 4, color: '4A66AC' },
|
||||||
|
left: { style: BorderStyle.SINGLE, size: 4, color: '4A66AC' },
|
||||||
|
right: { style: BorderStyle.SINGLE, size: 4, color: '4A66AC' },
|
||||||
|
insideHorizontal: { style: BorderStyle.SINGLE, size: 4, color: '4A66AC' },
|
||||||
|
insideVertical: { style: BorderStyle.SINGLE, size: 4, color: '4A66AC' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBorderlessTableBorders() {
|
||||||
|
return {
|
||||||
|
top: { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' },
|
||||||
|
bottom: { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' },
|
||||||
|
left: { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' },
|
||||||
|
right: { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' },
|
||||||
|
insideHorizontal: { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' },
|
||||||
|
insideVertical: { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(value: number): string {
|
||||||
|
return value.toString().padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHeaderLogoData(): Promise<ArrayBuffer> {
|
||||||
|
if (!headerLogoDataPromise) {
|
||||||
|
headerLogoDataPromise = loadPublicAssetData(HEADER_LOGO_PATH, 'logo do cabecalho').catch(
|
||||||
|
(error) => {
|
||||||
|
headerLogoDataPromise = null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return headerLogoDataPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFooterPageDropData(): Promise<ArrayBuffer> {
|
||||||
|
if (!footerPageDropDataPromise) {
|
||||||
|
footerPageDropDataPromise = loadPublicAssetData(
|
||||||
|
FOOTER_PAGE_DROP_PATH,
|
||||||
|
'imagem do footer'
|
||||||
|
).catch((error) => {
|
||||||
|
footerPageDropDataPromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return footerPageDropDataPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTemplateFooterXml(): Promise<string> {
|
||||||
|
if (!templateFooterXmlPromise) {
|
||||||
|
templateFooterXmlPromise = loadPublicTextAsset(
|
||||||
|
TEMPLATE_FOOTER_XML_PATH,
|
||||||
|
'template XML do footer'
|
||||||
|
).catch((error) => {
|
||||||
|
templateFooterXmlPromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return templateFooterXmlPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTemplateFooterRels(): Promise<string> {
|
||||||
|
if (!templateFooterRelsPromise) {
|
||||||
|
templateFooterRelsPromise = loadPublicTextAsset(
|
||||||
|
TEMPLATE_FOOTER_RELS_PATH,
|
||||||
|
'template de relacoes do footer'
|
||||||
|
).catch((error) => {
|
||||||
|
templateFooterRelsPromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return templateFooterRelsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPublicAssetData(assetPath: string, assetLabel: string): Promise<ArrayBuffer> {
|
||||||
|
const baseUri = globalThis.document?.baseURI ?? globalThis.location?.href;
|
||||||
|
if (!baseUri) {
|
||||||
|
throw new Error(`Nao foi possivel resolver ${assetLabel}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetUrl = new URL(assetPath, baseUri).toString();
|
||||||
|
const response = await fetch(assetUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Nao foi possivel carregar ${assetLabel} (${response.status}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.arrayBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPublicTextAsset(assetPath: string, assetLabel: string): Promise<string> {
|
||||||
|
const baseUri = globalThis.document?.baseURI ?? globalThis.location?.href;
|
||||||
|
if (!baseUri) {
|
||||||
|
throw new Error(`Nao foi possivel resolver ${assetLabel}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetUrl = new URL(assetPath, baseUri).toString();
|
||||||
|
const response = await fetch(assetUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Nao foi possivel carregar ${assetLabel} (${response.status}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyTemplateFooter(blob: Blob): Promise<Blob> {
|
||||||
|
const [{ default: JSZip }, footerXml, footerRels, footerImage] = await Promise.all([
|
||||||
|
import('jszip'),
|
||||||
|
loadTemplateFooterXml(),
|
||||||
|
loadTemplateFooterRels(),
|
||||||
|
loadPublicAssetData(TEMPLATE_FOOTER_IMAGE_PATH, 'imagem original do footer')
|
||||||
|
]);
|
||||||
|
|
||||||
|
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||||||
|
zip.file('word/footer1.xml', footerXml);
|
||||||
|
zip.file('word/_rels/footer1.xml.rels', footerRels);
|
||||||
|
zip.file('word/media/image2.jpeg', footerImage);
|
||||||
|
|
||||||
|
const contentTypesEntry = zip.file('[Content_Types].xml');
|
||||||
|
if (contentTypesEntry) {
|
||||||
|
const contentTypesXml = await contentTypesEntry.async('string');
|
||||||
|
if (!contentTypesXml.includes('Extension="jpeg"')) {
|
||||||
|
const patchedContentTypesXml = contentTypesXml.replace(
|
||||||
|
'</Types>',
|
||||||
|
'<Default Extension="jpeg" ContentType="image/jpeg"/></Types>'
|
||||||
|
);
|
||||||
|
zip.file('[Content_Types].xml', patchedContentTypesXml);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return zip.generateAsync({ type: 'blob' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageType(file: File): SupportedImageType {
|
||||||
|
const mimeType = file.type.toLowerCase();
|
||||||
|
if (mimeType === 'image/png') {
|
||||||
|
return 'png';
|
||||||
|
}
|
||||||
|
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') {
|
||||||
|
return 'jpg';
|
||||||
|
}
|
||||||
|
if (mimeType === 'image/gif') {
|
||||||
|
return 'gif';
|
||||||
|
}
|
||||||
|
if (mimeType === 'image/bmp') {
|
||||||
|
return 'bmp';
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = file.name.toLowerCase().split('.').pop();
|
||||||
|
if (extension === 'png') {
|
||||||
|
return 'png';
|
||||||
|
}
|
||||||
|
if (extension === 'jpg' || extension === 'jpeg') {
|
||||||
|
return 'jpg';
|
||||||
|
}
|
||||||
|
if (extension === 'gif') {
|
||||||
|
return 'gif';
|
||||||
|
}
|
||||||
|
if (extension === 'bmp') {
|
||||||
|
return 'bmp';
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Formato de imagem não suportado. Use PNG, JPG, GIF ou BMP.');
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
export type PresenceType = 'segurado' | 'lesado';
|
||||||
|
|
||||||
|
export interface UploadedImage {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
name: string;
|
||||||
|
objectUrl: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TechniqueBlock {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
photos: UploadedImage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresenceEntry {
|
||||||
|
id: string;
|
||||||
|
type: PresenceType;
|
||||||
|
name: string;
|
||||||
|
info: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportFormModel {
|
||||||
|
nproc: string;
|
||||||
|
segurado: string;
|
||||||
|
companhia: string;
|
||||||
|
terceiro: string;
|
||||||
|
dataVisita: string;
|
||||||
|
localVisita: string;
|
||||||
|
descricaoImovel: string;
|
||||||
|
anoConstrucao: string;
|
||||||
|
descricaoPesquisa: string;
|
||||||
|
area: string;
|
||||||
|
fotoImovel: UploadedImage | null;
|
||||||
|
fotosPesquisa: UploadedImage[];
|
||||||
|
presencas: PresenceEntry[];
|
||||||
|
inicioPesquisa: TechniqueBlock[];
|
||||||
|
tecnicas: TechniqueBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresenceNarrative {
|
||||||
|
header: string | null;
|
||||||
|
details: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresenceSection {
|
||||||
|
type: PresenceType;
|
||||||
|
title: string;
|
||||||
|
buttonLabel: string;
|
||||||
|
singularLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRESENCE_SECTIONS: PresenceSection[] = [
|
||||||
|
{
|
||||||
|
type: 'segurado',
|
||||||
|
title: 'Segurados',
|
||||||
|
buttonLabel: 'Adicionar segurado',
|
||||||
|
singularLabel: 'segurado'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'lesado',
|
||||||
|
title: 'Lesados',
|
||||||
|
buttonLabel: 'Adicionar lesado',
|
||||||
|
singularLabel: 'lesado'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TECHNIQUE_OPTIONS = [
|
||||||
|
'Controlo Visual',
|
||||||
|
'Medição de humidade',
|
||||||
|
'Câmara térmica',
|
||||||
|
'Câmara endoscópica',
|
||||||
|
'Obturação',
|
||||||
|
'Teste de pressão',
|
||||||
|
'Teste com corantes'
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CONCLUSION_PARAGRAPHS = [
|
||||||
|
'Resumo breve e sucinto das constatações e resultados.',
|
||||||
|
'Visto que as tubagens passam em calhas/galerias/coretes, é difícil determinar a localização exata da fuga, uma vez que o gás se expande pelas calhas/galerias/coretes.',
|
||||||
|
'As vibrações acústicas que resultam da fuga propagam-se pelas calhas/galerias/coretes.',
|
||||||
|
'O ruído da fuga é demasiadamente baixo para ser detetado com o Detector Electroacústico.',
|
||||||
|
'Existe uma perda de pressão entre 1 e 1,5 bar por dia; serão necessárias algumas horas para se efetuar uma deteção via Aquaphon/câmara térmica.'
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TECHNICIAN_NOTE =
|
||||||
|
'Assim que a causa da fuga esteja reparada, recomenda-se uma espera de tempo adequado, de acordo com as especificações dos materiais utilizados, antes de se avançar com as reparações dos danos, tendo em conta os tempos de secagem.';
|
||||||
|
|
||||||
|
export const TECHNICIAN_NAME = 'Vítor Gomes';
|
||||||
|
|
||||||
|
export function createInitialReportForm(): ReportFormModel {
|
||||||
|
return {
|
||||||
|
nproc: '',
|
||||||
|
segurado: '',
|
||||||
|
companhia: '',
|
||||||
|
terceiro: '',
|
||||||
|
dataVisita: '',
|
||||||
|
localVisita: '',
|
||||||
|
descricaoImovel: '',
|
||||||
|
anoConstrucao: '',
|
||||||
|
descricaoPesquisa: '',
|
||||||
|
area: '',
|
||||||
|
fotoImovel: null,
|
||||||
|
fotosPesquisa: [],
|
||||||
|
presencas: [createPresenceEntry('segurado')],
|
||||||
|
inicioPesquisa: [createTechniqueBlock()],
|
||||||
|
tecnicas: [createTechniqueBlock()]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPresenceEntry(type: PresenceType): PresenceEntry {
|
||||||
|
return {
|
||||||
|
id: createId(type),
|
||||||
|
type,
|
||||||
|
name: '',
|
||||||
|
info: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitParagraphs(value: string): string[] {
|
||||||
|
return value
|
||||||
|
.split(/\r?\n+/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPresenceNarrative(presences: PresenceEntry[]): PresenceNarrative {
|
||||||
|
const details: string[] = [];
|
||||||
|
const presentPeople: string[] = [];
|
||||||
|
|
||||||
|
for (const item of presences) {
|
||||||
|
const name = item.name.trim();
|
||||||
|
const info = item.info.trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === 'segurado') {
|
||||||
|
presentPeople.push(`o segurado, ${name}`);
|
||||||
|
if (info) {
|
||||||
|
details.push(`O segurado, ${name} informou que ${info}.`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === 'lesado') {
|
||||||
|
presentPeople.push(`o lesado, ${name}`);
|
||||||
|
if (info) {
|
||||||
|
details.push(`O lesado, ${name} informou que ${info}.`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!presentPeople.length) {
|
||||||
|
return {
|
||||||
|
header: null,
|
||||||
|
details
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const joined = joinPeople(presentPeople);
|
||||||
|
const header =
|
||||||
|
presentPeople.length === 1
|
||||||
|
? `${joined} esteve presente aquando a pesquisa.`
|
||||||
|
: `${joined} estiveram presentes aquando a pesquisa.`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
header,
|
||||||
|
details
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectSelectedMethods(form: ReportFormModel): string[] {
|
||||||
|
const methods: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const group of [form.inicioPesquisa, form.tecnicas]) {
|
||||||
|
for (const item of group) {
|
||||||
|
const name = item.name.trim();
|
||||||
|
if (!name || seen.has(name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(name);
|
||||||
|
methods.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return methods;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTechniqueBlock(name = ''): TechniqueBlock {
|
||||||
|
return {
|
||||||
|
id: createId(name || 'metodo'),
|
||||||
|
name,
|
||||||
|
description: '',
|
||||||
|
photos: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createId(prefix: string): string {
|
||||||
|
const safePrefix = (prefix || 'item').toLowerCase().replace(/\s+/g, '-');
|
||||||
|
const generated =
|
||||||
|
globalThis.crypto?.randomUUID?.() ??
|
||||||
|
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
|
||||||
|
return `${safePrefix}-${generated}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinPeople(items: string[]): string {
|
||||||
|
if (items.length === 1) {
|
||||||
|
return items[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${items.slice(0, -1).join(', ')} e ${items.at(-1)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>VAguatechAngular</title>
|
||||||
|
<base href="/">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { appConfig } from './app/app.config';
|
||||||
|
import { App } from './app/app';
|
||||||
|
|
||||||
|
bootstrapApplication(App, appConfig)
|
||||||
|
.catch((err) => console.error(err));
|
||||||
+679
@@ -0,0 +1,679 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #ede8dc;
|
||||||
|
--panel: rgba(250, 247, 239, 0.92);
|
||||||
|
--paper: #fffdfa;
|
||||||
|
--line: rgba(102, 102, 102, 0.16);
|
||||||
|
--ink: #27313a;
|
||||||
|
--muted: #66707a;
|
||||||
|
--accent: #4a66ac;
|
||||||
|
--accent-soft: rgba(74, 102, 172, 0.12);
|
||||||
|
--accent-strong: #39518d;
|
||||||
|
--gold: #d1ad66;
|
||||||
|
--success: #357756;
|
||||||
|
--error: #a04637;
|
||||||
|
--shadow: 0 28px 60px rgba(30, 42, 68, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(209, 173, 102, 0.32), transparent 32%),
|
||||||
|
radial-gradient(circle at top right, rgba(74, 102, 172, 0.18), transparent 28%),
|
||||||
|
linear-gradient(180deg, #f4efe5 0%, #ebe5d7 100%);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: 'Bahnschrift', 'Trebuchet MS', 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
app-root {
|
||||||
|
display: block;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(340px, 1fr) minmax(0, 2fr);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace__controls,
|
||||||
|
.workspace__preview {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace__controls {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 253, 248, 0.95), rgba(246, 240, 226, 0.94));
|
||||||
|
border-right: 1px solid rgba(74, 102, 172, 0.14);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace__preview {
|
||||||
|
overflow-y: auto;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(74, 102, 172, 0.08), transparent 40%),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.48), rgba(250, 246, 237, 0.82));
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
max-width: 30rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head h1,
|
||||||
|
.preview-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(2rem, 1.3rem + 2vw, 3.25rem);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 0.96;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro,
|
||||||
|
.preview-note,
|
||||||
|
.section-head p,
|
||||||
|
.upload-row p,
|
||||||
|
.group-head p,
|
||||||
|
.status,
|
||||||
|
.empty-state {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 2.9rem;
|
||||||
|
padding: 0.8rem 1.15rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
transition:
|
||||||
|
transform 160ms ease,
|
||||||
|
box-shadow 160ms ease,
|
||||||
|
background-color 160ms ease,
|
||||||
|
border-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled,
|
||||||
|
.button.is-disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--primary {
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||||
|
box-shadow: 0 14px 28px rgba(74, 102, 172, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--ghost {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
border-color: rgba(74, 102, 172, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--soft {
|
||||||
|
position: relative;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--small {
|
||||||
|
min-height: 2.3rem;
|
||||||
|
padding-inline: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border: 1px solid rgba(74, 102, 172, 0.12);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.is-success {
|
||||||
|
color: var(--success);
|
||||||
|
border-color: rgba(53, 119, 86, 0.18);
|
||||||
|
background: rgba(53, 119, 86, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.is-error {
|
||||||
|
color: var(--error);
|
||||||
|
border-color: rgba(160, 70, 55, 0.18);
|
||||||
|
background: rgba(160, 70, 55, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
max-width: 30rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 1.2rem;
|
||||||
|
border: 1px solid rgba(74, 102, 172, 0.12);
|
||||||
|
border-radius: 1.3rem;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: 0 14px 30px rgba(38, 52, 80, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head h2,
|
||||||
|
.group-head h3 {
|
||||||
|
margin: 0 0 0.2rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid,
|
||||||
|
.tech-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field span {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field--full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field textarea,
|
||||||
|
.field select {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(74, 102, 172, 0.16);
|
||||||
|
border-radius: 0.95rem;
|
||||||
|
padding: 0.85rem 0.95rem;
|
||||||
|
color: var(--ink);
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
transition:
|
||||||
|
border-color 150ms ease,
|
||||||
|
box-shadow 150ms ease,
|
||||||
|
background-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field textarea {
|
||||||
|
min-height: 6rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field select {
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus,
|
||||||
|
.field textarea:focus,
|
||||||
|
.field select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(74, 102, 172, 0.55);
|
||||||
|
box-shadow: 0 0 0 4px rgba(74, 102, 172, 0.12);
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-block + .upload-block,
|
||||||
|
.presence-group + .presence-group {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-block,
|
||||||
|
.presence-card,
|
||||||
|
.tech-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.66);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-block,
|
||||||
|
.presence-card {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-row,
|
||||||
|
.group-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-row strong,
|
||||||
|
.thumb-card__meta strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-list--single {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-card {
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
border: 1px solid rgba(74, 102, 172, 0.12);
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-card img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-card__meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.8rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button--danger {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.presence-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-head--section {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.95rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1.1rem;
|
||||||
|
transition:
|
||||||
|
border-color 160ms ease,
|
||||||
|
transform 160ms ease,
|
||||||
|
box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-card.is-selected {
|
||||||
|
border-color: rgba(74, 102, 172, 0.32);
|
||||||
|
box-shadow: 0 18px 36px rgba(74, 102, 172, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-card__top {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 0.9rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-card__top .link-button {
|
||||||
|
justify-self: end;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-card .upload-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-card .thumb-list {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: end;
|
||||||
|
max-width: 72rem;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-preview-shell {
|
||||||
|
max-width: 72rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-preview-styles {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-banner {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 12px 24px rgba(38, 52, 80, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-banner--error {
|
||||||
|
color: var(--error);
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-dot {
|
||||||
|
inline-size: 0.7rem;
|
||||||
|
block-size: 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent);
|
||||||
|
animation: preview-pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-preview-host {
|
||||||
|
min-height: 75vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-preview-host .docx-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-preview-host .docx-wrapper .docx {
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
box-shadow: 0 28px 60px rgba(30, 42, 68, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 54rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2.75rem 3rem;
|
||||||
|
border-radius: 1.6rem;
|
||||||
|
background: linear-gradient(180deg, #fffefb, #fffdfa);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(74, 102, 172, 0.28);
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-cell {
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border-right: 1px solid rgba(74, 102, 172, 0.16);
|
||||||
|
border-bottom: 1px solid rgba(74, 102, 172, 0.16);
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(244, 248, 255, 0.8));
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-cell:nth-child(2n) {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-cell:nth-last-child(-n + 2) {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-cell span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
color: #788194;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-cell strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-header {
|
||||||
|
margin: 2rem 0 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-kicker {
|
||||||
|
margin: 0 0 0.45rem;
|
||||||
|
color: var(--gold);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: clamp(1.8rem, 1.2rem + 1.7vw, 2.5rem);
|
||||||
|
line-height: 1.04;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-section {
|
||||||
|
margin-top: 1.8rem;
|
||||||
|
color: #5f646c;
|
||||||
|
font-family: 'Palatino Linotype', 'Book Antiqua', Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-section h4,
|
||||||
|
.method-block h5 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: 'Bahnschrift', 'Trebuchet MS', 'Segoe UI', sans-serif;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-section h4 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-block {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-block h5 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-section p,
|
||||||
|
.report-section li {
|
||||||
|
margin: 0 0 0.8rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.78;
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-figure,
|
||||||
|
.report-gallery__item {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-figure img,
|
||||||
|
.report-gallery__item img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 1rem;
|
||||||
|
object-fit: cover;
|
||||||
|
box-shadow: 0 18px 30px rgba(18, 26, 44, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-gallery {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 1rem 0 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-section--signature p {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-placeholder {
|
||||||
|
color: #9c9c9c;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1220px) {
|
||||||
|
.workspace {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace__controls {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid rgba(74, 102, 172, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-head,
|
||||||
|
.page {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.workspace__controls,
|
||||||
|
.workspace__preview {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid,
|
||||||
|
.thumb-list,
|
||||||
|
.report-gallery,
|
||||||
|
.report-summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-card__top {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-card__top .link-button {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-cell {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-cell:not(:last-child) {
|
||||||
|
border-bottom: 1px solid rgba(74, 102, 172, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-head,
|
||||||
|
.upload-row,
|
||||||
|
.group-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes preview-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.35;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/app",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/**/*.spec.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"compileOnSave": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"importHelpers": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "preserve"
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/spec",
|
||||||
|
"types": [
|
||||||
|
"vitest/globals"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.d.ts",
|
||||||
|
"src/**/*.spec.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user