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.
Files changed (46) hide show
  1. package/README.md +99 -2
  2. package/ng-package.json +7 -0
  3. package/package.json +23 -3
  4. package/src/lib/engine/barcode.ts +118 -0
  5. package/src/lib/engine/chart.ts +85 -0
  6. package/src/lib/engine/chartLayout.ts +265 -0
  7. package/src/lib/engine/crosstab.ts +96 -0
  8. package/src/lib/engine/dataSources.ts +204 -0
  9. package/src/lib/engine/expressions.ts +734 -0
  10. package/src/lib/engine/measure.ts +62 -0
  11. package/src/lib/engine/parameters.ts +96 -0
  12. package/src/lib/engine/qr.ts +26 -0
  13. package/src/lib/engine/renderer.ts +324 -0
  14. package/src/lib/engine/table.ts +58 -0
  15. package/src/lib/model/defaults.ts +249 -0
  16. package/src/lib/model/factories.ts +324 -0
  17. package/src/lib/model/types.ts +281 -0
  18. package/src/lib/ui/css.ts +12 -0
  19. package/src/lib/ui/designer/banded-table-dialog.component.ts +127 -0
  20. package/src/lib/ui/designer/canvas.component.ts +281 -0
  21. package/src/lib/ui/designer/data-source-dialog.component.ts +201 -0
  22. package/src/lib/ui/designer/element-box.component.ts +138 -0
  23. package/src/lib/ui/designer/graphics.components.ts +140 -0
  24. package/src/lib/ui/designer/left-panel.component.ts +189 -0
  25. package/src/lib/ui/designer/safe-image.pipe.ts +16 -0
  26. package/src/lib/ui/designer/table-view.component.ts +120 -0
  27. package/src/lib/ui/designer.component.ts +296 -0
  28. package/src/lib/ui/designer.store.ts +230 -0
  29. package/src/lib/ui/inputs.ts +34 -0
  30. package/src/lib/ui/myreport.css +267 -0
  31. package/src/lib/ui/properties/band-props.component.ts +86 -0
  32. package/src/lib/ui/properties/element-props.component.ts +326 -0
  33. package/src/lib/ui/properties/expr-input.component.ts +191 -0
  34. package/src/lib/ui/properties/graphics-props.component.ts +260 -0
  35. package/src/lib/ui/properties/image-picker.component.ts +62 -0
  36. package/src/lib/ui/properties/properties-panel.component.ts +37 -0
  37. package/src/lib/ui/properties/report-extras.component.ts +198 -0
  38. package/src/lib/ui/properties/report-props.component.ts +211 -0
  39. package/src/lib/ui/properties/table-props.component.ts +197 -0
  40. package/src/lib/ui/viewer/page-view.component.ts +73 -0
  41. package/src/lib/ui/viewer/render.ts +161 -0
  42. package/src/lib/ui/viewer/viewer.component.ts +555 -0
  43. package/src/lib/ui/viewer.component.ts +107 -0
  44. package/src/public-api.ts +12 -0
  45. package/tsconfig.lib.json +12 -0
  46. package/tsconfig.lib.prod.json +5 -0
