angular-my-report 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -2
- package/ng-package.json +7 -0
- package/package.json +23 -3
- package/src/lib/engine/barcode.ts +118 -0
- package/src/lib/engine/chart.ts +85 -0
- package/src/lib/engine/chartLayout.ts +265 -0
- package/src/lib/engine/crosstab.ts +96 -0
- package/src/lib/engine/dataSources.ts +204 -0
- package/src/lib/engine/expressions.ts +734 -0
- package/src/lib/engine/measure.ts +62 -0
- package/src/lib/engine/parameters.ts +96 -0
- package/src/lib/engine/qr.ts +26 -0
- package/src/lib/engine/renderer.ts +324 -0
- package/src/lib/engine/table.ts +58 -0
- package/src/lib/model/defaults.ts +249 -0
- package/src/lib/model/factories.ts +324 -0
- package/src/lib/model/types.ts +281 -0
- package/src/lib/ui/css.ts +12 -0
- package/src/lib/ui/designer/banded-table-dialog.component.ts +127 -0
- package/src/lib/ui/designer/canvas.component.ts +281 -0
- package/src/lib/ui/designer/data-source-dialog.component.ts +201 -0
- package/src/lib/ui/designer/element-box.component.ts +138 -0
- package/src/lib/ui/designer/graphics.components.ts +140 -0
- package/src/lib/ui/designer/left-panel.component.ts +189 -0
- package/src/lib/ui/designer/safe-image.pipe.ts +16 -0
- package/src/lib/ui/designer/table-view.component.ts +120 -0
- package/src/lib/ui/designer.component.ts +296 -0
- package/src/lib/ui/designer.store.ts +230 -0
- package/src/lib/ui/inputs.ts +34 -0
- package/src/lib/ui/myreport.css +267 -0
- package/src/lib/ui/properties/band-props.component.ts +86 -0
- package/src/lib/ui/properties/element-props.component.ts +326 -0
- package/src/lib/ui/properties/expr-input.component.ts +191 -0
- package/src/lib/ui/properties/graphics-props.component.ts +260 -0
- package/src/lib/ui/properties/image-picker.component.ts +62 -0
- package/src/lib/ui/properties/properties-panel.component.ts +37 -0
- package/src/lib/ui/properties/report-extras.component.ts +198 -0
- package/src/lib/ui/properties/report-props.component.ts +211 -0
- package/src/lib/ui/properties/table-props.component.ts +197 -0
- package/src/lib/ui/viewer/page-view.component.ts +73 -0
- package/src/lib/ui/viewer/render.ts +161 -0
- package/src/lib/ui/viewer/viewer.component.ts +555 -0
- package/src/lib/ui/viewer.component.ts +107 -0
- package/src/public-api.ts +12 -0
- package/tsconfig.lib.json +12 -0
- package/tsconfig.lib.prod.json +5 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
|
2
|
+
import { FormsModule } from '@angular/forms';
|
|
3
|
+
import type { BandType, PageSettings, ReportDefinition } from '../../model/types';
|
|
4
|
+
import { BAND_LABELS, PAGE_SIZES, createBand, uid } from '../../model/defaults';
|
|
5
|
+
import { DesignerStore } from '../designer.store';
|
|
6
|
+
import { ReportExtrasComponent } from './report-extras.component';
|
|
7
|
+
|
|
8
|
+
export function removeGroup(r: ReportDefinition, gid: string): void {
|
|
9
|
+
r.groups = r.groups.filter((g) => g.id !== gid);
|
|
10
|
+
r.bands = r.bands.filter((b) => b.groupId !== gid);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const OPTIONAL_BANDS: BandType[] = ['reportHeader', 'pageHeader', 'detail', 'pageFooter', 'reportFooter'];
|
|
14
|
+
type PageKey = Exclude<keyof PageSettings, 'continuous'>;
|
|
15
|
+
|
|
16
|
+
@Component({
|
|
17
|
+
selector: 'rpt-report-props',
|
|
18
|
+
standalone: true,
|
|
19
|
+
imports: [FormsModule, ReportExtrasComponent],
|
|
20
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
21
|
+
template: `
|
|
22
|
+
@let r = report();
|
|
23
|
+
<h3>Report</h3>
|
|
24
|
+
<section>
|
|
25
|
+
<label class="prop-row"><span>Name</span>
|
|
26
|
+
<div class="prop-value"><input [ngModel]="r.name" (ngModelChange)="setName($event)" /></div>
|
|
27
|
+
</label>
|
|
28
|
+
</section>
|
|
29
|
+
|
|
30
|
+
<section>
|
|
31
|
+
<h4>Page</h4>
|
|
32
|
+
<label class="prop-row"><span>Size</span>
|
|
33
|
+
<div class="prop-value">
|
|
34
|
+
<select [ngModel]="sizeName()" (ngModelChange)="setSize($event)">
|
|
35
|
+
@for (k of sizeNames; track k) {
|
|
36
|
+
<option [value]="k">{{ k }}</option>
|
|
37
|
+
}
|
|
38
|
+
@if (sizeName() === 'Custom') {
|
|
39
|
+
<option value="Custom">Custom</option>
|
|
40
|
+
}
|
|
41
|
+
</select>
|
|
42
|
+
</div>
|
|
43
|
+
</label>
|
|
44
|
+
<label class="prop-row"><span>Orientation</span>
|
|
45
|
+
<div class="prop-value">
|
|
46
|
+
<select [ngModel]="landscape() ? 'landscape' : 'portrait'" (ngModelChange)="setOrientation($event)">
|
|
47
|
+
<option value="portrait">Portrait</option>
|
|
48
|
+
<option value="landscape">Landscape</option>
|
|
49
|
+
</select>
|
|
50
|
+
</div>
|
|
51
|
+
</label>
|
|
52
|
+
<div class="grid2">
|
|
53
|
+
@for (f of pageFields; track f.key) {
|
|
54
|
+
<label class="prop-row"><span>{{ f.label }}</span>
|
|
55
|
+
<div class="prop-value">
|
|
56
|
+
<input type="number" [min]="f.min" [ngModel]="r.page[f.key]" (ngModelChange)="setPage(f.key, f.min, $event)" />
|
|
57
|
+
</div>
|
|
58
|
+
</label>
|
|
59
|
+
}
|
|
60
|
+
</div>
|
|
61
|
+
<label class="check" title="Receipt roll: one page as long as its content, no page breaks">
|
|
62
|
+
<input type="checkbox" [ngModel]="!!r.page.continuous" (ngModelChange)="setContinuous($event)" /> Continuous (receipt roll, no page breaks)
|
|
63
|
+
</label>
|
|
64
|
+
<p class="hint">Units are pixels at 96 DPI (A4 = 794 × 1123, 80 mm receipt = 302 wide).</p>
|
|
65
|
+
</section>
|
|
66
|
+
|
|
67
|
+
<section>
|
|
68
|
+
<h4>Sections</h4>
|
|
69
|
+
@for (t of optionalBands; track t) {
|
|
70
|
+
<label class="check">
|
|
71
|
+
<input type="checkbox" [ngModel]="isVisible(t)" (ngModelChange)="setVisible(t, $event)" />
|
|
72
|
+
{{ labels[t] }}
|
|
73
|
+
</label>
|
|
74
|
+
}
|
|
75
|
+
<p class="hint">Without Detail, the report prints its header/footer once — fields there show the first/last record, or use sum(), count()… for totals.</p>
|
|
76
|
+
</section>
|
|
77
|
+
|
|
78
|
+
<section>
|
|
79
|
+
<h4>Groups</h4>
|
|
80
|
+
@for (g of r.groups; track g.id; let i = $index) {
|
|
81
|
+
<div class="group-row">
|
|
82
|
+
<span class="muted">{{ i + 1 }}.</span>
|
|
83
|
+
<input list="field-names" placeholder="Field name" [ngModel]="g.field" (ngModelChange)="setGroupField(g.id, $event)" />
|
|
84
|
+
<button class="small danger" title="Remove group" (click)="deleteGroup(g.id)">✕</button>
|
|
85
|
+
</div>
|
|
86
|
+
}
|
|
87
|
+
<button (click)="addGroup()">+ Add group</button>
|
|
88
|
+
</section>
|
|
89
|
+
|
|
90
|
+
<section>
|
|
91
|
+
<h4>Sort</h4>
|
|
92
|
+
@for (s of r.sort; track $index; let i = $index) {
|
|
93
|
+
<div class="group-row">
|
|
94
|
+
<input list="field-names" placeholder="Field name" [ngModel]="s.field" (ngModelChange)="setSort(i, 'field', $event)" />
|
|
95
|
+
<select [ngModel]="s.direction" (ngModelChange)="setSort(i, 'direction', $event)">
|
|
96
|
+
<option value="asc">Asc</option>
|
|
97
|
+
<option value="desc">Desc</option>
|
|
98
|
+
</select>
|
|
99
|
+
<button class="small danger" (click)="removeSort(i)">✕</button>
|
|
100
|
+
</div>
|
|
101
|
+
}
|
|
102
|
+
<button (click)="addSort()">+ Add sort</button>
|
|
103
|
+
</section>
|
|
104
|
+
|
|
105
|
+
<rpt-report-extras />
|
|
106
|
+
`,
|
|
107
|
+
})
|
|
108
|
+
export class ReportPropsComponent {
|
|
109
|
+
private readonly store = inject(DesignerStore);
|
|
110
|
+
protected readonly report = this.store.report;
|
|
111
|
+
protected readonly labels = BAND_LABELS;
|
|
112
|
+
protected readonly optionalBands = OPTIONAL_BANDS;
|
|
113
|
+
protected readonly sizeNames = Object.keys(PAGE_SIZES);
|
|
114
|
+
protected readonly pageFields: { key: PageKey; label: string; min: number }[] = [
|
|
115
|
+
{ key: 'width', label: 'Width', min: 100 },
|
|
116
|
+
{ key: 'height', label: 'Height', min: 100 },
|
|
117
|
+
{ key: 'marginTop', label: 'Margin Top', min: 0 },
|
|
118
|
+
{ key: 'marginBottom', label: 'Margin Bottom', min: 0 },
|
|
119
|
+
{ key: 'marginLeft', label: 'Margin Left', min: 0 },
|
|
120
|
+
{ key: 'marginRight', label: 'Margin Right', min: 0 },
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
protected readonly landscape = computed(() => this.report().page.width > this.report().page.height);
|
|
124
|
+
protected readonly sizeName = computed(() => {
|
|
125
|
+
const p = this.report().page;
|
|
126
|
+
const hit = Object.entries(PAGE_SIZES).find(
|
|
127
|
+
([, s]) => (s.width === p.width && s.height === p.height) || (s.width === p.height && s.height === p.width),
|
|
128
|
+
);
|
|
129
|
+
return hit?.[0] ?? 'Custom';
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
protected setName(v: string): void {
|
|
133
|
+
this.store.update((r) => (r.name = v));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
protected setSize(name: string): void {
|
|
137
|
+
const s = PAGE_SIZES[name];
|
|
138
|
+
if (!s) return;
|
|
139
|
+
const land = this.landscape();
|
|
140
|
+
const receipt = name.startsWith('Receipt');
|
|
141
|
+
this.store.update((r) => {
|
|
142
|
+
r.page.width = land && !receipt ? s.height : s.width;
|
|
143
|
+
r.page.height = land && !receipt ? s.width : s.height;
|
|
144
|
+
if (receipt) {
|
|
145
|
+
// Thermal roll: narrow margins, page as long as the content.
|
|
146
|
+
r.page.continuous = true;
|
|
147
|
+
r.page.marginLeft = r.page.marginRight = name.includes('58') ? 6 : 10;
|
|
148
|
+
r.page.marginTop = 10;
|
|
149
|
+
r.page.marginBottom = 14;
|
|
150
|
+
} else r.page.continuous = false;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
protected setContinuous(on: boolean): void {
|
|
155
|
+
this.store.update((r) => (r.page.continuous = on));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
protected setOrientation(v: string): void {
|
|
159
|
+
if ((v === 'landscape') !== this.landscape()) this.store.update((r) => ([r.page.width, r.page.height] = [r.page.height, r.page.width]));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
protected setPage(k: PageKey, min: number, v: number | null): void {
|
|
163
|
+
if (v != null && Number.isFinite(v)) this.store.update((r) => (r.page[k] = Math.max(min, v)));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
protected isVisible(t: BandType): boolean {
|
|
167
|
+
return !!this.report().bands.find((b) => b.type === t)?.visible;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
protected setVisible(t: BandType, on: boolean): void {
|
|
171
|
+
this.store.update((r) => {
|
|
172
|
+
const ex = r.bands.find((x) => x.type === t);
|
|
173
|
+
if (ex) ex.visible = on;
|
|
174
|
+
else r.bands.push(createBand(t, 30));
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
protected setGroupField(gid: string, v: string): void {
|
|
179
|
+
this.store.update((r) => {
|
|
180
|
+
const g = r.groups.find((x) => x.id === gid);
|
|
181
|
+
if (g) g.field = v;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
protected deleteGroup(gid: string): void {
|
|
186
|
+
this.store.update((r) => removeGroup(r, gid));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
protected addGroup(): void {
|
|
190
|
+
this.store.update((r) => {
|
|
191
|
+
const id = uid('grp');
|
|
192
|
+
r.groups.push({ id, field: '', direction: 'asc' });
|
|
193
|
+
r.bands.push(createBand('groupHeader', 26, id), createBand('groupFooter', 26, id));
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
protected setSort(i: number, k: 'field' | 'direction', v: string): void {
|
|
198
|
+
this.store.update((r) => {
|
|
199
|
+
if (k === 'field') r.sort[i].field = v;
|
|
200
|
+
else r.sort[i].direction = v as 'asc' | 'desc';
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
protected removeSort(i: number): void {
|
|
205
|
+
this.store.update((r) => r.sort.splice(i, 1));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
protected addSort(): void {
|
|
209
|
+
this.store.update((r) => r.sort.push({ field: '', direction: 'asc' }));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
|
|
2
|
+
import { FormsModule } from '@angular/forms';
|
|
3
|
+
import { ExprInputComponent } from './expr-input.component';
|
|
4
|
+
import type { FormatKind, HAlign, ReportElement, TableColumn, TableSpec } from '../../model/types';
|
|
5
|
+
import { columnWidths, createColumn, designTableHeight } from '../../engine/table';
|
|
6
|
+
import { DesignerStore, findElement } from '../designer.store';
|
|
7
|
+
|
|
8
|
+
const FORMAT_KINDS: FormatKind[] = ['none', 'number', 'currency', 'percent', 'date', 'datetime', 'time'];
|
|
9
|
+
type ColorKey = 'headerBackground' | 'stripeBackground' | 'gridColor' | 'headerColor';
|
|
10
|
+
|
|
11
|
+
@Component({
|
|
12
|
+
selector: 'rpt-table-props',
|
|
13
|
+
standalone: true,
|
|
14
|
+
imports: [FormsModule, ExprInputComponent],
|
|
15
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
16
|
+
template: `
|
|
17
|
+
@let t = spec();
|
|
18
|
+
<section>
|
|
19
|
+
<h4>Table</h4>
|
|
20
|
+
<label class="check">
|
|
21
|
+
<input type="checkbox" [ngModel]="t.showHeader" (ngModelChange)="set(flag('showHeader', $event))" /> Header row
|
|
22
|
+
</label>
|
|
23
|
+
<label class="check">
|
|
24
|
+
<input type="checkbox" [ngModel]="t.repeatHeader" (ngModelChange)="set(flag('repeatHeader', $event))" /> Repeat header on each page
|
|
25
|
+
</label>
|
|
26
|
+
<label class="check">
|
|
27
|
+
<input type="checkbox" [ngModel]="t.showFooter" (ngModelChange)="set(flag('showFooter', $event))" /> Footer (totals) row
|
|
28
|
+
</label>
|
|
29
|
+
<div class="grid2">
|
|
30
|
+
<label class="prop-row"><span>Header h</span>
|
|
31
|
+
<div class="prop-value"><input type="number" min="8" [ngModel]="t.headerHeight" (ngModelChange)="height('headerHeight', $event)" /></div>
|
|
32
|
+
</label>
|
|
33
|
+
<label class="prop-row"><span>Row h</span>
|
|
34
|
+
<div class="prop-value"><input type="number" min="8" [ngModel]="t.rowHeight" (ngModelChange)="height('rowHeight', $event)" /></div>
|
|
35
|
+
</label>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="prop-row"><span>Header fill</span>
|
|
38
|
+
<div class="prop-value">
|
|
39
|
+
<input type="color" [ngModel]="colorValue(t.headerBackground)" (ngModelChange)="set(color('headerBackground', $event))" />
|
|
40
|
+
<button class="small" [class.on]="t.headerBackground === 'transparent'" (click)="set(color('headerBackground', 'transparent'))">None</button>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="prop-row"><span>Header text</span>
|
|
44
|
+
<div class="prop-value"><input type="color" [ngModel]="t.headerColor" (ngModelChange)="set(color('headerColor', $event))" /></div>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="prop-row"><span>Row fill</span>
|
|
47
|
+
<div class="prop-value">
|
|
48
|
+
<input type="color" [ngModel]="colorValue(el().style.background)" (ngModelChange)="set(rowFill($event))" />
|
|
49
|
+
<button class="small" [class.on]="el().style.background === 'transparent'" (click)="set(rowFill('transparent'))">None</button>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
<div class="prop-row"><span>Alt. row</span>
|
|
53
|
+
<div class="prop-value">
|
|
54
|
+
<input type="color" [ngModel]="colorValue(t.stripeBackground)" (ngModelChange)="set(color('stripeBackground', $event))" />
|
|
55
|
+
<button class="small" [class.on]="t.stripeBackground === 'transparent'" (click)="set(color('stripeBackground', 'transparent'))">None</button>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="prop-row"><span>Grid lines</span>
|
|
59
|
+
<div class="prop-value">
|
|
60
|
+
<input type="color" [ngModel]="colorValue(t.gridColor)" (ngModelChange)="set(color('gridColor', $event))" />
|
|
61
|
+
<button class="small" [class.on]="t.gridColor === 'transparent'" (click)="set(color('gridColor', 'transparent'))">None</button>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
<p class="hint">
|
|
65
|
+
Lists the rows of its band: all rows in Report Header/Footer, the group's rows in Group bands. It continues onto new pages
|
|
66
|
+
automatically. Drop a field on the table to add a column.
|
|
67
|
+
</p>
|
|
68
|
+
</section>
|
|
69
|
+
|
|
70
|
+
<section>
|
|
71
|
+
<h4>Columns</h4>
|
|
72
|
+
@for (c of t.columns; track c.id; let i = $index, last = $last) {
|
|
73
|
+
<div class="col-card">
|
|
74
|
+
<div class="col-head">
|
|
75
|
+
<strong>{{ i + 1 }}</strong>
|
|
76
|
+
<span class="muted">{{ widths()[i] }}px</span>
|
|
77
|
+
<span class="spacer"></span>
|
|
78
|
+
<button class="small" [disabled]="i === 0" title="Move left" (click)="moveColumn(i, -1)">←</button>
|
|
79
|
+
<button class="small" [disabled]="last" title="Move right" (click)="moveColumn(i, 1)">→</button>
|
|
80
|
+
<button class="small danger" title="Remove column" (click)="removeColumn(i)">✕</button>
|
|
81
|
+
</div>
|
|
82
|
+
<label class="prop-row"><span>Header</span>
|
|
83
|
+
<div class="prop-value"><input [ngModel]="c.header" (ngModelChange)="col(i, setHeader($event))" /></div>
|
|
84
|
+
</label>
|
|
85
|
+
<label class="prop-row"><span>Cell</span>
|
|
86
|
+
<div class="prop-value"><rpt-expr-input [value]="c.text" placeholder="{Field}" (valueChange)="col(i, setCell($event))" /></div>
|
|
87
|
+
</label>
|
|
88
|
+
@if (t.showFooter) {
|
|
89
|
+
<label class="prop-row"><span>Footer</span>
|
|
90
|
+
<div class="prop-value"><rpt-expr-input [value]="c.footer" placeholder="{sum(Amount)}" (valueChange)="col(i, setFooter($event))" /></div>
|
|
91
|
+
</label>
|
|
92
|
+
}
|
|
93
|
+
<div class="grid2">
|
|
94
|
+
<label class="prop-row"><span>Width</span>
|
|
95
|
+
<div class="prop-value">
|
|
96
|
+
<input type="number" min="0.1" step="0.1" [ngModel]="c.width" (ngModelChange)="colWidth(i, $event)" />
|
|
97
|
+
</div>
|
|
98
|
+
</label>
|
|
99
|
+
<label class="prop-row"><span>Align</span>
|
|
100
|
+
<div class="prop-value">
|
|
101
|
+
<select [ngModel]="c.align" (ngModelChange)="col(i, setAlign($event))">
|
|
102
|
+
<option value="left">Left</option>
|
|
103
|
+
<option value="center">Center</option>
|
|
104
|
+
<option value="right">Right</option>
|
|
105
|
+
</select>
|
|
106
|
+
</div>
|
|
107
|
+
</label>
|
|
108
|
+
</div>
|
|
109
|
+
<div class="grid2">
|
|
110
|
+
<label class="prop-row"><span>Format</span>
|
|
111
|
+
<div class="prop-value">
|
|
112
|
+
<select [ngModel]="c.format.kind" (ngModelChange)="col(i, setFormat($event))">
|
|
113
|
+
@for (k of formatKinds; track k) {
|
|
114
|
+
<option [value]="k">{{ k }}</option>
|
|
115
|
+
}
|
|
116
|
+
</select>
|
|
117
|
+
</div>
|
|
118
|
+
</label>
|
|
119
|
+
@if (c.format.kind === 'number' || c.format.kind === 'currency' || c.format.kind === 'percent') {
|
|
120
|
+
<label class="prop-row"><span>Decimals</span>
|
|
121
|
+
<div class="prop-value">
|
|
122
|
+
<input type="number" min="0" [ngModel]="c.format.decimals" (ngModelChange)="colDecimals(i, $event)" />
|
|
123
|
+
</div>
|
|
124
|
+
</label>
|
|
125
|
+
}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
}
|
|
129
|
+
<button (click)="addColumn()">+ Add column</button>
|
|
130
|
+
</section>
|
|
131
|
+
`,
|
|
132
|
+
})
|
|
133
|
+
export class TablePropsComponent {
|
|
134
|
+
private readonly store = inject(DesignerStore);
|
|
135
|
+
readonly el = input.required<ReportElement>();
|
|
136
|
+
|
|
137
|
+
protected readonly formatKinds = FORMAT_KINDS;
|
|
138
|
+
protected readonly spec = computed(() => this.el().table!);
|
|
139
|
+
protected readonly widths = computed(() => columnWidths(this.el()).map((w) => Math.round(w)));
|
|
140
|
+
|
|
141
|
+
/** Edits the table and keeps the element height in sync with its header/row/footer heights. */
|
|
142
|
+
protected set(fn: (t: TableSpec, e: ReportElement) => void): void {
|
|
143
|
+
const id = this.el().id;
|
|
144
|
+
this.store.update((r) => {
|
|
145
|
+
const f = findElement(r, id);
|
|
146
|
+
if (!f?.el.table) return;
|
|
147
|
+
fn(f.el.table, f.el);
|
|
148
|
+
f.el.h = designTableHeight(f.el.table);
|
|
149
|
+
f.band.height = Math.max(f.band.height, f.el.y + f.el.h);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
protected col(i: number, fn: (c: TableColumn) => void): void {
|
|
154
|
+
this.set((t) => t.columns[i] && fn(t.columns[i]));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
protected colorValue(v: string): string {
|
|
158
|
+
return !v || v === 'transparent' ? '#ffffff' : v;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
protected flag = (k: 'showHeader' | 'repeatHeader' | 'showFooter', v: boolean) => (t: TableSpec) => (t[k] = v);
|
|
162
|
+
protected color = (k: ColorKey, v: string) => (t: TableSpec) => (t[k] = v);
|
|
163
|
+
protected rowFill = (v: string) => (_: TableSpec, e: ReportElement) => (e.style.background = v);
|
|
164
|
+
protected setHeader = (v: string) => (c: TableColumn) => (c.header = v);
|
|
165
|
+
protected setCell = (v: string) => (c: TableColumn) => (c.text = v);
|
|
166
|
+
protected setFooter = (v: string) => (c: TableColumn) => (c.footer = v);
|
|
167
|
+
protected setAlign = (v: HAlign) => (c: TableColumn) => (c.align = v);
|
|
168
|
+
protected setFormat = (v: FormatKind) => (c: TableColumn) => (c.format.kind = v);
|
|
169
|
+
|
|
170
|
+
protected height(k: 'headerHeight' | 'rowHeight', v: number | null): void {
|
|
171
|
+
if (v != null && Number.isFinite(v)) this.set((t) => (t[k] = Math.max(8, v)));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
protected colWidth(i: number, v: number | null): void {
|
|
175
|
+
if (v != null && Number.isFinite(v)) this.col(i, (c) => (c.width = Math.max(0.1, v)));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
protected colDecimals(i: number, v: number | null): void {
|
|
179
|
+
if (v != null && Number.isFinite(v)) this.col(i, (c) => (c.format.decimals = Math.max(0, Math.min(10, v))));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
protected moveColumn(i: number, d: number): void {
|
|
183
|
+
this.set((t) => {
|
|
184
|
+
const j = i + d;
|
|
185
|
+
if (j < 0 || j >= t.columns.length) return;
|
|
186
|
+
[t.columns[i], t.columns[j]] = [t.columns[j], t.columns[i]];
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
protected removeColumn(i: number): void {
|
|
191
|
+
this.set((t) => t.columns.splice(i, 1));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
protected addColumn(): void {
|
|
195
|
+
this.set((t) => t.columns.push(createColumn('')));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
|
2
|
+
import { NgTemplateOutlet } from '@angular/common';
|
|
3
|
+
import type { PageBackground, PageSettings, Watermark } from '../../model/types';
|
|
4
|
+
import { contentWidth } from '../../model/defaults';
|
|
5
|
+
import { ElementBoxComponent } from '../designer/element-box.component';
|
|
6
|
+
import { SafeImagePipe } from '../designer/safe-image.pipe';
|
|
7
|
+
import type { ViewPage } from './render';
|
|
8
|
+
|
|
9
|
+
const FIT = { cover: 'cover', contain: 'contain', stretch: 'fill', center: 'none' } as const;
|
|
10
|
+
|
|
11
|
+
/** One printed page: background, watermark, bands and their elements. */
|
|
12
|
+
@Component({
|
|
13
|
+
selector: 'rpt-page-view',
|
|
14
|
+
standalone: true,
|
|
15
|
+
imports: [NgTemplateOutlet, ElementBoxComponent, SafeImagePipe],
|
|
16
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
17
|
+
template: `
|
|
18
|
+
@let p = settings();
|
|
19
|
+
<div class="rpt-page" [class.roll]="p.continuous" [style.width.px]="p.width" [style.height.px]="page().height">
|
|
20
|
+
@if (page().background) {
|
|
21
|
+
<img class="rpt-bg" alt="" [src]="background().image | safeImage" [style.object-fit]="fit()" />
|
|
22
|
+
}
|
|
23
|
+
@if (page().watermark && watermark().layer === 'behind') {
|
|
24
|
+
<ng-container *ngTemplateOutlet="wm" />
|
|
25
|
+
}
|
|
26
|
+
@for (b of page().bands; track $index) {
|
|
27
|
+
<div style="position: absolute" [style.left.px]="p.marginLeft" [style.top.px]="b.top" [style.width.px]="contentWidth()" [style.height.px]="b.height">
|
|
28
|
+
@for (it of b.items; track it.key) {
|
|
29
|
+
<rpt-element
|
|
30
|
+
[el]="it.el"
|
|
31
|
+
[top]="it.top"
|
|
32
|
+
[height]="it.height"
|
|
33
|
+
[content]="it.content"
|
|
34
|
+
[tableData]="it.tableData"
|
|
35
|
+
[rows]="it.rows"
|
|
36
|
+
[attr.data-key]="it.key"
|
|
37
|
+
[class.find-hit]="highlights().has(it.key)"
|
|
38
|
+
[class.find-active]="active() === it.key"
|
|
39
|
+
/>
|
|
40
|
+
}
|
|
41
|
+
</div>
|
|
42
|
+
}
|
|
43
|
+
@if (page().watermark && watermark().layer === 'front') {
|
|
44
|
+
<ng-container *ngTemplateOutlet="wm" />
|
|
45
|
+
}
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<ng-template #wm>
|
|
49
|
+
@let w = watermark();
|
|
50
|
+
<div class="rpt-watermark" [style.opacity]="w.opacity">
|
|
51
|
+
@if (w.kind === 'image') {
|
|
52
|
+
<img alt="" [src]="w.image | safeImage" [style.width.%]="w.imageScale" [style.transform]="'rotate(' + w.angle + 'deg)'" />
|
|
53
|
+
} @else {
|
|
54
|
+
<span [style.font-size.px]="w.fontSize" [style.color]="w.color" [style.font-weight]="w.bold ? 700 : 400" [style.transform]="'rotate(' + w.angle + 'deg)'">{{
|
|
55
|
+
watermarkText()
|
|
56
|
+
}}</span>
|
|
57
|
+
}
|
|
58
|
+
</div>
|
|
59
|
+
</ng-template>
|
|
60
|
+
`,
|
|
61
|
+
})
|
|
62
|
+
export class PageViewComponent {
|
|
63
|
+
readonly page = input.required<ViewPage>();
|
|
64
|
+
readonly settings = input.required<PageSettings>();
|
|
65
|
+
readonly watermark = input.required<Watermark>();
|
|
66
|
+
readonly background = input.required<PageBackground>();
|
|
67
|
+
readonly watermarkText = input('');
|
|
68
|
+
readonly highlights = input<ReadonlySet<string>>(new Set());
|
|
69
|
+
readonly active = input<string | null>(null);
|
|
70
|
+
|
|
71
|
+
protected readonly contentWidth = computed(() => contentWidth(this.settings()));
|
|
72
|
+
protected readonly fit = computed(() => FIT[this.background().fit]);
|
|
73
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { ElementStyle, ReportDefinition, ReportElement } from '../../model/types';
|
|
2
|
+
import { defaultBackground, defaultWatermark } from '../../model/defaults';
|
|
3
|
+
import { contextFor, paginate, type PlacedElement } from '../../engine/renderer';
|
|
4
|
+
import { evaluate, getPath, renderTemplate, truthy, type EvalContext, type Row } from '../../engine/expressions';
|
|
5
|
+
import type { TableRenderData } from '../../engine/table';
|
|
6
|
+
import type { ParamValues } from '../../engine/parameters';
|
|
7
|
+
|
|
8
|
+
export interface ViewItem {
|
|
9
|
+
/** Unique within the document: page:band:element. Used for find highlights. */
|
|
10
|
+
key: string;
|
|
11
|
+
el: ReportElement;
|
|
12
|
+
rows?: Row[];
|
|
13
|
+
top: number;
|
|
14
|
+
height: number;
|
|
15
|
+
content: string;
|
|
16
|
+
tableData?: TableRenderData;
|
|
17
|
+
/** Plain text of the item (content or table cells), for Find. */
|
|
18
|
+
text: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ViewBand {
|
|
22
|
+
top: number;
|
|
23
|
+
height: number;
|
|
24
|
+
items: ViewItem[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ViewPage {
|
|
28
|
+
index: number;
|
|
29
|
+
height: number;
|
|
30
|
+
background: boolean;
|
|
31
|
+
watermark: boolean;
|
|
32
|
+
bands: ViewBand[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Group headers as a navigable tree. */
|
|
36
|
+
export interface Bookmark {
|
|
37
|
+
label: string;
|
|
38
|
+
page: number;
|
|
39
|
+
top: number;
|
|
40
|
+
children: Bookmark[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ViewDocument {
|
|
44
|
+
pages: ViewPage[];
|
|
45
|
+
bookmarks: Bookmark[];
|
|
46
|
+
rowCount: number;
|
|
47
|
+
watermarkText: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const NO_FORMAT = { kind: 'none', decimals: 0, currency: '' } as const;
|
|
51
|
+
|
|
52
|
+
/** Condition check that treats an invalid expression as "true" (never hides content by accident). */
|
|
53
|
+
function holds(expr: string | undefined, ctx: EvalContext): boolean {
|
|
54
|
+
if (!expr?.trim()) return true;
|
|
55
|
+
try {
|
|
56
|
+
return truthy(evaluate(expr, ctx));
|
|
57
|
+
} catch {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Evaluates the visible slice of a table into cell strings. */
|
|
63
|
+
function tableData(item: PlacedElement, ctx: EvalContext): TableRenderData | undefined {
|
|
64
|
+
const p = item.table;
|
|
65
|
+
const t = item.el.table;
|
|
66
|
+
if (!p || !t) return undefined;
|
|
67
|
+
const all = ctx.rows;
|
|
68
|
+
return {
|
|
69
|
+
header: p.header ? t.columns.map((c) => renderTemplate(c.header, ctx, NO_FORMAT)) : null,
|
|
70
|
+
headerHeight: p.headerHeight,
|
|
71
|
+
rows: p.rows.map((row, i) => {
|
|
72
|
+
const rctx = { ...ctx, row, rowNumber: p.startIndex + i + 1 };
|
|
73
|
+
return { cells: t.columns.map((c) => renderTemplate(c.text, rctx, c.format)), height: p.rowHeights[i], index: p.startIndex + i };
|
|
74
|
+
}),
|
|
75
|
+
footer: p.footer ? t.columns.map((c) => renderTemplate(c.footer, { ...ctx, rows: all, row: all[all.length - 1] }, c.format)) : null,
|
|
76
|
+
footerHeight: p.footerHeight,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function evaluateItem(key: string, it: PlacedElement, ctx: EvalContext, lastValue: Map<string, string>): ViewItem {
|
|
81
|
+
let el: ReportElement = it.el;
|
|
82
|
+
let content = ['line', 'rect', 'table', 'chart'].includes(el.kind) ? '' : renderTemplate(el.text, ctx, el.format);
|
|
83
|
+
|
|
84
|
+
const opts = el.fieldOptions;
|
|
85
|
+
if (opts && (el.kind === 'text' || el.kind === 'field')) {
|
|
86
|
+
if (!content.trim() && opts.nullText) content = renderTemplate(opts.nullText, ctx, el.format);
|
|
87
|
+
if (opts.hideDuplicates) {
|
|
88
|
+
if (lastValue.get(el.id) === content) content = '';
|
|
89
|
+
else lastValue.set(el.id, content);
|
|
90
|
+
}
|
|
91
|
+
const rule = opts.rules.find((r) => r.when.trim() && holds(r.when, ctx));
|
|
92
|
+
if (rule) {
|
|
93
|
+
const style: Partial<ElementStyle> = { color: rule.color };
|
|
94
|
+
if (rule.background) style.background = rule.background;
|
|
95
|
+
if (rule.bold) style.bold = true;
|
|
96
|
+
el = { ...el, style: { ...el.style, ...style } };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Chart titles may contain fields, e.g. "Sales in {Country}".
|
|
100
|
+
if (el.chart) el = { ...el, chart: { ...el.chart, title: renderTemplate(el.chart.title, ctx, NO_FORMAT) } };
|
|
101
|
+
|
|
102
|
+
const td = tableData(it, ctx);
|
|
103
|
+
const text =
|
|
104
|
+
el.kind === 'barcode' || el.kind === 'qrcode' || el.kind === 'image'
|
|
105
|
+
? ''
|
|
106
|
+
: td
|
|
107
|
+
? [...(td.header ?? []), ...td.rows.flatMap((r) => r.cells), ...(td.footer ?? [])].join(' ')
|
|
108
|
+
: el.chart?.title ?? content;
|
|
109
|
+
return { key, el, rows: el.kind === 'chart' ? ctx.rows : undefined, top: it.top, height: it.height, content, tableData: td, text };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Lays out and evaluates the whole report into ready-to-draw pages, plus group bookmarks. */
|
|
113
|
+
export function buildDocument(report: ReportDefinition, rows: Row[], params: ParamValues, root: unknown): ViewDocument {
|
|
114
|
+
const layout = paginate(report, rows, params, root);
|
|
115
|
+
const wm = { ...defaultWatermark(), ...report.watermark };
|
|
116
|
+
const bg = { ...defaultBackground(), ...report.background };
|
|
117
|
+
|
|
118
|
+
const pages: ViewPage[] = layout.map((pg) => {
|
|
119
|
+
const lastValue = new Map<string, string>(); // hide-duplicates state, per page
|
|
120
|
+
return {
|
|
121
|
+
index: pg.index,
|
|
122
|
+
height: pg.height ?? report.page.height,
|
|
123
|
+
background: bg.enabled && !!bg.image && (bg.pages === 'all' || pg.index === 0),
|
|
124
|
+
watermark: wm.enabled && (wm.kind === 'image' ? !!wm.image : !!wm.text) && (wm.pages === 'all' || pg.index === 0),
|
|
125
|
+
bands: pg.bands.map((pb, bi) => {
|
|
126
|
+
const ctx = contextFor(pb, pg, layout.length);
|
|
127
|
+
return {
|
|
128
|
+
top: pb.top,
|
|
129
|
+
height: pb.height,
|
|
130
|
+
items: pb.items
|
|
131
|
+
.filter((it) => holds(it.el.visibleWhen, ctx))
|
|
132
|
+
.map((it) => evaluateItem(`${pg.index}:${bi}:${it.el.id}`, it, ctx, lastValue)),
|
|
133
|
+
};
|
|
134
|
+
}),
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Bookmarks: one per group header, nested by group level.
|
|
139
|
+
const bookmarks: Bookmark[] = [];
|
|
140
|
+
const stack: Bookmark[] = [];
|
|
141
|
+
for (const pg of layout) {
|
|
142
|
+
for (const pb of pg.bands) {
|
|
143
|
+
if (pb.band.type !== 'groupHeader' || !pb.band.groupId) continue;
|
|
144
|
+
const level = report.groups.findIndex((g) => g.id === pb.band.groupId);
|
|
145
|
+
if (level < 0) continue;
|
|
146
|
+
const g = report.groups[level];
|
|
147
|
+
const bm: Bookmark = { label: String(getPath(pb.ctx.row, g.field) ?? '(blank)'), page: pg.index, top: pb.top, children: [] };
|
|
148
|
+
stack.length = level;
|
|
149
|
+
(level > 0 && stack[level - 1] ? stack[level - 1].children : bookmarks).push(bm);
|
|
150
|
+
stack[level] = bm;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const ctx: EvalContext = { row: undefined, rows: [], allRows: [], pageRows: [], rowNumber: 0, pageNumber: 1, totalPages: 1, reportName: report.name, params, root };
|
|
155
|
+
return {
|
|
156
|
+
pages,
|
|
157
|
+
bookmarks,
|
|
158
|
+
rowCount: layout.reduce((n, p) => n + p.rows.length, 0),
|
|
159
|
+
watermarkText: renderTemplate(wm.text, ctx, NO_FORMAT),
|
|
160
|
+
};
|
|
161
|
+
}
|