@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.
- package/README.md +56 -0
- package/dist/index.cjs +2 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +3085 -0
- package/dist/print-button/NicePrintButton.d.ts +5 -0
- package/dist/print-button/NicePrintButton.d.ts.map +1 -0
- package/dist/print-button/index.d.ts +2 -0
- package/dist/print-button/index.d.ts.map +1 -0
- package/dist/print-preview/NicePrintPreview.d.ts +5 -0
- package/dist/print-preview/NicePrintPreview.d.ts.map +1 -0
- package/dist/print-preview/index.d.ts +2 -0
- package/dist/print-preview/index.d.ts.map +1 -0
- package/dist/print-queue/NicePrintQueue.d.ts +5 -0
- package/dist/print-queue/NicePrintQueue.d.ts.map +1 -0
- package/dist/print-queue/index.d.ts +2 -0
- package/dist/print-queue/index.d.ts.map +1 -0
- package/dist/style.css +1 -0
- package/dist/template-browser/NiceTemplateBrowser.d.ts +5 -0
- package/dist/template-browser/NiceTemplateBrowser.d.ts.map +1 -0
- package/dist/template-browser/index.d.ts +2 -0
- package/dist/template-browser/index.d.ts.map +1 -0
- package/dist/template-editor/NiceTemplateEditor.d.ts +6 -0
- package/dist/template-editor/NiceTemplateEditor.d.ts.map +1 -0
- package/dist/template-editor/index.d.ts +2 -0
- package/dist/template-editor/index.d.ts.map +1 -0
- package/dist/types/printingTypes.d.ts +249 -0
- package/dist/types/printingTypes.d.ts.map +1 -0
- package/package.json +66 -0
- package/src/globals.d.ts +9 -0
- package/src/index.ts +69 -0
- package/src/print-button/NicePrintButton.tsx +182 -0
- package/src/print-button/PrintButton.module.css +167 -0
- package/src/print-button/index.ts +1 -0
- package/src/print-preview/NicePrintPreview.tsx +417 -0
- package/src/print-preview/PrintPreview.module.css +122 -0
- package/src/print-preview/index.ts +1 -0
- package/src/print-queue/NicePrintQueue.tsx +350 -0
- package/src/print-queue/PrintQueue.module.css +203 -0
- package/src/print-queue/index.ts +1 -0
- package/src/template-browser/NiceTemplateBrowser.tsx +221 -0
- package/src/template-browser/TemplateBrowser.module.css +277 -0
- package/src/template-browser/index.ts +1 -0
- package/src/template-editor/NiceTemplateEditor.tsx +2386 -0
- package/src/template-editor/NiceTemplateEditor.tsx.first +1011 -0
- package/src/template-editor/NiceTemplateEditor.tsx.tmp +392 -0
- package/src/template-editor/TemplateEditor.module.css +1462 -0
- package/src/template-editor/index.ts +1 -0
- package/src/types/printingTypes.ts +301 -0
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useState, useCallback, useRef, useReducer, useEffect, useMemo,
|
|
3
|
+
} from 'react';
|
|
4
|
+
import type {
|
|
5
|
+
PrintTemplate,
|
|
6
|
+
PrintSection,
|
|
7
|
+
PrintDataField,
|
|
8
|
+
PrintWatermark,
|
|
9
|
+
PrintSignature,
|
|
10
|
+
DataSourceField,
|
|
11
|
+
TemplateSampleData,
|
|
12
|
+
PaperSize,
|
|
13
|
+
PaperOrientation,
|
|
14
|
+
NiceTemplateEditorProps,
|
|
15
|
+
SectionType,
|
|
16
|
+
FieldStyle,
|
|
17
|
+
FieldPosition,
|
|
18
|
+
} from '../types/printingTypes';
|
|
19
|
+
import styles from './TemplateEditor.module.css';
|
|
20
|
+
|
|
21
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
22
|
+
const MM_TO_PX = 3.78;
|
|
23
|
+
|
|
24
|
+
const PAPER_DIM: Record<PaperSize, [number, number]> = {
|
|
25
|
+
A3: [297, 420], A4: [210, 297], A5: [148, 210], A6: [105, 148],
|
|
26
|
+
Letter: [216, 279], Legal: [216, 356], Tabloid: [279, 432], custom: [210, 297],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const FONT_FAMILIES = ['sans-serif', 'serif', 'monospace', 'Arial', 'Times New Roman', 'Courier New', 'Georgia', 'Verdana', 'Tahoma'];
|
|
30
|
+
const FONT_SIZES = [6, 7, 8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 60, 72];
|
|
31
|
+
|
|
32
|
+
const FIELD_TYPE_ICONS: Record<string, string> = {
|
|
33
|
+
text: '📝', number: '#', currency: '€', date: '📅', datetime: '🕐',
|
|
34
|
+
boolean: '✓', image: '🖼', barcode: '▮▌▮', qr: '⬛', signature: '✍',
|
|
35
|
+
static_text: 'T', static_image: '🖼', line: '─', rect: '▭',
|
|
36
|
+
page_number: '#p', total_pages: '#pp', print_date: '📅',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const FIELD_TYPE_LABELS: Record<string, string> = {
|
|
40
|
+
text: 'Tekst', number: 'Liczba', currency: 'Waluta', date: 'Data', datetime: 'Data i czas',
|
|
41
|
+
boolean: 'Tak/Nie', image: 'Obraz', barcode: 'Kod kreskowy', qr: 'QR', signature: 'Podpis',
|
|
42
|
+
static_text: 'Tekst stały', static_image: 'Obraz stały', line: 'Linia', rect: 'Prostokąt',
|
|
43
|
+
page_number: 'Numer strony', total_pages: 'Liczba stron', print_date: 'Data wydruku',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ─── Undo/Redo history ───────────────────────────────────────────────────────
|
|
47
|
+
type HistoryState = { past: PrintTemplate[]; present: PrintTemplate; future: PrintTemplate[] };
|
|
48
|
+
type HistoryAction =
|
|
49
|
+
| { type: 'UPDATE'; payload: PrintTemplate }
|
|
50
|
+
| { type: 'UNDO' }
|
|
51
|
+
| { type: 'REDO' };
|
|
52
|
+
|
|
53
|
+
const MAX_HISTORY = 50;
|
|
54
|
+
|
|
55
|
+
function historyReducer(state: HistoryState, action: HistoryAction): HistoryState {
|
|
56
|
+
switch (action.type) {
|
|
57
|
+
case 'UPDATE':
|
|
58
|
+
return {
|
|
59
|
+
past: [...state.past.slice(-MAX_HISTORY), state.present],
|
|
60
|
+
present: action.payload,
|
|
61
|
+
future: [],
|
|
62
|
+
};
|
|
63
|
+
case 'UNDO':
|
|
64
|
+
if (!state.past.length) return state;
|
|
65
|
+
return {
|
|
66
|
+
past: state.past.slice(0, -1),
|
|
67
|
+
present: state.past[state.past.length - 1],
|
|
68
|
+
future: [state.present, ...state.future],
|
|
69
|
+
};
|
|
70
|
+
case 'REDO':
|
|
71
|
+
if (!state.future.length) return state;
|
|
72
|
+
return {
|
|
73
|
+
past: [...state.past, state.present],
|
|
74
|
+
present: state.future[0],
|
|
75
|
+
future: state.future.slice(1),
|
|
76
|
+
};
|
|
77
|
+
default: return state;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
82
|
+
function uid() { return `f-${Date.now()}-${Math.floor(Math.random() * 1000)}`; }
|
|
83
|
+
|
|
84
|
+
function makeField(type: string, schemaField?: DataSourceField): PrintDataField {
|
|
85
|
+
const base: PrintDataField = {
|
|
86
|
+
id: uid(), name: schemaField?.label ?? FIELD_TYPE_LABELS[type] ?? type,
|
|
87
|
+
label: schemaField?.label ?? FIELD_TYPE_LABELS[type] ?? type,
|
|
88
|
+
type: type as any,
|
|
89
|
+
dataPath: schemaField?.path ?? '',
|
|
90
|
+
fallback: type === 'static_text' ? 'Tekst statyczny' : undefined,
|
|
91
|
+
format: type === 'date' || type === 'datetime' ? 'DD.MM.YYYY' : undefined,
|
|
92
|
+
position: { x: 15, y: 15, width: type === 'line' ? 80 : 60, height: type === 'line' ? 1 : type === 'image' ? 30 : 8 },
|
|
93
|
+
style: { fontSize: 10, color: '#000000', fontFamily: 'sans-serif' },
|
|
94
|
+
};
|
|
95
|
+
if (type === 'rect') base.style.backgroundColor = '#f1f5f9';
|
|
96
|
+
if (type === 'line') base.style = { ...base.style, borderWidth: 0.5, borderColor: '#000000', borderStyle: 'solid' };
|
|
97
|
+
return base;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Ruler component ─────────────────────────────────────────────────────────
|
|
101
|
+
const Ruler: React.FC<{ lengthMm: number; zoom: number; orientation: 'h' | 'v' }> = ({ lengthMm, zoom, orientation }) => {
|
|
102
|
+
const ticks: React.ReactNode[] = [];
|
|
103
|
+
const step = zoom < 0.6 ? 20 : zoom < 1 ? 10 : 5;
|
|
104
|
+
for (let mm = 0; mm <= lengthMm; mm += step) {
|
|
105
|
+
const pos = mm * zoom * MM_TO_PX;
|
|
106
|
+
const isMajor = mm % (step * 2) === 0;
|
|
107
|
+
ticks.push(
|
|
108
|
+
<div key={mm} className={`${styles.rulerTick} ${isMajor ? styles.rulerTickMajor : ''}`}
|
|
109
|
+
style={orientation === 'h' ? { left: pos } as React.CSSProperties : { top: pos } as React.CSSProperties}>
|
|
110
|
+
{isMajor && <span className={styles.rulerLabel}>{mm}</span>}
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return (
|
|
115
|
+
<div className={`${styles.ruler} ${styles[`ruler_${orientation}`]}`}>
|
|
116
|
+
{ticks}
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// ─── Resize handle ────────────────────────────────────────────────────────────
|
|
122
|
+
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
|
|
123
|
+
const RESIZE_DIRS: ResizeDir[] = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
|
|
124
|
+
|
|
125
|
+
interface ResizeHandlesProps {
|
|
126
|
+
onResizeStart: (dir: ResizeDir, e: React.MouseEvent) => void;
|
|
127
|
+
}
|
|
128
|
+
const ResizeHandles: React.FC<ResizeHandlesProps> = ({ onResizeStart }) => (
|
|
129
|
+
<>
|
|
130
|
+
{RESIZE_DIRS.map(dir => (
|
|
131
|
+
<div
|
|
132
|
+
key={dir}
|
|
133
|
+
className={`${styles.resizeHandle} ${styles[`handle_${dir}`]}`}
|
|
134
|
+
onMouseDown={e => { e.stopPropagation(); onResizeStart(dir, e); }}
|
|
135
|
+
/>
|
|
136
|
+
))}
|
|
137
|
+
</>
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// ─── Field element on canvas ─────────────────────────────────────────────────
|
|
141
|
+
interface FieldElementProps {
|
|
142
|
+
field: PrintDataField;
|
|
143
|
+
zoom: number;
|
|
144
|
+
selected: boolean;
|
|
145
|
+
sampleData?: Record<string, unknown>;
|
|
146
|
+
onClick: (e: React.MouseEvent) => void;
|
|
147
|
+
onDragStart: (e: React.MouseEvent) => void;
|
|
148
|
+
onResizeStart: (dir: ResizeDir, e: React.MouseEvent) => void;
|
|
149
|
+
readOnly: boolean;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function resolveFieldPreview(field: PrintDataField, data?: Record<string, unknown>): string {
|
|
153
|
+
if (field.type === 'static_text') return field.fallback ?? 'Tekst';
|
|
154
|
+
if (field.type === 'page_number') return '1';
|
|
155
|
+
if (field.type === 'total_pages') return '3';
|
|
156
|
+
if (field.type === 'print_date') return new Date().toLocaleDateString('pl-PL');
|
|
157
|
+
if (data && field.dataPath) {
|
|
158
|
+
const val = field.dataPath.split('.').reduce<unknown>((o, k) => (o && typeof o === 'object' ? (o as any)[k] : null), data);
|
|
159
|
+
if (val != null) return String(val);
|
|
160
|
+
}
|
|
161
|
+
return field.label ? `{${field.label}}` : `{${field.dataPath}}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const FieldElement: React.FC<FieldElementProps> = ({
|
|
165
|
+
field, zoom, selected, sampleData, onClick, onDragStart, onResizeStart, readOnly,
|
|
166
|
+
}) => {
|
|
167
|
+
const pos = field.position;
|
|
168
|
+
const st = field.style;
|
|
169
|
+
const mm = (v: number) => v * zoom * MM_TO_PX;
|
|
170
|
+
|
|
171
|
+
const isLine = field.type === 'line';
|
|
172
|
+
const isRect = field.type === 'rect';
|
|
173
|
+
const isImage = field.type === 'image' || field.type === 'static_image';
|
|
174
|
+
const isBarcode = field.type === 'barcode' || field.type === 'qr';
|
|
175
|
+
|
|
176
|
+
const baseStyle: React.CSSProperties = {
|
|
177
|
+
position: 'absolute',
|
|
178
|
+
left: mm(pos.x), top: mm(pos.y),
|
|
179
|
+
width: mm(pos.width), height: mm(pos.height),
|
|
180
|
+
zIndex: pos.zIndex ?? 1,
|
|
181
|
+
transform: st.rotation ? `rotate(${st.rotation}deg)` : undefined,
|
|
182
|
+
boxSizing: 'border-box',
|
|
183
|
+
overflow: 'hidden',
|
|
184
|
+
...(!isLine && !isRect ? {
|
|
185
|
+
fontSize: (st.fontSize ?? 10) * zoom,
|
|
186
|
+
fontFamily: st.fontFamily ?? 'sans-serif',
|
|
187
|
+
fontWeight: st.fontWeight ?? 'normal',
|
|
188
|
+
fontStyle: st.fontStyle ?? 'normal',
|
|
189
|
+
color: st.color ?? '#000',
|
|
190
|
+
textAlign: st.textAlign ?? 'left',
|
|
191
|
+
lineHeight: st.lineHeight ?? 1.4,
|
|
192
|
+
letterSpacing: st.letterSpacing ? `${st.letterSpacing}em` : undefined,
|
|
193
|
+
padding: st.padding ? mm(st.padding) : 0,
|
|
194
|
+
} : {}),
|
|
195
|
+
backgroundColor: st.backgroundColor,
|
|
196
|
+
borderWidth: st.borderWidth ?? 0,
|
|
197
|
+
borderColor: st.borderColor ?? 'transparent',
|
|
198
|
+
borderStyle: (st.borderStyle ?? 'solid') as any,
|
|
199
|
+
borderRadius: st.borderRadius,
|
|
200
|
+
opacity: st.opacity,
|
|
201
|
+
outline: selected ? '1.5px solid #2563eb' : '1px dashed transparent',
|
|
202
|
+
outlineOffset: 1,
|
|
203
|
+
cursor: readOnly ? 'default' : 'move',
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<div style={baseStyle}
|
|
208
|
+
onClick={onClick}
|
|
209
|
+
onMouseDown={readOnly ? undefined : onDragStart}>
|
|
210
|
+
{isLine ? (
|
|
211
|
+
<div style={{ width: '100%', borderBottom: `${(st.borderWidth ?? 0.5) * zoom}px ${st.borderStyle ?? 'solid'} ${st.borderColor ?? '#000'}` }} />
|
|
212
|
+
) : isRect ? null
|
|
213
|
+
: isImage ? (
|
|
214
|
+
<div className={styles.fieldImagePlaceholder}>🖼</div>
|
|
215
|
+
) : isBarcode ? (
|
|
216
|
+
<div className={styles.fieldBarcodePlaceholder}>{field.type === 'qr' ? '⬛ QR' : '▮▌▮ Barcode'}</div>
|
|
217
|
+
) : field.type === 'signature' ? (
|
|
218
|
+
<div className={styles.fieldSignaturePlaceholder}>✍ {field.label}</div>
|
|
219
|
+
) : (
|
|
220
|
+
<span style={{ display: 'block', width: '100%' }}>
|
|
221
|
+
{resolveFieldPreview(field, sampleData)}
|
|
222
|
+
</span>
|
|
223
|
+
)}
|
|
224
|
+
{selected && !readOnly && <ResizeHandles onResizeStart={onResizeStart} />}
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// ─── Section canvas ──────────────────────────────────────────────────────────
|
|
230
|
+
interface SectionCanvasProps {
|
|
231
|
+
section: PrintSection;
|
|
232
|
+
zoom: number;
|
|
233
|
+
paperWidthMm: number;
|
|
234
|
+
selectedIds: Set<string>;
|
|
235
|
+
sampleData?: Record<string, unknown>;
|
|
236
|
+
readOnly: boolean;
|
|
237
|
+
showGrid: boolean;
|
|
238
|
+
onFieldClick: (id: string, e: React.MouseEvent) => void;
|
|
239
|
+
onFieldDragStart: (id: string, e: React.MouseEvent) => void;
|
|
240
|
+
onResizeStart: (id: string, dir: ResizeDir, e: React.MouseEvent) => void;
|
|
241
|
+
onSectionHeightChange: (sectionId: string, heightMm: number) => void;
|
|
242
|
+
onDropField: (sectionId: string, schemaPath: string, x: number, y: number) => void;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const SectionCanvas: React.FC<SectionCanvasProps> = ({
|
|
246
|
+
section, zoom, paperWidthMm, selectedIds, sampleData, readOnly, showGrid,
|
|
247
|
+
onFieldClick, onFieldDragStart, onResizeStart, onSectionHeightChange, onDropField,
|
|
248
|
+
}) => {
|
|
249
|
+
const heightPx = (section.heightMm ?? (section.type === 'body' ? 200 : 40)) * zoom * MM_TO_PX;
|
|
250
|
+
const widthPx = paperWidthMm * zoom * MM_TO_PX;
|
|
251
|
+
const [draggingOver, setDraggingOver] = useState(false);
|
|
252
|
+
|
|
253
|
+
const sectionTypeColors: Record<string, string> = {
|
|
254
|
+
header: 'rgba(219,234,254,0.35)',
|
|
255
|
+
footer: 'rgba(220,252,231,0.35)',
|
|
256
|
+
background: 'rgba(254,249,195,0.35)',
|
|
257
|
+
body: 'transparent',
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<div
|
|
262
|
+
className={`${styles.sectionCanvas} ${draggingOver ? styles.sectionCanvasOver : ''}`}
|
|
263
|
+
style={{
|
|
264
|
+
width: widthPx, height: heightPx, position: 'relative',
|
|
265
|
+
background: sectionTypeColors[section.type] ?? 'transparent',
|
|
266
|
+
backgroundImage: showGrid
|
|
267
|
+
? `repeating-linear-gradient(0deg, rgba(148,163,184,0.15) 0, rgba(148,163,184,0.15) 1px, transparent 1px, transparent ${5 * zoom * MM_TO_PX}px),
|
|
268
|
+
repeating-linear-gradient(90deg, rgba(148,163,184,0.15) 0, rgba(148,163,184,0.15) 1px, transparent 1px, transparent ${5 * zoom * MM_TO_PX}px)`
|
|
269
|
+
: undefined,
|
|
270
|
+
} as React.CSSProperties}
|
|
271
|
+
onDragOver={e => { e.preventDefault(); setDraggingOver(true); }}
|
|
272
|
+
onDragLeave={() => setDraggingOver(false)}
|
|
273
|
+
onDrop={e => {
|
|
274
|
+
e.preventDefault();
|
|
275
|
+
setDraggingOver(false);
|
|
276
|
+
const path = e.dataTransfer.getData('fieldPath');
|
|
277
|
+
if (!path) return;
|
|
278
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
279
|
+
const xMm = (e.clientX - rect.left) / (zoom * MM_TO_PX);
|
|
280
|
+
const yMm = (e.clientY - rect.top) / (zoom * MM_TO_PX);
|
|
281
|
+
onDropField(section.id, path, Math.max(0, xMm), Math.max(0, yMm));
|
|
282
|
+
}}
|
|
283
|
+
>
|
|
284
|
+
{/* Section label chip */}
|
|
285
|
+
<div className={styles.sectionTypeChip}>{section.type}</div>
|
|
286
|
+
|
|
287
|
+
{/* Fields */}
|
|
288
|
+
{section.fields.map(field => (
|
|
289
|
+
<FieldElement
|
|
290
|
+
key={field.id}
|
|
291
|
+
field={field}
|
|
292
|
+
zoom={zoom}
|
|
293
|
+
selected={selectedIds.has(field.id)}
|
|
294
|
+
sampleData={sampleData}
|
|
295
|
+
readOnly={readOnly}
|
|
296
|
+
onClick={e => onFieldClick(field.id, e)}
|
|
297
|
+
onDragStart={e => onFieldDragStart(field.id, e)}
|
|
298
|
+
onResizeStart={(dir, e) => onResizeStart(field.id, dir, e)}
|
|
299
|
+
/>
|
|
300
|
+
))}
|
|
301
|
+
|
|
302
|
+
{/* Section resize handle (bottom) for non-body sections */}
|
|
303
|
+
{!readOnly && section.type !== 'body' && (
|
|
304
|
+
<div
|
|
305
|
+
className={styles.sectionResizeBar}
|
|
306
|
+
onMouseDown={e => {
|
|
307
|
+
e.preventDefault();
|
|
308
|
+
const startY = e.clientY;
|
|
309
|
+
const startH = section.heightMm ?? 40;
|
|
310
|
+
const move = (me: MouseEvent) => {
|
|
311
|
+
const delta = (me.clientY - startY) / (zoom * MM_TO_PX);
|
|
312
|
+
onSectionHeightChange(section.id, Math.max(10, startH + delta));
|
|
313
|
+
};
|
|
314
|
+
const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); };
|
|
315
|
+
document.addEventListener('mousemove', move);
|
|
316
|
+
document.addEventListener('mouseup', up);
|
|
317
|
+
}}
|
|
318
|
+
>
|
|
319
|
+
<div className={styles.sectionResizeGrip} />
|
|
320
|
+
</div>
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
);
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
// ─── Keyboard shortcuts chip ──────────────────────────────────────────────────
|
|
327
|
+
const ShortcutChip: React.FC<{ keys: string; label: string }> = ({ keys, label }) => (
|
|
328
|
+
<div className={styles.shortcutChip}>
|
|
329
|
+
<span className={styles.shortcutKeys}>{keys}</span>
|
|
330
|
+
<span className={styles.shortcutLabel}>{label}</span>
|
|
331
|
+
</div>
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
// ─── Left panel tabs ──────────────────────────────────────────────────────────
|
|
335
|
+
type LeftTab = 'fields' | 'layers' | 'watermarks' | 'signatures' | 'settings';
|
|
336
|
+
const LEFT_TABS: { id: LeftTab; icon: string; label: string }[] = [
|
|
337
|
+
{ id: 'fields', icon: '📝', label: 'Pola' },
|
|
338
|
+
{ id: 'layers', icon: '◧', label: 'Warstwy' },
|
|
339
|
+
{ id: 'watermarks', icon: '💧', label: 'Znaki' },
|
|
340
|
+
{ id: 'signatures', icon: '✍', label: 'Podpisy' },
|
|
341
|
+
{ id: 'settings', icon: '⚙', label: 'Ustawienia' },
|
|
342
|
+
];
|
|
343
|
+
|
|
344
|
+
// ─── Right panel tabs ─────────────────────────────────────────────────────────
|
|
345
|
+
type RightTab = 'position' | 'style' | 'data' | 'conditional';
|
|
346
|
+
const RIGHT_TABS: { id: RightTab; label: string }[] = [
|
|
347
|
+
{ id: 'position', label: 'Pozycja' },
|
|
348
|
+
{ id: 'style', label: 'Styl' },
|
|
349
|
+
{ id: 'data', label: 'Dane' },
|
|
350
|
+
{ id: 'conditional', label: 'Warunki' },
|
|
351
|
+
];
|
|
352
|
+
|
|
353
|
+
// ─── Toolbar ─────────────────────────────────────────────────────────────────
|
|
354
|
+
interface ToolbarProps {
|
|
355
|
+
tpl: PrintTemplate;
|
|
356
|
+
zoom: number;
|
|
357
|
+
canUndo: boolean;
|
|
358
|
+
canRedo: boolean;
|
|
359
|
+
showGrid: boolean;
|
|
360
|
+
showRulers: boolean;
|
|
361
|
+
readOnly: boolean;
|
|
362
|
+
selectedCount: number;
|
|
363
|
+
onPaperChange: (size: PaperSize, orient: PaperOrientation) => void;
|
|
364
|
+
onZoom: (z: number) => void;
|
|
365
|
+
onUndo: () => void;
|
|
366
|
+
onRedo: () => void;
|
|
367
|
+
onToggleGrid: () => void;
|
|
368
|
+
onToggleRulers: () => void;
|
|
369
|
+
onInsert: (type: string) => void;
|
|
370
|
+
onDelete: () => void;
|
|
371
|
+
onDuplicate: () => void;
|
|
372
|
+
onAlignH: (align: 'left' | 'center' | 'right') => void;
|
|
373
|
+
onAlignV: (align: 'top' | 'center' | 'bottom') => void;
|
|
374
|
+
onBringForward: () => void;
|
|
375
|
+
onSendBackward: () => void;
|
|
376
|
+
onSave?: () => void;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const Toolbar: React.FC<ToolbarProps> = ({
|
|
380
|
+
tpl, zoom, canUndo, canRedo, showGrid, showRulers, readOnly, selectedCount,
|
|
381
|
+
onPaperChange, onZoom, onUndo, onRedo, onToggleGrid, onToggleRulers,
|
|
382
|
+
onInsert, onDelete, onDuplicate, onAlignH, onAlignV, onBringForward, onSendBackward, onSave,
|
|
383
|
+
}) => {
|
|
384
|
+
const [insertOpen, setInsertOpen] = useState(false);
|
|
385
|
+
const ZOOM_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
|
386
|
+
|
|
387
|
+
return (
|
|
388
|
+
<div className={styles.toolbar}>
|
|
389
|
+
{/* Paper size + orientation */}
|
|
390
|
+
<div className={styles.toolbarGroup}>
|
|
391
|
+
<select className={styles.toolbarSelect} value={tpl.paperSize}
|
|
392
|
+
onChange={e => onPaperChange(e.target.value as PaperSize, tpl.orientation)}>
|
|
393
|
+
{(['A4', 'A5', 'A3', 'Letter', 'Legal', 'Tabloid', 'custom'] as PaperSize[]).map(s => (
|
|
394
|
+
<option key={s} value={s}>{s}</option>
|
|
395
|
+
))}
|
|
396
|
+
</select>
|
|
397
|
+
<button className={`${styles.toolbarBtn} ${tpl.orientation === 'portrait' ? styles.toolbarBtnActive : ''}`}
|
|
398
|
+
onClick={() => onPaperChange(tpl.paperSize, 'portrait')} title="Pion">⬜</button>
|
|
399
|
+
<button className={`${styles.toolbarBtn} ${tpl.orientation === 'landscape' ? styles.toolbarBtnActive : ''}`}
|
|
400
|
+
onClick={() => onPaperChange(tpl.paperSize, 'landscape')} title="Poziom">▭</button>
|
|
401
|
+
</div>
|
|
402
|
+
|
|
403
|
+
{/* Undo / Redo */}
|
|
404
|
+
{!readOnly && (
|
|
405
|
+
<div className={styles.toolbarGroup}>
|
|
406
|
+
<button className={styles.toolbarBtn} disabled={!canUndo} onClick={onUndo} title="Cofnij (Ctrl+Z)">↩</button>
|
|
407
|
+
<button className={styles.toolbarBtn} disabled={!canRedo} onClick={onRedo} title="Ponów (Ctrl+Y)">↪</button>
|
|
408
|
+
</div>
|
|
409
|
+
)}
|
|
410
|
+
|
|
411
|
+
{/* Insert dropdown */}
|
|
412
|
+
{!readOnly && (
|
|
413
|
+
<div className={styles.insertWrapper}>
|
|
414
|
+
<button className={`${styles.toolbarBtn} ${styles.insertBtn}`} onClick={() => setInsertOpen(o => !o)}>
|
|
415
|
+
+ Wstaw ▾
|
|
416
|
+
</button>
|
|
417
|
+
{insertOpen && (
|
|
418
|
+
<div className={styles.insertDropdown} onMouseLeave={() => setInsertOpen(false)}>
|
|
419
|
+
<div className={styles.insertCategory}>Pola danych</div>
|
|
420
|
+
{['text', 'number', 'currency', 'date', 'image', 'barcode', 'qr'].map(t => (
|
|
421
|
+
<button key={t} className={styles.insertItem} onClick={() => { onInsert(t); setInsertOpen(false); }}>
|
|
422
|
+
<span className={styles.insertIcon}>{FIELD_TYPE_ICONS[t]}</span>
|
|
423
|
+
{FIELD_TYPE_LABELS[t]}
|
|
424
|
+
</button>
|
|
425
|
+
))}
|
|
426
|
+
<div className={styles.insertCategory}>Elementy statyczne</div>
|
|
427
|
+
{['static_text', 'static_image', 'line', 'rect', 'page_number', 'total_pages', 'print_date'].map(t => (
|
|
428
|
+
<button key={t} className={styles.insertItem} onClick={() => { onInsert(t); setInsertOpen(false); }}>
|
|
429
|
+
<span className={styles.insertIcon}>{FIELD_TYPE_ICONS[t]}</span>
|
|
430
|
+
{FIELD_TYPE_LABELS[t]}
|
|
431
|
+
</button>
|
|
432
|
+
))}
|
|
433
|
+
<div className={styles.insertCategory}>Specjalne</div>
|
|
434
|
+
<button className={styles.insertItem} onClick={() => { onInsert('watermark'); setInsertOpen(false); }}>
|
|
435
|
+
<span className={styles.insertIcon}>💧</span>Znak wodny
|
|
436
|
+
</button>
|
|
437
|
+
<button className={styles.insertItem} onClick={() => { onInsert('signature'); setInsertOpen(false); }}>
|
|
438
|
+
<span className={styles.insertIcon}>✍</span>Podpis
|
|
439
|
+
</button>
|
|
440
|
+
</div>
|
|
441
|
+
)}
|
|
442
|
+
</div>
|
|
443
|
+
)}
|
|
444
|
+
|
|
445
|
+
{/* Selection actions */}
|
|
446
|
+
{!readOnly && selectedCount > 0 && (
|
|
447
|
+
<div className={styles.toolbarGroup}>
|
|
448
|
+
<button className={styles.toolbarBtn} onClick={onDuplicate} title="Duplikuj (Ctrl+D)">⧉</button>
|
|
449
|
+
<button className={`${styles.toolbarBtn} ${styles.toolbarBtnDanger}`} onClick={onDelete} title="Usuń (Del)">🗑</button>
|
|
450
|
+
<span className={styles.toolbarSep} />
|
|
451
|
+
<button className={styles.toolbarBtn} onClick={() => onAlignH('left')} title="Wyrównaj lewo">⬛□□</button>
|
|
452
|
+
<button className={styles.toolbarBtn} onClick={() => onAlignH('center')} title="Wyśrodkuj">□⬛□</button>
|
|
453
|
+
<button className={styles.toolbarBtn} onClick={() => onAlignH('right')} title="Wyrównaj prawo">□□⬛</button>
|
|
454
|
+
<button className={styles.toolbarBtn} onClick={() => onAlignV('top')} title="Góra">⊤</button>
|
|
455
|
+
<button className={styles.toolbarBtn} onClick={() => onAlignV('center')} title="Środek V">⊕</button>
|
|
456
|
+
<button className={styles.toolbarBtn} onClick={() => onAlignV('bottom')} title="Dół">⊥</button>
|
|
457
|
+
<span className={styles.toolbarSep} />
|
|
458
|
+
<button className={styles.toolbarBtn} onClick={onBringForward} title="Do przodu">▲</button>
|
|
459
|
+
<button className={styles.toolbarBtn} onClick={onSendBackward} title="Do tyłu">▼</button>
|
|
460
|
+
</div>
|
|
461
|
+
)}
|
|
462
|
+
|
|
463
|
+
{/* View options */}
|
|
464
|
+
<div className={styles.toolbarGroup}>
|
|
465
|
+
<button className={`${styles.toolbarBtn} ${showGrid ? styles.toolbarBtnActive : ''}`} onClick={onToggleGrid} title="Siatka">⊞</button>
|
|
466
|
+
<button className={`${styles.toolbarBtn} ${showRulers ? styles.toolbarBtnActive : ''}`} onClick={onToggleRulers} title="Linijki">⊢</button>
|
|
467
|
+
</div>
|
|
468
|
+
|
|
469
|
+
{/* Zoom */}
|
|
470
|
+
<div className={styles.toolbarGroup}>
|
|
471
|
+
<button className={styles.toolbarBtn} onClick={() => onZoom(Math.max(0.25, zoom - 0.1))}>−</button>
|
|
472
|
+
<select className={styles.zoomSelect} value={ZOOM_PRESETS.includes(zoom) ? zoom : ''}
|
|
473
|
+
onChange={e => onZoom(+e.target.value)}>
|
|
474
|
+
{ZOOM_PRESETS.map(z => <option key={z} value={z}>{Math.round(z * 100)}%</option>)}
|
|
475
|
+
</select>
|
|
476
|
+
<button className={styles.toolbarBtn} onClick={() => onZoom(Math.min(3, zoom + 0.1))}>+</button>
|
|
477
|
+
<button className={styles.toolbarBtn} onClick={() => onZoom(1)} title="100%">⊡</button>
|
|
478
|
+
</div>
|
|
479
|
+
|
|
480
|
+
{!readOnly && onSave && (
|
|
481
|
+
<button className={styles.saveBtn} onClick={onSave}>💾 Zapisz</button>
|
|
482
|
+
)}
|
|
483
|
+
</div>
|
|
484
|
+
);
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
// ─── Left panel ───────────────────────────────────────────────────────────────
|
|
488
|
+
interface LeftPanelProps {
|
|
489
|
+
activeTab: LeftTab;
|
|
490
|
+
onTabChange: (t: LeftTab) => void;
|
|
491
|
+
schema?: { fields: DataSourceField[] };
|
|
492
|
+
tpl: PrintTemplate;
|
|
493
|
+
allFields: PrintDataField[];
|
|
494
|
+
selectedIds: Set<string>;
|
|
495
|
+
onInsertSchemaField: (f: DataSourceField) => void;
|
|
496
|
+
onSelectField: (id: string) => void;
|
|
497
|
+
onUpdateTpl: (patch: Partial<PrintTemplate>) => void;
|
|
498
|
+
onReorderField: (fieldId: string, delta: -1 | 1) => void;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const LeftPanel: React.FC<LeftPanelProps> = ({
|
|
502
|
+
activeTab, onTabChange, schema, tpl, allFields, selectedIds,
|
|
503
|
+
onInsertSchemaField, onSelectField, onUpdateTpl, onReorderField,
|
|
504
|
+
}) => {
|
|
505
|
+
const [search, setSearch] = useState('');
|
|
506
|
+
const filteredFields = schema?.fields.filter(f =>
|
|
507
|
+
f.label.toLowerCase().includes(search.toLowerCase()) ||
|
|
508
|
+
f.path.toLowerCase().includes(search.toLowerCase())
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
return (
|
|
512
|
+
<div className={styles.leftPanel}>
|
|
513
|
+
<div className={styles.leftTabs}>
|
|
514
|
+
{LEFT_TABS.map(t => (
|
|
515
|
+
<button key={t.id}
|
|
516
|
+
className={`${styles.leftTab} ${activeTab === t.id ? styles.leftTabActive : ''}`}
|
|
517
|
+
onClick={() => onTabChange(t.id)} title={t.label}>
|
|
518
|
+
<span className={styles.leftTabIcon}>{t.icon}</span>
|
|
519
|
+
<span className={styles.leftTabLabel}>{t.label}</span>
|
|
520
|
+
</button>
|
|
521
|
+
))}
|
|
522
|
+
</div>
|
|
523
|
+
|
|
524
|
+
<div className={styles.leftContent}>
|
|
525
|
+
{/* ── Fields tab ── */}
|
|
526
|
+
{activeTab === 'fields' && (
|
|
527
|
+
<>
|
|
528
|
+
<div className={styles.panelHeader}>Pola danych</div>
|
|
529
|
+
<div className={styles.searchBox}>
|
|
530
|
+
<input className={styles.searchInput} placeholder="Szukaj..." value={search} onChange={e => setSearch(e.target.value)} />
|
|
531
|
+
</div>
|
|
532
|
+
{schema ? (
|
|
533
|
+
<div className={styles.fieldList}>
|
|
534
|
+
{(filteredFields ?? []).map(f => (
|
|
535
|
+
<div key={f.path} className={styles.fieldItem}
|
|
536
|
+
draggable
|
|
537
|
+
onDragStart={e => { e.dataTransfer.setData('fieldPath', f.path); e.dataTransfer.setData('fieldLabel', f.label); e.dataTransfer.setData('fieldType', f.type); }}
|
|
538
|
+
onClick={() => onInsertSchemaField(f)}>
|
|
539
|
+
<span className={styles.fieldItemIcon}>{FIELD_TYPE_ICONS[f.type] ?? '📝'}</span>
|
|
540
|
+
<div className={styles.fieldItemInfo}>
|
|
541
|
+
<div className={styles.fieldItemLabel}>{f.label}</div>
|
|
542
|
+
<div className={styles.fieldItemPath}>{f.path}</div>
|
|
543
|
+
</div>
|
|
544
|
+
<span className={styles.fieldItemType}>{f.type}</span>
|
|
545
|
+
</div>
|
|
546
|
+
))}
|
|
547
|
+
{filteredFields?.length === 0 && <div className={styles.emptyMsg}>Brak wyników</div>}
|
|
548
|
+
</div>
|
|
549
|
+
) : (
|
|
550
|
+
<div className={styles.emptyMsg}>Brak schematu danych. Przypisz encję w zakładce Ustawienia.</div>
|
|
551
|
+
)}
|
|
552
|
+
</>
|
|
553
|
+
)}
|
|
554
|
+
|
|
555
|
+
{/* ── Layers tab ── */}
|
|
556
|
+
{activeTab === 'layers' && (
|
|
557
|
+
<>
|
|
558
|
+
<div className={styles.panelHeader}>Warstwy ({allFields.length})</div>
|
|
559
|
+
<div className={styles.layerList}>
|
|
560
|
+
{allFields.length === 0 && <div className={styles.emptyMsg}>Brak elementów</div>}
|
|
561
|
+
{allFields.slice().reverse().map(f => (
|
|
562
|
+
<div key={f.id}
|
|
563
|
+
className={`${styles.layerItem} ${selectedIds.has(f.id) ? styles.layerItemSelected : ''}`}
|
|
564
|
+
onClick={() => onSelectField(f.id)}>
|
|
565
|
+
<span className={styles.layerIcon}>{FIELD_TYPE_ICONS[f.type] ?? '📝'}</span>
|
|
566
|
+
<span className={styles.layerName}>{f.name || f.label}</span>
|
|
567
|
+
<div className={styles.layerActions}>
|
|
568
|
+
<button className={styles.layerBtn} onClick={e => { e.stopPropagation(); onReorderField(f.id, -1); }} title="Do przodu">↑</button>
|
|
569
|
+
<button className={styles.layerBtn} onClick={e => { e.stopPropagation(); onReorderField(f.id, 1); }} title="Do tyłu">↓</button>
|
|
570
|
+
</div>
|
|
571
|
+
</div>
|
|
572
|
+
))}
|
|
573
|
+
</div>
|
|
574
|
+
</>
|
|
575
|
+
)}
|
|
576
|
+
|
|
577
|
+
{/* ── Watermarks tab ── */}
|
|
578
|
+
{activeTab === 'watermarks' && (
|
|
579
|
+
<>
|
|
580
|
+
<div className={styles.panelHeader}>Znaki wodne</div>
|
|
581
|
+
<div className={styles.wmList}>
|
|
582
|
+
{(tpl.watermarks ?? []).map(wm => (
|
|
583
|
+
<div key={wm.id} className={styles.wmItem}>
|
|
584
|
+
<div className={styles.wmRow}>
|
|
585
|
+
<span className={styles.wmLabel}>Typ</span>
|
|
586
|
+
<select className={styles.wmSelect} value={wm.type}
|
|
587
|
+
onChange={e => onUpdateTpl({ watermarks: tpl.watermarks?.map(w => w.id === wm.id ? { ...w, type: e.target.value as 'text' | 'image' } : w) })}>
|
|
588
|
+
<option value="text">Tekst</option>
|
|
589
|
+
<option value="image">Obraz</option>
|
|
590
|
+
</select>
|
|
591
|
+
</div>
|
|
592
|
+
{wm.type === 'text' && (
|
|
593
|
+
<div className={styles.wmRow}>
|
|
594
|
+
<span className={styles.wmLabel}>Tekst</span>
|
|
595
|
+
<input className={styles.wmInput} value={wm.text ?? ''}
|
|
596
|
+
onChange={e => onUpdateTpl({ watermarks: tpl.watermarks?.map(w => w.id === wm.id ? { ...w, text: e.target.value } : w) })} />
|
|
597
|
+
</div>
|
|
598
|
+
)}
|
|
599
|
+
<div className={styles.wmRow}>
|
|
600
|
+
<span className={styles.wmLabel}>Krycie</span>
|
|
601
|
+
<input type="range" min={0.02} max={0.5} step={0.01} className={styles.wmSlider} value={wm.opacity}
|
|
602
|
+
onChange={e => onUpdateTpl({ watermarks: tpl.watermarks?.map(w => w.id === wm.id ? { ...w, opacity: +e.target.value } : w) })} />
|
|
603
|
+
<span className={styles.wmValue}>{Math.round(wm.opacity * 100)}%</span>
|
|
604
|
+
</div>
|
|
605
|
+
<div className={styles.wmRow}>
|
|
606
|
+
<span className={styles.wmLabel}>Kąt</span>
|
|
607
|
+
<input type="range" min={-90} max={90} step={5} className={styles.wmSlider} value={wm.angle}
|
|
608
|
+
onChange={e => onUpdateTpl({ watermarks: tpl.watermarks?.map(w => w.id === wm.id ? { ...w, angle: +e.target.value } : w) })} />
|
|
609
|
+
<span className={styles.wmValue}>{wm.angle}°</span>
|
|
610
|
+
</div>
|
|
611
|
+
<div className={styles.wmRow}>
|
|
612
|
+
<span className={styles.wmLabel}>Rozmiar</span>
|
|
613
|
+
<input type="number" min={8} max={200} className={styles.wmInputNum} value={wm.fontSize ?? 48}
|
|
614
|
+
onChange={e => onUpdateTpl({ watermarks: tpl.watermarks?.map(w => w.id === wm.id ? { ...w, fontSize: +e.target.value } : w) })} />
|
|
615
|
+
<span className={styles.wmUnit}>pt</span>
|
|
616
|
+
</div>
|
|
617
|
+
<div className={styles.wmRow}>
|
|
618
|
+
<span className={styles.wmLabel}>Kolor</span>
|
|
619
|
+
<input type="color" className={styles.colorPicker} value={wm.color ?? '#94a3b8'}
|
|
620
|
+
onChange={e => onUpdateTpl({ watermarks: tpl.watermarks?.map(w => w.id === wm.id ? { ...w, color: e.target.value } : w) })} />
|
|
621
|
+
</div>
|
|
622
|
+
<button className={styles.wmDelete} onClick={() => onUpdateTpl({ watermarks: tpl.watermarks?.filter(w => w.id !== wm.id) })}>Usuń</button>
|
|
623
|
+
</div>
|
|
624
|
+
))}
|
|
625
|
+
<button className={styles.addBtn} onClick={() => onUpdateTpl({ watermarks: [...(tpl.watermarks ?? []), { id: uid(), type: 'text', text: 'KOPIA', opacity: 0.12, angle: -45, fontSize: 60, color: '#94a3b8' }] })}>
|
|
626
|
+
+ Dodaj znak wodny
|
|
627
|
+
</button>
|
|
628
|
+
</div>
|
|
629
|
+
</>
|
|
630
|
+
)}
|
|
631
|
+
|
|
632
|
+
{/* ── Signatures tab ── */}
|
|
633
|
+
{activeTab === 'signatures' && (
|
|
634
|
+
<>
|
|
635
|
+
<div className={styles.panelHeader}>Podpisy</div>
|
|
636
|
+
<div className={styles.wmList}>
|
|
637
|
+
{(tpl.signatures ?? []).map(sig => (
|
|
638
|
+
<div key={sig.id} className={styles.wmItem}>
|
|
639
|
+
<div className={styles.wmRow}>
|
|
640
|
+
<span className={styles.wmLabel}>Etykieta</span>
|
|
641
|
+
<input className={styles.wmInput} value={sig.label}
|
|
642
|
+
onChange={e => onUpdateTpl({ signatures: tpl.signatures?.map(s => s.id === sig.id ? { ...s, label: e.target.value } : s) })} />
|
|
643
|
+
</div>
|
|
644
|
+
<div className={styles.wmRow}>
|
|
645
|
+
<span className={styles.wmLabel}>Nazwisko</span>
|
|
646
|
+
<input className={styles.wmInput} value={sig.signerName ?? ''}
|
|
647
|
+
onChange={e => onUpdateTpl({ signatures: tpl.signatures?.map(s => s.id === sig.id ? { ...s, signerName: e.target.value } : s) })} />
|
|
648
|
+
</div>
|
|
649
|
+
<div className={styles.wmRow}>
|
|
650
|
+
<span className={styles.wmLabel}>Linia</span>
|
|
651
|
+
<input type="checkbox" checked={sig.showLine ?? true}
|
|
652
|
+
onChange={e => onUpdateTpl({ signatures: tpl.signatures?.map(s => s.id === sig.id ? { ...s, showLine: e.target.checked } : s) })} />
|
|
653
|
+
</div>
|
|
654
|
+
<div className={styles.wmRow}>
|
|
655
|
+
<span className={styles.wmLabel}>Pokaż datę</span>
|
|
656
|
+
<input type="checkbox" checked={sig.showDate ?? false}
|
|
657
|
+
onChange={e => onUpdateTpl({ signatures: tpl.signatures?.map(s => s.id === sig.id ? { ...s, showDate: e.target.checked } : s) })} />
|
|
658
|
+
</div>
|
|
659
|
+
<button className={styles.wmDelete} onClick={() => onUpdateTpl({ signatures: tpl.signatures?.filter(s => s.id !== sig.id) })}>Usuń</button>
|
|
660
|
+
</div>
|
|
661
|
+
))}
|
|
662
|
+
<button className={styles.addBtn} onClick={() => onUpdateTpl({ signatures: [...(tpl.signatures ?? []), { id: uid(), label: 'Podpis', position: { x: 10, y: 5, width: 70, height: 22 }, showLine: true, showDate: false }] })}>
|
|
663
|
+
+ Dodaj podpis
|
|
664
|
+
</button>
|
|
665
|
+
</div>
|
|
666
|
+
</>
|
|
667
|
+
)}
|
|
668
|
+
|
|
669
|
+
{/* ── Settings tab ── */}
|
|
670
|
+
{activeTab === 'settings' && (
|
|
671
|
+
<>
|
|
672
|
+
<div className={styles.panelHeader}>Ustawienia szablonu</div>
|
|
673
|
+
<div className={styles.settingsForm}>
|
|
674
|
+
<div className={styles.settingsRow}>
|
|
675
|
+
<label className={styles.settingsLabel}>Nazwa</label>
|
|
676
|
+
<input className={styles.settingsInput} value={tpl.name} onChange={e => onUpdateTpl({ name: e.target.value })} />
|
|
677
|
+
</div>
|
|
678
|
+
<div className={styles.settingsRow}>
|
|
679
|
+
<label className={styles.settingsLabel}>Opis</label>
|
|
680
|
+
<textarea className={styles.settingsTextarea} value={tpl.description ?? ''} onChange={e => onUpdateTpl({ description: e.target.value })} rows={2} />
|
|
681
|
+
</div>
|
|
682
|
+
<div className={styles.settingsRow}>
|
|
683
|
+
<label className={styles.settingsLabel}>Kategoria</label>
|
|
684
|
+
<input className={styles.settingsInput} value={tpl.category ?? ''} onChange={e => onUpdateTpl({ category: e.target.value })} />
|
|
685
|
+
</div>
|
|
686
|
+
<div className={styles.settingsDivider}>Marginesy (mm)</div>
|
|
687
|
+
{(['top', 'right', 'bottom', 'left'] as const).map(side => (
|
|
688
|
+
<div key={side} className={styles.settingsRow}>
|
|
689
|
+
<label className={styles.settingsLabel}>{side === 'top' ? 'Góra' : side === 'right' ? 'Prawo' : side === 'bottom' ? 'Dół' : 'Lewo'}</label>
|
|
690
|
+
<input type="number" min={0} max={50} step={1} className={styles.settingsInputNum}
|
|
691
|
+
value={tpl.margins[side]} onChange={e => onUpdateTpl({ margins: { ...tpl.margins, [side]: +e.target.value } })} />
|
|
692
|
+
<span className={styles.settingsUnit}>mm</span>
|
|
693
|
+
</div>
|
|
694
|
+
))}
|
|
695
|
+
</div>
|
|
696
|
+
</>
|
|
697
|
+
)}
|
|
698
|
+
</div>
|
|
699
|
+
</div>
|
|
700
|
+
);
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
// ─── Right panel (Properties) ─────────────────────────────────────────────────
|
|
704
|
+
interface PropertiesPanelProps {
|
|
705
|
+
field: PrintDataField | null;
|
|
706
|
+
activeTab: RightTab;
|
|
707
|
+
onTabChange: (t: RightTab) => void;
|
|
708
|
+
onChange: (patch: Partial<PrintDataField>) => void;
|
|
709
|
+
paperWidthMm: number;
|
|
710
|
+
paperHeightMm: number;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
|
714
|
+
field, activeTab, onTabChange, onChange, paperWidthMm, paperHeightMm,
|
|
715
|
+
}) => {
|
|
716
|
+
const p = useCallback((patch: Partial<FieldPosition>) => field && onChange({ position: { ...field.position, ...patch } }), [field, onChange]);
|
|
717
|
+
const s = useCallback((patch: Partial<FieldStyle>) => field && onChange({ style: { ...(field?.style ?? {}), ...patch } }), [field, onChange]);
|
|
718
|
+
|
|
719
|
+
if (!field) {
|
|
720
|
+
return (
|
|
721
|
+
<div className={styles.rightPanel}>
|
|
722
|
+
<div className={styles.panelHeader}>Właściwości</div>
|
|
723
|
+
<div className={styles.propsEmpty}>
|
|
724
|
+
<div className={styles.propsEmptyIcon}>⬚</div>
|
|
725
|
+
<div className={styles.propsEmptyText}>Zaznacz element na szablonie</div>
|
|
726
|
+
<div className={styles.shortcutGrid}>
|
|
727
|
+
<ShortcutChip keys="Del" label="Usuń" />
|
|
728
|
+
<ShortcutChip keys="Ctrl+D" label="Duplikuj" />
|
|
729
|
+
<ShortcutChip keys="↑↓←→" label="Przesuń" />
|
|
730
|
+
<ShortcutChip keys="Ctrl+Z" label="Cofnij" />
|
|
731
|
+
<ShortcutChip keys="Ctrl+A" label="Zaznacz wszystko" />
|
|
732
|
+
<ShortcutChip keys="Shift+↑" label="Przesuń 5mm" />
|
|
733
|
+
</div>
|
|
734
|
+
</div>
|
|
735
|
+
</div>
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const hasText = !['line', 'rect', 'image', 'static_image', 'barcode', 'qr', 'signature'].includes(field.type);
|
|
740
|
+
const hasData = !['static_text', 'static_image', 'line', 'rect', 'page_number', 'total_pages', 'print_date'].includes(field.type);
|
|
741
|
+
|
|
742
|
+
return (
|
|
743
|
+
<div className={styles.rightPanel}>
|
|
744
|
+
<div className={styles.panelHeader}>
|
|
745
|
+
<span className={styles.panelHeaderIcon}>{FIELD_TYPE_ICONS[field.type]}</span>
|
|
746
|
+
<span className={styles.panelHeaderName}>{field.name}</span>
|
|
747
|
+
</div>
|
|
748
|
+
|
|
749
|
+
<div className={styles.rightTabs}>
|
|
750
|
+
{RIGHT_TABS.map(t => {
|
|
751
|
+
if (t.id === 'data' && !hasData) return null;
|
|
752
|
+
return (
|
|
753
|
+
<button key={t.id}
|
|
754
|
+
className={`${styles.rightTab} ${activeTab === t.id ? styles.rightTabActive : ''}`}
|
|
755
|
+
onClick={() => onTabChange(t.id)}>
|
|
756
|
+
{t.label}
|
|
757
|
+
</button>
|
|
758
|
+
);
|
|
759
|
+
})}
|
|
760
|
+
</div>
|
|
761
|
+
|
|
762
|
+
<div className={styles.propsBody}>
|
|
763
|
+
{/* ── Position ── */}
|
|
764
|
+
{activeTab === 'position' && (
|
|
765
|
+
<>
|
|
766
|
+
<div className={styles.posGrid}>
|
|
767
|
+
{[
|
|
768
|
+
{ key: 'x' as const, label: 'X', unit: 'mm' },
|
|
769
|
+
{ key: 'y' as const, label: 'Y', unit: 'mm' },
|
|
770
|
+
{ key: 'width' as const, label: 'Szer.', unit: 'mm' },
|
|
771
|
+
{ key: 'height' as const, label: 'Wys.', unit: 'mm' },
|
|
772
|
+
].map(({ key, label, unit }) => (
|
|
773
|
+
<div key={key} className={styles.posCell}>
|
|
774
|
+
<label className={styles.posLabel}>{label}</label>
|
|
775
|
+
<input type="number" step={0.5} className={styles.posInput}
|
|
776
|
+
value={Math.round(field.position[key] * 10) / 10}
|
|
777
|
+
onChange={e => p({ [key]: +e.target.value })} />
|
|
778
|
+
<span className={styles.posUnit}>{unit}</span>
|
|
779
|
+
</div>
|
|
780
|
+
))}
|
|
781
|
+
<div className={styles.posCell}>
|
|
782
|
+
<label className={styles.posLabel}>Obrót</label>
|
|
783
|
+
<input type="number" step={5} className={styles.posInput}
|
|
784
|
+
value={field.style.rotation ?? 0} onChange={e => s({ rotation: +e.target.value })} />
|
|
785
|
+
<span className={styles.posUnit}>°</span>
|
|
786
|
+
</div>
|
|
787
|
+
<div className={styles.posCell}>
|
|
788
|
+
<label className={styles.posLabel}>Z-idx</label>
|
|
789
|
+
<input type="number" step={1} min={1} className={styles.posInput}
|
|
790
|
+
value={field.position.zIndex ?? 1} onChange={e => p({ zIndex: +e.target.value })} />
|
|
791
|
+
</div>
|
|
792
|
+
</div>
|
|
793
|
+
|
|
794
|
+
<div className={styles.snapSection}>
|
|
795
|
+
<div className={styles.snapTitle}>Wyrównanie do strony</div>
|
|
796
|
+
<div className={styles.snapRow}>
|
|
797
|
+
<button className={styles.snapBtn} onClick={() => p({ x: 0 })} title="Lewa krawędź">⫷</button>
|
|
798
|
+
<button className={styles.snapBtn} onClick={() => p({ x: (paperWidthMm - field.position.width) / 2 })} title="Środek poziomy">⊕</button>
|
|
799
|
+
<button className={styles.snapBtn} onClick={() => p({ x: paperWidthMm - field.position.width })} title="Prawa krawędź">⫸</button>
|
|
800
|
+
</div>
|
|
801
|
+
</div>
|
|
802
|
+
</>
|
|
803
|
+
)}
|
|
804
|
+
|
|
805
|
+
{/* ── Style ── */}
|
|
806
|
+
{activeTab === 'style' && (
|
|
807
|
+
<>
|
|
808
|
+
{hasText && (
|
|
809
|
+
<div className={styles.styleGroup}>
|
|
810
|
+
<div className={styles.styleGroupTitle}>Czcionka</div>
|
|
811
|
+
<div className={styles.styleRow}>
|
|
812
|
+
<label className={styles.styleLabel}>Rodzina</label>
|
|
813
|
+
<select className={styles.styleSelectFull} value={field.style.fontFamily ?? 'sans-serif'}
|
|
814
|
+
onChange={e => s({ fontFamily: e.target.value })}>
|
|
815
|
+
{FONT_FAMILIES.map(f => <option key={f} value={f}>{f}</option>)}
|
|
816
|
+
</select>
|
|
817
|
+
</div>
|
|
818
|
+
<div className={styles.styleRow}>
|
|
819
|
+
<label className={styles.styleLabel}>Rozmiar</label>
|
|
820
|
+
<select className={styles.styleSelectSm} value={FONT_SIZES.includes(field.style.fontSize ?? 10) ? field.style.fontSize ?? 10 : ''}
|
|
821
|
+
onChange={e => s({ fontSize: +e.target.value })}>
|
|
822
|
+
{FONT_SIZES.map(f => <option key={f} value={f}>{f}</option>)}
|
|
823
|
+
</select>
|
|
824
|
+
<input type="number" step={0.5} min={4} max={200} className={styles.styleInput}
|
|
825
|
+
value={field.style.fontSize ?? 10} onChange={e => s({ fontSize: +e.target.value })} />
|
|
826
|
+
<span className={styles.styleUnit}>pt</span>
|
|
827
|
+
</div>
|
|
828
|
+
<div className={styles.styleRow}>
|
|
829
|
+
<label className={styles.styleLabel}>Kolor</label>
|
|
830
|
+
<input type="color" className={styles.colorPicker} value={field.style.color ?? '#000000'}
|
|
831
|
+
onChange={e => s({ color: e.target.value })} />
|
|
832
|
+
<input className={styles.colorHex} value={field.style.color ?? '#000000'}
|
|
833
|
+
onChange={e => s({ color: e.target.value })} maxLength={7} />
|
|
834
|
+
</div>
|
|
835
|
+
<div className={styles.styleRow}>
|
|
836
|
+
<label className={styles.styleLabel}>Styl</label>
|
|
837
|
+
<div className={styles.toggleGroup}>
|
|
838
|
+
<button className={`${styles.toggleBtn} ${field.style.fontWeight === 'bold' ? styles.toggleBtnActive : ''}`}
|
|
839
|
+
onClick={() => s({ fontWeight: field.style.fontWeight === 'bold' ? 'normal' : 'bold' })}><b>B</b></button>
|
|
840
|
+
<button className={`${styles.toggleBtn} ${field.style.fontStyle === 'italic' ? styles.toggleBtnActive : ''}`}
|
|
841
|
+
onClick={() => s({ fontStyle: field.style.fontStyle === 'italic' ? 'normal' : 'italic' })}><i>I</i></button>
|
|
842
|
+
</div>
|
|
843
|
+
</div>
|
|
844
|
+
<div className={styles.styleRow}>
|
|
845
|
+
<label className={styles.styleLabel}>Wyrówn.</label>
|
|
846
|
+
<div className={styles.toggleGroup}>
|
|
847
|
+
{(['left', 'center', 'right', 'justify'] as const).map(a => (
|
|
848
|
+
<button key={a} title={a}
|
|
849
|
+
className={`${styles.toggleBtn} ${(field.style.textAlign ?? 'left') === a ? styles.toggleBtnActive : ''}`}
|
|
850
|
+
onClick={() => s({ textAlign: a })}>
|
|
851
|
+
{a === 'left' ? '≡L' : a === 'center' ? '≡C' : a === 'right' ? '≡R' : '≡J'}
|
|
852
|
+
</button>
|
|
853
|
+
))}
|
|
854
|
+
</div>
|
|
855
|
+
</div>
|
|
856
|
+
<div className={styles.styleRow}>
|
|
857
|
+
<label className={styles.styleLabel}>W. linii</label>
|
|
858
|
+
<input type="number" step={0.1} min={1} max={4} className={styles.styleInput}
|
|
859
|
+
value={field.style.lineHeight ?? 1.4} onChange={e => s({ lineHeight: +e.target.value })} />
|
|
860
|
+
</div>
|
|
861
|
+
<div className={styles.styleRow}>
|
|
862
|
+
<label className={styles.styleLabel}>Odstępy</label>
|
|
863
|
+
<input type="number" step={0.01} min={-0.1} max={0.5} className={styles.styleInput}
|
|
864
|
+
value={field.style.letterSpacing ?? 0} onChange={e => s({ letterSpacing: +e.target.value })} />
|
|
865
|
+
<span className={styles.styleUnit}>em</span>
|
|
866
|
+
</div>
|
|
867
|
+
</div>
|
|
868
|
+
)}
|
|
869
|
+
|
|
870
|
+
<div className={styles.styleGroup}>
|
|
871
|
+
<div className={styles.styleGroupTitle}>Tło i ramka</div>
|
|
872
|
+
<div className={styles.styleRow}>
|
|
873
|
+
<label className={styles.styleLabel}>Tło</label>
|
|
874
|
+
<input type="color" className={styles.colorPicker} value={field.style.backgroundColor ?? '#ffffff'}
|
|
875
|
+
onChange={e => s({ backgroundColor: e.target.value })} />
|
|
876
|
+
<button className={styles.clearBtn} onClick={() => s({ backgroundColor: undefined })} title="Wyczyść tło">✕</button>
|
|
877
|
+
</div>
|
|
878
|
+
<div className={styles.styleRow}>
|
|
879
|
+
<label className={styles.styleLabel}>Ramka</label>
|
|
880
|
+
<input type="number" step={0.5} min={0} max={10} className={styles.styleInput}
|
|
881
|
+
value={field.style.borderWidth ?? 0} onChange={e => s({ borderWidth: +e.target.value })} />
|
|
882
|
+
<input type="color" className={styles.colorPicker} value={field.style.borderColor ?? '#000000'}
|
|
883
|
+
onChange={e => s({ borderColor: e.target.value })} />
|
|
884
|
+
<select className={styles.styleSelectXs} value={field.style.borderStyle ?? 'solid'}
|
|
885
|
+
onChange={e => s({ borderStyle: e.target.value as any })}>
|
|
886
|
+
<option value="solid">─</option>
|
|
887
|
+
<option value="dashed">- -</option>
|
|
888
|
+
<option value="dotted">···</option>
|
|
889
|
+
</select>
|
|
890
|
+
</div>
|
|
891
|
+
<div className={styles.styleRow}>
|
|
892
|
+
<label className={styles.styleLabel}>Zaokr.</label>
|
|
893
|
+
<input type="number" step={1} min={0} max={50} className={styles.styleInput}
|
|
894
|
+
value={field.style.borderRadius ?? 0} onChange={e => s({ borderRadius: +e.target.value })} />
|
|
895
|
+
<span className={styles.styleUnit}>px</span>
|
|
896
|
+
</div>
|
|
897
|
+
<div className={styles.styleRow}>
|
|
898
|
+
<label className={styles.styleLabel}>Padding</label>
|
|
899
|
+
<input type="number" step={0.5} min={0} max={20} className={styles.styleInput}
|
|
900
|
+
value={field.style.padding ?? 0} onChange={e => s({ padding: +e.target.value })} />
|
|
901
|
+
<span className={styles.styleUnit}>mm</span>
|
|
902
|
+
</div>
|
|
903
|
+
<div className={styles.styleRow}>
|
|
904
|
+
<label className={styles.styleLabel}>Krycie</label>
|
|
905
|
+
<input type="range" min={0.1} max={1} step={0.05} className={styles.sliderInput}
|
|
906
|
+
value={field.style.opacity ?? 1} onChange={e => s({ opacity: +e.target.value })} />
|
|
907
|
+
<span className={styles.styleValue}>{Math.round((field.style.opacity ?? 1) * 100)}%</span>
|
|
908
|
+
</div>
|
|
909
|
+
</div>
|
|
910
|
+
</>
|
|
911
|
+
)}
|
|
912
|
+
|
|
913
|
+
{/* ── Data ── */}
|
|
914
|
+
{activeTab === 'data' && hasData && (
|
|
915
|
+
<div className={styles.styleGroup}>
|
|
916
|
+
<div className={styles.styleGroupTitle}>Źródło danych</div>
|
|
917
|
+
<div className={styles.styleRow}>
|
|
918
|
+
<label className={styles.styleLabel}>Ścieżka</label>
|
|
919
|
+
<input className={styles.styleInputFull} value={field.dataPath}
|
|
920
|
+
onChange={e => onChange({ dataPath: e.target.value })} placeholder="np. invoice.number" />
|
|
921
|
+
</div>
|
|
922
|
+
<div className={styles.styleRow}>
|
|
923
|
+
<label className={styles.styleLabel}>Format</label>
|
|
924
|
+
<input className={styles.styleInputFull} value={field.format ?? ''}
|
|
925
|
+
onChange={e => onChange({ format: e.target.value })} placeholder="DD.MM.YYYY / #,##0.00" />
|
|
926
|
+
</div>
|
|
927
|
+
<div className={styles.styleRow}>
|
|
928
|
+
<label className={styles.styleLabel}>Domyślny</label>
|
|
929
|
+
<input className={styles.styleInputFull} value={field.fallback ?? ''}
|
|
930
|
+
onChange={e => onChange({ fallback: e.target.value })} placeholder="Gdy brak wartości" />
|
|
931
|
+
</div>
|
|
932
|
+
<div className={styles.styleRow}>
|
|
933
|
+
<label className={styles.styleLabel}>Typ</label>
|
|
934
|
+
<select className={styles.styleSelectFull} value={field.type}
|
|
935
|
+
onChange={e => onChange({ type: e.target.value as any })}>
|
|
936
|
+
{Object.entries(FIELD_TYPE_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
|
937
|
+
</select>
|
|
938
|
+
</div>
|
|
939
|
+
{(field.type === 'barcode' || field.type === 'qr') && (
|
|
940
|
+
<div className={styles.styleRow}>
|
|
941
|
+
<label className={styles.styleLabel}>Format kodu</label>
|
|
942
|
+
<select className={styles.styleSelectFull} value={field.barcodeType ?? 'code128'}
|
|
943
|
+
onChange={e => onChange({ barcodeType: e.target.value as any })}>
|
|
944
|
+
{['ean13', 'ean8', 'code128', 'code39', 'upc', 'itf14', 'datamatrix'].map(b => (
|
|
945
|
+
<option key={b} value={b}>{b.toUpperCase()}</option>
|
|
946
|
+
))}
|
|
947
|
+
</select>
|
|
948
|
+
</div>
|
|
949
|
+
)}
|
|
950
|
+
</div>
|
|
951
|
+
)}
|
|
952
|
+
|
|
953
|
+
{/* ── Conditional ── */}
|
|
954
|
+
{activeTab === 'conditional' && (
|
|
955
|
+
<div className={styles.styleGroup}>
|
|
956
|
+
<div className={styles.styleGroupTitle}>Widoczność warunkowa</div>
|
|
957
|
+
<div className={styles.conditionalHint}>
|
|
958
|
+
Element zostanie ukryty gdy warunek nie jest spełniony.
|
|
959
|
+
</div>
|
|
960
|
+
<div className={styles.styleRow}>
|
|
961
|
+
<label className={styles.styleLabel}>Pole</label>
|
|
962
|
+
<input className={styles.styleInputFull} value={field.conditional?.field ?? ''}
|
|
963
|
+
onChange={e => onChange({ conditional: { field: e.target.value, operator: field.conditional?.operator ?? 'eq', value: field.conditional?.value } })}
|
|
964
|
+
placeholder="np. invoice.isPaid" />
|
|
965
|
+
</div>
|
|
966
|
+
<div className={styles.styleRow}>
|
|
967
|
+
<label className={styles.styleLabel}>Operator</label>
|
|
968
|
+
<select className={styles.styleSelectFull} value={field.conditional?.operator ?? 'eq'}
|
|
969
|
+
onChange={e => onChange({ conditional: { field: field.conditional?.field ?? '', operator: e.target.value as any, value: field.conditional?.value } })}>
|
|
970
|
+
<option value="eq">= równe</option>
|
|
971
|
+
<option value="neq">≠ różne</option>
|
|
972
|
+
<option value="gt">> większe</option>
|
|
973
|
+
<option value="lt">< mniejsze</option>
|
|
974
|
+
<option value="contains">zawiera</option>
|
|
975
|
+
</select>
|
|
976
|
+
</div>
|
|
977
|
+
<div className={styles.styleRow}>
|
|
978
|
+
<label className={styles.styleLabel}>Wartość</label>
|
|
979
|
+
<input className={styles.styleInputFull} value={String(field.conditional?.value ?? '')}
|
|
980
|
+
onChange={e => onChange({ conditional: { field: field.conditional?.field ?? '', operator: field.conditional?.operator ?? 'eq', value: e.target.value } })}
|
|
981
|
+
placeholder="np. true / "PAID"" />
|
|
982
|
+
</div>
|
|
983
|
+
{field.conditional?.field && (
|
|
984
|
+
<button className={styles.clearConditionalBtn} onClick={() => onChange({ conditional: undefined })}>
|
|
985
|
+
Wyczyść warunek
|
|
986
|
+
</button>
|
|
987
|
+
)}
|
|
988
|
+
</div>
|
|
989
|
+
)}
|
|
990
|
+
</div>
|
|
991
|
+
</div>
|
|
992
|
+
);
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
// ─── Default template factory ─────────────────────────────────────────────────
|
|
996
|
+
function defaultTemplate(): PrintTemplate {
|
|
997
|
+
return {
|
|
998
|
+
id: `tpl-${Date.now()}`, name: 'Nowy szablon', description: '',
|
|
999
|
+
paperSize: 'A4', orientation: 'portrait',
|
|
1000
|
+
margins: { top: 20, right: 15, bottom: 20, left: 15 },
|
|
1001
|
+
sections: [
|
|
1002
|
+
{ id: 'sec-header', type: 'header', heightMm: 35, fields: [] },
|
|
1003
|
+
{ id: 'sec-body', type: 'body', fields: [] },
|
|
1004
|
+
{ id: 'sec-footer', type: 'footer', heightMm: 20, fields: [] },
|
|
1005
|
+
],
|
|
1006
|
+
watermarks: [],
|
|
1007
|
+
signatures: [],
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// ─── Main component ───────────────────────────────────────────────────────────
|