@@ -0,0 +1,249 @@
1
+ import { createColumn, defaultTableSpec, designTableHeight } from '../engine/table';
2
+ import type {
3
+ Band,
4
+ BandType,
5
+ DataSourceDef,
6
+ ElementKind,
7
+ ElementStyle,
8
+ FormatSpec,
9
+ PageSettings,
10
+ ReportDefinition,
11
+ ReportElement,
12
+ } from './types';
13
+
14
+ let counter = 0;
15
+ export const uid = (prefix = 'id') =>
16
+ `${prefix}_${Date.now().toString(36)}${(counter++).toString(36)}${Math.random().toString(36).slice(2, 6)}`;
17
+
18
+ export const PAGE_SIZES: Record<string, { width: number; height: number }> = {
19
+ A4: { width: 794, height: 1123 },
20
+ A5: { width: 559, height: 794 },
21
+ Letter: { width: 816, height: 1056 },
22
+ Legal: { width: 816, height: 1344 },
23
+ 'Receipt 80mm': { width: 302, height: 1123 },
24
+ 'Receipt 58mm': { width: 219, height: 1123 },
25
+ };
26
+
27
+ export const BAND_LABELS: Record<BandType, string> = {
28
+ reportHeader: 'Report Header',
29
+ pageHeader: 'Page Header',
30
+ groupHeader: 'Group Header',
31
+ detail: 'Detail',
32
+ groupFooter: 'Group Footer',
33
+ pageFooter: 'Page Footer',
34
+ reportFooter: 'Report Footer',
35
+ };
36
+
37
+ export const FOOTER_TYPES: BandType[] = ['groupFooter', 'reportFooter', 'pageFooter'];
38
+
39
+ export const defaultStyle = (): ElementStyle => ({
40
+ fontFamily: 'Segoe UI, Arial, sans-serif',
41
+ fontSize: 12,
42
+ bold: false,
43
+ italic: false,
44
+ underline: false,
45
+ color: '#111111',
46
+ background: 'transparent',
47
+ align: 'left',
48
+ vAlign: 'middle',
49
+ borderWidth: 0,
50
+ borderColor: '#333333',
51
+ padding: 2,
52
+ });
53
+
54
+ export const defaultFormat = (): FormatSpec => ({ kind: 'none', decimals: 2, currency: 'USD' });
55
+
56
+ export function createElement(
57
+ kind: ElementKind,
58
+ x: number,
59
+ y: number,
60
+ overrides: Partial<Omit<ReportElement, 'style' | 'format'>> & {
61
+ style?: Partial<ElementStyle>;
62
+ format?: Partial<FormatSpec>;
63
+ } = {},
64
+ ): ReportElement {
65
+ const size: Record<ElementKind, [number, number]> = {
66
+ text: [120, 20],
67
+ field: [120, 20],
68
+ line: [200, 4],
69
+ rect: [120, 60],
70
+ image: [80, 80],
71
+ table: [400, 46],
72
+ chart: [360, 220],
73
+ barcode: [200, 70],
74
+ qrcode: [100, 100],
75
+ crosstab: [480, 120],
76
+ };
77
+ const text: Record<ElementKind, string> = {
78
+ text: 'Label',
79
+ field: '{Field}',
80
+ line: '',
81
+ rect: '',
82
+ image: '',
83
+ table: '',
84
+ chart: '',
85
+ barcode: '{OrderID}',
86
+ qrcode: 'https://example.com/{OrderID}',
87
+ crosstab: '',
88
+ };
89
+ const { style, format, ...rest } = overrides;
90
+ return {
91
+ id: uid('el'),
92
+ kind,
93
+ x,
94
+ y,
95
+ w: size[kind][0],
96
+ h: size[kind][1],
97
+ text: text[kind],
98
+ ...rest,
99
+ format: { ...defaultFormat(), ...format },
100
+ style: {
101
+ ...defaultStyle(),
102
+ ...(kind === 'rect' ? { borderWidth: 1 } : {}),
103
+ ...(kind === 'line' ? { borderWidth: 1 } : {}),
104
+ ...style,
105
+ },
106
+ };
107
+ }
108
+
109
+ /** A table with one column per field (up to 6), or two blank columns. */
110
+ export function createTable(x: number, y: number, w: number, fields: { name: string; type?: string }[]): ReportElement {
111
+ const cols = fields.length ? fields.slice(0, 6).map((f) => createColumn(f.name, f.type)) : [createColumn(''), createColumn('')];
112
+ const table = defaultTableSpec(cols);
113
+ return createElement('table', x, y, { w, h: designTableHeight(table), text: '', table, style: { padding: 4 } });
114
+ }
115
+
116
+ export const createBand = (type: BandType, height: number, groupId?: string): Band => ({
117
+ id: uid('band'),
118
+ type,
119
+ height,
120
+ visible: true,
121
+ groupId,
122
+ elements: [],
123
+ });
124
+
125
+ export const emptyDataSource = (): DataSourceDef => ({
126
+ type: 'json',
127
+ url: '',
128
+ inline: '',
129
+ dataPath: '',
130
+ headers: '',
131
+ entitySet: '',
132
+ select: '',
133
+ filter: '',
134
+ orderby: '',
135
+ expand: '',
136
+ top: null,
137
+ maxRows: 5000,
138
+ });
139
+
140
+ export const defaultPage = (): PageSettings => ({
141
+ ...PAGE_SIZES.A4,
142
+ marginTop: 38,
143
+ marginBottom: 38,
144
+ marginLeft: 38,
145
+ marginRight: 38,
146
+ });
147
+
148
+ export const contentWidth = (p: PageSettings) => p.width - p.marginLeft - p.marginRight;
149
+
150
+ export function newReport(): ReportDefinition {
151
+ return {
152
+ version: 1,
153
+ name: 'New Report',
154
+ page: defaultPage(),
155
+ dataSource: emptyDataSource(),
156
+ groups: [],
157
+ sort: [],
158
+ bands: [
159
+ createBand('reportHeader', 60),
160
+ createBand('pageHeader', 30),
161
+ createBand('detail', 24),
162
+ createBand('pageFooter', 30),
163
+ createBand('reportFooter', 40),
164
+ ],
165
+ };
166
+ }
167
+
168
+ /** Bands in the order they are laid out on the page. */
169
+ export function orderedBands(r: ReportDefinition): Band[] {
170
+ const of = (t: BandType) => r.bands.filter((b) => b.type === t);
171
+ const groupBand = (t: BandType, gid: string) => r.bands.find((b) => b.type === t && b.groupId === gid);
172
+ const gh = r.groups.map((g) => groupBand('groupHeader', g.id)).filter(Boolean) as Band[];
173
+ const gf = [...r.groups].reverse().map((g) => groupBand('groupFooter', g.id)).filter(Boolean) as Band[];
174
+ return [...of('reportHeader'), ...of('pageHeader'), ...gh, ...of('detail'), ...gf, ...of('pageFooter'), ...of('reportFooter')];
175
+ }
176
+
177
+ /** A ready-made report bound to the bundled sample JSON file. */
178
+ export function sampleReport(): ReportDefinition {
179
+ const r = newReport();
180
+ r.name = 'Sales Orders';
181
+ r.dataSource = { ...emptyDataSource(), type: 'json', url: '/samples/orders.json', dataPath: 'data.items' };
182
+ r.sort = [{ field: 'OrderDate', direction: 'asc' }];
183
+ const band = (t: BandType) => r.bands.find((b) => b.type === t)!;
184
+
185
+ band('reportHeader').elements.push(
186
+ createElement('text', 0, 4, { w: 400, h: 32, text: 'Sales Orders Report', style: { fontSize: 22, bold: true, color: '#1f3a68' } }),
187
+ createElement('text', 418, 10, { w: 300, h: 20, text: 'Printed {Now}', format: { kind: 'datetime' }, style: { align: 'right', color: '#666666' } }),
188
+ createElement('line', 0, 46, { w: 718, h: 4, style: { borderColor: '#1f3a68', borderWidth: 2 } }),
189
+ );
190
+
191
+ const cols: [string, string, number, number, 'left' | 'right', Partial<FormatSpec>?][] = [
192
+ ['Order #', 'OrderID', 0, 60, 'left'],
193
+ ['Date', 'OrderDate', 64, 90, 'left', { kind: 'date' }],
194
+ ['Customer', 'Customer', 158, 170, 'left'],
195
+ ['Product', 'Product', 332, 150, 'left'],
196
+ ['Qty', 'Quantity', 486, 50, 'right', { kind: 'number', decimals: 0 }],
197
+ ['Unit Price', 'UnitPrice', 540, 80, 'right', { kind: 'currency' }],
198
+ ['Amount', 'Amount', 624, 94, 'right', { kind: 'currency' }],
199
+ ];
200
+ for (const [label, field, x, w, align, format] of cols) {
201
+ band('pageHeader').elements.push(createElement('text', x, 4, { w, h: 20, text: label, style: { bold: true, align, color: '#1f3a68' } }));
202
+ band('detail').elements.push(createElement('field', x, 2, { w, h: 20, text: `{${field}}`, format, style: { align } }));
203
+ }
204
+ band('pageHeader').elements.push(createElement('line', 0, 26, { w: 718, h: 4, style: { borderColor: '#1f3a68' } }));
205
+
206
+ const g = { id: uid('grp'), field: 'Country', direction: 'asc' as const };
207
+ r.groups.push(g);
208
+ const gh = createBand('groupHeader', 28, g.id);
209
+ gh.elements.push(
210
+ createElement('field', 0, 4, { w: 718, h: 22, text: 'Country: {Country}', style: { bold: true, background: '#e8eef8', padding: 4 } }),
211
+ );
212
+ const gf = createBand('groupFooter', 30, g.id);
213
+ gf.elements.push(
214
+ createElement('field', 332, 4, { w: 204, h: 20, text: '{count()} orders in {Country}', style: { italic: true, color: '#555555' } }),
215
+ createElement('field', 540, 4, { w: 178, h: 20, text: '{sum(Amount)}', format: { kind: 'currency' }, style: { bold: true, align: 'right' } }),
216
+ createElement('line', 540, 0, { w: 178, h: 4, style: { borderColor: '#999999' } }),
217
+ );
218
+ r.bands.push(gh, gf);
219
+
220
+ band('pageFooter').elements.push(
221
+ createElement('field', 418, 6, { w: 300, h: 20, text: 'Page {PageNumber} of {TotalPages}', style: { align: 'right', color: '#666666', fontSize: 11 } }),
222
+ createElement('field', 0, 6, { w: 300, h: 20, text: '{ReportName}', style: { color: '#666666', fontSize: 11 } }),
223
+ );
224
+ band('reportFooter').elements.push(
225
+ createElement('line', 0, 2, { w: 718, h: 4, style: { borderColor: '#1f3a68', borderWidth: 2 } }),
226
+ createElement('text', 332, 10, { w: 204, h: 22, text: 'Grand total', style: { bold: true, fontSize: 14 } }),
227
+ createElement('field', 540, 10, { w: 178, h: 22, text: '{sum(Amount)}', format: { kind: 'currency' }, style: { bold: true, fontSize: 14, align: 'right' } }),
228
+ );
229
+ return r;
230
+ }
231
+
232
+ export const defaultWatermark = (): import('./types').Watermark => ({
233
+ enabled: false,
234
+ kind: 'text',
235
+ text: 'CONFIDENTIAL',
236
+ image: '',
237
+ opacity: 0.12,
238
+ angle: -40,
239
+ fontSize: 96,
240
+ color: '#c4372f',
241
+ bold: true,
242
+ imageScale: 60,
243
+ layer: 'front',
244
+ pages: 'all',
245
+ });
246
+
247
+ export const defaultBackground = (): import('./types').PageBackground => ({ enabled: false, image: '', fit: 'cover', pages: 'all' });
248
+
249
+ export const defaultFieldOptions = (): import('./types').FieldOptions => ({ nullText: '', hideDuplicates: false, rules: [] });
@@ -0,0 +1,324 @@
1
+ import type { Band, BandType, ReportDefinition, ReportElement } from './types';
2
+ import { contentWidth, createBand, createElement } from './defaults';
3
+
4
+ export interface FieldRef {
5
+ name: string;
6
+ type?: string;
7
+ }
8
+
9
+ const firstOf = (fields: FieldRef[], type: string, skip: string[] = []) =>
10
+ fields.find((f) => f.type === type && !skip.includes(f.name))?.name ?? '';
11
+
12
+ export function createChart(x: number, y: number, w: number, fields: FieldRef[]): ReportElement {
13
+ const category = firstOf(fields, 'string');
14
+ const value = fields.find((f) => f.type === 'number' && !/id$/i.test(f.name))?.name ?? firstOf(fields, 'number');
15
+ return createElement('chart', x, y, {
16
+ w: Math.min(w, 420),
17
+ h: 240,
18
+ style: { fontSize: 11, color: '#333333' },
19
+ chart: {
20
+ // Horizontal bars read best for ranked categories with long names.
21
+ type: 'hbar',
22
+ title: value && category ? `${value} by ${category}` : 'Chart',
23
+ categoryField: category,
24
+ valueFields: value ? [value] : [],
25
+ aggregate: value ? 'sum' : 'count',
26
+ sort: 'valueDesc',
27
+ maxCategories: 12,
28
+ showLegend: true,
29
+ showValues: false,
30
+ showGrid: true,
31
+ valueFormat: { kind: 'number', decimals: 0, currency: 'USD' },
32
+ },
33
+ });
34
+ }
35
+
36
+ export function createBarcode(x: number, y: number, fields: FieldRef[]): ReportElement {
37
+ const id = fields.find((f) => /id$|code|number|sku/i.test(f.name))?.name ?? fields[0]?.name;
38
+ return createElement('barcode', x, y, {
39
+ text: id ? `{${id}}` : '12345678',
40
+ style: { fontSize: 11, fontFamily: "Consolas, 'Courier New', monospace" },
41
+ barcode: { symbology: 'code128', showText: true, color: '#000000' },
42
+ });
43
+ }
44
+
45
+ export function createQr(x: number, y: number, fields: FieldRef[]): ReportElement {
46
+ const id = fields.find((f) => /id$|code|number/i.test(f.name))?.name ?? fields[0]?.name;
47
+ return createElement('qrcode', x, y, {
48
+ text: id ? `https://example.com/item/{${id}}` : 'https://example.com',
49
+ qr: { errorCorrection: 'M', color: '#000000' },
50
+ });
51
+ }
52
+
53
+ export function createCrosstab(x: number, y: number, w: number, fields: FieldRef[]): ReportElement {
54
+ const rowField = firstOf(fields, 'string');
55
+ const columnField = firstOf(fields, 'string', [rowField]);
56
+ const valueField = fields.find((f) => f.type === 'number' && !/id$/i.test(f.name))?.name ?? '';
57
+ return createElement('crosstab', x, y, {
58
+ w: Math.max(200, w),
59
+ h: 120,
60
+ style: { fontSize: 11, padding: 4 },
61
+ crosstab: {
62
+ rowField,
63
+ columnField,
64
+ valueField,
65
+ aggregate: valueField ? 'sum' : 'count',
66
+ cornerText: '',
67
+ showRowTotals: true,
68
+ showColumnTotals: true,
69
+ valueFormat: { kind: 'number', decimals: 0, currency: 'USD' },
70
+ headerHeight: 24,
71
+ rowHeight: 22,
72
+ headerBackground: '#1f3a68',
73
+ headerColor: '#ffffff',
74
+ stripeBackground: '#f3f6fb',
75
+ gridColor: '#c9d1dd',
76
+ repeatHeader: true,
77
+ },
78
+ });
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------------------------
82
+ // Header/Detail ("banded") table: column titles in a header band, one field per column in Detail.
83
+
84
+ export interface BandedTableOptions {
85
+ fields: FieldRef[];
86
+ /** Band that receives the column titles. */
87
+ titleBand: 'pageHeader' | 'reportHeader' | 'groupHeader';
88
+ /** For titleBand 'groupHeader': which group. Defaults to the innermost. */
89
+ groupId?: string;
90
+ grid: boolean;
91
+ headerBackground: string;
92
+ headerColor: string;
93
+ /** Adds sum() of numeric columns to the Report Footer. */
94
+ totals: boolean;
95
+ rowHeight: number;
96
+ /** Clear the title band and Detail band first (otherwise the table is appended below). */
97
+ replace: boolean;
98
+ }
99
+
100
+ /** Relative column width by field type. */
101
+ const weight = (f: FieldRef) => (f.type === 'number' ? 1 : f.type === 'date' ? 1.2 : 2);
102
+
103
+ /** Finds or creates (and shows) the band a banded table writes into. */
104
+ function ensureBand(r: ReportDefinition, type: BandType, groupId?: string): Band {
105
+ let b = r.bands.find((x) => x.type === type && (!groupId || x.groupId === groupId));
106
+ if (!b) {
107
+ b = createBand(type, 0, groupId);
108
+ b.elements = [];
109
+ r.bands.push(b);
110
+ }
111
+ b.visible = true;
112
+ return b;
113
+ }
114
+
115
+ /** Appends one row of cells to the bottom of a band (growing the band). */
116
+ function appendRow(band: Band, cells: ReportElement[], rowHeight: number) {
117
+ const y = band.elements.length ? band.height : 0;
118
+ for (const c of cells) c.y = y;
119
+ band.elements.push(...cells);
120
+ band.height = y + rowHeight;
121
+ }
122
+
123
+ /** Adds the banded table to the report; returns the ids of the created elements. */
124
+ export function addBandedTable(r: ReportDefinition, o: BandedTableOptions): string[] {
125
+ const width = contentWidth(r.page);
126
+ const total = o.fields.reduce((s, f) => s + weight(f), 0) || 1;
127
+ const border = o.grid ? 1 : 0;
128
+ const lineColor = '#c9d1dd';
129
+
130
+ // Integer column edges so neighbouring cells share their 1px border instead of doubling it.
131
+ let acc = 0;
132
+ const cols = o.fields.map((f) => {
133
+ const x0 = Math.round((acc / total) * width);
134
+ acc += weight(f);
135
+ const x1 = Math.round((acc / total) * width);
136
+ const align = f.type === 'number' ? ('right' as const) : ('left' as const);
137
+ return { f, x: x0, w: x1 - x0 + (o.grid && x1 < width ? border : 0), align };
138
+ });
139
+
140
+ const groupId = o.titleBand === 'groupHeader' ? o.groupId ?? r.groups[r.groups.length - 1]?.id : undefined;
141
+ const titleBand = ensureBand(r, o.titleBand === 'groupHeader' && !groupId ? 'pageHeader' : o.titleBand, groupId);
142
+ const detail = ensureBand(r, 'detail');
143
+ if (o.replace) for (const b of [titleBand, detail]) (b.elements = []), (b.height = 0);
144
+ const ids: string[] = [];
145
+ const cell = (el: ReportElement) => (ids.push(el.id), el);
146
+ const cellStyle = { borderWidth: border, borderColor: lineColor, padding: 4 };
147
+
148
+ appendRow(
149
+ titleBand,
150
+ cols.map((c) =>
151
+ cell(
152
+ createElement('text', c.x, 0, {
153
+ w: c.w,
154
+ h: o.rowHeight + border,
155
+ text: c.f.name,
156
+ canGrow: false,
157
+ style: { ...cellStyle, bold: true, align: c.align, background: o.headerBackground, color: o.headerColor },
158
+ }),
159
+ ),
160
+ ),
161
+ o.rowHeight,
162
+ );
163
+ if (!o.grid) {
164
+ appendRow(titleBand, [cell(createElement('line', 0, 0, { w: width, h: 4, style: { borderColor: o.headerBackground, borderWidth: 2 } }))], 4);
165
+ }
166
+
167
+ appendRow(
168
+ detail,
169
+ cols.map((c) =>
170
+ cell(
171
+ createElement('field', c.x, 0, {
172
+ w: c.w,
173
+ h: o.rowHeight + border,
174
+ text: `{${c.f.name}}`,
175
+ format: c.f.type === 'date' ? { kind: 'date' } : {},
176
+ // Top-aligned so short cells line up with the first line of a tall neighbour.
177
+ style: { ...cellStyle, align: c.align, vAlign: 'top' },
178
+ }),
179
+ ),
180
+ ),
181
+ o.rowHeight,
182
+ );
183
+
184
+ // Totals only for measures: skip identifiers such as OrderID.
185
+ const summable = (c: (typeof cols)[number]) => c.f.type === 'number' && !/id$/i.test(c.f.name);
186
+ if (o.totals && cols.some(summable)) {
187
+ const footer = ensureBand(r, 'reportFooter');
188
+ const firstNumeric = cols.findIndex(summable);
189
+ const cells: ReportElement[] = [];
190
+ if (firstNumeric > 0) {
191
+ const label = cols[firstNumeric - 1];
192
+ cells.push(cell(createElement('text', label.x, 0, { w: label.w, h: o.rowHeight, text: 'Total', style: { bold: true, align: 'right', padding: 4 } })));
193
+ }
194
+ for (const c of cols.filter(summable)) {
195
+ cells.push(
196
+ cell(
197
+ createElement('field', c.x, 0, {
198
+ w: c.w,
199
+ h: o.rowHeight + border,
200
+ text: `{sum(${c.f.name})}`,
201
+ format: { kind: 'number', decimals: 2 },
202
+ style: { ...cellStyle, bold: true, align: 'right' },
203
+ }),
204
+ ),
205
+ );
206
+ }
207
+ appendRow(footer, cells, o.rowHeight);
208
+ }
209
+ return ids;
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------------------------
213
+ // Receipt template (thermal roll): rows are the receipt's items; shop/payment details come from
214
+ // the document itself as root.x (e.g. {root.store.name}).
215
+
216
+ export const RECEIPT_WIDTHS = { 80: 302, 58: 219 } as const; // px at 96 dpi
217
+
218
+ export function receiptReport(paper: 80 | 58 = 80): ReportDefinition {
219
+ const width = RECEIPT_WIDTHS[paper];
220
+ const margin = paper === 80 ? 10 : 6;
221
+ const cw = width - margin * 2;
222
+ const fs = paper === 80 ? 11 : 10;
223
+ const mono = "Consolas, 'Courier New', monospace";
224
+ const base = { fontFamily: mono, fontSize: fs, color: '#111111', padding: 1, vAlign: 'top' as const };
225
+ const money = (expr: string) => `{format(${expr}, "number", 2)}`;
226
+ const totalExpr = 'sum(qty * price) * (1 + root.taxRate)';
227
+
228
+ const text = (x: number, y: number, w: number, h: number, t: string, style: Record<string, unknown> = {}) =>
229
+ createElement('field', x, y, { w, h, text: t, style: { ...base, ...style } });
230
+ const left = (y: number, t: string, style: Record<string, unknown> = {}, h = fs + 5) => text(0, y, Math.round(cw * 0.6), h, t, style);
231
+ const right = (y: number, t: string, style: Record<string, unknown> = {}, h = fs + 5) =>
232
+ text(Math.round(cw * 0.4), y, cw - Math.round(cw * 0.4), h, t, { ...style, align: 'right' });
233
+ const center = (y: number, t: string, style: Record<string, unknown> = {}, h = fs + 5) => text(0, y, cw, h, t, { ...style, align: 'center' });
234
+ const dashed = (y: number) => createElement('line', 0, y, { w: cw, h: 4, dash: 'dashed', style: { borderColor: '#444444', borderWidth: 1 } });
235
+
236
+ const r: ReportDefinition = {
237
+ version: 1,
238
+ name: `Receipt ${paper}mm`,
239
+ page: { width, height: 1123, marginTop: 10, marginBottom: 14, marginLeft: margin, marginRight: margin, continuous: true },
240
+ dataSource: {
241
+ type: 'json', url: '/samples/receipt.json', inline: '', dataPath: 'items', headers: '',
242
+ entitySet: '', select: '', filter: '', orderby: '', expand: '', top: null, maxRows: 5000,
243
+ },
244
+ groups: [],
245
+ sort: [],
246
+ bands: [],
247
+ };
248
+
249
+ const header = createBand('reportHeader', 0);
250
+ const lh = fs + 4;
251
+ header.elements.push(
252
+ center(0, '{root.store.name}', { fontSize: fs + 5, bold: true }, fs + 10),
253
+ center(fs + 11, '{root.store.address}', { fontSize: fs - 1 }),
254
+ center(fs + 11 + lh, 'Tel {root.store.phone}', { fontSize: fs - 1 }),
255
+ center(fs + 11 + lh * 2, 'Tax ID {root.store.taxId}', { fontSize: fs - 1 }),
256
+ );
257
+ let y = fs + 15 + lh * 3;
258
+ header.elements.push(dashed(y));
259
+ y += 6;
260
+ header.elements.push(
261
+ left(y, 'Receipt'), right(y, '{root.receiptNo}'),
262
+ left(y + lh, 'Date'), right(y + lh, '{formatdate(root.date, "dd/MM/yyyy HH:mm")}'),
263
+ left(y + lh * 2, 'Cashier {root.cashier}'), right(y + lh * 2, 'Table {root.table}'),
264
+ );
265
+ y += lh * 3 + 2;
266
+ header.elements.push(dashed(y));
267
+ y += 6;
268
+ header.elements.push(left(y, 'ITEM', { bold: true }), right(y, `TOTAL ${'{root.currency}'}`, { bold: true }));
269
+ y += lh + 2;
270
+ header.elements.push(dashed(y));
271
+ header.height = y + 6;
272
+
273
+ // Detail: name + line total on line 1 (they grow together for long names); "qty x price" below.
274
+ const detail = createBand('detail', 0);
275
+ const priceW = Math.round(cw * 0.32);
276
+ detail.elements.push(
277
+ text(0, 0, cw - priceW, fs + 5, '{name}', { bold: true }),
278
+ text(cw - priceW, 0, priceW, fs + 5, money('qty * price'), { align: 'right' }),
279
+ text(0, fs + 5, cw, fs + 4, ` {qty} x ${money('price')}`, { fontSize: fs - 1, color: '#444444' }),
280
+ );
281
+ detail.height = fs * 2 + 11;
282
+
283
+ const footer = createBand('reportFooter', 0);
284
+ y = 0;
285
+ footer.elements.push(dashed(y));
286
+ y += 6;
287
+ footer.elements.push(left(y, 'Subtotal'), right(y, money('sum(qty * price)')));
288
+ y += lh;
289
+ footer.elements.push(left(y, 'VAT {format(root.taxRate, "percent", 0)}'), right(y, money('sum(qty * price) * root.taxRate')));
290
+ y += lh + 4;
291
+ footer.elements.push(
292
+ left(y, 'TOTAL', { bold: true, fontSize: fs + 3 }, fs + 9),
293
+ right(y, `{root.currency} ${money(totalExpr)}`, { bold: true, fontSize: fs + 3 }, fs + 9),
294
+ );
295
+ y += fs + 12;
296
+ footer.elements.push(left(y, '{root.payment.method}'), right(y, money('root.payment.paid')));
297
+ y += lh;
298
+ footer.elements.push(left(y, 'Change'), right(y, money(`root.payment.paid - ${totalExpr}`)));
299
+ y += lh + 4;
300
+ footer.elements.push(dashed(y));
301
+ y += 8;
302
+ footer.elements.push(center(y, '{count()} items · {sum(qty)} pcs', { fontSize: fs - 1, color: '#444444' }));
303
+ y += lh + 6;
304
+
305
+ const bcW = Math.min(cw, 220);
306
+ const barcode = createBarcode((cw - bcW) / 2, y, []);
307
+ Object.assign(barcode, { w: bcW, h: 54, text: '{root.receiptNo}' });
308
+ barcode.style = { ...barcode.style, fontSize: fs - 1 };
309
+ footer.elements.push(barcode);
310
+ y += 62;
311
+ const qrSize = paper === 80 ? 96 : 84;
312
+ const qr = createQr((cw - qrSize) / 2, y, []);
313
+ Object.assign(qr, { w: qrSize, h: qrSize, text: 'https://example.com/r/{root.receiptNo}' });
314
+ footer.elements.push(qr);
315
+ y += qrSize + 4;
316
+ footer.elements.push(center(y, 'Thank you for your visit!', { bold: true }), center(y + lh + 2, 'Please keep this receipt', { fontSize: fs - 1, color: '#444444' }));
317
+ footer.height = y + lh * 2 + 4;
318
+
319
+ const pageHeader = createBand('pageHeader', 0);
320
+ const pageFooter = createBand('pageFooter', 0);
321
+ pageHeader.visible = pageFooter.visible = false;
322
+ r.bands.push(header, pageHeader, detail, pageFooter, footer);
323
+ return r;
324
+ }