@nice2dev/ui-printing 1.0.28

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 (49) hide show
  1. package/README.md +56 -0
  2. package/dist/index.cjs +2 -0
  3. package/dist/index.d.ts +15 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.mjs +3085 -0
  6. package/dist/print-button/NicePrintButton.d.ts +5 -0
  7. package/dist/print-button/NicePrintButton.d.ts.map +1 -0
  8. package/dist/print-button/index.d.ts +2 -0
  9. package/dist/print-button/index.d.ts.map +1 -0
  10. package/dist/print-preview/NicePrintPreview.d.ts +5 -0
  11. package/dist/print-preview/NicePrintPreview.d.ts.map +1 -0
  12. package/dist/print-preview/index.d.ts +2 -0
  13. package/dist/print-preview/index.d.ts.map +1 -0
  14. package/dist/print-queue/NicePrintQueue.d.ts +5 -0
  15. package/dist/print-queue/NicePrintQueue.d.ts.map +1 -0
  16. package/dist/print-queue/index.d.ts +2 -0
  17. package/dist/print-queue/index.d.ts.map +1 -0
  18. package/dist/style.css +1 -0
  19. package/dist/template-browser/NiceTemplateBrowser.d.ts +5 -0
  20. package/dist/template-browser/NiceTemplateBrowser.d.ts.map +1 -0
  21. package/dist/template-browser/index.d.ts +2 -0
  22. package/dist/template-browser/index.d.ts.map +1 -0
  23. package/dist/template-editor/NiceTemplateEditor.d.ts +6 -0
  24. package/dist/template-editor/NiceTemplateEditor.d.ts.map +1 -0
  25. package/dist/template-editor/index.d.ts +2 -0
  26. package/dist/template-editor/index.d.ts.map +1 -0
  27. package/dist/types/printingTypes.d.ts +249 -0
  28. package/dist/types/printingTypes.d.ts.map +1 -0
  29. package/package.json +66 -0
  30. package/src/globals.d.ts +9 -0
  31. package/src/index.ts +69 -0
  32. package/src/print-button/NicePrintButton.tsx +182 -0
  33. package/src/print-button/PrintButton.module.css +167 -0
  34. package/src/print-button/index.ts +1 -0
  35. package/src/print-preview/NicePrintPreview.tsx +417 -0
  36. package/src/print-preview/PrintPreview.module.css +122 -0
  37. package/src/print-preview/index.ts +1 -0
  38. package/src/print-queue/NicePrintQueue.tsx +350 -0
  39. package/src/print-queue/PrintQueue.module.css +203 -0
  40. package/src/print-queue/index.ts +1 -0
  41. package/src/template-browser/NiceTemplateBrowser.tsx +221 -0
  42. package/src/template-browser/TemplateBrowser.module.css +277 -0
  43. package/src/template-browser/index.ts +1 -0
  44. package/src/template-editor/NiceTemplateEditor.tsx +2386 -0
  45. package/src/template-editor/NiceTemplateEditor.tsx.first +1011 -0
  46. package/src/template-editor/NiceTemplateEditor.tsx.tmp +392 -0
  47. package/src/template-editor/TemplateEditor.module.css +1462 -0
  48. package/src/template-editor/index.ts +1 -0
  49. package/src/types/printingTypes.ts +301 -0
