v-aguatech

This commit is contained in:
kl3z00
2026-05-22 08:42:53 +01:00
commit f113ae5b7c
32 changed files with 11717 additions and 0 deletions
+17
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
+4
View File
@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}
+20
View File
@@ -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"
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
// For more information, visit: https://angular.dev/ai/mcp
"servers": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}
+42
View File
@@ -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)"
}
}
}
}
]
}
+59
View File
@@ -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.
+77
View File
@@ -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"
}
}
}
}
}
+8795
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -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
+2
View File
@@ -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>
+11
View File
@@ -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)
]
};
+3
View File
@@ -0,0 +1,3 @@
:host {
display: block;
}
+439
View File
@@ -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>
+3
View File
@@ -0,0 +1,3 @@
import { Routes } from '@angular/router';
export const routes: Routes = [];
+25
View File
@@ -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
View File
@@ -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.');
}
}
+760
View File
@@ -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'}`));
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.');
}
+222
View File
@@ -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)}`;
}
+13
View File
@@ -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>
+6
View File
@@ -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
View File
@@ -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);
}
}
+15
View File
@@ -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"
]
}
+33
View File
@@ -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"
}
]
}
+15
View File
@@ -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"
]
}