@@ -0,0 +1,2386 @@
1
+ /* eslint-disable jsx-a11y/click-events-have-key-events -- modal overlay backdrops (Esc-on-parent provides keyboard close), selection rows in .map() accompanied by visible cursor:pointer styling and sibling buttons; full keyboard migration via useAccessibleClick + extracted subcomponents tracked in TODO_1.0.10 A2.3 */
2
+ import React, { useState, useCallback, useRef as _useRef, useReducer, useEffect, useMemo } from 'react';
3
+
4
+ import type {
5
+ PrintTemplate,
6
+ PrintSection,
7
+ PrintDataField,
8
+ PrintWatermark as _PrintWatermark,
9
+ PrintSignature as _PrintSignature,
10
+ DataSourceField,
11
+ TemplateSampleData as _TemplateSampleData,
12
+ PaperSize,
13
+ PaperOrientation,
14
+ NiceTemplateEditorProps,
15
+ SectionType as _SectionType,
16
+ FieldStyle,
17
+ FieldPosition,
18
+ BarcodeType,
19
+ } from '../types/printingTypes';
20
+
21
+ import styles from './TemplateEditor.module.css';
22
+
23
+ // ─── Constants ───────────────────────────────────────────────────────────────
24
+ const MM_TO_PX = 3.78;
25
+
26
+ const PAPER_DIM: Record<PaperSize, [number, number]> = {
27
+ A3: [297, 420],
28
+ A4: [210, 297],
29
+ A5: [148, 210],
30
+ A6: [105, 148],
31
+ Letter: [216, 279],
32
+ Legal: [216, 356],
33
+ Tabloid: [279, 432],
34
+ custom: [210, 297],
35
+ };
36
+
37
+ const FONT_FAMILIES = [
38
+ 'sans-serif',
39
+ 'serif',
40
+ 'monospace',
41
+ 'Arial',
42
+ 'Times New Roman',
43
+ 'Courier New',
44
+ 'Georgia',
45
+ 'Verdana',
46
+ 'Tahoma',
47
+ ];
48
+ const FONT_SIZES = [6, 7, 8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 60, 72];
49
+
50
+ const FIELD_TYPE_ICONS: Record<string, string> = {
51
+ text: '📝',
52
+ number: '#',
53
+ currency: '€',
54
+ date: '📅',
55
+ datetime: '🕐',
56
+ boolean: '✓',
57
+ image: '🖼',
58
+ barcode: '▮▌▮',
59
+ qr: '⬛',
60
+ signature: '✍',
61
+ static_text: 'T',
62
+ static_image: '🖼',
63
+ line: '─',
64
+ rect: '▭',
65
+ page_number: '#p',
66
+ total_pages: '#pp',
67
+ print_date: '📅',
68
+ };
69
+
70
+ const FIELD_TYPE_LABELS: Record<string, string> = {
71
+ text: 'Tekst',
72
+ number: 'Liczba',
73
+ currency: 'Waluta',
74
+ date: 'Data',
75
+ datetime: 'Data i czas',
76
+ boolean: 'Tak/Nie',
77
+ image: 'Obraz',
78
+ barcode: 'Kod kreskowy',
79
+ qr: 'QR',
80
+ signature: 'Podpis',
81
+ static_text: 'Tekst stały',
82
+ static_image: 'Obraz stały',
83
+ line: 'Linia',
84
+ rect: 'Prostokąt',
85
+ page_number: 'Numer strony',
86
+ total_pages: 'Liczba stron',
87
+ print_date: 'Data wydruku',
88
+ };
89
+
90
+ // ─── Undo/Redo history ───────────────────────────────────────────────────────
91
+ type HistoryState = { past: PrintTemplate[]; present: PrintTemplate; future: PrintTemplate[] };
92
+ type HistoryAction =
93
+ | { type: 'UPDATE'; payload: PrintTemplate }
94
+ | { type: 'UNDO' }
95
+ | { type: 'REDO' };
96
+
97
+ const MAX_HISTORY = 50;
98
+
99
+ function historyReducer(state: HistoryState, action: HistoryAction): HistoryState {
100
+ switch (action.type) {
101
+ case 'UPDATE':
102
+ return {
103
+ past: [...state.past.slice(-MAX_HISTORY), state.present],
104
+ present: action.payload,
105
+ future: [],
106
+ };
107
+ case 'UNDO':
108
+ if (!state.past.length) {
109
+ return state;
110
+ }
111
+ return {
112
+ past: state.past.slice(0, -1),
113
+ present: state.past[state.past.length - 1],
114
+ future: [state.present, ...state.future],
115
+ };
116
+ case 'REDO':
117
+ if (!state.future.length) {
118
+ return state;
119
+ }
120
+ return {
121
+ past: [...state.past, state.present],
122
+ present: state.future[0],
123
+ future: state.future.slice(1),
124
+ };
125
+ default:
126
+ return state;
127
+ }
128
+ }
129
+
130
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
131
+ function uid() {
132
+ return `f-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
133
+ }
134
+
135
+ function makeField(type: string, schemaField?: DataSourceField): PrintDataField {
136
+ const base: PrintDataField = {
137
+ id: uid(),
138
+ name: schemaField?.label ?? FIELD_TYPE_LABELS[type] ?? type,
139
+ label: schemaField?.label ?? FIELD_TYPE_LABELS[type] ?? type,
140
+ type: type as PrintDataField['type'],
141
+ dataPath: schemaField?.path ?? '',
142
+ fallback: type === 'static_text' ? 'Tekst statyczny' : undefined,
143
+ format: type === 'date' || type === 'datetime' ? 'DD.MM.YYYY' : undefined,
144
+ position: {
145
+ x: 15,
146
+ y: 15,
147
+ width: type === 'line' ? 80 : 60,
148
+ height: type === 'line' ? 1 : type === 'image' ? 30 : 8,
149
+ },
150
+ style: { fontSize: 10, color: 'var(--nice-text, #000000)', fontFamily: 'sans-serif' },
151
+ };
152
+ if (type === 'rect') {
153
+ base.style.backgroundColor = 'var(--nice-bg-secondary, #f1f5f9)';
154
+ }
155
+ if (type === 'line') {
156
+ base.style = { ...base.style, borderWidth: 0.5, borderColor: 'var(--nice-text, #000000)', borderStyle: 'solid' };
157
+ }
158
+ return base;
159
+ }
160
+
161
+ // ─── Ruler component ─────────────────────────────────────────────────────────
162
+ const Ruler: React.FC<{ lengthMm: number; zoom: number; orientation: 'h' | 'v' }> = ({
163
+ lengthMm,
164
+ zoom,
165
+ orientation,
166
+ }) => {
167
+ const ticks: React.ReactNode[] = [];
168
+ const step = zoom < 0.6 ? 20 : zoom < 1 ? 10 : 5;
169
+ for (let mm = 0; mm <= lengthMm; mm += step) {
170
+ const pos = mm * zoom * MM_TO_PX;
171
+ const isMajor = mm % (step * 2) === 0;
172
+ ticks.push(
173
+ <div
174
+ key={mm}
175
+ className={`${styles.rulerTick} ${isMajor ? styles.rulerTickMajor : ''}`}
176
+ style={
177
+ orientation === 'h'
178
+ ? ({ left: pos } as React.CSSProperties)
179
+ : ({ top: pos } as React.CSSProperties)
180
+ }
181
+ >
182
+ {isMajor && <span className={styles.rulerLabel}>{mm}</span>}
183
+ </div>,
184
+ );
185
+ }
186
+ return <div className={`${styles.ruler} ${styles[`ruler_${orientation}`]}`}>{ticks}</div>;
187
+ };
188
+
189
+ // ─── Resize handle ────────────────────────────────────────────────────────────
190
+ type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
191
+ const RESIZE_DIRS: ResizeDir[] = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
192
+
193
+ interface ResizeHandlesProps {
194
+ onResizeStart: (dir: ResizeDir, e: React.MouseEvent) => void;
195
+ }
196
+ const ResizeHandles: React.FC<ResizeHandlesProps> = ({ onResizeStart }) => (
197
+ <>
198
+ {RESIZE_DIRS.map((dir) => (
199
+ <div
200
+ key={dir}
201
+ className={`${styles.resizeHandle} ${styles[`handle_${dir}`]}`}
202
+ onMouseDown={(e) => {
203
+ e.stopPropagation();
204
+ onResizeStart(dir, e);
205
+ }}
206
+ />
207
+ ))}
208
+ </>
209
+ );
210
+
211
+ // ─── Field element on canvas ─────────────────────────────────────────────────
212
+ interface FieldElementProps {
213
+ field: PrintDataField;
214
+ zoom: number;
215
+ selected: boolean;
216
+ sampleData?: Record<string, unknown>;
217
+ onClick: (e: React.MouseEvent) => void;
218
+ onDragStart: (e: React.MouseEvent) => void;
219
+ onResizeStart: (dir: ResizeDir, e: React.MouseEvent) => void;
220
+ readOnly: boolean;
221
+ }
222
+
223
+ function resolveFieldPreview(field: PrintDataField, data?: Record<string, unknown>): string {
224
+ if (field.type === 'static_text') {
225
+ return field.fallback ?? 'Tekst';
226
+ }
227
+ if (field.type === 'page_number') {
228
+ return '1';
229
+ }
230
+ if (field.type === 'total_pages') {
231
+ return '3';
232
+ }
233
+ if (field.type === 'print_date') {
234
+ return new Date().toLocaleDateString('pl-PL');
235
+ }
236
+ if (data && field.dataPath) {
237
+ const val = field.dataPath
238
+ .split('.')
239
+ .reduce<unknown>(
240
+ (o, k) => (o && typeof o === 'object' ? (o as Record<string, unknown>)[k] : null),
241
+ data,
242
+ );
243
+ if (val != null) {
244
+ return String(val);
245
+ }
246
+ }
247
+ return field.label ? `{${field.label}}` : `{${field.dataPath}}`;
248
+ }
249
+
250
+ const FieldElement: React.FC<FieldElementProps> = ({
251
+ field,
252
+ zoom,
253
+ selected,
254
+ sampleData,
255
+ onClick,
256
+ onDragStart,
257
+ onResizeStart,
258
+ readOnly,
259
+ }) => {
260
+ const pos = field.position;
261
+ const st = field.style;
262
+ const mm = (v: number) => v * zoom * MM_TO_PX;
263
+
264
+ const isLine = field.type === 'line';
265
+ const isRect = field.type === 'rect';
266
+ const isImage = field.type === 'image' || field.type === 'static_image';
267
+ const isBarcode = field.type === 'barcode' || field.type === 'qr';
268
+
269
+ const baseStyle: React.CSSProperties = {
270
+ position: 'absolute',
271
+ left: mm(pos.x),
272
+ top: mm(pos.y),
273
+ width: mm(pos.width),
274
+ height: mm(pos.height),
275
+ zIndex: pos.zIndex ?? 1,
276
+ transform: st.rotation ? `rotate(${st.rotation}deg)` : undefined,
277
+ boxSizing: 'border-box',
278
+ overflow: 'hidden',
279
+ ...(!isLine && !isRect
280
+ ? {
281
+ fontSize: (st.fontSize ?? 10) * zoom,
282
+ fontFamily: st.fontFamily ?? 'sans-serif',
283
+ fontWeight: st.fontWeight ?? 'normal',
284
+ fontStyle: st.fontStyle ?? 'normal',
285
+ color: st.color ?? 'var(--nice-text, #000)',
286
+ textAlign: st.textAlign ?? 'left',
287
+ lineHeight: st.lineHeight ?? 1.4,
288
+ letterSpacing: st.letterSpacing ? `${st.letterSpacing}em` : undefined,
289
+ padding: st.padding ? mm(st.padding) : 0,
290
+ }
291
+ : {}),
292
+ backgroundColor: st.backgroundColor,
293
+ borderWidth: st.borderWidth ?? 0,
294
+ borderColor: st.borderColor ?? 'transparent',
295
+ borderStyle: st.borderStyle ?? 'solid',
296
+ borderRadius: st.borderRadius,
297
+ opacity: st.opacity,
298
+ outline: selected ? '1.5px solid var(--nice-primary-hover, #2563eb)' : '1px dashed transparent',
299
+ outlineOffset: 1,
300
+ cursor: readOnly ? 'default' : 'move',
301
+ };
302
+
303
+ return (
304
+ <div style={baseStyle} onClick={onClick} onMouseDown={readOnly ? undefined : onDragStart}>
305
+ {isLine ? (
306
+ <div
307
+ style={{
308
+ width: '100%',
309
+ borderBottom: `${(st.borderWidth ?? 0.5) * zoom}px ${st.borderStyle ?? 'solid'} ${st.borderColor ?? 'var(--nice-text, #000)'}`,
310
+ }}
311
+ />
312
+ ) : isRect ? null : isImage ? (
313
+ <div className={styles.fieldImagePlaceholder}>🖼</div>
314
+ ) : isBarcode ? (
315
+ <div className={styles.fieldBarcodePlaceholder}>
316
+ {field.type === 'qr' ? '⬛ QR' : '▮▌▮ Barcode'}
317
+ </div>
318
+ ) : field.type === 'signature' ? (
319
+ <div className={styles.fieldSignaturePlaceholder}>✍ {field.label}</div>
320
+ ) : (
321
+ <span style={{ display: 'block', width: '100%' }}>
322
+ {resolveFieldPreview(field, sampleData)}
323
+ </span>
324
+ )}
325
+ {selected && !readOnly && <ResizeHandles onResizeStart={onResizeStart} />}
326
+ </div>
327
+ );
328
+ };
329
+
330
+ // ─── Section canvas ──────────────────────────────────────────────────────────
331
+ interface SectionCanvasProps {
332
+ section: PrintSection;
333
+ zoom: number;
334
+ paperWidthMm: number;
335
+ selectedIds: Set<string>;
336
+ sampleData?: Record<string, unknown>;
337
+ readOnly: boolean;
338
+ showGrid: boolean;
339
+ onFieldClick: (id: string, e: React.MouseEvent) => void;
340
+ onFieldDragStart: (id: string, e: React.MouseEvent) => void;
341
+ onResizeStart: (id: string, dir: ResizeDir, e: React.MouseEvent) => void;
342
+ onSectionHeightChange: (sectionId: string, heightMm: number) => void;
343
+ onDropField: (sectionId: string, schemaPath: string, x: number, y: number) => void;
344
+ }
345
+
346
+ const SectionCanvas: React.FC<SectionCanvasProps> = ({
347
+ section,
348
+ zoom,
349
+ paperWidthMm,
350
+ selectedIds,
351
+ sampleData,
352
+ readOnly,
353
+ showGrid,
354
+ onFieldClick,
355
+ onFieldDragStart,
356
+ onResizeStart,
357
+ onSectionHeightChange,
358
+ onDropField,
359
+ }) => {
360
+ const heightPx = (section.heightMm ?? (section.type === 'body' ? 200 : 40)) * zoom * MM_TO_PX;
361
+ const widthPx = paperWidthMm * zoom * MM_TO_PX;
362
+ const [draggingOver, setDraggingOver] = useState(false);
363
+
364
+ const sectionTypeColors: Record<string, string> = {
365
+ header: 'rgba(219,234,254,0.35)',
366
+ footer: 'rgba(220,252,231,0.35)',
367
+ background: 'rgba(254,249,195,0.35)',
368
+ body: 'transparent',
369
+ };
370
+
371
+ return (
372
+ <div
373
+ className={`${styles.sectionCanvas} ${draggingOver ? styles.sectionCanvasOver : ''}`}
374
+ style={
375
+ {
376
+ width: widthPx,
377
+ height: heightPx,
378
+ position: 'relative',
379
+ background: sectionTypeColors[section.type] ?? 'transparent',
380
+ backgroundImage: showGrid
381
+ ? `repeating-linear-gradient(0deg, var(--nice-overlay-15, rgba(148, 163, 184, 0.15)) 0, var(--nice-overlay-15, rgba(148, 163, 184, 0.15)) 1px, transparent 1px, transparent ${5 * zoom * MM_TO_PX}px),
382
+ repeating-linear-gradient(90deg, var(--nice-overlay-15, rgba(148, 163, 184, 0.15)) 0, var(--nice-overlay-15, rgba(148, 163, 184, 0.15)) 1px, transparent 1px, transparent ${5 * zoom * MM_TO_PX}px)`
383
+ : undefined,
384
+ } as React.CSSProperties
385
+ }
386
+ onDragOver={(e) => {
387
+ e.preventDefault();
388
+ setDraggingOver(true);
389
+ }}
390
+ onDragLeave={() => setDraggingOver(false)}
391
+ onDrop={(e) => {
392
+ e.preventDefault();
393
+ setDraggingOver(false);
394
+ const path = e.dataTransfer.getData('fieldPath');
395
+ if (!path) {
396
+ return;
397
+ }
398
+ const rect = e.currentTarget.getBoundingClientRect();
399
+ const xMm = (e.clientX - rect.left) / (zoom * MM_TO_PX);
400
+ const yMm = (e.clientY - rect.top) / (zoom * MM_TO_PX);
401
+ onDropField(section.id, path, Math.max(0, xMm), Math.max(0, yMm));
402
+ }}
403
+ >
404
+ {/* Section label chip */}
405
+ <div className={styles.sectionTypeChip}>{section.type}</div>
406
+
407
+ {/* Fields */}
408
+ {section.fields.map((field) => (
409
+ <FieldElement
410
+ key={field.id}
411
+ field={field}
412
+ zoom={zoom}
413
+ selected={selectedIds.has(field.id)}
414
+ sampleData={sampleData}
415
+ readOnly={readOnly}
416
+ onClick={(e) => onFieldClick(field.id, e)}
417
+ onDragStart={(e) => onFieldDragStart(field.id, e)}
418
+ onResizeStart={(dir, e) => onResizeStart(field.id, dir, e)}
419
+ />
420
+ ))}
421
+
422
+ {/* Section resize handle (bottom) for non-body sections */}
423
+ {!readOnly && section.type !== 'body' && (
424
+ <div
425
+ className={styles.sectionResizeBar}
426
+ onMouseDown={(e) => {
427
+ e.preventDefault();
428
+ const startY = e.clientY;
429
+ const startH = section.heightMm ?? 40;
430
+ const move = (me: MouseEvent) => {
431
+ const delta = (me.clientY - startY) / (zoom * MM_TO_PX);
432
+ onSectionHeightChange(section.id, Math.max(10, startH + delta));
433
+ };
434
+ const up = () => {
435
+ document.removeEventListener('mousemove', move);
436
+ document.removeEventListener('mouseup', up);
437
+ };
438
+ document.addEventListener('mousemove', move);
439
+ document.addEventListener('mouseup', up);
440
+ }}
441
+ >
442
+ <div className={styles.sectionResizeGrip} />
443
+ </div>
444
+ )}
445
+ </div>
446
+ );
447
+ };
448
+
449
+ // ─── Keyboard shortcuts chip ──────────────────────────────────────────────────
450
+ const ShortcutChip: React.FC<{ keys: string; label: string }> = ({ keys, label }) => (
451
+ <div className={styles.shortcutChip}>
452
+ <span className={styles.shortcutKeys}>{keys}</span>
453
+ <span className={styles.shortcutLabel}>{label}</span>
454
+ </div>
455
+ );
456
+
457
+ // ─── Left panel tabs ──────────────────────────────────────────────────────────
458
+ type LeftTab = 'fields' | 'layers' | 'watermarks' | 'signatures' | 'settings';
459
+ const LEFT_TABS: { id: LeftTab; icon: string; label: string }[] = [
460
+ { id: 'fields', icon: '📝', label: 'Pola' },
461
+ { id: 'layers', icon: '◧', label: 'Warstwy' },
462
+ { id: 'watermarks', icon: '💧', label: 'Znaki' },
463
+ { id: 'signatures', icon: '✍', label: 'Podpisy' },
464
+ { id: 'settings', icon: '⚙', label: 'Ustawienia' },
465
+ ];
466
+
467
+ // ─── Right panel tabs ─────────────────────────────────────────────────────────
468
+ type RightTab = 'position' | 'style' | 'data' | 'conditional';
469
+ const RIGHT_TABS: { id: RightTab; label: string }[] = [
470
+ { id: 'position', label: 'Pozycja' },
471
+ { id: 'style', label: 'Styl' },
472
+ { id: 'data', label: 'Dane' },
473
+ { id: 'conditional', label: 'Warunki' },
474
+ ];
475
+
476
+ // ─── Toolbar ─────────────────────────────────────────────────────────────────
477
+ interface ToolbarProps {
478
+ tpl: PrintTemplate;
479
+ zoom: number;
480
+ canUndo: boolean;
481
+ canRedo: boolean;
482
+ showGrid: boolean;
483
+ showRulers: boolean;
484
+ readOnly: boolean;
485
+ selectedCount: number;
486
+ onPaperChange: (size: PaperSize, orient: PaperOrientation) => void;
487
+ onZoom: (z: number) => void;
488
+ onUndo: () => void;
489
+ onRedo: () => void;
490
+ onToggleGrid: () => void;
491
+ onToggleRulers: () => void;
492
+ onInsert: (type: string) => void;
493
+ onDelete: () => void;
494
+ onDuplicate: () => void;
495
+ onAlignH: (align: 'left' | 'center' | 'right') => void;
496
+ onAlignV: (align: 'top' | 'center' | 'bottom') => void;
497
+ onBringForward: () => void;
498
+ onSendBackward: () => void;
499
+ onSave?: () => void;
500
+ }
501
+
502
+ const Toolbar: React.FC<ToolbarProps> = ({
503
+ tpl,
504
+ zoom,
505
+ canUndo,
506
+ canRedo,
507
+ showGrid,
508
+ showRulers,
509
+ readOnly,
510
+ selectedCount,
511
+ onPaperChange,
512
+ onZoom,
513
+ onUndo,
514
+ onRedo,
515
+ onToggleGrid,
516
+ onToggleRulers,
517
+ onInsert,
518
+ onDelete,
519
+ onDuplicate,
520
+ onAlignH,
521
+ onAlignV,
522
+ onBringForward,
523
+ onSendBackward,
524
+ onSave,
525
+ }) => {
526
+ const [insertOpen, setInsertOpen] = useState(false);
527
+ const ZOOM_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2];
528
+
529
+ return (
530
+ <div className={styles.toolbar}>
531
+ {/* Paper size + orientation */}
532
+ <div className={styles.toolbarGroup}>
533
+ <select
534
+ className={styles.toolbarSelect}
535
+ value={tpl.paperSize}
536
+ onChange={(e) => onPaperChange(e.target.value as PaperSize, tpl.orientation)}
537
+ >
538
+ {(['A4', 'A5', 'A3', 'Letter', 'Legal', 'Tabloid', 'custom'] as PaperSize[]).map((s) => (
539
+ <option key={s} value={s}>
540
+ {s}
541
+ </option>
542
+ ))}
543
+ </select>
544
+ <button
545
+ className={`${styles.toolbarBtn} ${tpl.orientation === 'portrait' ? styles.toolbarBtnActive : ''}`}
546
+ onClick={() => onPaperChange(tpl.paperSize, 'portrait')}
547
+ title="Pion"
548
+ >
549
+
550
+ </button>
551
+ <button
552
+ className={`${styles.toolbarBtn} ${tpl.orientation === 'landscape' ? styles.toolbarBtnActive : ''}`}
553
+ onClick={() => onPaperChange(tpl.paperSize, 'landscape')}
554
+ title="Poziom"
555
+ >
556
+
557
+ </button>
558
+ </div>
559
+
560
+ {/* Undo / Redo */}
561
+ {!readOnly && (
562
+ <div className={styles.toolbarGroup}>
563
+ <button
564
+ className={styles.toolbarBtn}
565
+ disabled={!canUndo}
566
+ onClick={onUndo}
567
+ title="Cofnij (Ctrl+Z)"
568
+ >
569
+
570
+ </button>
571
+ <button
572
+ className={styles.toolbarBtn}
573
+ disabled={!canRedo}
574
+ onClick={onRedo}
575
+ title="Ponów (Ctrl+Y)"
576
+ >
577
+
578
+ </button>
579
+ </div>
580
+ )}
581
+
582
+ {/* Insert dropdown */}
583
+ {!readOnly && (
584
+ <div className={styles.insertWrapper}>
585
+ <button
586
+ className={`${styles.toolbarBtn} ${styles.insertBtn}`}
587
+ onClick={() => setInsertOpen((o) => !o)}
588
+ >
589
+ + Wstaw ▾
590
+ </button>
591
+ {insertOpen && (
592
+ <div className={styles.insertDropdown} onMouseLeave={() => setInsertOpen(false)}>
593
+ <div className={styles.insertCategory}>Pola danych</div>
594
+ {['text', 'number', 'currency', 'date', 'image', 'barcode', 'qr'].map((t) => (
595
+ <button
596
+ key={t}
597
+ className={styles.insertItem}
598
+ onClick={() => {
599
+ onInsert(t);
600
+ setInsertOpen(false);
601
+ }}
602
+ >
603
+ <span className={styles.insertIcon}>{FIELD_TYPE_ICONS[t]}</span>
604
+ {FIELD_TYPE_LABELS[t]}
605
+ </button>
606
+ ))}
607
+ <div className={styles.insertCategory}>Elementy statyczne</div>
608
+ {[
609
+ 'static_text',
610
+ 'static_image',
611
+ 'line',
612
+ 'rect',
613
+ 'page_number',
614
+ 'total_pages',
615
+ 'print_date',
616
+ ].map((t) => (
617
+ <button
618
+ key={t}
619
+ className={styles.insertItem}
620
+ onClick={() => {
621
+ onInsert(t);
622
+ setInsertOpen(false);
623
+ }}
624
+ >
625
+ <span className={styles.insertIcon}>{FIELD_TYPE_ICONS[t]}</span>
626
+ {FIELD_TYPE_LABELS[t]}
627
+ </button>
628
+ ))}
629
+ <div className={styles.insertCategory}>Specjalne</div>
630
+ <button
631
+ className={styles.insertItem}
632
+ onClick={() => {
633
+ onInsert('watermark');
634
+ setInsertOpen(false);
635
+ }}
636
+ >
637
+ <span className={styles.insertIcon}>💧</span>Znak wodny
638
+ </button>
639
+ <button
640
+ className={styles.insertItem}
641
+ onClick={() => {
642
+ onInsert('signature');
643
+ setInsertOpen(false);
644
+ }}
645
+ >
646
+ <span className={styles.insertIcon}>✍</span>Podpis
647
+ </button>
648
+ </div>
649
+ )}
650
+ </div>
651
+ )}
652
+
653
+ {/* Selection actions */}
654
+ {!readOnly && selectedCount > 0 && (
655
+ <div className={styles.toolbarGroup}>
656
+ <button className={styles.toolbarBtn} onClick={onDuplicate} title="Duplikuj (Ctrl+D)">
657
+
658
+ </button>
659
+ <button
660
+ className={`${styles.toolbarBtn} ${styles.toolbarBtnDanger}`}
661
+ onClick={onDelete}
662
+ title="Usuń (Del)"
663
+ >
664
+ 🗑
665
+ </button>
666
+ <span className={styles.toolbarSep} />
667
+ <button
668
+ className={styles.toolbarBtn}
669
+ onClick={() => onAlignH('left')}
670
+ title="Wyrównaj lewo"
671
+ >
672
+ ⬛□□
673
+ </button>
674
+ <button
675
+ className={styles.toolbarBtn}
676
+ onClick={() => onAlignH('center')}
677
+ title="Wyśrodkuj"
678
+ >
679
+ □⬛□
680
+ </button>
681
+ <button
682
+ className={styles.toolbarBtn}
683
+ onClick={() => onAlignH('right')}
684
+ title="Wyrównaj prawo"
685
+ >
686
+ □□⬛
687
+ </button>
688
+ <button className={styles.toolbarBtn} onClick={() => onAlignV('top')} title="Góra">
689
+
690
+ </button>
691
+ <button className={styles.toolbarBtn} onClick={() => onAlignV('center')} title="Środek V">
692
+
693
+ </button>
694
+ <button className={styles.toolbarBtn} onClick={() => onAlignV('bottom')} title="Dół">
695
+
696
+ </button>
697
+ <span className={styles.toolbarSep} />
698
+ <button className={styles.toolbarBtn} onClick={onBringForward} title="Do przodu">
699
+
700
+ </button>
701
+ <button className={styles.toolbarBtn} onClick={onSendBackward} title="Do tyłu">
702
+
703
+ </button>
704
+ </div>
705
+ )}
706
+
707
+ {/* View options */}
708
+ <div className={styles.toolbarGroup}>
709
+ <button
710
+ className={`${styles.toolbarBtn} ${showGrid ? styles.toolbarBtnActive : ''}`}
711
+ onClick={onToggleGrid}
712
+ title="Siatka"
713
+ >
714
+
715
+ </button>
716
+ <button
717
+ className={`${styles.toolbarBtn} ${showRulers ? styles.toolbarBtnActive : ''}`}
718
+ onClick={onToggleRulers}
719
+ title="Linijki"
720
+ >
721
+
722
+ </button>
723
+ </div>
724
+
725
+ {/* Zoom */}
726
+ <div className={styles.toolbarGroup}>
727
+ <button className={styles.toolbarBtn} onClick={() => onZoom(Math.max(0.25, zoom - 0.1))}>
728
+
729
+ </button>
730
+ <select
731
+ className={styles.zoomSelect}
732
+ value={ZOOM_PRESETS.includes(zoom) ? zoom : ''}
733
+ onChange={(e) => onZoom(+e.target.value)}
734
+ >
735
+ {ZOOM_PRESETS.map((z) => (
736
+ <option key={z} value={z}>
737
+ {Math.round(z * 100)}%
738
+ </option>
739
+ ))}
740
+ </select>
741
+ <button className={styles.toolbarBtn} onClick={() => onZoom(Math.min(3, zoom + 0.1))}>
742
+ +
743
+ </button>
744
+ <button className={styles.toolbarBtn} onClick={() => onZoom(1)} title="100%">
745
+
746
+ </button>
747
+ </div>
748
+
749
+ {!readOnly && onSave && (
750
+ <button className={styles.saveBtn} onClick={onSave}>
751
+ 💾 Zapisz
752
+ </button>
753
+ )}
754
+ </div>
755
+ );
756
+ };
757
+
758
+ // ─── Left panel ───────────────────────────────────────────────────────────────
759
+ interface LeftPanelProps {
760
+ activeTab: LeftTab;
761
+ onTabChange: (t: LeftTab) => void;
762
+ schema?: { fields: DataSourceField[] };
763
+ tpl: PrintTemplate;
764
+ allFields: PrintDataField[];
765
+ selectedIds: Set<string>;
766
+ onInsertSchemaField: (f: DataSourceField) => void;
767
+ onSelectField: (id: string) => void;
768
+ onUpdateTpl: (patch: Partial<PrintTemplate>) => void;
769
+ onReorderField: (fieldId: string, delta: -1 | 1) => void;
770
+ }
771
+
772
+ const LeftPanel: React.FC<LeftPanelProps> = ({
773
+ activeTab,
774
+ onTabChange,
775
+ schema,
776
+ tpl,
777
+ allFields,
778
+ selectedIds,
779
+ onInsertSchemaField,
780
+ onSelectField,
781
+ onUpdateTpl,
782
+ onReorderField,
783
+ }) => {
784
+ const [search, setSearch] = useState('');
785
+ const filteredFields = schema?.fields.filter(
786
+ (f) =>
787
+ f.label.toLowerCase().includes(search.toLowerCase()) ||
788
+ f.path.toLowerCase().includes(search.toLowerCase()),
789
+ );
790
+
791
+ return (
792
+ <div className={styles.leftPanel}>
793
+ <div className={styles.leftTabs}>
794
+ {LEFT_TABS.map((t) => (
795
+ <button
796
+ key={t.id}
797
+ className={`${styles.leftTab} ${activeTab === t.id ? styles.leftTabActive : ''}`}
798
+ onClick={() => onTabChange(t.id)}
799
+ title={t.label}
800
+ >
801
+ <span className={styles.leftTabIcon}>{t.icon}</span>
802
+ <span className={styles.leftTabLabel}>{t.label}</span>
803
+ </button>
804
+ ))}
805
+ </div>
806
+
807
+ <div className={styles.leftContent}>
808
+ {/* ── Fields tab ── */}
809
+ {activeTab === 'fields' && (
810
+ <>
811
+ <div className={styles.panelHeader}>Pola danych</div>
812
+ <div className={styles.searchBox}>
813
+ <input
814
+ className={styles.searchInput}
815
+ placeholder="Szukaj..."
816
+ value={search}
817
+ onChange={(e) => setSearch(e.target.value)}
818
+ />
819
+ </div>
820
+ {schema ? (
821
+ <div className={styles.fieldList}>
822
+ {(filteredFields ?? []).map((f) => (
823
+ <div
824
+ key={f.path}
825
+ className={styles.fieldItem}
826
+ draggable
827
+ onDragStart={(e) => {
828
+ e.dataTransfer.setData('fieldPath', f.path);
829
+ e.dataTransfer.setData('fieldLabel', f.label);
830
+ e.dataTransfer.setData('fieldType', f.type);
831
+ }}
832
+ onClick={() => onInsertSchemaField(f)}
833
+ >
834
+ <span className={styles.fieldItemIcon}>{FIELD_TYPE_ICONS[f.type] ?? '📝'}</span>
835
+ <div className={styles.fieldItemInfo}>
836
+ <div className={styles.fieldItemLabel}>{f.label}</div>
837
+ <div className={styles.fieldItemPath}>{f.path}</div>
838
+ </div>
839
+ <span className={styles.fieldItemType}>{f.type}</span>
840
+ </div>
841
+ ))}
842
+ {filteredFields?.length === 0 && (
843
+ <div className={styles.emptyMsg}>Brak wyników</div>
844
+ )}
845
+ </div>
846
+ ) : (
847
+ <div className={styles.emptyMsg}>
848
+ Brak schematu danych. Przypisz encję w zakładce Ustawienia.
849
+ </div>
850
+ )}
851
+ </>
852
+ )}
853
+
854
+ {/* ── Layers tab ── */}
855
+ {activeTab === 'layers' && (
856
+ <>
857
+ <div className={styles.panelHeader}>Warstwy ({allFields.length})</div>
858
+ <div className={styles.layerList}>
859
+ {allFields.length === 0 && <div className={styles.emptyMsg}>Brak elementów</div>}
860
+ {allFields
861
+ .slice()
862
+ .reverse()
863
+ .map((f) => (
864
+ <div
865
+ key={f.id}
866
+ className={`${styles.layerItem} ${selectedIds.has(f.id) ? styles.layerItemSelected : ''}`}
867
+ onClick={() => onSelectField(f.id)}
868
+ >
869
+ <span className={styles.layerIcon}>{FIELD_TYPE_ICONS[f.type] ?? '📝'}</span>
870
+ <span className={styles.layerName}>{f.name || f.label}</span>
871
+ <div className={styles.layerActions}>
872
+ <button
873
+ className={styles.layerBtn}
874
+ onClick={(e) => {
875
+ e.stopPropagation();
876
+ onReorderField(f.id, -1);
877
+ }}
878
+ title="Do przodu"
879
+ >
880
+
881
+ </button>
882
+ <button
883
+ className={styles.layerBtn}
884
+ onClick={(e) => {
885
+ e.stopPropagation();
886
+ onReorderField(f.id, 1);
887
+ }}
888
+ title="Do tyłu"
889
+ >
890
+
891
+ </button>
892
+ </div>
893
+ </div>
894
+ ))}
895
+ </div>
896
+ </>
897
+ )}
898
+
899
+ {/* ── Watermarks tab ── */}
900
+ {activeTab === 'watermarks' && (
901
+ <>
902
+ <div className={styles.panelHeader}>Znaki wodne</div>
903
+ <div className={styles.wmList}>
904
+ {(tpl.watermarks ?? []).map((wm) => (
905
+ <div key={wm.id} className={styles.wmItem}>
906
+ <div className={styles.wmRow}>
907
+ <span className={styles.wmLabel}>Typ</span>
908
+ <select
909
+ className={styles.wmSelect}
910
+ value={wm.type}
911
+ onChange={(e) =>
912
+ onUpdateTpl({
913
+ watermarks: tpl.watermarks?.map((w) =>
914
+ w.id === wm.id ? { ...w, type: e.target.value as 'text' | 'image' } : w,
915
+ ),
916
+ })
917
+ }
918
+ >
919
+ <option value="text">Tekst</option>
920
+ <option value="image">Obraz</option>
921
+ </select>
922
+ </div>
923
+ {wm.type === 'text' && (
924
+ <div className={styles.wmRow}>
925
+ <span className={styles.wmLabel}>Tekst</span>
926
+ <input
927
+ className={styles.wmInput}
928
+ value={wm.text ?? ''}
929
+ onChange={(e) =>
930
+ onUpdateTpl({
931
+ watermarks: tpl.watermarks?.map((w) =>
932
+ w.id === wm.id ? { ...w, text: e.target.value } : w,
933
+ ),
934
+ })
935
+ }
936
+ />
937
+ </div>
938
+ )}
939
+ <div className={styles.wmRow}>
940
+ <span className={styles.wmLabel}>Krycie</span>
941
+ <input
942
+ type="range"
943
+ min={0.02}
944
+ max={0.5}
945
+ step={0.01}
946
+ className={styles.wmSlider}
947
+ value={wm.opacity}
948
+ onChange={(e) =>
949
+ onUpdateTpl({
950
+ watermarks: tpl.watermarks?.map((w) =>
951
+ w.id === wm.id ? { ...w, opacity: +e.target.value } : w,
952
+ ),
953
+ })
954
+ }
955
+ />
956
+ <span className={styles.wmValue}>{Math.round(wm.opacity * 100)}%</span>
957
+ </div>
958
+ <div className={styles.wmRow}>
959
+ <span className={styles.wmLabel}>Kąt</span>
960
+ <input
961
+ type="range"
962
+ min={-90}
963
+ max={90}
964
+ step={5}
965
+ className={styles.wmSlider}
966
+ value={wm.angle}
967
+ onChange={(e) =>
968
+ onUpdateTpl({
969
+ watermarks: tpl.watermarks?.map((w) =>
970
+ w.id === wm.id ? { ...w, angle: +e.target.value } : w,
971
+ ),
972
+ })
973
+ }
974
+ />
975
+ <span className={styles.wmValue}>{wm.angle}°</span>
976
+ </div>
977
+ <div className={styles.wmRow}>
978
+ <span className={styles.wmLabel}>Rozmiar</span>
979
+ <input
980
+ type="number"
981
+ min={8}
982
+ max={200}
983
+ className={styles.wmInputNum}
984
+ value={wm.fontSize ?? 48}
985
+ onChange={(e) =>
986
+ onUpdateTpl({
987
+ watermarks: tpl.watermarks?.map((w) =>
988
+ w.id === wm.id ? { ...w, fontSize: +e.target.value } : w,
989
+ ),
990
+ })
991
+ }
992
+ />
993
+ <span className={styles.wmUnit}>pt</span>
994
+ </div>
995
+ <div className={styles.wmRow}>
996
+ <span className={styles.wmLabel}>Kolor</span>
997
+ <input
998
+ type="color"
999
+ className={styles.colorPicker}
1000
+ value={wm.color ?? 'var(--nice-text-muted, #94a3b8)'}
1001
+ onChange={(e) =>
1002
+ onUpdateTpl({
1003
+ watermarks: tpl.watermarks?.map((w) =>
1004
+ w.id === wm.id ? { ...w, color: e.target.value } : w,
1005
+ ),
1006
+ })
1007
+ }
1008
+ />
1009
+ </div>
1010
+ <button
1011
+ className={styles.wmDelete}
1012
+ onClick={() =>
1013
+ onUpdateTpl({ watermarks: tpl.watermarks?.filter((w) => w.id !== wm.id) })
1014
+ }
1015
+ >
1016
+ Usuń
1017
+ </button>
1018
+ </div>
1019
+ ))}
1020
+ <button
1021
+ className={styles.addBtn}
1022
+ onClick={() =>
1023
+ onUpdateTpl({
1024
+ watermarks: [
1025
+ ...(tpl.watermarks ?? []),
1026
+ {
1027
+ id: uid(),
1028
+ type: 'text',
1029
+ text: 'KOPIA',
1030
+ opacity: 0.12,
1031
+ angle: -45,
1032
+ fontSize: 60,
1033
+ color: 'var(--nice-text-muted, #94a3b8)',
1034
+ },
1035
+ ],
1036
+ })
1037
+ }
1038
+ >
1039
+ + Dodaj znak wodny
1040
+ </button>
1041
+ </div>
1042
+ </>
1043
+ )}
1044
+
1045
+ {/* ── Signatures tab ── */}
1046
+ {activeTab === 'signatures' && (
1047
+ <>
1048
+ <div className={styles.panelHeader}>Podpisy</div>
1049
+ <div className={styles.wmList}>
1050
+ {(tpl.signatures ?? []).map((sig) => (
1051
+ <div key={sig.id} className={styles.wmItem}>
1052
+ <div className={styles.wmRow}>
1053
+ <span className={styles.wmLabel}>Etykieta</span>
1054
+ <input
1055
+ className={styles.wmInput}
1056
+ value={sig.label}
1057
+ onChange={(e) =>
1058
+ onUpdateTpl({
1059
+ signatures: tpl.signatures?.map((s) =>
1060
+ s.id === sig.id ? { ...s, label: e.target.value } : s,
1061
+ ),
1062
+ })
1063
+ }
1064
+ />
1065
+ </div>
1066
+ <div className={styles.wmRow}>
1067
+ <span className={styles.wmLabel}>Nazwisko</span>
1068
+ <input
1069
+ className={styles.wmInput}
1070
+ value={sig.signerName ?? ''}
1071
+ onChange={(e) =>
1072
+ onUpdateTpl({
1073
+ signatures: tpl.signatures?.map((s) =>
1074
+ s.id === sig.id ? { ...s, signerName: e.target.value } : s,
1075
+ ),
1076
+ })
1077
+ }
1078
+ />
1079
+ </div>
1080
+ <div className={styles.wmRow}>
1081
+ <span className={styles.wmLabel}>Linia</span>
1082
+ <input
1083
+ type="checkbox"
1084
+ checked={sig.showLine ?? true}
1085
+ onChange={(e) =>
1086
+ onUpdateTpl({
1087
+ signatures: tpl.signatures?.map((s) =>
1088
+ s.id === sig.id ? { ...s, showLine: e.target.checked } : s,
1089
+ ),
1090
+ })
1091
+ }
1092
+ />
1093
+ </div>
1094
+ <div className={styles.wmRow}>
1095
+ <span className={styles.wmLabel}>Pokaż datę</span>
1096
+ <input
1097
+ type="checkbox"
1098
+ checked={sig.showDate ?? false}
1099
+ onChange={(e) =>
1100
+ onUpdateTpl({
1101
+ signatures: tpl.signatures?.map((s) =>
1102
+ s.id === sig.id ? { ...s, showDate: e.target.checked } : s,
1103
+ ),
1104
+ })
1105
+ }
1106
+ />
1107
+ </div>
1108
+ <button
1109
+ className={styles.wmDelete}
1110
+ onClick={() =>
1111
+ onUpdateTpl({ signatures: tpl.signatures?.filter((s) => s.id !== sig.id) })
1112
+ }
1113
+ >
1114
+ Usuń
1115
+ </button>
1116
+ </div>
1117
+ ))}
1118
+ <button
1119
+ className={styles.addBtn}
1120
+ onClick={() =>
1121
+ onUpdateTpl({
1122
+ signatures: [
1123
+ ...(tpl.signatures ?? []),
1124
+ {
1125
+ id: uid(),
1126
+ label: 'Podpis',
1127
+ position: { x: 10, y: 5, width: 70, height: 22 },
1128
+ showLine: true,
1129
+ showDate: false,
1130
+ },
1131
+ ],
1132
+ })
1133
+ }
1134
+ >
1135
+ + Dodaj podpis
1136
+ </button>
1137
+ </div>
1138
+ </>
1139
+ )}
1140
+
1141
+ {/* ── Settings tab ── */}
1142
+ {activeTab === 'settings' && (
1143
+ <>
1144
+ <div className={styles.panelHeader}>Ustawienia szablonu</div>
1145
+ <div className={styles.settingsForm}>
1146
+ <div className={styles.settingsRow}>
1147
+ <label className={styles.settingsLabel}>Nazwa</label>
1148
+ <input
1149
+ className={styles.settingsInput}
1150
+ value={tpl.name}
1151
+ onChange={(e) => onUpdateTpl({ name: e.target.value })}
1152
+ />
1153
+ </div>
1154
+ <div className={styles.settingsRow}>
1155
+ <label className={styles.settingsLabel}>Opis</label>
1156
+ <textarea
1157
+ className={styles.settingsTextarea}
1158
+ value={tpl.description ?? ''}
1159
+ onChange={(e) => onUpdateTpl({ description: e.target.value })}
1160
+ rows={2}
1161
+ />
1162
+ </div>
1163
+ <div className={styles.settingsRow}>
1164
+ <label className={styles.settingsLabel}>Kategoria</label>
1165
+ <input
1166
+ className={styles.settingsInput}
1167
+ value={tpl.category ?? ''}
1168
+ onChange={(e) => onUpdateTpl({ category: e.target.value })}
1169
+ />
1170
+ </div>
1171
+ <div className={styles.settingsDivider}>Marginesy (mm)</div>
1172
+ {(['top', 'right', 'bottom', 'left'] as const).map((side) => (
1173
+ <div key={side} className={styles.settingsRow}>
1174
+ <label className={styles.settingsLabel}>
1175
+ {side === 'top'
1176
+ ? 'Góra'
1177
+ : side === 'right'
1178
+ ? 'Prawo'
1179
+ : side === 'bottom'
1180
+ ? 'Dół'
1181
+ : 'Lewo'}
1182
+ </label>
1183
+ <input
1184
+ type="number"
1185
+ min={0}
1186
+ max={50}
1187
+ step={1}
1188
+ className={styles.settingsInputNum}
1189
+ value={tpl.margins[side]}
1190
+ onChange={(e) =>
1191
+ onUpdateTpl({ margins: { ...tpl.margins, [side]: +e.target.value } })
1192
+ }
1193
+ />
1194
+ <span className={styles.settingsUnit}>mm</span>
1195
+ </div>
1196
+ ))}
1197
+ </div>
1198
+ </>
1199
+ )}
1200
+ </div>
1201
+ </div>
1202
+ );
1203
+ };
1204
+
1205
+ // ─── Right panel (Properties) ─────────────────────────────────────────────────
1206
+ interface PropertiesPanelProps {
1207
+ field: PrintDataField | null;
1208
+ activeTab: RightTab;
1209
+ onTabChange: (t: RightTab) => void;
1210
+ onChange: (patch: Partial<PrintDataField>) => void;
1211
+ paperWidthMm: number;
1212
+ paperHeightMm: number;
1213
+ }
1214
+
1215
+ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
1216
+ field,
1217
+ activeTab,
1218
+ onTabChange,
1219
+ onChange,
1220
+ paperWidthMm,
1221
+ paperHeightMm: _paperHeightMm,
1222
+ }) => {
1223
+ const p = useCallback(
1224
+ (patch: Partial<FieldPosition>) =>
1225
+ field && onChange({ position: { ...field.position, ...patch } }),
1226
+ [field, onChange],
1227
+ );
1228
+ const s = useCallback(
1229
+ (patch: Partial<FieldStyle>) =>
1230
+ field && onChange({ style: { ...(field?.style ?? {}), ...patch } }),
1231
+ [field, onChange],
1232
+ );
1233
+
1234
+ if (!field) {
1235
+ return (
1236
+ <div className={styles.rightPanel}>
1237
+ <div className={styles.panelHeader}>Właściwości</div>
1238
+ <div className={styles.propsEmpty}>
1239
+ <div className={styles.propsEmptyIcon}>⬚</div>
1240
+ <div className={styles.propsEmptyText}>Zaznacz element na szablonie</div>
1241
+ <div className={styles.shortcutGrid}>
1242
+ <ShortcutChip keys="Del" label="Usuń" />
1243
+ <ShortcutChip keys="Ctrl+D" label="Duplikuj" />
1244
+ <ShortcutChip keys="↑↓←→" label="Przesuń" />
1245
+ <ShortcutChip keys="Ctrl+Z" label="Cofnij" />
1246
+ <ShortcutChip keys="Ctrl+A" label="Zaznacz wszystko" />
1247
+ <ShortcutChip keys="Shift+↑" label="Przesuń 5mm" />
1248
+ </div>
1249
+ </div>
1250
+ </div>
1251
+ );
1252
+ }
1253
+
1254
+ const hasText = !['line', 'rect', 'image', 'static_image', 'barcode', 'qr', 'signature'].includes(
1255
+ field.type,
1256
+ );
1257
+ const hasData = ![
1258
+ 'static_text',
1259
+ 'static_image',
1260
+ 'line',
1261
+ 'rect',
1262
+ 'page_number',
1263
+ 'total_pages',
1264
+ 'print_date',
1265
+ ].includes(field.type);
1266
+
1267
+ return (
1268
+ <div className={styles.rightPanel}>
1269
+ <div className={styles.panelHeader}>
1270
+ <span className={styles.panelHeaderIcon}>{FIELD_TYPE_ICONS[field.type]}</span>
1271
+ <span className={styles.panelHeaderName}>{field.name}</span>
1272
+ </div>
1273
+
1274
+ <div className={styles.rightTabs}>
1275
+ {RIGHT_TABS.map((t) => {
1276
+ if (t.id === 'data' && !hasData) {
1277
+ return null;
1278
+ }
1279
+ return (
1280
+ <button
1281
+ key={t.id}
1282
+ className={`${styles.rightTab} ${activeTab === t.id ? styles.rightTabActive : ''}`}
1283
+ onClick={() => onTabChange(t.id)}
1284
+ >
1285
+ {t.label}
1286
+ </button>
1287
+ );
1288
+ })}
1289
+ </div>
1290
+
1291
+ <div className={styles.propsBody}>
1292
+ {/* ── Position ── */}
1293
+ {activeTab === 'position' && (
1294
+ <>
1295
+ <div className={styles.posGrid}>
1296
+ {[
1297
+ { key: 'x' as const, label: 'X', unit: 'mm' },
1298
+ { key: 'y' as const, label: 'Y', unit: 'mm' },
1299
+ { key: 'width' as const, label: 'Szer.', unit: 'mm' },
1300
+ { key: 'height' as const, label: 'Wys.', unit: 'mm' },
1301
+ ].map(({ key, label, unit }) => (
1302
+ <div key={key} className={styles.posCell}>
1303
+ <label className={styles.posLabel}>{label}</label>
1304
+ <input
1305
+ type="number"
1306
+ step={0.5}
1307
+ className={styles.posInput}
1308
+ value={Math.round(field.position[key] * 10) / 10}
1309
+ onChange={(e) => p({ [key]: +e.target.value })}
1310
+ />
1311
+ <span className={styles.posUnit}>{unit}</span>
1312
+ </div>
1313
+ ))}
1314
+ <div className={styles.posCell}>
1315
+ <label className={styles.posLabel}>Obrót</label>
1316
+ <input
1317
+ type="number"
1318
+ step={5}
1319
+ className={styles.posInput}
1320
+ value={field.style.rotation ?? 0}
1321
+ onChange={(e) => s({ rotation: +e.target.value })}
1322
+ />
1323
+ <span className={styles.posUnit}>°</span>
1324
+ </div>
1325
+ <div className={styles.posCell}>
1326
+ <label className={styles.posLabel}>Z-idx</label>
1327
+ <input
1328
+ type="number"
1329
+ step={1}
1330
+ min={1}
1331
+ className={styles.posInput}
1332
+ value={field.position.zIndex ?? 1}
1333
+ onChange={(e) => p({ zIndex: +e.target.value })}
1334
+ />
1335
+ </div>
1336
+ </div>
1337
+
1338
+ <div className={styles.snapSection}>
1339
+ <div className={styles.snapTitle}>Wyrównanie do strony</div>
1340
+ <div className={styles.snapRow}>
1341
+ <button className={styles.snapBtn} onClick={() => p({ x: 0 })} title="Lewa krawędź">
1342
+
1343
+ </button>
1344
+ <button
1345
+ className={styles.snapBtn}
1346
+ onClick={() => p({ x: (paperWidthMm - field.position.width) / 2 })}
1347
+ title="Środek poziomy"
1348
+ >
1349
+
1350
+ </button>
1351
+ <button
1352
+ className={styles.snapBtn}
1353
+ onClick={() => p({ x: paperWidthMm - field.position.width })}
1354
+ title="Prawa krawędź"
1355
+ >
1356
+
1357
+ </button>
1358
+ </div>
1359
+ </div>
1360
+ </>
1361
+ )}
1362
+
1363
+ {/* ── Style ── */}
1364
+ {activeTab === 'style' && (
1365
+ <>
1366
+ {hasText && (
1367
+ <div className={styles.styleGroup}>
1368
+ <div className={styles.styleGroupTitle}>Czcionka</div>
1369
+ <div className={styles.styleRow}>
1370
+ <label className={styles.styleLabel}>Rodzina</label>
1371
+ <select
1372
+ className={styles.styleSelectFull}
1373
+ value={field.style.fontFamily ?? 'sans-serif'}
1374
+ onChange={(e) => s({ fontFamily: e.target.value })}
1375
+ >
1376
+ {FONT_FAMILIES.map((f) => (
1377
+ <option key={f} value={f}>
1378
+ {f}
1379
+ </option>
1380
+ ))}
1381
+ </select>
1382
+ </div>
1383
+ <div className={styles.styleRow}>
1384
+ <label className={styles.styleLabel}>Rozmiar</label>
1385
+ <select
1386
+ className={styles.styleSelectSm}
1387
+ value={
1388
+ FONT_SIZES.includes(field.style.fontSize ?? 10)
1389
+ ? (field.style.fontSize ?? 10)
1390
+ : ''
1391
+ }
1392
+ onChange={(e) => s({ fontSize: +e.target.value })}
1393
+ >
1394
+ {FONT_SIZES.map((f) => (
1395
+ <option key={f} value={f}>
1396
+ {f}
1397
+ </option>
1398
+ ))}
1399
+ </select>
1400
+ <input
1401
+ type="number"
1402
+ step={0.5}
1403
+ min={4}
1404
+ max={200}
1405
+ className={styles.styleInput}
1406
+ value={field.style.fontSize ?? 10}
1407
+ onChange={(e) => s({ fontSize: +e.target.value })}
1408
+ />
1409
+ <span className={styles.styleUnit}>pt</span>
1410
+ </div>
1411
+ <div className={styles.styleRow}>
1412
+ <label className={styles.styleLabel}>Kolor</label>
1413
+ <input
1414
+ type="color"
1415
+ className={styles.colorPicker}
1416
+ value={field.style.color ?? 'var(--nice-text, #000000)'}
1417
+ onChange={(e) => s({ color: e.target.value })}
1418
+ />
1419
+ <input
1420
+ className={styles.colorHex}
1421
+ value={field.style.color ?? 'var(--nice-text, #000000)'}
1422
+ onChange={(e) => s({ color: e.target.value })}
1423
+ maxLength={7}
1424
+ />
1425
+ </div>
1426
+ <div className={styles.styleRow}>
1427
+ <label className={styles.styleLabel}>Styl</label>
1428
+ <div className={styles.toggleGroup}>
1429
+ <button
1430
+ className={`${styles.toggleBtn} ${field.style.fontWeight === 'bold' ? styles.toggleBtnActive : ''}`}
1431
+ onClick={() =>
1432
+ s({ fontWeight: field.style.fontWeight === 'bold' ? 'normal' : 'bold' })
1433
+ }
1434
+ >
1435
+ <b>B</b>
1436
+ </button>
1437
+ <button
1438
+ className={`${styles.toggleBtn} ${field.style.fontStyle === 'italic' ? styles.toggleBtnActive : ''}`}
1439
+ onClick={() =>
1440
+ s({ fontStyle: field.style.fontStyle === 'italic' ? 'normal' : 'italic' })
1441
+ }
1442
+ >
1443
+ <i>I</i>
1444
+ </button>
1445
+ </div>
1446
+ </div>
1447
+ <div className={styles.styleRow}>
1448
+ <label className={styles.styleLabel}>Wyrówn.</label>
1449
+ <div className={styles.toggleGroup}>
1450
+ {(['left', 'center', 'right', 'justify'] as const).map((a) => (
1451
+ <button
1452
+ key={a}
1453
+ title={a}
1454
+ className={`${styles.toggleBtn} ${(field.style.textAlign ?? 'left') === a ? styles.toggleBtnActive : ''}`}
1455
+ onClick={() => s({ textAlign: a })}
1456
+ >
1457
+ {a === 'left' ? '≡L' : a === 'center' ? '≡C' : a === 'right' ? '≡R' : '≡J'}
1458
+ </button>
1459
+ ))}
1460
+ </div>
1461
+ </div>
1462
+ <div className={styles.styleRow}>
1463
+ <label className={styles.styleLabel}>W. linii</label>
1464
+ <input
1465
+ type="number"
1466
+ step={0.1}
1467
+ min={1}
1468
+ max={4}
1469
+ className={styles.styleInput}
1470
+ value={field.style.lineHeight ?? 1.4}
1471
+ onChange={(e) => s({ lineHeight: +e.target.value })}
1472
+ />
1473
+ </div>
1474
+ <div className={styles.styleRow}>
1475
+ <label className={styles.styleLabel}>Odstępy</label>
1476
+ <input
1477
+ type="number"
1478
+ step={0.01}
1479
+ min={-0.1}
1480
+ max={0.5}
1481
+ className={styles.styleInput}
1482
+ value={field.style.letterSpacing ?? 0}
1483
+ onChange={(e) => s({ letterSpacing: +e.target.value })}
1484
+ />
1485
+ <span className={styles.styleUnit}>em</span>
1486
+ </div>
1487
+ </div>
1488
+ )}
1489
+
1490
+ <div className={styles.styleGroup}>
1491
+ <div className={styles.styleGroupTitle}>Tło i ramka</div>
1492
+ <div className={styles.styleRow}>
1493
+ <label className={styles.styleLabel}>Tło</label>
1494
+ <input
1495
+ type="color"
1496
+ className={styles.colorPicker}
1497
+ value={field.style.backgroundColor ?? 'var(--nice-bg, #fff)'}
1498
+ onChange={(e) => s({ backgroundColor: e.target.value })}
1499
+ />
1500
+ <button
1501
+ className={styles.clearBtn}
1502
+ onClick={() => s({ backgroundColor: undefined })}
1503
+ title="Wyczyść tło"
1504
+ >
1505
+
1506
+ </button>
1507
+ </div>
1508
+ <div className={styles.styleRow}>
1509
+ <label className={styles.styleLabel}>Ramka</label>
1510
+ <input
1511
+ type="number"
1512
+ step={0.5}
1513
+ min={0}
1514
+ max={10}
1515
+ className={styles.styleInput}
1516
+ value={field.style.borderWidth ?? 0}
1517
+ onChange={(e) => s({ borderWidth: +e.target.value })}
1518
+ />
1519
+ <input
1520
+ type="color"
1521
+ className={styles.colorPicker}
1522
+ value={field.style.borderColor ?? 'var(--nice-text, #000000)'}
1523
+ onChange={(e) => s({ borderColor: e.target.value })}
1524
+ />
1525
+ <select
1526
+ className={styles.styleSelectXs}
1527
+ value={field.style.borderStyle ?? 'solid'}
1528
+ onChange={(e) => s({ borderStyle: e.target.value as FieldStyle['borderStyle'] })}
1529
+ >
1530
+ <option value="solid">─</option>
1531
+ <option value="dashed">- -</option>
1532
+ <option value="dotted">···</option>
1533
+ </select>
1534
+ </div>
1535
+ <div className={styles.styleRow}>
1536
+ <label className={styles.styleLabel}>Zaokr.</label>
1537
+ <input
1538
+ type="number"
1539
+ step={1}
1540
+ min={0}
1541
+ max={50}
1542
+ className={styles.styleInput}
1543
+ value={field.style.borderRadius ?? 0}
1544
+ onChange={(e) => s({ borderRadius: +e.target.value })}
1545
+ />
1546
+ <span className={styles.styleUnit}>px</span>
1547
+ </div>
1548
+ <div className={styles.styleRow}>
1549
+ <label className={styles.styleLabel}>Padding</label>
1550
+ <input
1551
+ type="number"
1552
+ step={0.5}
1553
+ min={0}
1554
+ max={20}
1555
+ className={styles.styleInput}
1556
+ value={field.style.padding ?? 0}
1557
+ onChange={(e) => s({ padding: +e.target.value })}
1558
+ />
1559
+ <span className={styles.styleUnit}>mm</span>
1560
+ </div>
1561
+ <div className={styles.styleRow}>
1562
+ <label className={styles.styleLabel}>Krycie</label>
1563
+ <input
1564
+ type="range"
1565
+ min={0.1}
1566
+ max={1}
1567
+ step={0.05}
1568
+ className={styles.sliderInput}
1569
+ value={field.style.opacity ?? 1}
1570
+ onChange={(e) => s({ opacity: +e.target.value })}
1571
+ />
1572
+ <span className={styles.styleValue}>
1573
+ {Math.round((field.style.opacity ?? 1) * 100)}%
1574
+ </span>
1575
+ </div>
1576
+ </div>
1577
+ </>
1578
+ )}
1579
+
1580
+ {/* ── Data ── */}
1581
+ {activeTab === 'data' && hasData && (
1582
+ <div className={styles.styleGroup}>
1583
+ <div className={styles.styleGroupTitle}>Źródło danych</div>
1584
+ <div className={styles.styleRow}>
1585
+ <label className={styles.styleLabel}>Ścieżka</label>
1586
+ <input
1587
+ className={styles.styleInputFull}
1588
+ value={field.dataPath}
1589
+ onChange={(e) => onChange({ dataPath: e.target.value })}
1590
+ placeholder="np. invoice.number"
1591
+ />
1592
+ </div>
1593
+ <div className={styles.styleRow}>
1594
+ <label className={styles.styleLabel}>Format</label>
1595
+ <input
1596
+ className={styles.styleInputFull}
1597
+ value={field.format ?? ''}
1598
+ onChange={(e) => onChange({ format: e.target.value })}
1599
+ placeholder="DD.MM.YYYY / #,##0.00"
1600
+ />
1601
+ </div>
1602
+ <div className={styles.styleRow}>
1603
+ <label className={styles.styleLabel}>Domyślny</label>
1604
+ <input
1605
+ className={styles.styleInputFull}
1606
+ value={field.fallback ?? ''}
1607
+ onChange={(e) => onChange({ fallback: e.target.value })}
1608
+ placeholder="Gdy brak wartości"
1609
+ />
1610
+ </div>
1611
+ <div className={styles.styleRow}>
1612
+ <label className={styles.styleLabel}>Typ</label>
1613
+ <select
1614
+ className={styles.styleSelectFull}
1615
+ value={field.type}
1616
+ onChange={(e) => onChange({ type: e.target.value as PrintDataField['type'] })}
1617
+ >
1618
+ {Object.entries(FIELD_TYPE_LABELS).map(([k, v]) => (
1619
+ <option key={k} value={k}>
1620
+ {v}
1621
+ </option>
1622
+ ))}
1623
+ </select>
1624
+ </div>
1625
+ {(field.type === 'barcode' || field.type === 'qr') && (
1626
+ <div className={styles.styleRow}>
1627
+ <label className={styles.styleLabel}>Format kodu</label>
1628
+ <select
1629
+ className={styles.styleSelectFull}
1630
+ value={field.barcodeType ?? 'code128'}
1631
+ onChange={(e) => onChange({ barcodeType: e.target.value as BarcodeType })}
1632
+ >
1633
+ {['ean13', 'ean8', 'code128', 'code39', 'upc', 'itf14', 'datamatrix'].map((b) => (
1634
+ <option key={b} value={b}>
1635
+ {b.toUpperCase()}
1636
+ </option>
1637
+ ))}
1638
+ </select>
1639
+ </div>
1640
+ )}
1641
+ </div>
1642
+ )}
1643
+
1644
+ {/* ── Conditional ── */}
1645
+ {activeTab === 'conditional' && (
1646
+ <div className={styles.styleGroup}>
1647
+ <div className={styles.styleGroupTitle}>Widoczność warunkowa</div>
1648
+ <div className={styles.conditionalHint}>
1649
+ Element zostanie ukryty gdy warunek nie jest spełniony.
1650
+ </div>
1651
+ <div className={styles.styleRow}>
1652
+ <label className={styles.styleLabel}>Pole</label>
1653
+ <input
1654
+ className={styles.styleInputFull}
1655
+ value={field.conditional?.field ?? ''}
1656
+ onChange={(e) =>
1657
+ onChange({
1658
+ conditional: {
1659
+ field: e.target.value,
1660
+ operator: field.conditional?.operator ?? 'eq',
1661
+ value: field.conditional?.value,
1662
+ },
1663
+ })
1664
+ }
1665
+ placeholder="np. invoice.isPaid"
1666
+ />
1667
+ </div>
1668
+ <div className={styles.styleRow}>
1669
+ <label className={styles.styleLabel}>Operator</label>
1670
+ <select
1671
+ className={styles.styleSelectFull}
1672
+ value={field.conditional?.operator ?? 'eq'}
1673
+ onChange={(e) =>
1674
+ onChange({
1675
+ conditional: {
1676
+ field: field.conditional?.field ?? '',
1677
+ operator: e.target.value as NonNullable<
1678
+ PrintDataField['conditional']
1679
+ >['operator'],
1680
+ value: field.conditional?.value,
1681
+ },
1682
+ })
1683
+ }
1684
+ >
1685
+ <option value="eq">= r�wne</option>
1686
+ <option value="neq">≠ różne</option>
1687
+ <option value="gt">&gt; wieksze</option>
1688
+ <option value="lt">&lt; mniejsze</option>
1689
+ <option value="contains">zawiera</option>
1690
+ </select>
1691
+ </div>
1692
+ <div className={styles.styleRow}>
1693
+ <label className={styles.styleLabel}>Wartosc</label>
1694
+ <input
1695
+ className={styles.styleInputFull}
1696
+ value={String(field.conditional?.value ?? '')}
1697
+ onChange={(e) =>
1698
+ onChange({
1699
+ conditional: {
1700
+ field: field.conditional?.field ?? '',
1701
+ operator: field.conditional?.operator ?? 'eq',
1702
+ value: e.target.value,
1703
+ },
1704
+ })
1705
+ }
1706
+ placeholder='np. true / "PAID"'
1707
+ />
1708
+ </div>
1709
+ {field.conditional?.field && (
1710
+ <button
1711
+ className={styles.clearConditionalBtn}
1712
+ onClick={() => onChange({ conditional: undefined })}
1713
+ >
1714
+ Wyczyść warunek
1715
+ </button>
1716
+ )}
1717
+ </div>
1718
+ )}
1719
+ </div>
1720
+ </div>
1721
+ );
1722
+ };
1723
+
1724
+ // ─── Default template factory ─────────────────────────────────────────────────
1725
+ function defaultTemplate(): PrintTemplate {
1726
+ return {
1727
+ id: `tpl-${Date.now()}`,
1728
+ name: 'Nowy szablon',
1729
+ description: '',
1730
+ paperSize: 'A4',
1731
+ orientation: 'portrait',
1732
+ margins: { top: 20, right: 15, bottom: 20, left: 15 },
1733
+ sections: [
1734
+ { id: 'sec-header', type: 'header', heightMm: 35, fields: [] },
1735
+ { id: 'sec-body', type: 'body', fields: [] },
1736
+ { id: 'sec-footer', type: 'footer', heightMm: 20, fields: [] },
1737
+ ],
1738
+ watermarks: [],
1739
+ signatures: [],
1740
+ };
1741
+ }
1742
+
1743
+ // --- Main component -----------------------------------------------------------
1744
+ export const NiceTemplateEditor: React.FC<NiceTemplateEditorProps> = ({
1745
+ template: initialTemplate,
1746
+ dataSchema,
1747
+ sampleData = [],
1748
+ onChange,
1749
+ onSave,
1750
+ readOnly = false,
1751
+ className,
1752
+ style,
1753
+ theme = 'light',
1754
+ }) => {
1755
+ const [{ past, present: tpl, future }, dispatch] = useReducer(historyReducer, {
1756
+ past: [],
1757
+ present: initialTemplate ?? defaultTemplate(),
1758
+ future: [],
1759
+ });
1760
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
1761
+ const [activeSection, _setActiveSection] = useState('sec-body');
1762
+ const [zoom, setZoom] = useState(1);
1763
+ const [testDataIdx, setTestDataIdx] = useState(0);
1764
+ const [leftTab, setLeftTab] = useState<LeftTab>('fields');
1765
+ const [rightTab, setRightTab] = useState<RightTab>('position');
1766
+ const [showGrid, setShowGrid] = useState(false);
1767
+ const [showRulers, setShowRulers] = useState(true);
1768
+
1769
+ const update = useCallback(
1770
+ (patch: Partial<PrintTemplate>) => {
1771
+ const next = { ...tpl, ...patch };
1772
+ dispatch({ type: 'UPDATE', payload: next });
1773
+ onChange?.(next);
1774
+ },
1775
+ [tpl, onChange],
1776
+ );
1777
+
1778
+ // Paper dimensions
1779
+ const [paperW, paperH] = useMemo((): [number, number] => {
1780
+ const [w, h] = PAPER_DIM[tpl.paperSize] ?? PAPER_DIM.A4;
1781
+ return tpl.orientation === 'landscape' ? [h, w] : [w, h];
1782
+ }, [tpl.paperSize, tpl.orientation]);
1783
+
1784
+ // All fields (for layers panel and keyboard ops)
1785
+ const allFields = useMemo(() => tpl.sections.flatMap((s) => s.fields), [tpl]);
1786
+ const firstSelected = useMemo(
1787
+ () => allFields.find((f) => selectedIds.has(f.id)) ?? null,
1788
+ [allFields, selectedIds],
1789
+ );
1790
+
1791
+ // -- Field mutation helpers --
1792
+ const updateFieldById = useCallback(
1793
+ (id: string, patch: Partial<PrintDataField>) => {
1794
+ update({
1795
+ sections: tpl.sections.map((s) => ({
1796
+ ...s,
1797
+ fields: s.fields.map((f) =>
1798
+ f.id === id ? { ...f, ...patch, style: { ...f.style, ...(patch.style ?? {}) } } : f,
1799
+ ),
1800
+ })),
1801
+ });
1802
+ },
1803
+ [tpl, update],
1804
+ );
1805
+
1806
+ const updateSelected = useCallback(
1807
+ (patch: Partial<PrintDataField>) => {
1808
+ update({
1809
+ sections: tpl.sections.map((s) => ({
1810
+ ...s,
1811
+ fields: s.fields.map((f) =>
1812
+ selectedIds.has(f.id)
1813
+ ? { ...f, ...patch, style: { ...f.style, ...(patch.style ?? {}) } }
1814
+ : f,
1815
+ ),
1816
+ })),
1817
+ });
1818
+ },
1819
+ [tpl, selectedIds, update],
1820
+ );
1821
+
1822
+ // -- Insert field --
1823
+ const insertField = useCallback(
1824
+ (type: string, schemaField?: DataSourceField) => {
1825
+ if (type === 'watermark') {
1826
+ update({
1827
+ watermarks: [
1828
+ ...(tpl.watermarks ?? []),
1829
+ {
1830
+ id: uid(),
1831
+ type: 'text',
1832
+ text: 'KOPIA',
1833
+ opacity: 0.12,
1834
+ angle: -45,
1835
+ fontSize: 60,
1836
+ color: 'var(--nice-text-muted, #94a3b8)',
1837
+ },
1838
+ ],
1839
+ });
1840
+ setLeftTab('watermarks');
1841
+ return;
1842
+ }
1843
+ if (type === 'signature') {
1844
+ update({
1845
+ signatures: [
1846
+ ...(tpl.signatures ?? []),
1847
+ {
1848
+ id: uid(),
1849
+ label: 'Podpis',
1850
+ position: { x: 10, y: 5, width: 70, height: 22 },
1851
+ showLine: true,
1852
+ showDate: false,
1853
+ },
1854
+ ],
1855
+ });
1856
+ setLeftTab('signatures');
1857
+ return;
1858
+ }
1859
+ const section =
1860
+ tpl.sections.find((s) => s.id === activeSection) ??
1861
+ tpl.sections.find((s) => s.type === 'body');
1862
+ if (!section) {
1863
+ return;
1864
+ }
1865
+ const field = makeField(type, schemaField);
1866
+ const updated = {
1867
+ sections: tpl.sections.map((s) =>
1868
+ s.id === section.id ? { ...s, fields: [...s.fields, field] } : s,
1869
+ ),
1870
+ };
1871
+ dispatch({ type: 'UPDATE', payload: { ...tpl, ...updated } });
1872
+ onChange?.({ ...tpl, ...updated });
1873
+ setSelectedIds(new Set([field.id]));
1874
+ },
1875
+ [tpl, activeSection, onChange],
1876
+ );
1877
+
1878
+ // -- Drop from left panel --
1879
+ const handleDropField = useCallback(
1880
+ (sectionId: string, schemaPath: string, x: number, y: number) => {
1881
+ const sf = dataSchema?.fields.find((f) => f.path === schemaPath);
1882
+ const field = makeField(sf?.type ?? 'text', sf);
1883
+ field.position.x = Math.round(x * 2) / 2;
1884
+ field.position.y = Math.round(y * 2) / 2;
1885
+ const updated = {
1886
+ sections: tpl.sections.map((s) =>
1887
+ s.id === sectionId ? { ...s, fields: [...s.fields, field] } : s,
1888
+ ),
1889
+ };
1890
+ dispatch({ type: 'UPDATE', payload: { ...tpl, ...updated } });
1891
+ onChange?.({ ...tpl, ...updated });
1892
+ setSelectedIds(new Set([field.id]));
1893
+ },
1894
+ [tpl, dataSchema, onChange],
1895
+ );
1896
+
1897
+ // -- Selection --
1898
+ const handleFieldClick = useCallback((id: string, e: React.MouseEvent) => {
1899
+ if (e.ctrlKey || e.metaKey) {
1900
+ setSelectedIds((prev) => {
1901
+ const n = new Set(prev);
1902
+ n.has(id) ? n.delete(id) : n.add(id);
1903
+ return n;
1904
+ });
1905
+ } else if (e.shiftKey) {
1906
+ setSelectedIds((prev) => new Set([...prev, id]));
1907
+ } else {
1908
+ setSelectedIds(new Set([id]));
1909
+ }
1910
+ }, []);
1911
+
1912
+ // -- Drag to move --
1913
+ const handleFieldDragStart = useCallback(
1914
+ (fieldId: string, e: React.MouseEvent) => {
1915
+ if (readOnly) {
1916
+ return;
1917
+ }
1918
+ e.preventDefault();
1919
+ // Ensure this field is selected
1920
+ if (!selectedIds.has(fieldId)) {
1921
+ setSelectedIds(new Set([fieldId]));
1922
+ }
1923
+
1924
+ const startX = e.clientX;
1925
+ const startY = e.clientY;
1926
+ const snapshot = tpl.sections.map((s) => ({
1927
+ id: s.id,
1928
+ fields: s.fields.map((f) => ({ id: f.id, pos: { ...f.position } })),
1929
+ }));
1930
+
1931
+ const move = (me: MouseEvent) => {
1932
+ const dx = (me.clientX - startX) / (zoom * MM_TO_PX);
1933
+ const dy = (me.clientY - startY) / (zoom * MM_TO_PX);
1934
+ const ids = selectedIds.has(fieldId) ? selectedIds : new Set([fieldId]);
1935
+ const sections = tpl.sections.map((sec) => {
1936
+ const snap = snapshot.find((s) => s.id === sec.id);
1937
+ return {
1938
+ ...sec,
1939
+ fields: sec.fields.map((f) => {
1940
+ if (!ids.has(f.id)) {
1941
+ return f;
1942
+ }
1943
+ const origPos = snap?.fields.find((sf) => sf.id === f.id)?.pos ?? f.position;
1944
+ return {
1945
+ ...f,
1946
+ position: {
1947
+ ...f.position,
1948
+ x: Math.max(0, Math.round((origPos.x + dx) * 2) / 2),
1949
+ y: Math.max(0, Math.round((origPos.y + dy) * 2) / 2),
1950
+ },
1951
+ };
1952
+ }),
1953
+ };
1954
+ });
1955
+ // Live update without history push
1956
+ const next = { ...tpl, sections };
1957
+ onChange?.(next);
1958
+ // Update reducer state directly (without undo step until mouseup)
1959
+ dispatch({ type: 'UPDATE', payload: next });
1960
+ };
1961
+
1962
+ const up = () => {
1963
+ document.removeEventListener('mousemove', move);
1964
+ document.removeEventListener('mouseup', up);
1965
+ };
1966
+ document.addEventListener('mousemove', move);
1967
+ document.addEventListener('mouseup', up);
1968
+ },
1969
+ [tpl, zoom, selectedIds, readOnly, onChange],
1970
+ );
1971
+
1972
+ // -- Resize --
1973
+ const handleResizeStart = useCallback(
1974
+ (fieldId: string, dir: ResizeDir, e: React.MouseEvent) => {
1975
+ if (readOnly) {
1976
+ return;
1977
+ }
1978
+ e.preventDefault();
1979
+ const field = allFields.find((f) => f.id === fieldId);
1980
+ if (!field) {
1981
+ return;
1982
+ }
1983
+ const origPos = { ...field.position };
1984
+ const startX = e.clientX;
1985
+ const startY = e.clientY;
1986
+
1987
+ const move = (me: MouseEvent) => {
1988
+ const dx = (me.clientX - startX) / (zoom * MM_TO_PX);
1989
+ const dy = (me.clientY - startY) / (zoom * MM_TO_PX);
1990
+ let { x, y, width, height } = origPos;
1991
+ if (dir.includes('e')) {
1992
+ width = Math.max(5, width + dx);
1993
+ }
1994
+ if (dir.includes('s')) {
1995
+ height = Math.max(3, height + dy);
1996
+ }
1997
+ if (dir.includes('w')) {
1998
+ x = x + dx;
1999
+ width = Math.max(5, width - dx);
2000
+ }
2001
+ if (dir.includes('n')) {
2002
+ y = y + dy;
2003
+ height = Math.max(3, height - dy);
2004
+ }
2005
+ updateFieldById(fieldId, {
2006
+ position: {
2007
+ ...origPos,
2008
+ x: Math.round(x * 2) / 2,
2009
+ y: Math.round(y * 2) / 2,
2010
+ width: Math.round(width * 2) / 2,
2011
+ height: Math.round(height * 2) / 2,
2012
+ },
2013
+ });
2014
+ };
2015
+ const up = () => {
2016
+ document.removeEventListener('mousemove', move);
2017
+ document.removeEventListener('mouseup', up);
2018
+ };
2019
+ document.addEventListener('mousemove', move);
2020
+ document.addEventListener('mouseup', up);
2021
+ },
2022
+ [allFields, zoom, readOnly, updateFieldById],
2023
+ );
2024
+
2025
+ // -- Selection operations --
2026
+ const deleteSelected = useCallback(() => {
2027
+ update({
2028
+ sections: tpl.sections.map((s) => ({
2029
+ ...s,
2030
+ fields: s.fields.filter((f) => !selectedIds.has(f.id)),
2031
+ })),
2032
+ });
2033
+ setSelectedIds(new Set());
2034
+ }, [tpl, selectedIds, update]);
2035
+
2036
+ const duplicateSelected = useCallback(() => {
2037
+ const newIds = new Set<string>();
2038
+ update({
2039
+ sections: tpl.sections.map((s) => {
2040
+ const dupes = s.fields
2041
+ .filter((f) => selectedIds.has(f.id))
2042
+ .map((f) => {
2043
+ const nf = {
2044
+ ...f,
2045
+ id: uid(),
2046
+ position: { ...f.position, x: f.position.x + 5, y: f.position.y + 5 },
2047
+ };
2048
+ newIds.add(nf.id);
2049
+ return nf;
2050
+ });
2051
+ return { ...s, fields: [...s.fields, ...dupes] };
2052
+ }),
2053
+ });
2054
+ setSelectedIds(newIds);
2055
+ }, [tpl, selectedIds, update]);
2056
+
2057
+ const alignH = useCallback(
2058
+ (align: 'left' | 'center' | 'right') => {
2059
+ update({
2060
+ sections: tpl.sections.map((s) => ({
2061
+ ...s,
2062
+ fields: s.fields.map((f) => {
2063
+ if (!selectedIds.has(f.id)) {
2064
+ return f;
2065
+ }
2066
+ const x =
2067
+ align === 'left'
2068
+ ? tpl.margins.left
2069
+ : align === 'right'
2070
+ ? paperW - tpl.margins.right - f.position.width
2071
+ : (paperW - f.position.width) / 2;
2072
+ return { ...f, position: { ...f.position, x } };
2073
+ }),
2074
+ })),
2075
+ });
2076
+ },
2077
+ [tpl, selectedIds, paperW, update],
2078
+ );
2079
+
2080
+ const alignV = useCallback(
2081
+ (align: 'top' | 'center' | 'bottom') => {
2082
+ const section = tpl.sections.find((s) => s.id === activeSection);
2083
+ if (!section) {
2084
+ return;
2085
+ }
2086
+ const sectionH = section.heightMm ?? 200;
2087
+ update({
2088
+ sections: tpl.sections.map((s) => {
2089
+ if (s.id !== activeSection) {
2090
+ return s;
2091
+ }
2092
+ return {
2093
+ ...s,
2094
+ fields: s.fields.map((f) => {
2095
+ if (!selectedIds.has(f.id)) {
2096
+ return f;
2097
+ }
2098
+ const y =
2099
+ align === 'top'
2100
+ ? 0
2101
+ : align === 'bottom'
2102
+ ? sectionH - f.position.height
2103
+ : (sectionH - f.position.height) / 2;
2104
+ return { ...f, position: { ...f.position, y } };
2105
+ }),
2106
+ };
2107
+ }),
2108
+ });
2109
+ },
2110
+ [tpl, selectedIds, activeSection, update],
2111
+ );
2112
+
2113
+ const bringForward = useCallback(() => {
2114
+ update({
2115
+ sections: tpl.sections.map((s) => ({
2116
+ ...s,
2117
+ fields: s.fields.map((f) =>
2118
+ selectedIds.has(f.id)
2119
+ ? { ...f, position: { ...f.position, zIndex: (f.position.zIndex ?? 1) + 1 } }
2120
+ : f,
2121
+ ),
2122
+ })),
2123
+ });
2124
+ }, [tpl, selectedIds, update]);
2125
+
2126
+ const sendBackward = useCallback(() => {
2127
+ update({
2128
+ sections: tpl.sections.map((s) => ({
2129
+ ...s,
2130
+ fields: s.fields.map((f) =>
2131
+ selectedIds.has(f.id)
2132
+ ? {
2133
+ ...f,
2134
+ position: { ...f.position, zIndex: Math.max(1, (f.position.zIndex ?? 1) - 1) },
2135
+ }
2136
+ : f,
2137
+ ),
2138
+ })),
2139
+ });
2140
+ }, [tpl, selectedIds, update]);
2141
+
2142
+ const reorderFieldInLayers = useCallback(
2143
+ (fieldId: string, delta: -1 | 1) => {
2144
+ update({
2145
+ sections: tpl.sections.map((s) => ({
2146
+ ...s,
2147
+ fields: s.fields.map((f) =>
2148
+ f.id === fieldId
2149
+ ? {
2150
+ ...f,
2151
+ position: {
2152
+ ...f.position,
2153
+ zIndex: Math.max(1, (f.position.zIndex ?? 1) - delta),
2154
+ },
2155
+ }
2156
+ : f,
2157
+ ),
2158
+ })),
2159
+ });
2160
+ },
2161
+ [tpl, update],
2162
+ );
2163
+
2164
+ // -- Keyboard shortcuts --
2165
+ useEffect(() => {
2166
+ const handler = (e: KeyboardEvent) => {
2167
+ const tag = (e.target as HTMLElement).tagName;
2168
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
2169
+ return;
2170
+ }
2171
+ const NUDGE = e.shiftKey ? 5 : 0.5;
2172
+ if (e.key === 'Delete' || e.key === 'Backspace') {
2173
+ if (selectedIds.size) {
2174
+ e.preventDefault();
2175
+ deleteSelected();
2176
+ }
2177
+ } else if (e.ctrlKey && e.key === 'z') {
2178
+ e.preventDefault();
2179
+ dispatch({ type: 'UNDO' });
2180
+ } else if (e.ctrlKey && (e.key === 'y' || e.key === 'Y')) {
2181
+ e.preventDefault();
2182
+ dispatch({ type: 'REDO' });
2183
+ } else if (e.ctrlKey && (e.key === 'd' || e.key === 'D')) {
2184
+ e.preventDefault();
2185
+ if (selectedIds.size) {
2186
+ duplicateSelected();
2187
+ }
2188
+ } else if (e.ctrlKey && e.key === 'a') {
2189
+ e.preventDefault();
2190
+ setSelectedIds(new Set(allFields.map((f) => f.id)));
2191
+ } else if (e.key === 'ArrowLeft') {
2192
+ e.preventDefault();
2193
+ updateSelected({
2194
+ position: { ...firstSelected!.position, x: (firstSelected?.position.x ?? 0) - NUDGE },
2195
+ });
2196
+ } else if (e.key === 'ArrowRight') {
2197
+ e.preventDefault();
2198
+ updateSelected({
2199
+ position: { ...firstSelected!.position, x: (firstSelected?.position.x ?? 0) + NUDGE },
2200
+ });
2201
+ } else if (e.key === 'ArrowUp') {
2202
+ e.preventDefault();
2203
+ updateSelected({
2204
+ position: { ...firstSelected!.position, y: (firstSelected?.position.y ?? 0) - NUDGE },
2205
+ });
2206
+ } else if (e.key === 'ArrowDown') {
2207
+ e.preventDefault();
2208
+ updateSelected({
2209
+ position: { ...firstSelected!.position, y: (firstSelected?.position.y ?? 0) + NUDGE },
2210
+ });
2211
+ }
2212
+ };
2213
+ window.addEventListener('keydown', handler);
2214
+ return () => window.removeEventListener('keydown', handler);
2215
+ }, [selectedIds, allFields, firstSelected, deleteSelected, duplicateSelected, updateSelected]);
2216
+
2217
+ // -- Section height change --
2218
+ const handleSectionHeightChange = useCallback(
2219
+ (sectionId: string, h: number) => {
2220
+ update({
2221
+ sections: tpl.sections.map((s) =>
2222
+ s.id === sectionId ? { ...s, heightMm: Math.round(h * 2) / 2 } : s,
2223
+ ),
2224
+ });
2225
+ },
2226
+ [tpl, update],
2227
+ );
2228
+
2229
+ // -- Test/sample data --
2230
+ const sampleDataRecord = useMemo((): Record<string, unknown> => {
2231
+ const entry = Array.isArray(sampleData) ? sampleData[testDataIdx] : sampleData;
2232
+ if (!entry) {
2233
+ return {};
2234
+ }
2235
+ if ('data' in entry) {
2236
+ return entry.data as Record<string, unknown>;
2237
+ }
2238
+ return entry as Record<string, unknown>;
2239
+ }, [sampleData, testDataIdx]);
2240
+
2241
+ const paperWPx = paperW * zoom * MM_TO_PX;
2242
+ const paperHPx = paperH * zoom * MM_TO_PX;
2243
+
2244
+ return (
2245
+ <div
2246
+ className={`${styles.root} ${theme === 'dark' ? styles.dark : ''} ${className ?? ''}`}
2247
+ style={style}
2248
+ onClick={() => setSelectedIds(new Set())}
2249
+ >
2250
+ <Toolbar
2251
+ tpl={tpl}
2252
+ zoom={zoom}
2253
+ canUndo={past.length > 0}
2254
+ canRedo={future.length > 0}
2255
+ showGrid={showGrid}
2256
+ showRulers={showRulers}
2257
+ readOnly={readOnly}
2258
+ selectedCount={selectedIds.size}
2259
+ onPaperChange={(size, orient) => update({ paperSize: size, orientation: orient })}
2260
+ onZoom={setZoom}
2261
+ onUndo={() => dispatch({ type: 'UNDO' })}
2262
+ onRedo={() => dispatch({ type: 'REDO' })}
2263
+ onToggleGrid={() => setShowGrid((g) => !g)}
2264
+ onToggleRulers={() => setShowRulers((r) => !r)}
2265
+ onInsert={insertField}
2266
+ onDelete={deleteSelected}
2267
+ onDuplicate={duplicateSelected}
2268
+ onAlignH={alignH}
2269
+ onAlignV={alignV}
2270
+ onBringForward={bringForward}
2271
+ onSendBackward={sendBackward}
2272
+ onSave={onSave ? () => onSave(tpl) : undefined}
2273
+ />
2274
+
2275
+ <div className={styles.editorBody}>
2276
+ {/* Left panel */}
2277
+ <LeftPanel
2278
+ activeTab={leftTab}
2279
+ onTabChange={setLeftTab}
2280
+ schema={dataSchema}
2281
+ tpl={tpl}
2282
+ allFields={allFields}
2283
+ selectedIds={selectedIds}
2284
+ onInsertSchemaField={(f) => insertField(f.type, f)}
2285
+ onSelectField={(id) => setSelectedIds(new Set([id]))}
2286
+ onUpdateTpl={(patch) => update(patch)}
2287
+ onReorderField={reorderFieldInLayers}
2288
+ />
2289
+
2290
+ {/* Canvas area */}
2291
+ <div className={styles.canvasArea}>
2292
+ {/* Test data bar */}
2293
+ {Array.isArray(sampleData) && sampleData.length > 0 && (
2294
+ <div className={styles.testDataBar}>
2295
+ <span className={styles.testDataLabel}>Dane testowe:</span>
2296
+ <select
2297
+ className={styles.testDataSelect}
2298
+ value={testDataIdx}
2299
+ onChange={(e) => setTestDataIdx(+e.target.value)}
2300
+ >
2301
+ {sampleData.map((_, i) => (
2302
+ <option key={i} value={i}>
2303
+ Rekord {i + 1}
2304
+ </option>
2305
+ ))}
2306
+ </select>
2307
+ </div>
2308
+ )}
2309
+
2310
+ {/* Ruler corner + rulers */}
2311
+ {showRulers && (
2312
+ <div className={styles.rulerWrapper}>
2313
+ <div className={styles.rulerCorner} />
2314
+ <Ruler lengthMm={paperW} zoom={zoom} orientation="h" />
2315
+ <Ruler lengthMm={paperH} zoom={zoom} orientation="v" />
2316
+ </div>
2317
+ )}
2318
+
2319
+ {/* Scrollable paper area */}
2320
+ <div className={styles.paperScroll} onClick={(e) => e.stopPropagation()}>
2321
+ <div className={styles.paperShadow} style={{ width: paperWPx, minHeight: paperHPx }}>
2322
+ {/* Margin guides */}
2323
+ <div
2324
+ className={styles.marginGuide}
2325
+ style={{
2326
+ top: tpl.margins.top * zoom * MM_TO_PX,
2327
+ right: tpl.margins.right * zoom * MM_TO_PX,
2328
+ bottom: tpl.margins.bottom * zoom * MM_TO_PX,
2329
+ left: tpl.margins.left * zoom * MM_TO_PX,
2330
+ }}
2331
+ />
2332
+
2333
+ {/* Watermarks */}
2334
+ {(tpl.watermarks ?? []).map((wm) => (
2335
+ <div
2336
+ key={wm.id}
2337
+ className={styles.watermark}
2338
+ style={{
2339
+ fontSize: (wm.fontSize ?? 60) * zoom,
2340
+ color: wm.color ?? 'var(--nice-text-muted, #94a3b8)',
2341
+ opacity: wm.opacity,
2342
+ transform: `translate(-50%, -50%) rotate(${wm.angle}deg)`,
2343
+ }}
2344
+ >
2345
+ {wm.type === 'text' ? wm.text : '??'}
2346
+ </div>
2347
+ ))}
2348
+
2349
+ {/* Sections */}
2350
+ {tpl.sections.map((section) => (
2351
+ <div key={section.id}>
2352
+ <SectionCanvas
2353
+ section={section}
2354
+ zoom={zoom}
2355
+ paperWidthMm={paperW}
2356
+ selectedIds={selectedIds}
2357
+ sampleData={sampleDataRecord}
2358
+ readOnly={readOnly}
2359
+ showGrid={showGrid}
2360
+ onFieldClick={handleFieldClick}
2361
+ onFieldDragStart={handleFieldDragStart}
2362
+ onResizeStart={handleResizeStart}
2363
+ onSectionHeightChange={handleSectionHeightChange}
2364
+ onDropField={handleDropField}
2365
+ />
2366
+ </div>
2367
+ ))}
2368
+ </div>
2369
+ </div>
2370
+ </div>
2371
+
2372
+ {/* Right properties panel */}
2373
+ <PropertiesPanel
2374
+ field={firstSelected}
2375
+ activeTab={rightTab}
2376
+ onTabChange={setRightTab}
2377
+ onChange={(patch) => firstSelected && updateFieldById(firstSelected.id, patch)}
2378
+ paperWidthMm={paperW}
2379
+ paperHeightMm={paperH}
2380
+ />
2381
+ </div>
2382
+ </div>
2383
+ );
2384
+ };
2385
+
2386
+ export default NiceTemplateEditor;