.gitattributes CHANGED
@@ -34,3 +34,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  Roboto-VariableFont_wdth,wght.ttf filter=lfs diff=lfs merge=lfs -text
 
 
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  Roboto-VariableFont_wdth,wght.ttf filter=lfs diff=lfs merge=lfs -text
37
+ 1.png filter=lfs diff=lfs merge=lfs -text
0f5c994848b44107af0395713ad69da0-free.png ADDED
1.png ADDED

Git LFS Details

  • SHA256: cd2c1f2405363a43e9fc9ce9e40a88b148dc8be3eb7031066d89e90be57c7a6d
  • Pointer size: 131 Bytes
  • Size of remote file: 115 kB
Dockerfile CHANGED
@@ -1,31 +1,18 @@
1
- # syntax=docker/dockerfile:1
2
  FROM python:3.11-slim
3
 
4
- # System deps (jeśli używasz psycopg2-binary, wystarczy ca-certificates)
5
- RUN apt-get update && apt-get install -y --no-install-recommends \
6
- ca-certificates curl && rm -rf /var/lib/apt/lists/*
7
-
8
- # Dobre praktyki
9
- ENV PYTHONDONTWRITEBYTECODE=1
10
- ENV PYTHONUNBUFFERED=1
11
-
12
- # Nie-root user (HF i tak odpala jako uid 1000)
13
- RUN useradd -m appuser
14
  WORKDIR /app
15
 
16
- # Zależności
17
  COPY requirements.txt .
18
  RUN pip install --no-cache-dir -r requirements.txt
19
 
20
- # Kod
21
  COPY . .
22
 
23
- # Entrypoint (migracje/init + start)
24
- COPY entrypoint.sh /entrypoint.sh
25
- RUN chmod +x /entrypoint.sh
 
26
 
27
- USER appuser
28
- EXPOSE 7860
29
 
30
- # UWAGA: zmienne ze Spaces (DATABASE_URL, itp.) będą dostępne dopiero w runtime
31
- ENTRYPOINT ["/entrypoint.sh"]
 
 
1
  FROM python:3.11-slim
2
 
 
 
 
 
 
 
 
 
 
 
3
  WORKDIR /app
4
 
 
5
  COPY requirements.txt .
6
  RUN pip install --no-cache-dir -r requirements.txt
7
 
 
8
  COPY . .
9
 
10
+ RUN mkdir -p /data \
11
+ && chmod -R 777 /data
12
+ # opcjonalnie, jeśli chcesz skopiować istniejący plik: COPY web_invoice_store.json /data/
13
+ # i po nim: RUN chmod 666 /data/web_invoice_store.json
14
 
15
+ ENV DATA_DIR=/data
16
+ ENV PORT=7860
17
 
18
+ CMD ["python", "server.py"]
 
gitattributes ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ Roboto-VariableFont_wdth,wght.ttf filter=lfs diff=lfs merge=lfs -text
index.html ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="pl">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Generator faktur</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;600;700&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="styles.css">
11
+ </head>
12
+ <body>
13
+ <main class="container">
14
+ <div class="header-section">
15
+ <div class="logo-container">
16
+ <img src="small_logotyp do strony.jpg" alt="Logotyp FakturON!" class="logo">
17
+ <h1 class="app-title">Generator faktur</h1>
18
+ </div>
19
+ <p class="app-description">
20
+ Strona internetowa, gdzie możesz zupełnie bezpłatnie wystawić fakturę, edytować ją,
21
+ przeglądać swoje faktury oraz śledzić przychody.
22
+ </p>
23
+ </div>
24
+
25
+ <section id="auth-section" class="panel">
26
+
27
+ <div class="auth-login">
28
+ <div class="auth-card login-card">
29
+ <h3>Zaloguj się</h3>
30
+ <form id="login-form" class="form">
31
+ <label>
32
+ Email
33
+ <input type="email" name="email" autocomplete="email" required>
34
+ </label>
35
+ <label>
36
+ Hasło
37
+ <input type="password" name="password" autocomplete="current-password" required>
38
+ </label>
39
+ <button type="submit">Zaloguj</button>
40
+ <hr class="form-divider">
41
+ </form>
42
+ <p id="login-feedback" class="feedback" aria-live="polite"></p>
43
+ <div class="auth-actions">
44
+ <span>Nie masz konta?</span>
45
+ <button id="show-register-button" type="button" class="ghost-button">Stwórz konto</button>
46
+ </div>
47
+ <p id="legacy-login-hint" class="hint hidden"></p>
48
+ </div>
49
+ </div>
50
+ </section>
51
+
52
+ <section id="register-section" class="panel hidden">
53
+ <div class="auth-card register-card">
54
+ <div class="register-header">
55
+ <h3>Załóż konto</h3>
56
+ <button id="back-to-login" type="button" class="link-button">Wróć do logowania</button>
57
+ </div>
58
+ <form id="register-form" class="form">
59
+ <div class="register-fields">
60
+ <div class="field-grid register-credentials">
61
+ <label>
62
+ Email
63
+ <input type="email" name="email" autocomplete="email" required>
64
+ </label>
65
+ <label>
66
+ Hasło
67
+ <input type="password" name="password" autocomplete="new-password" required>
68
+ </label>
69
+ <label>
70
+ Powtórz hasło
71
+ <input type="password" name="confirm_password" autocomplete="new-password" required>
72
+ </label>
73
+ </div>
74
+
75
+ <div class="field-grid register-company">
76
+ <label>
77
+ Nazwa firmy
78
+ <input type="text" name="company_name" required>
79
+ </label>
80
+ <label>
81
+ Imię i nazwisko właściciela
82
+ <input type="text" name="owner_name" required>
83
+ </label>
84
+ <label>
85
+ Ulica i numer
86
+ <input type="text" name="address_line" required>
87
+ </label>
88
+ <label>
89
+ Kod pocztowy
90
+ <input type="text" name="postal_code" required>
91
+ </label>
92
+ <label>
93
+ Miejscowość
94
+ <input type="text" name="city" required>
95
+ </label>
96
+ <label>
97
+ NIP
98
+ <input type="text" name="tax_id" required>
99
+ </label>
100
+ <label>
101
+ Numer konta bankowego
102
+ <input type="text" name="bank_account" required>
103
+ </label>
104
+ </div>
105
+ </div>
106
+ <div class="form-actions">
107
+ <button type="submit">Utwórz konto</button>
108
+ <button id="cancel-register" type="button" class="link-button">Anuluj</button>
109
+ </div>
110
+ </form>
111
+ <p id="register-feedback" class="feedback"></p>
112
+ <p class="hint">Dane konta przechowywane są lokalnie na serwerze.</p>
113
+ </div>
114
+ </section>
115
+
116
+ <section id="app-section" class="panel hidden">
117
+ <header class="app-header">
118
+ <div>
119
+ <h2>Panel faktur</h2>
120
+ <p id="current-login-label" class="app-subtitle"></p>
121
+ </div>
122
+ <nav class="app-nav">
123
+ <button type="button" class="app-nav-button active" data-view="invoice-builder">Nowa faktura</button>
124
+ <button type="button" class="app-nav-button" data-view="dashboard">Dashboard</button>
125
+ </nav>
126
+ <button id="logout-button" type="button" class="link-button">Wyloguj</button>
127
+ </header>
128
+
129
+ <section id="invoice-builder-section" class="app-view">
130
+ <section class="business-section">
131
+ <div class="business-section-header">
132
+ <h3>Dane sprzedawcy</h3>
133
+ <div class="business-actions">
134
+ <button id="toggle-business-form" type="button" class="link-button">Edycja danych</button>
135
+ <label for="logo-input" class="button secondary">
136
+ <input id="logo-input" type="file" accept="image/png,image/jpeg" hidden>
137
+ Wgraj logo
138
+ </label>
139
+ <button id="remove-logo-button" type="button" class="link-button hidden">Usuń logo</button>
140
+ </div>
141
+ </div>
142
+ <div id="business-display" class="business-display"></div>
143
+ <div id="logo-preview" class="logo-preview hidden">
144
+ <span class="logo-preview-label">Logo sprzedawcy</span>
145
+ <img id="logo-preview-image" alt="Logo firmy">
146
+ </div>
147
+ <p id="logo-feedback" class="feedback"></p>
148
+ <form id="business-form" class="form hidden">
149
+ <div class="field-grid">
150
+ <label>
151
+ Nazwa firmy
152
+ <input type="text" name="company_name" required>
153
+ </label>
154
+ <label>
155
+ Imię i nazwisko właściciela
156
+ <input type="text" name="owner_name" required>
157
+ </label>
158
+ <label>
159
+ Ulica i numer
160
+ <input type="text" name="address_line" required>
161
+ </label>
162
+ <label>
163
+ Kod pocztowy
164
+ <input type="text" name="postal_code" required>
165
+ </label>
166
+ <label>
167
+ Miejscowość
168
+ <input type="text" name="city" required>
169
+ </label>
170
+ <label>
171
+ NIP
172
+ <input type="text" name="tax_id" required>
173
+ </label>
174
+ <label>
175
+ Numer konta bankowego
176
+ <input type="text" name="bank_account" required>
177
+ </label>
178
+ </div>
179
+ <div class="form-actions">
180
+ <button type="submit">Zapisz</button>
181
+ <button id="cancel-business-update" type="button" class="link-button">Anuluj</button>
182
+ </div>
183
+ <p id="business-feedback" class="feedback"></p>
184
+ </form>
185
+ </section>
186
+
187
+ <form id="invoice-form" class="form">
188
+ <fieldset>
189
+ <legend>Informacje o fakturze</legend>
190
+ <div class="field-grid">
191
+ <label>
192
+ Data sprzedaży / wykonania usługi
193
+ <input type="date" name="saleDate">
194
+ </label>
195
+ <label>
196
+ Termin płatności (dni)
197
+ <input type="number" name="paymentTerm" min="1" step="1" value="14">
198
+ </label>
199
+ </div>
200
+ </fieldset>
201
+
202
+ <fieldset>
203
+ <legend>Dane nabywcy</legend>
204
+ <div class="field-grid">
205
+ <label>
206
+ Nazwa / Imię i nazwisko
207
+ <input type="text" name="clientName">
208
+ </label>
209
+ <label>
210
+ NIP
211
+ <input type="text" name="clientTaxId">
212
+ </label>
213
+ <label>
214
+ Ulica i numer
215
+ <input type="text" name="clientAddress">
216
+ </label>
217
+ <label>
218
+ Kod pocztowy
219
+ <input type="text" name="clientPostalCode">
220
+ </label>
221
+ <label>
222
+ Miejscowość
223
+ <input type="text" name="clientCity">
224
+ </label>
225
+ <label>
226
+ Numer telefonu
227
+ <input type="tel" name="clientPhone">
228
+ </label>
229
+ </div>
230
+ </fieldset>
231
+
232
+ <section class="items-section">
233
+ <header class="items-header">
234
+ <h3>Pozycje faktury</h3>
235
+ <button type="button" id="add-item-button">Dodaj pozycję</button>
236
+ </header>
237
+ <div class="items-table-wrapper">
238
+ <table class="items-table">
239
+ <thead>
240
+ <tr>
241
+ <th>Nazwa towaru/usługi</th>
242
+ <th>Ilość</th>
243
+ <th>Jednostka</th>
244
+ <th>Cena jedn. brutto (PLN)</th>
245
+ <th>Stawka VAT</th>
246
+ <th>Wartość brutto (PLN)</th>
247
+ <th></th>
248
+ </tr>
249
+ </thead>
250
+ <tbody id="items-body"></tbody>
251
+ </table>
252
+ </div>
253
+ </section>
254
+
255
+ <div id="totals-container" class="totals">
256
+ <span id="total-net">Suma netto: 0.00 PLN</span>
257
+ <span id="total-vat">Kwota VAT: 0.00 PLN</span>
258
+ <span id="total-gross">Suma brutto: 0.00 PLN</span>
259
+ </div>
260
+
261
+ <section id="rate-summary" class="rate-summary"></section>
262
+
263
+ <div id="exemption-note-wrapper" class="hidden">
264
+ <label for="exemption-reason">Powód zastosowania stawki ZW/0%</label>
265
+ <select id="exemption-reason" class="form-select">
266
+ <option value="">Wybierz podstawę zwolnienia...</option>
267
+ </select>
268
+ <label for="exemption-note" id="exemption-note-label">Podstawa prawna zwolnienia</label>
269
+ <textarea id="exemption-note" rows="3" placeholder="np. Art. 43 ust. 1 pkt 19 ustawy o VAT"></textarea>
270
+ </div>
271
+
272
+ <div class="form-actions">
273
+ <button type="submit" id="save-invoice-button">Generuj fakturę</button>
274
+ <button id="cancel-edit-invoice" type="button" class="link-button hidden">Anuluj edycję</button>
275
+ </div>
276
+ </form>
277
+
278
+ <section id="invoice-result" class="panel hidden">
279
+ <h3>Podgląd faktury</h3>
280
+ <div id="invoice-output" class="invoice-preview"></div>
281
+ <button id="download-button" type="button">Pobierz jako plik PDF</button>
282
+ </section>
283
+ </section>
284
+
285
+ <section id="dashboard-section" class="app-view hidden">
286
+ <header class="dashboard-header">
287
+ <div class="filters">
288
+ <label>
289
+ Od
290
+ <input type="date" id="filter-start-date">
291
+ </label>
292
+ <label>
293
+ Do
294
+ <input type="date" id="filter-end-date">
295
+ </label>
296
+ <button type="button" id="clear-filters" class="button secondary">Wyczyść</button>
297
+ </div>
298
+ <p id="dashboard-feedback" class="feedback"></p>
299
+ </header>
300
+
301
+ <section class="dashboard-summary">
302
+ <div class="summary-card">
303
+ <span class="summary-label">Ostatnie 30 dni</span>
304
+ <span id="summary-month-count" class="summary-count">0 faktur</span>
305
+ <span id="summary-month-amount" class="summary-amount">0.00 PLN</span>
306
+ </div>
307
+ <div class="summary-card">
308
+ <span class="summary-label">Bieżący kwartał</span>
309
+ <span id="summary-quarter-count" class="summary-count">0 faktur</span>
310
+ <span id="summary-quarter-amount" class="summary-amount">0.00 PLN</span>
311
+ </div>
312
+ <div class="summary-card">
313
+ <span class="summary-label">Bieżący rok</span>
314
+ <span id="summary-year-count" class="summary-count">0 faktur</span>
315
+ <span id="summary-year-amount" class="summary-amount">0.00 PLN</span>
316
+ </div>
317
+ </section>
318
+
319
+ <section class="dashboard-chart">
320
+ <canvas id="invoices-chart" aria-label="Podsumowanie faktur"></canvas>
321
+ </section>
322
+
323
+ <section class="dashboard-table">
324
+ <div class="items-table-wrapper">
325
+ <table class="items-table">
326
+ <thead>
327
+ <tr>
328
+ <th>Numer</th>
329
+ <th>Data wystawienia</th>
330
+ <th>Nabywca</th>
331
+ <th>Suma brutto</th>
332
+ <th>Akcje</th>
333
+ </tr>
334
+ </thead>
335
+ <tbody id="invoices-table-body"></tbody>
336
+ </table>
337
+ <p id="invoices-empty" class="hint hidden">Brak faktur do wyświetlenia.</p>
338
+ </div>
339
+ </section>
340
+ </section>
341
+ </section>
342
+ </main>
343
+
344
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js" defer></script>
345
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.6/dist/chart.umd.min.js" defer></script>
346
+ <script src="main.js" defer></script>
347
+ </body>
348
+ </html>
logotyp do strony.png ADDED
main.js ADDED
@@ -0,0 +1,2072 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const VAT_OPTIONS = [
2
+ { value: "23", label: "23%" },
3
+ { value: "8", label: "8%" },
4
+ { value: "5", label: "5%" },
5
+ { value: "0", label: "0% (ZW)" },
6
+ { value: "ZW", label: "ZW - zwolnione" },
7
+ { value: "NP", label: "NP - poza zakresem" },
8
+ ];
9
+
10
+ const VAT_RATE_VALUES = {
11
+ "23": 0.23,
12
+ "8": 0.08,
13
+ "5": 0.05,
14
+ "0": 0,
15
+ ZW: 0,
16
+ NP: 0,
17
+ };
18
+
19
+ const UNIT_OPTIONS = [
20
+ { value: "szt.", label: "szt." },
21
+ { value: "godz.", label: "godz." },
22
+ ];
23
+
24
+ const DEFAULT_UNIT = UNIT_OPTIONS[0].value;
25
+
26
+ const EXEMPTION_REASONS = [
27
+ {
28
+ value: "art_43_1_19",
29
+ label: "Art. 43 ust. 1 pkt 19 ustawy o VAT - usługi medyczne",
30
+ note: "Art. 43 ust. 1 pkt 19 ustawy o VAT - usługi w zakresie opieki medycznej.",
31
+ },
32
+ {
33
+ value: "art_43_1_18",
34
+ label: "Art. 43 ust. 1 pkt 18 ustawy o VAT - usługi edukacyjne",
35
+ note: "Art. 43 ust. 1 pkt 18 ustawy o VAT - usługi edukacyjne w formach przewidzianych w przepisach.",
36
+ },
37
+ {
38
+ value: "art_43_1_37",
39
+ label: "Art. 43 ust. 1 pkt 37 ustawy o VAT - usługi finansowe",
40
+ note: "Art. 43 ust. 1 pkt 37 ustawy o VAT - usługi finansowe i pośrednictwa finansowego.",
41
+ },
42
+ {
43
+ value: "art_113",
44
+ label: "Art. 113 ust. 1 i 9 ustawy o VAT - zwolnienie podmiotowe",
45
+ note: "Art. 113 ust. 1 i 9 ustawy o VAT - zwolnienie podmiotowe do 200 000 PLN obrotu.",
46
+ },
47
+ {
48
+ value: "par_3_ust_1_pkt_1",
49
+ label: "Par. 3 ust. 1 pkt 1 rozporządzenia MF z 20.12.2013 r.",
50
+ note: "Par. 3 ust. 1 pkt 1 rozporządzenia MF z 20.12.2013 r. - dostawa towarów używanych.",
51
+ },
52
+ {
53
+ value: "custom",
54
+ label: "Inne (wpisz własny opis)",
55
+ note: "",
56
+ },
57
+ ];
58
+
59
+ const EXEMPTION_REASON_LOOKUP = new Map(EXEMPTION_REASONS.map((reason) => [reason.value, reason]));
60
+
61
+ const authSection = document.getElementById("auth-section");
62
+ const appSection = document.getElementById("app-section");
63
+
64
+ const registerForm = document.getElementById("register-form");
65
+ const loginForm = document.getElementById("login-form");
66
+ const loginSubmitButton = loginForm ? loginForm.querySelector('button[type="submit"]') : null;
67
+ const loginSubmitButtonDefaultText = loginSubmitButton && loginSubmitButton.textContent ? loginSubmitButton.textContent.trim() || "Zaloguj" : "Zaloguj";
68
+ const invoiceForm = document.getElementById("invoice-form");
69
+ const businessForm = document.getElementById("business-form");
70
+
71
+ const registerFeedback = document.getElementById("register-feedback");
72
+ const loginFeedback = document.getElementById("login-feedback");
73
+ const businessFeedback = document.getElementById("business-feedback");
74
+ const logoFeedback = document.getElementById("logo-feedback");
75
+ const registerSection = document.getElementById("register-section");
76
+ const showRegisterButton = document.getElementById("show-register-button");
77
+ const backToLoginButton = document.getElementById("back-to-login");
78
+ const cancelRegisterButton = document.getElementById("cancel-register");
79
+
80
+ const businessDisplay = document.getElementById("business-display");
81
+ const toggleBusinessFormButton = document.getElementById("toggle-business-form");
82
+ const cancelBusinessUpdateButton = document.getElementById("cancel-business-update");
83
+ const currentLoginLabel = document.getElementById("current-login-label");
84
+
85
+ const itemsBody = document.getElementById("items-body");
86
+ const addItemButton = document.getElementById("add-item-button");
87
+
88
+ const totalNetLabel = document.getElementById("total-net");
89
+ const totalVatLabel = document.getElementById("total-vat");
90
+ const totalGrossLabel = document.getElementById("total-gross");
91
+ const rateSummaryContainer = document.getElementById("rate-summary");
92
+
93
+ const exemptionNoteWrapper = document.getElementById("exemption-note-wrapper");
94
+ const exemptionReasonSelect = document.getElementById("exemption-reason");
95
+ const exemptionNoteInput = document.getElementById("exemption-note");
96
+
97
+ const invoiceResult = document.getElementById("invoice-result");
98
+ const invoiceOutput = document.getElementById("invoice-output");
99
+ const downloadButton = document.getElementById("download-button");
100
+ const logoutButton = document.getElementById("logout-button");
101
+ const cancelEditInvoiceButton = document.getElementById("cancel-edit-invoice");
102
+ const saveInvoiceButton = document.getElementById("save-invoice-button");
103
+
104
+ const invoiceBuilderSection = document.getElementById("invoice-builder-section");
105
+ const dashboardSection = document.getElementById("dashboard-section");
106
+ const appNavButtons = Array.from(document.querySelectorAll(".app-nav-button"));
107
+
108
+ const invoicesTableBody = document.getElementById("invoices-table-body");
109
+ const invoicesEmpty = document.getElementById("invoices-empty");
110
+ const dashboardFeedback = document.getElementById("dashboard-feedback");
111
+
112
+ const filterStartDate = document.getElementById("filter-start-date");
113
+ const filterEndDate = document.getElementById("filter-end-date");
114
+ const clearFiltersButton = document.getElementById("clear-filters");
115
+
116
+ const summaryMonthCount = document.getElementById("summary-month-count");
117
+ const summaryMonthAmount = document.getElementById("summary-month-amount");
118
+ const summaryQuarterCount = document.getElementById("summary-quarter-count");
119
+ const summaryQuarterAmount = document.getElementById("summary-quarter-amount");
120
+ const summaryYearCount = document.getElementById("summary-year-count");
121
+ const summaryYearAmount = document.getElementById("summary-year-amount");
122
+
123
+ const logoInput = document.getElementById("logo-input");
124
+ const logoPreview = document.getElementById("logo-preview");
125
+ const logoPreviewImage = document.getElementById("logo-preview-image");
126
+ const removeLogoButton = document.getElementById("remove-logo-button");
127
+ const legacyLoginHint = document.getElementById("legacy-login-hint");
128
+ const invoicesChartCanvas = document.getElementById("invoices-chart");
129
+
130
+ let authToken = sessionStorage.getItem("invoiceAuthToken") || null;
131
+ let currentLogin = sessionStorage.getItem("invoiceLogin") || "";
132
+ let currentBusiness = null;
133
+ let currentLogo = null;
134
+ let lastInvoice = null;
135
+ let invoicesCache = [];
136
+ let editingInvoiceId = null;
137
+ let activeView = "invoice-builder";
138
+ let invoicesChart = null;
139
+ let maxLogoSize = 512 * 1024;
140
+ let pdfFontPromise = null;
141
+ let pdfFontBase64 = null;
142
+ let customExemptionNote = "";
143
+
144
+ function setVisibility(element, visible) {
145
+ if (!element) {
146
+ return;
147
+ }
148
+ if (visible) {
149
+ element.classList.remove("hidden");
150
+ element.style.removeProperty("display");
151
+ } else {
152
+ element.classList.add("hidden");
153
+ element.style.display = "none";
154
+ }
155
+ }
156
+
157
+ function setAppState(state) {
158
+ if (state === "app") {
159
+ setVisibility(authSection, false);
160
+ setVisibility(registerSection, false);
161
+ setVisibility(appSection, true);
162
+ } else {
163
+ setVisibility(authSection, true);
164
+ setVisibility(registerSection, false);
165
+ setVisibility(appSection, false);
166
+ }
167
+ }
168
+
169
+ function openRegisterPanel() {
170
+ if (!registerSection) {
171
+ return;
172
+ }
173
+ setVisibility(authSection, false);
174
+ setVisibility(registerSection, true);
175
+ setVisibility(appSection, false);
176
+ clearFeedback(registerFeedback);
177
+ clearFeedback(loginFeedback);
178
+ if (registerForm) {
179
+ const emailInput = registerForm.elements.email;
180
+ if (emailInput) {
181
+ emailInput.focus();
182
+ }
183
+ }
184
+ const scrollTarget = registerSection.querySelector(".register-card") || registerSection;
185
+ const scrollIntoView = () => scrollTarget.scrollIntoView({ behavior: "smooth", block: "start" });
186
+ if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
187
+ window.requestAnimationFrame(scrollIntoView);
188
+ } else if (typeof requestAnimationFrame === "function") {
189
+ requestAnimationFrame(scrollIntoView);
190
+ } else {
191
+ scrollIntoView();
192
+ }
193
+ }
194
+
195
+ function closeRegisterPanel({ resetForm = true, focusTrigger = false } = {}) {
196
+ if (!registerSection) {
197
+ return;
198
+ }
199
+ setVisibility(registerSection, false);
200
+ setVisibility(authSection, true);
201
+ setVisibility(appSection, false);
202
+ clearFeedback(registerFeedback);
203
+ clearFeedback(loginFeedback);
204
+ if (resetForm && registerForm) {
205
+ registerForm.reset();
206
+ }
207
+ if (focusTrigger) {
208
+ if (showRegisterButton) {
209
+ showRegisterButton.focus();
210
+ }
211
+ const scrollTarget = authSection ? authSection.querySelector(".login-card") || authSection : null;
212
+ if (scrollTarget) {
213
+ const scrollToLogin = () => scrollTarget.scrollIntoView({ behavior: "smooth", block: "start" });
214
+ if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
215
+ window.requestAnimationFrame(scrollToLogin);
216
+ } else if (typeof requestAnimationFrame === "function") {
217
+ requestAnimationFrame(scrollToLogin);
218
+ } else {
219
+ scrollToLogin();
220
+ }
221
+ }
222
+ }
223
+ }
224
+
225
+ function clearFeedback(element) {
226
+ if (!element) {
227
+ return;
228
+ }
229
+ element.textContent = "";
230
+ element.classList.remove("error", "success");
231
+ }
232
+
233
+ function showFeedback(element, message, type = "error") {
234
+ if (!element) {
235
+ return;
236
+ }
237
+ element.textContent = message;
238
+ element.classList.remove("error", "success");
239
+ if (type) {
240
+ element.classList.add(type);
241
+ }
242
+ }
243
+
244
+ function parseNumber(value) {
245
+ if (typeof value === "number") {
246
+ return Number.isFinite(value) ? value : 0;
247
+ }
248
+ if (!value) {
249
+ return 0;
250
+ }
251
+ const normalized = value.toString().replace(",", ".");
252
+ const parsed = Number.parseFloat(normalized);
253
+ return Number.isFinite(parsed) ? parsed : 0;
254
+ }
255
+
256
+ function parseIntegerString(value) {
257
+ if (value === null || value === undefined) {
258
+ return Number.NaN;
259
+ }
260
+ const normalized = value.toString().trim();
261
+ if (!normalized) {
262
+ return 0;
263
+ }
264
+ if (!/^\d+$/.test(normalized)) {
265
+ return Number.NaN;
266
+ }
267
+ const parsed = Number.parseInt(normalized, 10);
268
+ return Number.isNaN(parsed) ? Number.NaN : parsed;
269
+ }
270
+
271
+ function formatQuantity(value) {
272
+ const parsed = parseIntegerString(value);
273
+ if (Number.isNaN(parsed)) {
274
+ return "0";
275
+ }
276
+ return parsed.toString();
277
+ }
278
+
279
+ function formatCurrency(value) {
280
+ const number = parseNumber(value);
281
+ return `${number.toFixed(2)} PLN`;
282
+ }
283
+
284
+ function vatLabelFromCode(code) {
285
+ if (code === "ZW" || code === "0") {
286
+ return "ZW";
287
+ }
288
+ if (code === "NP") {
289
+ return "NP";
290
+ }
291
+ return `${code}%`;
292
+ }
293
+
294
+ function requiresExemption(code) {
295
+ return code === "ZW" || code === "0";
296
+ }
297
+
298
+ function populateExemptionReasons() {
299
+ if (!exemptionReasonSelect || exemptionReasonSelect.dataset.initialized === "true") {
300
+ return;
301
+ }
302
+ const existingValues = new Set(Array.from(exemptionReasonSelect.options).map((option) => option.value));
303
+ EXEMPTION_REASONS.forEach((reason) => {
304
+ if (existingValues.has(reason.value)) {
305
+ return;
306
+ }
307
+ const option = document.createElement("option");
308
+ option.value = reason.value;
309
+ option.textContent = reason.label;
310
+ exemptionReasonSelect.appendChild(option);
311
+ });
312
+ exemptionReasonSelect.dataset.initialized = "true";
313
+ }
314
+
315
+ function applyExemptionReasonSelection({ preserveCustom = false } = {}) {
316
+ if (!exemptionReasonSelect || !exemptionNoteInput) {
317
+ return;
318
+ }
319
+ const selectedValue = exemptionReasonSelect.value;
320
+ const selectedReason = EXEMPTION_REASON_LOOKUP.get(selectedValue);
321
+
322
+ // Ukryj pole "Podstawa prawna zwolnienia" jeśli nie wybrano opcji "Inne..."
323
+ const exemptionNoteLabel = document.getElementById("exemption-note-label");
324
+ if (exemptionNoteLabel) {
325
+ if (selectedValue === "custom") {
326
+ exemptionNoteLabel.style.display = "block";
327
+ exemptionNoteInput.style.display = "block";
328
+ } else {
329
+ exemptionNoteLabel.style.display = "none";
330
+ exemptionNoteInput.style.display = "none";
331
+ }
332
+ }
333
+
334
+ if (!selectedReason) {
335
+ if (!preserveCustom) {
336
+ exemptionNoteInput.readOnly = false;
337
+ exemptionNoteInput.value = "";
338
+ }
339
+ return;
340
+ }
341
+ if (selectedValue === "custom") {
342
+ exemptionNoteInput.readOnly = false;
343
+ if (!preserveCustom) {
344
+ exemptionNoteInput.value = customExemptionNote;
345
+ }
346
+ return;
347
+ }
348
+ exemptionNoteInput.readOnly = true;
349
+ exemptionNoteInput.value = selectedReason.note;
350
+ }
351
+
352
+ function findExemptionReasonByNote(note) {
353
+ if (!note) {
354
+ return null;
355
+ }
356
+ const normalized = note.trim().toLowerCase();
357
+ return (
358
+ EXEMPTION_REASONS.find(
359
+ (reason) =>
360
+ reason.value !== "custom" && reason.note && reason.note.trim().toLowerCase() === normalized
361
+ ) || null
362
+ );
363
+ }
364
+
365
+ function syncExemptionControlsWithNote(note) {
366
+ if (!exemptionNoteInput) {
367
+ return;
368
+ }
369
+ const trimmed = (note || "").trim();
370
+ exemptionNoteInput.readOnly = false;
371
+ if (!exemptionReasonSelect) {
372
+ exemptionNoteInput.value = trimmed;
373
+ return;
374
+ }
375
+ if (!trimmed) {
376
+ customExemptionNote = "";
377
+ exemptionReasonSelect.value = "";
378
+ exemptionNoteInput.value = "";
379
+ return;
380
+ }
381
+ const matchedReason = findExemptionReasonByNote(trimmed);
382
+ if (matchedReason && matchedReason.value !== "custom") {
383
+ exemptionReasonSelect.value = matchedReason.value;
384
+ applyExemptionReasonSelection({ preserveCustom: true });
385
+ } else {
386
+ customExemptionNote = trimmed;
387
+ exemptionReasonSelect.value = "custom";
388
+ exemptionNoteInput.readOnly = false;
389
+ exemptionNoteInput.value = trimmed;
390
+ }
391
+ }
392
+
393
+ function updateExemptionVisibility(exemptionNeeded) {
394
+ if (!exemptionNoteWrapper || !exemptionNoteInput) {
395
+ return;
396
+ }
397
+ if (exemptionNeeded) {
398
+ populateExemptionReasons();
399
+ setVisibility(exemptionNoteWrapper, true);
400
+ applyExemptionReasonSelection({ preserveCustom: true });
401
+ return;
402
+ }
403
+ setVisibility(exemptionNoteWrapper, false);
404
+ if (exemptionReasonSelect) {
405
+ exemptionReasonSelect.value = "";
406
+ }
407
+ customExemptionNote = "";
408
+ exemptionNoteInput.readOnly = false;
409
+ exemptionNoteInput.value = "";
410
+ }
411
+
412
+ function formatInvoicesCount(count) {
413
+ const value = Number.parseInt(count, 10) || 0;
414
+ const absolute = Math.abs(value);
415
+ const mod10 = absolute % 10;
416
+ const mod100 = absolute % 100;
417
+ let suffix = "faktur";
418
+ if (mod10 === 1 && mod100 !== 11) {
419
+ suffix = "faktura";
420
+ } else if ([2, 3, 4].includes(mod10) && ![12, 13, 14].includes(mod100)) {
421
+ suffix = "faktury";
422
+ }
423
+ return `${value} ${suffix}`;
424
+ }
425
+
426
+ function parseInvoiceIssuedAt(invoice) {
427
+ if (!invoice || !invoice.issued_at) {
428
+ return null;
429
+ }
430
+ const normalized = invoice.issued_at.replace(" ", "T");
431
+ const parsed = new Date(normalized);
432
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
433
+ }
434
+
435
+ function parseDateInput(value) {
436
+ if (!value) {
437
+ return null;
438
+ }
439
+ const parts = value.split("-").map((part) => Number.parseInt(part, 10));
440
+ if (parts.length !== 3 || parts.some(Number.isNaN)) {
441
+ return null;
442
+ }
443
+ return new Date(parts[0], parts[1] - 1, parts[2]);
444
+ }
445
+
446
+ function setActiveView(view) {
447
+ activeView = view === "dashboard" ? "dashboard" : "invoice-builder";
448
+ setVisibility(invoiceBuilderSection, activeView === "invoice-builder");
449
+ setVisibility(dashboardSection, activeView === "dashboard");
450
+ const showDashboard = activeView === "dashboard";
451
+ appNavButtons.forEach((button) => {
452
+ button.classList.toggle("active", button.dataset.view === activeView);
453
+ });
454
+ if (showDashboard) {
455
+ applyInvoiceFilters();
456
+ }
457
+ }
458
+
459
+ function updateLoginLabel() {
460
+ if (!currentLogin) {
461
+ currentLoginLabel.textContent = "";
462
+ return;
463
+ }
464
+ currentLoginLabel.textContent = `Zalogowany jako ${currentLogin}`;
465
+ }
466
+
467
+ function updateLogoPreview() {
468
+ if (currentLogo && currentLogo.data && currentLogo.mime_type) {
469
+ const dataUrl = currentLogo.data_url || `data:${currentLogo.mime_type};base64,${currentLogo.data}`;
470
+ logoPreviewImage.src = dataUrl;
471
+ logoPreview.classList.remove("hidden");
472
+ removeLogoButton.classList.remove("hidden");
473
+ } else {
474
+ logoPreviewImage.removeAttribute("src");
475
+ logoPreview.classList.add("hidden");
476
+ removeLogoButton.classList.add("hidden");
477
+ }
478
+ }
479
+
480
+ function renderInvoicesTable(invoices) {
481
+ invoicesTableBody.innerHTML = "";
482
+ if (!Array.isArray(invoices) || invoices.length === 0) {
483
+ invoicesEmpty.classList.remove("hidden");
484
+ return;
485
+ }
486
+
487
+ invoicesEmpty.classList.add("hidden");
488
+ invoices.forEach((invoice) => {
489
+ const row = document.createElement("tr");
490
+
491
+ const numberCell = document.createElement("td");
492
+ numberCell.textContent = invoice.invoice_id || "---";
493
+ row.appendChild(numberCell);
494
+
495
+ const issuedCell = document.createElement("td");
496
+ issuedCell.textContent = invoice.issued_at || "-";
497
+ row.appendChild(issuedCell);
498
+
499
+ const clientCell = document.createElement("td");
500
+ const clientName = invoice.client?.name || "";
501
+ const clientCity = invoice.client?.city || "";
502
+ clientCell.textContent = clientName ? `${clientName}${clientCity ? ` (${clientCity})` : ""}` : "-";
503
+ row.appendChild(clientCell);
504
+
505
+ const grossCell = document.createElement("td");
506
+ grossCell.textContent = formatCurrency(invoice.totals?.gross ?? 0);
507
+ row.appendChild(grossCell);
508
+
509
+ const actionsCell = document.createElement("td");
510
+ const actionsWrapper = document.createElement("div");
511
+ actionsWrapper.className = "table-actions";
512
+
513
+ const editButton = document.createElement("button");
514
+ editButton.type = "button";
515
+ editButton.textContent = "Edytuj";
516
+ editButton.addEventListener("click", () => {
517
+ startInvoiceEdit(invoice.invoice_id);
518
+ });
519
+
520
+ const deleteButton = document.createElement("button");
521
+ deleteButton.type = "button";
522
+ deleteButton.className = "button secondary";
523
+ deleteButton.textContent = "Usuń";
524
+ deleteButton.addEventListener("click", async () => {
525
+ clearFeedback(dashboardFeedback);
526
+ const shouldDelete = window.confirm(`Usuńac fakturę ${invoice.invoice_id}?`);
527
+ if (!shouldDelete) {
528
+ return;
529
+ }
530
+ await deleteInvoice(invoice.invoice_id);
531
+ });
532
+
533
+ actionsWrapper.appendChild(editButton);
534
+ actionsWrapper.appendChild(deleteButton);
535
+ actionsCell.appendChild(actionsWrapper);
536
+ row.appendChild(actionsCell);
537
+
538
+ invoicesTableBody.appendChild(row);
539
+ });
540
+ }
541
+
542
+ function applyInvoiceFilters() {
543
+ if (!Array.isArray(invoicesCache)) {
544
+ renderInvoicesTable([]);
545
+ return;
546
+ }
547
+
548
+ let filtered = invoicesCache.slice();
549
+ const startDate = parseDateInput(filterStartDate?.value);
550
+ const endDate = parseDateInput(filterEndDate?.value);
551
+
552
+ if (startDate) {
553
+ const startTime = startDate.getTime();
554
+ filtered = filtered.filter((invoice) => {
555
+ const issued = parseInvoiceIssuedAt(invoice);
556
+ return !issued || issued.getTime() >= startTime;
557
+ });
558
+ }
559
+
560
+ if (endDate) {
561
+ const endBoundary = new Date(endDate);
562
+ endBoundary.setHours(23, 59, 59, 999);
563
+ const endTime = endBoundary.getTime();
564
+ filtered = filtered.filter((invoice) => {
565
+ const issued = parseInvoiceIssuedAt(invoice);
566
+ return !issued || issued.getTime() <= endTime;
567
+ });
568
+ }
569
+
570
+ filtered.sort((a, b) => (b.issued_at || "").localeCompare(a.issued_at || ""));
571
+ renderInvoicesTable(filtered);
572
+ }
573
+
574
+ async function refreshInvoices() {
575
+ if (!authToken) {
576
+ invoicesCache = [];
577
+ renderInvoicesTable([]);
578
+ return;
579
+ }
580
+ clearFeedback(dashboardFeedback);
581
+ try {
582
+ const data = await apiRequest("/api/invoices", {}, true);
583
+ invoicesCache = Array.isArray(data.invoices) ? data.invoices.slice() : [];
584
+ invoicesCache.sort((a, b) => (b.issued_at || "").localeCompare(a.issued_at || ""));
585
+ applyInvoiceFilters();
586
+ } catch (error) {
587
+ console.error(error);
588
+ showFeedback(dashboardFeedback, error.message || "Nie udało się pobrać faktur.");
589
+ }
590
+ }
591
+
592
+ function updateSummaryCards(summary) {
593
+ const monthSummary = summary?.last_month || { count: 0, gross_total: 0 };
594
+ const quarterSummary = summary?.quarter || { count: 0, gross_total: 0 };
595
+ const yearSummary = summary?.year || { count: 0, gross_total: 0 };
596
+
597
+ summaryMonthCount.textContent = formatInvoicesCount(monthSummary.count);
598
+ summaryQuarterCount.textContent = formatInvoicesCount(quarterSummary.count);
599
+ summaryYearCount.textContent = formatInvoicesCount(yearSummary.count);
600
+
601
+ summaryMonthAmount.textContent = formatCurrency(monthSummary.gross_total ?? 0);
602
+ summaryQuarterAmount.textContent = formatCurrency(quarterSummary.gross_total ?? 0);
603
+ summaryYearAmount.textContent = formatCurrency(yearSummary.gross_total ?? 0);
604
+ }
605
+
606
+ function updateSummaryChart(summary) {
607
+ if (!invoicesChartCanvas || typeof window.Chart === "undefined") {
608
+ return;
609
+ }
610
+
611
+ const labels = ["Ostatnie 30 dni", "Bieżący kwartał", "Bieżący rok"];
612
+ const counts = [
613
+ Number.parseInt(summary?.last_month?.count ?? 0, 10) || 0,
614
+ Number.parseInt(summary?.quarter?.count ?? 0, 10) || 0,
615
+ Number.parseInt(summary?.year?.count ?? 0, 10) || 0,
616
+ ];
617
+ const amounts = [
618
+ parseNumber(summary?.last_month?.gross_total ?? 0),
619
+ parseNumber(summary?.quarter?.gross_total ?? 0),
620
+ parseNumber(summary?.year?.gross_total ?? 0),
621
+ ];
622
+
623
+ const chartData = {
624
+ labels,
625
+ datasets: [
626
+ {
627
+ label: "Liczba faktur",
628
+ data: counts,
629
+ backgroundColor: "rgba(26, 115, 232, 0.65)",
630
+ yAxisID: "count",
631
+ borderRadius: 6,
632
+ },
633
+ {
634
+ label: "Suma brutto (PLN)",
635
+ data: amounts,
636
+ type: "line",
637
+ fill: false,
638
+ borderColor: "rgba(26, 115, 232, 0.65)",
639
+ backgroundColor: "rgba(26, 115, 232, 0.35)",
640
+ tension: 0.3,
641
+ yAxisID: "amount",
642
+ },
643
+ ],
644
+ };
645
+
646
+ const options = {
647
+ responsive: true,
648
+ maintainAspectRatio: false,
649
+ scales: {
650
+ count: {
651
+ beginAtZero: true,
652
+ position: "left",
653
+ ticks: {
654
+ precision: 0,
655
+ stepSize: 1,
656
+ },
657
+ },
658
+ amount: {
659
+ beginAtZero: true,
660
+ position: "right",
661
+ grid: {
662
+ drawOnChartArea: false,
663
+ },
664
+ ticks: {
665
+ callback: (value) => `${new Intl.NumberFormat("pl-PL", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value)} PLN`,
666
+ },
667
+ },
668
+ },
669
+ plugins: {
670
+ legend: {
671
+ position: "bottom",
672
+ },
673
+ tooltip: {
674
+ callbacks: {
675
+ label(context) {
676
+ if (context.dataset.yAxisID === "amount") {
677
+ return `${context.dataset.label}: ${new Intl.NumberFormat("pl-PL", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(context.parsed.y)} PLN`;
678
+ }
679
+ return `${context.dataset.label}: ${context.parsed.y}`;
680
+ },
681
+ },
682
+ },
683
+ },
684
+ };
685
+
686
+ if (!invoicesChart) {
687
+ invoicesChart = new window.Chart(invoicesChartCanvas, {
688
+ type: "bar",
689
+ data: chartData,
690
+ options,
691
+ });
692
+ } else {
693
+ invoicesChart.data = chartData;
694
+ invoicesChart.options = options;
695
+ invoicesChart.update();
696
+ }
697
+ }
698
+
699
+ async function refreshSummary() {
700
+ if (!authToken) {
701
+ updateSummaryCards({});
702
+ updateSummaryChart({});
703
+ return;
704
+ }
705
+ clearFeedback(dashboardFeedback);
706
+ try {
707
+ const data = await apiRequest("/api/invoices/summary", {}, true);
708
+ updateSummaryCards(data.summary);
709
+ updateSummaryChart(data.summary);
710
+ } catch (error) {
711
+ console.error(error);
712
+ showFeedback(dashboardFeedback, error.message || "Nie udało się pobrać podsumowania.");
713
+ }
714
+ }
715
+
716
+ async function deleteInvoice(invoiceId) {
717
+ if (!invoiceId) {
718
+ return;
719
+ }
720
+ try {
721
+ await apiRequest(`/api/invoices/${encodeURIComponent(invoiceId)}`, { method: "DELETE" }, true);
722
+ invoicesCache = invoicesCache.filter((invoice) => invoice.invoice_id !== invoiceId);
723
+ applyInvoiceFilters();
724
+ await refreshSummary();
725
+ } catch (error) {
726
+ console.error(error);
727
+ showFeedback(dashboardFeedback, error.message || "Nie udało się usunąć faktury.");
728
+ }
729
+ }
730
+
731
+ function startInvoiceEdit(invoiceId) {
732
+ if (!invoiceId) {
733
+ return;
734
+ }
735
+ const invoice = invoicesCache.find((item) => item.invoice_id === invoiceId);
736
+ if (!invoice) {
737
+ showFeedback(dashboardFeedback, "Nie znaleziono wybranej faktury.");
738
+ return;
739
+ }
740
+
741
+ editingInvoiceId = invoiceId;
742
+ saveInvoiceButton.textContent = "Zapisz zmiany";
743
+ cancelEditInvoiceButton.classList.remove("hidden");
744
+ setActiveView("invoice-builder");
745
+
746
+ resetInvoiceForm();
747
+ invoiceForm.elements.saleDate.value = invoice.sale_date || "";
748
+ invoiceForm.elements.paymentTerm.value = invoice.payment_term || 14;
749
+
750
+ if (invoice.client) {
751
+ invoiceForm.elements.clientName.value = invoice.client.name || "";
752
+ invoiceForm.elements.clientTaxId.value = invoice.client.tax_id || "";
753
+ invoiceForm.elements.clientAddress.value = invoice.client.address_line || "";
754
+ invoiceForm.elements.clientPostalCode.value = invoice.client.postal_code || "";
755
+ invoiceForm.elements.clientCity.value = invoice.client.city || "";
756
+ invoiceForm.elements.clientPhone.value = invoice.client.phone || "";
757
+ }
758
+
759
+ itemsBody.innerHTML = "";
760
+ if (Array.isArray(invoice.items) && invoice.items.length > 0) {
761
+ invoice.items.forEach((item) => {
762
+ createItemRow({
763
+ name: item.name,
764
+ quantity: item.quantity,
765
+ unit_price_gross: item.unit_price_gross ?? item.gross_total,
766
+ vat_code: item.vat_code,
767
+ unit: item.unit,
768
+ });
769
+ });
770
+ } else {
771
+ createItemRow();
772
+ }
773
+
774
+ const note = invoice.exemption_note || "";
775
+ syncExemptionControlsWithNote(note);
776
+ const requiresNote = Array.isArray(invoice.items)
777
+ ? invoice.items.some((item) => requiresExemption(item.vat_code))
778
+ : false;
779
+ updateExemptionVisibility(requiresNote);
780
+
781
+ lastInvoice = invoice;
782
+ }
783
+
784
+ function exitInvoiceEdit() {
785
+ editingInvoiceId = null;
786
+ saveInvoiceButton.textContent = "Generuj fakturę";
787
+ cancelEditInvoiceButton.classList.add("hidden");
788
+ }
789
+
790
+ async function apiRequest(path, { method = "GET", body, headers = {} } = {}, requireAuth = false) {
791
+ const options = {
792
+ method,
793
+ headers: {
794
+ "Content-Type": "application/json",
795
+ ...headers,
796
+ },
797
+ };
798
+
799
+ if (body !== undefined) {
800
+ options.body = JSON.stringify(body);
801
+ }
802
+
803
+ if (requireAuth) {
804
+ if (!authToken) {
805
+ throw new Error("Brak tokenu autoryzacyjnego.");
806
+ }
807
+ options.headers.Authorization = `Bearer ${authToken}`;
808
+ }
809
+
810
+ const response = await fetch(path, options);
811
+ const isJson = response.headers.get("content-type")?.includes("application/json");
812
+ const data = isJson ? await response.json() : {};
813
+
814
+ if (response.status === 401) {
815
+ authToken = null;
816
+ currentLogin = "";
817
+ sessionStorage.removeItem("invoiceAuthToken");
818
+ sessionStorage.removeItem("invoiceLogin");
819
+ setAppState("auth");
820
+ throw new Error(data.error || "Sesja wygasła. Zaloguj się ponownie.");
821
+ }
822
+
823
+ if (!response.ok) {
824
+ throw new Error(data.error || "Wystapil błąd podczas komunikacji z serwerem.");
825
+ }
826
+
827
+ return data;
828
+ }
829
+
830
+ function renderBusinessDisplay(business) {
831
+ if (!business) {
832
+ businessDisplay.textContent = "Brak zapisanych danych firmy.";
833
+ return;
834
+ }
835
+
836
+ const fallback = (value) => {
837
+ if (!value) {
838
+ return "---";
839
+ }
840
+ const trimmed = value.toString().trim();
841
+ return trimmed || "---";
842
+ };
843
+
844
+ const companyName = fallback(business.company_name);
845
+ const ownerName = fallback(business.owner_name);
846
+ const addressLine = fallback(business.address_line);
847
+ const location = fallback([business.postal_code, business.city].filter(Boolean).join(" "));
848
+ const taxLine = `NIP: ${fallback(business.tax_id)}`;
849
+ const bankLine = `Konto: ${fallback(business.bank_account)}`;
850
+
851
+ businessDisplay.innerHTML = `
852
+ <div class="business-display-grid">
853
+ <div class="business-display-item business-display-item--name">
854
+ <strong>${companyName}</strong>
855
+ <span>${ownerName}</span>
856
+ </div>
857
+ <div class="business-display-item">
858
+ <span>${addressLine}</span>
859
+ <span>${location}</span>
860
+ </div>
861
+ <div class="business-display-item">
862
+ <span>${taxLine}</span>
863
+ <span>${bankLine}</span>
864
+ </div>
865
+ </div>
866
+ `;
867
+ }
868
+
869
+ function fillBusinessForm(business) {
870
+ if (!business) {
871
+ return;
872
+ }
873
+ businessForm.elements.company_name.value = business.company_name || "";
874
+ businessForm.elements.owner_name.value = business.owner_name || "";
875
+ businessForm.elements.address_line.value = business.address_line || "";
876
+ businessForm.elements.postal_code.value = business.postal_code || "";
877
+ businessForm.elements.city.value = business.city || "";
878
+ businessForm.elements.tax_id.value = business.tax_id || "";
879
+ businessForm.elements.bank_account.value = business.bank_account || "";
880
+ }
881
+
882
+ function setBusinessFormVisibility(visible, { preserveFeedback = false } = {}) {
883
+ setVisibility(businessForm, visible);
884
+ if (toggleBusinessFormButton) {
885
+ toggleBusinessFormButton.textContent = visible ? "Ukryj formularz" : "Edycja danych";
886
+ }
887
+ if (!visible && !preserveFeedback) {
888
+ clearFeedback(businessFeedback);
889
+ }
890
+ }
891
+
892
+ function vatSelectElement(initialValue = "23") {
893
+ const select = document.createElement("select");
894
+ select.className = "item-vat";
895
+ VAT_OPTIONS.forEach((option) => {
896
+ const element = document.createElement("option");
897
+ element.value = option.value;
898
+ element.textContent = option.label;
899
+ select.appendChild(element);
900
+ });
901
+ select.value = VAT_OPTIONS.some((option) => option.value === initialValue) ? initialValue : "23";
902
+ return select;
903
+ }
904
+
905
+ function unitSelectElement(initialValue = DEFAULT_UNIT) {
906
+ const select = document.createElement("select");
907
+ select.className = "item-unit";
908
+ UNIT_OPTIONS.forEach((option) => {
909
+ const element = document.createElement("option");
910
+ element.value = option.value;
911
+ element.textContent = option.label;
912
+ select.appendChild(element);
913
+ });
914
+ select.value = UNIT_OPTIONS.some((option) => option.value === initialValue) ? initialValue : DEFAULT_UNIT;
915
+ return select;
916
+ }
917
+
918
+ function createItemRow(initialValues = {}) {
919
+ const row = document.createElement("tr");
920
+
921
+ const nameCell = document.createElement("td");
922
+ const nameInput = document.createElement("input");
923
+ nameInput.type = "text";
924
+ nameInput.className = "item-name";
925
+ nameInput.placeholder = "Nazwa towaru lub usługi";
926
+ if (initialValues.name) {
927
+ nameInput.value = initialValues.name;
928
+ }
929
+ nameCell.appendChild(nameInput);
930
+
931
+ const quantityCell = document.createElement("td");
932
+ const quantityInput = document.createElement("input");
933
+ quantityInput.type = "number";
934
+ quantityInput.className = "item-quantity";
935
+ quantityInput.min = "1";
936
+ quantityInput.step = "1";
937
+ quantityInput.inputMode = "numeric";
938
+ const parsedQuantity = parseIntegerString(initialValues.quantity);
939
+ const safeQuantity = Number.isNaN(parsedQuantity) || parsedQuantity <= 0 ? 1 : parsedQuantity;
940
+ quantityInput.value = String(safeQuantity);
941
+ quantityCell.appendChild(quantityInput);
942
+
943
+ const unitCell = document.createElement("td");
944
+ const unitSelect = unitSelectElement(initialValues.unit);
945
+ unitCell.appendChild(unitSelect);
946
+
947
+ const unitGrossCell = document.createElement("td");
948
+ const unitGrossInput = document.createElement("input");
949
+ unitGrossInput.type = "number";
950
+ unitGrossInput.className = "item-gross";
951
+ unitGrossInput.min = "0.01";
952
+ unitGrossInput.step = "0.01";
953
+ unitGrossInput.placeholder = "Brutto";
954
+ if (initialValues.unit_price_gross) {
955
+ unitGrossInput.value = initialValues.unit_price_gross;
956
+ }
957
+ unitGrossCell.appendChild(unitGrossInput);
958
+
959
+ const vatCell = document.createElement("td");
960
+ const vatSelect = vatSelectElement(initialValues.vat_code);
961
+ vatCell.appendChild(vatSelect);
962
+
963
+ const totalCell = document.createElement("td");
964
+ totalCell.className = "item-total";
965
+ totalCell.textContent = "0.00 PLN";
966
+
967
+ const actionsCell = document.createElement("td");
968
+ const removeButton = document.createElement("button");
969
+ removeButton.type = "button";
970
+ removeButton.className = "remove-item";
971
+ removeButton.textContent = "Usuń";
972
+ actionsCell.appendChild(removeButton);
973
+
974
+ row.appendChild(nameCell);
975
+ row.appendChild(quantityCell);
976
+ row.appendChild(unitCell);
977
+ row.appendChild(unitGrossCell);
978
+ row.appendChild(vatCell);
979
+ row.appendChild(totalCell);
980
+ row.appendChild(actionsCell);
981
+
982
+ const handleChange = () => updateTotals();
983
+ nameInput.addEventListener("input", handleChange);
984
+ quantityInput.addEventListener("input", () => {
985
+ const sanitized = quantityInput.value.replace(/[^0-9]/g, "");
986
+ quantityInput.value = sanitized;
987
+ handleChange();
988
+ });
989
+ quantityInput.addEventListener("blur", () => {
990
+ const parsed = parseIntegerString(quantityInput.value);
991
+ quantityInput.value = Number.isNaN(parsed) || parsed <= 0 ? "1" : String(parsed);
992
+ handleChange();
993
+ });
994
+ unitGrossInput.addEventListener("input", handleChange);
995
+ vatSelect.addEventListener("change", handleChange);
996
+ unitSelect.addEventListener("change", handleChange);
997
+
998
+ removeButton.addEventListener("click", () => {
999
+ if (itemsBody.children.length === 1) {
1000
+ nameInput.value = "";
1001
+ quantityInput.value = "1";
1002
+ unitGrossInput.value = "";
1003
+ vatSelect.value = "23";
1004
+ unitSelect.value = DEFAULT_UNIT;
1005
+ updateTotals();
1006
+ return;
1007
+ }
1008
+ row.remove();
1009
+ updateTotals();
1010
+ });
1011
+
1012
+ itemsBody.appendChild(row);
1013
+ updateTotals();
1014
+ }
1015
+
1016
+ function calculateRowTotals(row) {
1017
+ const name = row.querySelector(".item-name")?.value.trim() ?? "";
1018
+ const quantityRaw = row.querySelector(".item-quantity")?.value;
1019
+ const quantityParsed = parseIntegerString(quantityRaw);
1020
+ const quantityValid = Number.isInteger(quantityParsed) && quantityParsed > 0;
1021
+ const quantity = quantityValid ? quantityParsed : 0;
1022
+ const unitGross = parseNumber(row.querySelector(".item-gross")?.value);
1023
+ const vatCode = row.querySelector(".item-vat")?.value ?? "23";
1024
+ const rate = VAT_RATE_VALUES[vatCode] ?? 0;
1025
+ const unit = row.querySelector(".item-unit")?.value || DEFAULT_UNIT;
1026
+ const unitLabel = UNIT_OPTIONS.some((option) => option.value === unit) ? unit : DEFAULT_UNIT;
1027
+
1028
+ const hasValues = name || quantity > 0 || unitGross > 0;
1029
+ if (!hasValues) {
1030
+ return {
1031
+ valid: false,
1032
+ vatCode,
1033
+ vatLabel: vatLabelFromCode(vatCode),
1034
+ requiresExemption: requiresExemption(vatCode),
1035
+ quantity,
1036
+ unitGross,
1037
+ unitNet: 0,
1038
+ netTotal: 0,
1039
+ vatAmount: 0,
1040
+ grossTotal: 0,
1041
+ unit: unitLabel,
1042
+ };
1043
+ }
1044
+
1045
+ if (!quantityValid || unitGross <= 0) {
1046
+ return {
1047
+ valid: false,
1048
+ vatCode,
1049
+ vatLabel: vatLabelFromCode(vatCode),
1050
+ requiresExemption: requiresExemption(vatCode),
1051
+ quantity,
1052
+ unitGross,
1053
+ unitNet: 0,
1054
+ netTotal: 0,
1055
+ vatAmount: 0,
1056
+ grossTotal: quantity * unitGross,
1057
+ unit: unitLabel,
1058
+ };
1059
+ }
1060
+
1061
+ const grossTotal = quantity * unitGross;
1062
+ const netTotal = rate > 0 ? grossTotal / (1 + rate) : grossTotal;
1063
+ const vatAmount = grossTotal - netTotal;
1064
+ const unitNet = netTotal / quantity;
1065
+
1066
+ return {
1067
+ valid: true,
1068
+ vatCode,
1069
+ vatLabel: vatLabelFromCode(vatCode),
1070
+ requiresExemption: requiresExemption(vatCode),
1071
+ quantity,
1072
+ unitGross,
1073
+ unitNet,
1074
+ netTotal,
1075
+ vatAmount,
1076
+ grossTotal,
1077
+ unit: unitLabel,
1078
+ };
1079
+ }
1080
+
1081
+ function updateTotals() {
1082
+ let totalNet = 0;
1083
+ let totalVat = 0;
1084
+ let totalGross = 0;
1085
+ const summary = new Map();
1086
+ let exemptionNeeded = false;
1087
+
1088
+ const rows = Array.from(itemsBody.querySelectorAll("tr"));
1089
+ rows.forEach((row) => {
1090
+ const totals = calculateRowTotals(row);
1091
+ if (totals.requiresExemption) {
1092
+ exemptionNeeded = true;
1093
+ }
1094
+ const totalCell = row.querySelector(".item-total");
1095
+ totalCell.textContent = formatCurrency(totals.grossTotal);
1096
+
1097
+ if (!totals.valid) {
1098
+ return;
1099
+ }
1100
+
1101
+ totalNet += totals.netTotal;
1102
+ totalVat += totals.vatAmount;
1103
+ totalGross += totals.grossTotal;
1104
+
1105
+ const existing = summary.get(totals.vatLabel) || { net: 0, vat: 0, gross: 0 };
1106
+ existing.net += totals.netTotal;
1107
+ existing.vat += totals.vatAmount;
1108
+ existing.gross += totals.grossTotal;
1109
+ summary.set(totals.vatLabel, existing);
1110
+ });
1111
+
1112
+ totalNetLabel.textContent = `Suma netto: ${totalNet.toFixed(2)} PLN`;
1113
+ totalVatLabel.textContent = `Kwota VAT: ${totalVat.toFixed(2)} PLN`;
1114
+ totalGrossLabel.textContent = `Suma brutto: ${totalGross.toFixed(2)} PLN`;
1115
+ renderRateSummary(summary);
1116
+
1117
+ updateExemptionVisibility(exemptionNeeded);
1118
+ }
1119
+
1120
+ function renderRateSummary(summary) {
1121
+ if (!summary || summary.size === 0) {
1122
+ rateSummaryContainer.innerHTML = "";
1123
+ return;
1124
+ }
1125
+
1126
+ const entries = Array.from(summary.entries()).sort(([a], [b]) => a.localeCompare(b));
1127
+ const markup = entries
1128
+ .map(
1129
+ ([label, totals]) =>
1130
+ `<div class="rate-summary-item">
1131
+ <span>${label}</span>
1132
+ <span>Netto: ${totals.net.toFixed(2)} PLN</span>
1133
+ <span>VAT: ${totals.vat.toFixed(2)} PLN</span>
1134
+ <span>Brutto: ${totals.gross.toFixed(2)} PLN</span>
1135
+ </div>`
1136
+ )
1137
+ .join("");
1138
+ rateSummaryContainer.innerHTML = `<h4>Podsumowanie stawek</h4>${markup}`;
1139
+ }
1140
+
1141
+ function collectInvoicePayload() {
1142
+ const items = [];
1143
+ const rows = Array.from(itemsBody.querySelectorAll("tr"));
1144
+
1145
+ rows.forEach((row) => {
1146
+ const name = row.querySelector(".item-name")?.value.trim() ?? "";
1147
+ const quantityRaw = row.querySelector(".item-quantity")?.value;
1148
+ const quantityParsed = parseIntegerString(quantityRaw);
1149
+ const quantityValid = Number.isInteger(quantityParsed) && quantityParsed > 0;
1150
+ const quantity = quantityValid ? quantityParsed : 0;
1151
+ const unitGross = parseNumber(row.querySelector(".item-gross")?.value);
1152
+ const vatCode = row.querySelector(".item-vat")?.value ?? "23";
1153
+ const unitValue = row.querySelector(".item-unit")?.value || DEFAULT_UNIT;
1154
+ const unit = UNIT_OPTIONS.some((option) => option.value === unitValue) ? unitValue : DEFAULT_UNIT;
1155
+
1156
+ const hasValues = name || quantity > 0 || unitGross > 0;
1157
+ if (!hasValues) {
1158
+ return;
1159
+ }
1160
+
1161
+ if (!name) {
1162
+ throw new Error("Każda pozycja musi mieć nazwę.");
1163
+ }
1164
+ if (!quantityValid) {
1165
+ throw new Error("Ilość musi byc dodatnia liczba calkowita.");
1166
+ }
1167
+ if (unitGross <= 0) {
1168
+ throw new Error("Cena brutto musi być większa od zera.");
1169
+ }
1170
+
1171
+ items.push({
1172
+ name,
1173
+ quantity,
1174
+ unit,
1175
+ unit_price_gross: unitGross.toFixed(2),
1176
+ vat_code: vatCode,
1177
+ });
1178
+ });
1179
+
1180
+ if (items.length === 0) {
1181
+ throw new Error("Dodaj przynajmniej jedną pozycję.");
1182
+ }
1183
+
1184
+ const saleDate = invoiceForm.elements.saleDate.value || null;
1185
+ const paymentTerm = parseInt(invoiceForm.elements.paymentTerm.value) || 14;
1186
+ const requiresExemptionNote = items.some((item) => item.vat_code === "ZW" || item.vat_code === "0");
1187
+ let exemptionNote = "";
1188
+ if (requiresExemptionNote) {
1189
+ const noteFromTextarea = exemptionNoteInput.value.trim();
1190
+ if (exemptionReasonSelect) {
1191
+ const selectedReason = EXEMPTION_REASON_LOOKUP.get(exemptionReasonSelect.value);
1192
+ if (selectedReason && selectedReason.value !== "custom") {
1193
+ exemptionNote = selectedReason.note;
1194
+ } else {
1195
+ exemptionNote = noteFromTextarea;
1196
+ }
1197
+ } else {
1198
+ exemptionNote = noteFromTextarea;
1199
+ }
1200
+ if (!exemptionNote) {
1201
+ throw new Error("Wybierz lub wpisz podstawę zwolnienia dla pozycji ze stawka ZW/0%.");
1202
+ }
1203
+ }
1204
+ const client = {
1205
+ name: (invoiceForm.elements.clientName.value || "").trim(),
1206
+ tax_id: (invoiceForm.elements.clientTaxId.value || "").trim(),
1207
+ address_line: (invoiceForm.elements.clientAddress.value || "").trim(),
1208
+ postal_code: (invoiceForm.elements.clientPostalCode.value || "").trim(),
1209
+ city: (invoiceForm.elements.clientCity.value || "").trim(),
1210
+ phone: (invoiceForm.elements.clientPhone.value || "").trim(),
1211
+ };
1212
+
1213
+ return {
1214
+ sale_date: saleDate,
1215
+ payment_term: paymentTerm,
1216
+ client,
1217
+ items,
1218
+ exemption_note: exemptionNote,
1219
+ };
1220
+ }
1221
+
1222
+ function renderInvoicePreview(invoice) {
1223
+ if (!invoice || !currentBusiness) {
1224
+ invoiceOutput.innerHTML = "<p>Brak danych faktury.</p>";
1225
+ return;
1226
+ }
1227
+
1228
+ const client = invoice.client || {};
1229
+ const hasClientData = client.name || client.address_line || client.postal_code || client.city || client.tax_id;
1230
+
1231
+ const itemsRows = (invoice.items || [])
1232
+ .map((item) => {
1233
+ const quantityDisplay = formatQuantity(item.quantity);
1234
+ const unitDisplay = UNIT_OPTIONS.some((option) => option.value === item.unit) ? item.unit : DEFAULT_UNIT;
1235
+ return `
1236
+ <tr>
1237
+ <td>${item.name}</td>
1238
+ <td>${quantityDisplay}</td>
1239
+ <td>${unitDisplay}</td>
1240
+ <td>${formatCurrency(item.unit_price_net)}</td>
1241
+ <td>${formatCurrency(item.net_total)}</td>
1242
+ <td>${item.vat_label}</td>
1243
+ <td>${formatCurrency(item.vat_amount)}</td>
1244
+ <td>${formatCurrency(item.gross_total)}</td>
1245
+ </tr>`;
1246
+ })
1247
+ .join("");
1248
+
1249
+ const summaryRows = (invoice.summary || [])
1250
+ .map(
1251
+ (entry) =>
1252
+ `<div class="rate-summary-item">
1253
+ <span>${entry.vat_label}</span>
1254
+ <span>Netto: ${formatCurrency(entry.net_total)}</span>
1255
+ <span>VAT: ${formatCurrency(entry.vat_total)}</span>
1256
+ <span>Brutto: ${formatCurrency(entry.gross_total)}</span>
1257
+ </div>`
1258
+ )
1259
+ .join("");
1260
+
1261
+ invoiceOutput.innerHTML = `
1262
+ <div class="invoice-preview-meta">
1263
+ <span><strong>Numer:</strong> ${invoice.invoice_id}</span>
1264
+ <span><strong>Data wystawienia:</strong> ${invoice.issued_at}</span>
1265
+ <span><strong>Data sprzedaży:</strong> ${invoice.sale_date}</span>
1266
+ ${invoice.payment_term ? `<span><strong>Termin płatności:</strong> ${invoice.payment_term} dni</span>` : ''}
1267
+ </div>
1268
+ <div class="invoice-preview-header">
1269
+ <div class="invoice-preview-card">
1270
+ <h4>Nabywca</h4>
1271
+ ${
1272
+ hasClientData
1273
+ ? `
1274
+ <p>${client.name || "---"}</p>
1275
+ <p>${client.address_line || "---"}</p>
1276
+ <p>${client.postal_code || ""} ${client.city || ""}</p>
1277
+ <p>${client.tax_id ? `NIP: ${client.tax_id}` : ""}</p>
1278
+ ${client.phone ? `<p>Tel: ${client.phone}</p>` : ''}
1279
+ `
1280
+ : "<p>Brak danych nabywcy.</p>"
1281
+ }
1282
+ </div>
1283
+ <div class="invoice-preview-card">
1284
+ <h4>Sprzedawca</h4>
1285
+ <p>${currentBusiness.company_name}</p>
1286
+ <p>${currentBusiness.owner_name}</p>
1287
+ <p>${currentBusiness.address_line}</p>
1288
+ <p>${currentBusiness.postal_code} ${currentBusiness.city}</p>
1289
+ <p>NIP: ${currentBusiness.tax_id}</p>
1290
+ <p>Konto: ${currentBusiness.bank_account}</p>
1291
+ </div>
1292
+ </div>
1293
+ <table>
1294
+ <thead>
1295
+ <tr>
1296
+ <th>Nazwa</th>
1297
+ <th>Ilość</th>
1298
+ <th>Jednostka</th>
1299
+ <th>Cena jedn. netto</th>
1300
+ <th>Wartość netto (pozycja)</th>
1301
+ <th>Stawka VAT</th>
1302
+ <th>Kwota VAT (pozycja)</th>
1303
+ <th>Wartość brutto</th>
1304
+ </tr>
1305
+ </thead>
1306
+ <tbody>${itemsRows}</tbody>
1307
+ </table>
1308
+ <div class="rate-summary">
1309
+ <h4>Podsumowanie stawek</h4>
1310
+ ${summaryRows}
1311
+ </div>
1312
+ <div class="invoice-preview-summary">
1313
+ <span>Netto: ${formatCurrency(invoice.totals.net)}</span>
1314
+ <span>VAT: ${formatCurrency(invoice.totals.vat)}</span>
1315
+ <span>Brutto: ${formatCurrency(invoice.totals.gross)}</span>
1316
+ </div>
1317
+ ${
1318
+ invoice.exemption_note
1319
+ ? `<div class="invoice-preview-note"><strong>Podstawa prawna zwolnienia:</strong> ${invoice.exemption_note}</div>`
1320
+ : ""
1321
+ }
1322
+ `;
1323
+ }
1324
+
1325
+ function drawPartyBox(doc, title, lines, x, y, width) {
1326
+ const lineHeight = 5;
1327
+ const wrappedLines = lines.flatMap((line) => doc.splitTextToSize(line, width));
1328
+ const boxHeight = wrappedLines.length * lineHeight + 14;
1329
+
1330
+ doc.roundedRect(x - 4, y - 8, width + 8, boxHeight, 2, 2);
1331
+ doc.setFontSize(11);
1332
+ doc.text(title, x, y);
1333
+ doc.setFontSize(10);
1334
+
1335
+ let cursor = y + 5;
1336
+ wrappedLines.forEach((line) => {
1337
+ doc.text(line, x, cursor);
1338
+ cursor += lineHeight;
1339
+ });
1340
+
1341
+ return y - 8 + boxHeight;
1342
+ }
1343
+
1344
+ function arrayBufferToBase64(buffer) {
1345
+ const bytes = new Uint8Array(buffer);
1346
+ const chunkSize = 0x8000;
1347
+ let binary = "";
1348
+ for (let offset = 0; offset < bytes.length; offset += chunkSize) {
1349
+ const chunk = bytes.subarray(offset, Math.min(offset + chunkSize, bytes.length));
1350
+ binary += String.fromCharCode.apply(null, chunk);
1351
+ }
1352
+ return btoa(binary);
1353
+ }
1354
+
1355
+ const PDF_FONT_FILE = "Roboto-VariableFont_wdth,wght.ttf";
1356
+ const PDF_FONT_NAME = "RobotoPolish";
1357
+
1358
+ async function ensurePdfFont() {
1359
+ if (pdfFontPromise) {
1360
+ return pdfFontPromise;
1361
+ }
1362
+
1363
+ if (!window.jspdf || !window.jspdf.jsPDF) {
1364
+ throw new Error("Biblioteka jsPDF nie została załadowana.");
1365
+ }
1366
+
1367
+ const { jsPDF } = window.jspdf;
1368
+ const loadBase64 = async () => {
1369
+ if (typeof window !== "undefined" && window.PDF_FONT_BASE64) {
1370
+ return window.PDF_FONT_BASE64;
1371
+ }
1372
+ const response = await fetch(`/${encodeURIComponent(PDF_FONT_FILE)}`);
1373
+ if (!response.ok) {
1374
+ throw new Error(`Nie udało się pobrać czcionki Roboto (status ${response.status}).`);
1375
+ }
1376
+ const buffer = await response.arrayBuffer();
1377
+ return arrayBufferToBase64(buffer);
1378
+ };
1379
+
1380
+ pdfFontPromise = loadBase64().then((data) => {
1381
+ pdfFontBase64 = data;
1382
+ return data;
1383
+ });
1384
+
1385
+ return pdfFontPromise;
1386
+ }
1387
+
1388
+ async function generatePdf(business, invoice, logo) {
1389
+ if (!window.jspdf || !window.jspdf.jsPDF) {
1390
+ alert("Biblioteka jsPDF nie została załadowana. Sprawdź połączenie z internetem.");
1391
+ return;
1392
+ }
1393
+
1394
+ let fontBase64;
1395
+ try {
1396
+ fontBase64 = await ensurePdfFont();
1397
+ } catch (error) {
1398
+ alert(error.message || "Nie udało się przygotować czcionki do PDF.");
1399
+ return;
1400
+ }
1401
+
1402
+ const { jsPDF } = window.jspdf;
1403
+ const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
1404
+ const marginX = 18;
1405
+ let cursorY = 20;
1406
+ const pageWidth = doc.internal.pageSize.getWidth();
1407
+
1408
+ if (!doc.getFontList()[PDF_FONT_NAME]) {
1409
+ const embeddedFont = pdfFontBase64 || fontBase64;
1410
+ doc.addFileToVFS(PDF_FONT_FILE, embeddedFont);
1411
+ doc.addFont(PDF_FONT_FILE, PDF_FONT_NAME, "normal");
1412
+ }
1413
+
1414
+ doc.setFont(PDF_FONT_NAME, "normal");
1415
+ if (logo && logo.data && logo.mime_type) {
1416
+ const format = logo.mime_type === "image/png" ? "PNG" : "JPEG";
1417
+ const dataUrl = logo.data_url || `data:${logo.mime_type};base64,${logo.data}`;
1418
+ try {
1419
+ let logoWidth = 45;
1420
+ let logoHeight = 18;
1421
+ if (doc.getImageProperties) {
1422
+ const props = doc.getImageProperties(dataUrl);
1423
+ if (props?.width && props?.height) {
1424
+ const ratio = props.height / props.width;
1425
+ logoHeight = logoWidth * ratio;
1426
+ if (logoHeight > 22) {
1427
+ logoHeight = 22;
1428
+ logoWidth = logoHeight / ratio;
1429
+ }
1430
+ }
1431
+ }
1432
+ const logoX = pageWidth - marginX - logoWidth;
1433
+ const logoY = 14;
1434
+ doc.addImage(dataUrl, format, logoX, logoY, logoWidth, logoHeight);
1435
+ } catch (error) {
1436
+ console.warn("Nie udało się dodać logo do PDF:", error);
1437
+ }
1438
+ }
1439
+ doc.setFontSize(16);
1440
+ doc.text(`Faktura ${invoice.invoice_id}`, marginX, cursorY);
1441
+ doc.setFontSize(10);
1442
+ doc.text(`Data wystawienia: ${invoice.issued_at}`, marginX, cursorY + 6);
1443
+ doc.text(`Data sprzedaży: ${invoice.sale_date}`, marginX, cursorY + 12);
1444
+
1445
+ cursorY += 22;
1446
+ const columnWidth = 85;
1447
+ const sellerX = marginX + columnWidth + 12;
1448
+
1449
+ const clientLines = invoice.client && (invoice.client.name || invoice.client.address_line || invoice.client.city || invoice.client.tax_id || invoice.client.phone)
1450
+ ? [
1451
+ invoice.client.name || "---",
1452
+ invoice.client.address_line || "",
1453
+ `${(invoice.client.postal_code || "").trim()} ${(invoice.client.city || "").trim()}`.trim(),
1454
+ invoice.client.tax_id ? `NIP: ${invoice.client.tax_id}` : "",
1455
+ invoice.client.phone ? `Tel: ${invoice.client.phone}` : "",
1456
+ ].filter((line) => line && line.trim())
1457
+ : ["Brak danych nabywcy"];
1458
+
1459
+ const sellerLines = [
1460
+ business.company_name,
1461
+ business.owner_name,
1462
+ business.address_line,
1463
+ `${business.postal_code} ${business.city}`.trim(),
1464
+ `NIP: ${business.tax_id}`,
1465
+ `Konto: ${business.bank_account}`,
1466
+ ];
1467
+
1468
+ const buyerBottom = drawPartyBox(doc, "NABYWCA", clientLines, marginX, cursorY, columnWidth);
1469
+ const sellerBottom = drawPartyBox(doc, "SPRZEDAWCA", sellerLines, sellerX, cursorY, columnWidth);
1470
+ cursorY = Math.max(buyerBottom, sellerBottom) + 12;
1471
+
1472
+ const tableColumns = [
1473
+ { key: "name", label: "Nazwa", width: 44 },
1474
+ { key: "quantity", label: "Ilość", width: 14 },
1475
+ { key: "unit", label: "Jednostka", width: 14 },
1476
+ { key: "unitNet", label: "Cena jedn. netto", width: 23 },
1477
+ { key: "netTotal", label: "Wartość netto", width: 23 },
1478
+ { key: "vatLabel", label: "Stawka VAT", width: 14 },
1479
+ { key: "vatAmount", label: "Kwota VAT", width: 21 },
1480
+ { key: "grossTotal", label: "Wartość brutto", width: 21 },
1481
+ ];
1482
+ const lineHeight = 5;
1483
+ const headerLineHeight = 4.2;
1484
+ tableColumns.forEach((column) => {
1485
+ column.headerLines = doc.splitTextToSize(column.label, column.width - 4);
1486
+ });
1487
+ const headerHeight = Math.max(...tableColumns.map((column) => column.headerLines.length * headerLineHeight + 3));
1488
+
1489
+ const tableWidth = tableColumns.reduce((sum, column) => sum + column.width, 0);
1490
+
1491
+ doc.setFillColor(241, 243, 247);
1492
+ doc.rect(marginX, cursorY, tableWidth, headerHeight, "F");
1493
+ doc.rect(marginX, cursorY, tableWidth, headerHeight);
1494
+ let offsetX = marginX;
1495
+ doc.setFontSize(10);
1496
+ tableColumns.forEach((column) => {
1497
+ doc.rect(offsetX, cursorY, column.width, headerHeight);
1498
+ column.headerLines.forEach((line, index) => {
1499
+ const textY = cursorY + 4 + index * headerLineHeight;
1500
+ doc.text((line || "").trim(), offsetX + 2, textY);
1501
+ });
1502
+ offsetX += column.width;
1503
+ });
1504
+ cursorY += headerHeight;
1505
+
1506
+ const invoiceItems = Array.isArray(invoice.items) ? invoice.items : [];
1507
+ invoiceItems.forEach((item) => {
1508
+ const quantity = formatQuantity(item.quantity);
1509
+ const unitLabel = UNIT_OPTIONS.some((option) => option.value === item.unit) ? item.unit : DEFAULT_UNIT;
1510
+ const unitNet = formatCurrency(item.unit_price_net);
1511
+ const netTotal = formatCurrency(item.net_total);
1512
+ const vatAmount = formatCurrency(item.vat_amount);
1513
+ const grossTotal = formatCurrency(item.gross_total);
1514
+
1515
+ const wrapText = (text, width) =>
1516
+ doc
1517
+ .splitTextToSize(text ?? "", width)
1518
+ .map((line) => line.trim());
1519
+
1520
+ const columnData = tableColumns.map((column) => {
1521
+ switch (column.key) {
1522
+ case "name":
1523
+ return wrapText(item.name, column.width - 4);
1524
+ case "quantity":
1525
+ return [quantity];
1526
+ case "unit":
1527
+ return [unitLabel];
1528
+ case "unitNet":
1529
+ return [unitNet];
1530
+ case "netTotal":
1531
+ return [netTotal];
1532
+ case "vatLabel":
1533
+ return [item.vat_label];
1534
+ case "vatAmount":
1535
+ return [vatAmount];
1536
+ case "grossTotal":
1537
+ return [grossTotal];
1538
+ default:
1539
+ return [""];
1540
+ }
1541
+ });
1542
+
1543
+ const rowHeight = Math.max(...columnData.map((data) => data.length)) * lineHeight + 2;
1544
+ offsetX = marginX;
1545
+ tableColumns.forEach((column, index) => {
1546
+ doc.rect(offsetX, cursorY, column.width, rowHeight);
1547
+ const lines = columnData[index];
1548
+ lines.forEach((line, lineIndex) => {
1549
+ const textY = cursorY + (lineIndex + 1) * lineHeight;
1550
+ const content = (line || "").trim();
1551
+ doc.text(content, offsetX + 2, textY);
1552
+ });
1553
+ offsetX += column.width;
1554
+ });
1555
+
1556
+ cursorY += rowHeight;
1557
+ });
1558
+
1559
+ cursorY += 10;
1560
+ doc.setFontSize(11);
1561
+ doc.text("Podsumowanie stawek:", marginX, cursorY);
1562
+ cursorY += 6;
1563
+
1564
+ const summaryEntries = Array.isArray(invoice.summary) ? invoice.summary : [];
1565
+ summaryEntries.forEach((entry) => {
1566
+ const summaryLine = `${entry.vat_label} – Netto: ${formatCurrency(entry.net_total)} / VAT: ${formatCurrency(entry.vat_total)} / Brutto: ${formatCurrency(entry.gross_total)}`;
1567
+ const wrapped = doc.splitTextToSize(summaryLine, 170);
1568
+ wrapped.forEach((line) => {
1569
+ doc.text((line || "").trim(), marginX, cursorY);
1570
+ cursorY += lineHeight;
1571
+ });
1572
+ });
1573
+
1574
+ cursorY += 6;
1575
+ doc.setFontSize(12);
1576
+ doc.text(`Suma netto: ${formatCurrency(invoice.totals.net)}`, marginX, cursorY);
1577
+ doc.text(`Suma VAT: ${formatCurrency(invoice.totals.vat)}`, marginX, cursorY + 6);
1578
+ doc.text(`Suma brutto: ${formatCurrency(invoice.totals.gross)}`, marginX, cursorY + 12);
1579
+ if (invoice.payment_term) {
1580
+ doc.text(`Termin płatności: ${invoice.payment_term} dni`, marginX, cursorY + 18);
1581
+ }
1582
+ cursorY += 30;
1583
+
1584
+ if (invoice.exemption_note) {
1585
+ doc.setFontSize(10);
1586
+ const noteLines = doc.splitTextToSize(`Podstawa prawna zwolnienia: ${invoice.exemption_note}`, 170);
1587
+ doc.text(noteLines, marginX, cursorY);
1588
+ }
1589
+
1590
+ doc.save(`${invoice.invoice_id}.pdf`);
1591
+ }
1592
+
1593
+ async function loadBusinessData() {
1594
+ const data = await apiRequest("/api/business", {}, true);
1595
+ currentBusiness = data.business;
1596
+ renderBusinessDisplay(currentBusiness);
1597
+ fillBusinessForm(currentBusiness);
1598
+ setBusinessFormVisibility(false);
1599
+ }
1600
+
1601
+ async function loadLogo() {
1602
+ try {
1603
+ const data = await apiRequest("/api/logo", {}, true);
1604
+ currentLogo = data.logo || null;
1605
+ } catch (error) {
1606
+ console.error("Nie udało się pobrać logo:", error);
1607
+ currentLogo = null;
1608
+ }
1609
+ updateLogoPreview();
1610
+ }
1611
+
1612
+ function resetInvoiceForm() {
1613
+ invoiceForm.reset();
1614
+ customExemptionNote = "";
1615
+ updateExemptionVisibility(false);
1616
+ itemsBody.innerHTML = "";
1617
+ createItemRow();
1618
+ const now = new Date();
1619
+ const year = now.getFullYear();
1620
+ const month = String(now.getMonth() + 1).padStart(2, "0");
1621
+ const day = String(now.getDate()).padStart(2, "0");
1622
+ if (invoiceForm.elements.saleDate) {
1623
+ invoiceForm.elements.saleDate.value = `${year}-${month}-${day}`;
1624
+ }
1625
+ updateTotals();
1626
+ }
1627
+
1628
+ async function bootstrapApp() {
1629
+ try {
1630
+ await loadBusinessData();
1631
+ await loadLogo();
1632
+ exitInvoiceEdit();
1633
+ resetInvoiceForm();
1634
+ invoiceResult.classList.add("hidden");
1635
+ lastInvoice = null;
1636
+ await refreshInvoices();
1637
+ await refreshSummary();
1638
+ updateLoginLabel();
1639
+ setAppState("app");
1640
+ activeView = "invoice-builder";
1641
+ setActiveView(activeView);
1642
+ } catch (error) {
1643
+ console.error(error);
1644
+ authToken = null;
1645
+ currentLogin = "";
1646
+ sessionStorage.removeItem("invoiceAuthToken");
1647
+ sessionStorage.removeItem("invoiceLogin");
1648
+ showFeedback(loginFeedback, error.message || "Nie udało się pobrać danych konta.");
1649
+ setAppState("auth");
1650
+ }
1651
+ }
1652
+
1653
+ async function initialize() {
1654
+ exitInvoiceEdit();
1655
+ resetInvoiceForm();
1656
+ updateLogoPreview();
1657
+ updateSummaryCards({});
1658
+ updateSummaryChart({});
1659
+ setActiveView("invoice-builder");
1660
+ setAppState("auth");
1661
+ closeRegisterPanel({ resetForm: true, focusTrigger: false });
1662
+ clearFeedback(registerFeedback);
1663
+ clearFeedback(loginFeedback);
1664
+ try {
1665
+ const status = await apiRequest("/api/status");
1666
+ if (typeof status.max_logo_size === "number") {
1667
+ maxLogoSize = status.max_logo_size;
1668
+ }
1669
+ if (status.legacy_login_hint) {
1670
+ legacyLoginHint.textContent = `Zaloguj się korzystajac ze starego loginu "${status.legacy_login_hint}", a nastepnie dodaj adres email.`;
1671
+ legacyLoginHint.classList.remove("hidden");
1672
+ } else {
1673
+ legacyLoginHint.classList.add("hidden");
1674
+ legacyLoginHint.textContent = "";
1675
+ }
1676
+
1677
+ if (authToken) {
1678
+ await bootstrapApp();
1679
+ }
1680
+ } catch (error) {
1681
+ console.error(error);
1682
+ showFeedback(registerFeedback, "Nie udało się nawiązać połączenia z serwerem.");
1683
+ }
1684
+ }
1685
+
1686
+ if (registerForm && registerFeedback && loginFeedback) {
1687
+ registerForm.addEventListener("submit", async (event) => {
1688
+ event.preventDefault();
1689
+ clearFeedback(registerFeedback);
1690
+ clearFeedback(loginFeedback);
1691
+
1692
+ const formData = new FormData(registerForm);
1693
+ const emailValue = formData.get("email")?.toString().trim() ?? "";
1694
+ const password = formData.get("password")?.toString() ?? "";
1695
+ const confirmPassword = formData.get("confirm_password")?.toString() ?? "";
1696
+
1697
+ if (!emailValue) {
1698
+ showFeedback(registerFeedback, "Podaj adres email.");
1699
+ return;
1700
+ }
1701
+ if (password !== confirmPassword) {
1702
+ showFeedback(registerFeedback, "Hasła musza byc identyczne.");
1703
+ return;
1704
+ }
1705
+
1706
+ if (password.trim().length < 4) {
1707
+ showFeedback(registerFeedback, "Hasło musi miec co najmniej 4 znaki.");
1708
+ return;
1709
+ }
1710
+
1711
+ const payload = {
1712
+ email: emailValue,
1713
+ company_name: formData.get("company_name")?.toString().trim(),
1714
+ owner_name: formData.get("owner_name")?.toString().trim(),
1715
+ address_line: formData.get("address_line")?.toString().trim(),
1716
+ postal_code: formData.get("postal_code")?.toString().trim(),
1717
+ city: formData.get("city")?.toString().trim(),
1718
+ tax_id: formData.get("tax_id")?.toString().trim(),
1719
+ bank_account: formData.get("bank_account")?.toString().trim(),
1720
+ password,
1721
+ };
1722
+
1723
+ try {
1724
+ await apiRequest("/api/setup", { method: "POST", body: payload });
1725
+ showFeedback(registerFeedback, "Konto utworzone. Możesz sie zalogowac.", "success");
1726
+ if (loginForm && loginForm.elements.email) {
1727
+ loginForm.elements.email.value = emailValue;
1728
+ }
1729
+ registerForm.reset();
1730
+ setTimeout(() => {
1731
+ closeRegisterPanel({ resetForm: true, focusTrigger: false });
1732
+ clearFeedback(registerFeedback);
1733
+ clearFeedback(loginFeedback);
1734
+ showFeedback(loginFeedback, "Konto utworzone. Zaloguj się haslem.", "success");
1735
+ if (loginForm) {
1736
+ const passwordInput = loginForm.elements.password;
1737
+ if (passwordInput) {
1738
+ passwordInput.focus();
1739
+ }
1740
+ }
1741
+ }, 1600);
1742
+ } catch (error) {
1743
+ showFeedback(registerFeedback, error.message || "Nie udało się utworzyć konta.");
1744
+ }
1745
+ });
1746
+ }
1747
+
1748
+ if (loginForm && loginFeedback) {
1749
+ const setLoginSubmittingState = (isSubmitting) => {
1750
+ if (!loginSubmitButton) {
1751
+ return;
1752
+ }
1753
+ if (isSubmitting) {
1754
+ loginSubmitButton.disabled = true;
1755
+ loginSubmitButton.setAttribute("data-loading", "true");
1756
+ loginSubmitButton.textContent = "Logowanie...";
1757
+ } else {
1758
+ loginSubmitButton.disabled = false;
1759
+ loginSubmitButton.textContent = loginSubmitButtonDefaultText;
1760
+ loginSubmitButton.removeAttribute("data-loading");
1761
+ }
1762
+ };
1763
+
1764
+ loginForm.addEventListener("submit", async (event) => {
1765
+ event.preventDefault();
1766
+ clearFeedback(loginFeedback);
1767
+
1768
+ const emailElement = loginForm.elements.email;
1769
+ const emailValue = emailElement ? emailElement.value.trim() : "";
1770
+ const password = loginForm.elements.password.value;
1771
+
1772
+ if (!emailValue) {
1773
+ showFeedback(loginFeedback, "Podaj adres email.");
1774
+ return;
1775
+ }
1776
+
1777
+ if (!password) {
1778
+ showFeedback(loginFeedback, "Podaj hasło.");
1779
+ return;
1780
+ }
1781
+
1782
+ setLoginSubmittingState(true);
1783
+
1784
+ try {
1785
+ const response = await apiRequest("/api/login", { method: "POST", body: { email: emailValue, password } });
1786
+ authToken = response.token;
1787
+ currentLogin = response.email || response.login || emailValue;
1788
+ sessionStorage.setItem("invoiceAuthToken", authToken);
1789
+ sessionStorage.setItem("invoiceLogin", currentLogin);
1790
+ loginForm.reset();
1791
+ await bootstrapApp();
1792
+ } catch (error) {
1793
+ const errorMessage = error instanceof Error ? error.message : String(error || "");
1794
+ let feedbackMessage = errorMessage || "Logowanie nie powiodło się.";
1795
+ if (/nieprawidlowy (login|email) lub hasło/i.test(errorMessage)) {
1796
+ feedbackMessage = "Podany email lub hasło są nieprawidłowe. Utwórz konto, jeśli jeszcze go nie masz.";
1797
+ } else if (/brak autoryzacji/i.test(errorMessage) || /brak tokenu autoryzacyjnego/i.test(errorMessage)) {
1798
+ feedbackMessage = "Sesja wygasła. Zaloguj się ponownie.";
1799
+ } else if (/failed to fetch|networkerror/i.test(errorMessage) || error instanceof TypeError) {
1800
+ feedbackMessage = "Nie udało się nawiązać połączenia z serwerem. Sprawdź, czy aplikacja serwerowa jest uruchomiona.";
1801
+ }
1802
+ showFeedback(loginFeedback, feedbackMessage);
1803
+ } finally {
1804
+ setLoginSubmittingState(false);
1805
+ }
1806
+ });
1807
+ }
1808
+
1809
+ if (toggleBusinessFormButton && businessForm && businessFeedback) {
1810
+ toggleBusinessFormButton.addEventListener("click", () => {
1811
+ const isVisible = !businessForm.classList.contains("hidden");
1812
+ if (!isVisible) {
1813
+ fillBusinessForm(currentBusiness);
1814
+ setBusinessFormVisibility(true, { preserveFeedback: true });
1815
+ } else {
1816
+ setBusinessFormVisibility(false);
1817
+ }
1818
+ });
1819
+ }
1820
+
1821
+ if (cancelBusinessUpdateButton && businessForm && businessFeedback && toggleBusinessFormButton) {
1822
+ cancelBusinessUpdateButton.addEventListener("click", () => {
1823
+ setBusinessFormVisibility(false);
1824
+ });
1825
+ }
1826
+
1827
+ if (businessForm && businessFeedback) {
1828
+ businessForm.addEventListener("submit", async (event) => {
1829
+ event.preventDefault();
1830
+ clearFeedback(businessFeedback);
1831
+
1832
+ const formData = new FormData(businessForm);
1833
+ const payload = {
1834
+ company_name: formData.get("company_name")?.toString().trim(),
1835
+ owner_name: formData.get("owner_name")?.toString().trim(),
1836
+ address_line: formData.get("address_line")?.toString().trim(),
1837
+ postal_code: formData.get("postal_code")?.toString().trim(),
1838
+ city: formData.get("city")?.toString().trim(),
1839
+ tax_id: formData.get("tax_id")?.toString().trim(),
1840
+ bank_account: formData.get("bank_account")?.toString().trim(),
1841
+ };
1842
+
1843
+ try {
1844
+ const data = await apiRequest("/api/business", { method: "PUT", body: payload }, true);
1845
+ currentBusiness = data.business;
1846
+ renderBusinessDisplay(currentBusiness);
1847
+ fillBusinessForm(currentBusiness);
1848
+ setBusinessFormVisibility(false, { preserveFeedback: true });
1849
+ showFeedback(businessFeedback, "Dane sprzedawcy zaktualizowane.", "success");
1850
+ setTimeout(() => clearFeedback(businessFeedback), 2000);
1851
+ } catch (error) {
1852
+ showFeedback(businessFeedback, error.message || "Nie udało się zaktualizować danych.");
1853
+ }
1854
+ });
1855
+ }
1856
+
1857
+ if (exemptionReasonSelect) {
1858
+ populateExemptionReasons();
1859
+ let previousReasonValue = exemptionReasonSelect.value;
1860
+ applyExemptionReasonSelection({ preserveCustom: true });
1861
+ exemptionReasonSelect.addEventListener("change", () => {
1862
+ if (previousReasonValue === "custom" && exemptionNoteInput) {
1863
+ customExemptionNote = exemptionNoteInput.value.trim();
1864
+ }
1865
+ previousReasonValue = exemptionReasonSelect.value;
1866
+ applyExemptionReasonSelection();
1867
+ if (exemptionReasonSelect.value === "custom" && exemptionNoteInput) {
1868
+ exemptionNoteInput.focus();
1869
+ }
1870
+ });
1871
+ }
1872
+
1873
+ if (exemptionNoteInput) {
1874
+ exemptionNoteInput.addEventListener("input", () => {
1875
+ if (exemptionReasonSelect && exemptionReasonSelect.value === "custom") {
1876
+ customExemptionNote = exemptionNoteInput.value;
1877
+ }
1878
+ });
1879
+ }
1880
+
1881
+ if (invoiceForm) {
1882
+ invoiceForm.addEventListener("submit", async (event) => {
1883
+ event.preventDefault();
1884
+ try {
1885
+ const payload = collectInvoicePayload();
1886
+ let response;
1887
+ if (editingInvoiceId) {
1888
+ response = await apiRequest(`/api/invoices/${encodeURIComponent(editingInvoiceId)}`, { method: "PUT", body: payload }, true);
1889
+ exitInvoiceEdit();
1890
+ } else {
1891
+ response = await apiRequest("/api/invoices", { method: "POST", body: payload }, true);
1892
+ }
1893
+ lastInvoice = response.invoice;
1894
+ renderInvoicePreview(lastInvoice);
1895
+ if (invoiceResult) {
1896
+ invoiceResult.classList.remove("hidden");
1897
+ }
1898
+ await refreshInvoices();
1899
+ await refreshSummary();
1900
+ resetInvoiceForm();
1901
+ } catch (error) {
1902
+ alert(error.message || "Nie udało się zapisać faktury.");
1903
+ }
1904
+ });
1905
+ }
1906
+
1907
+ if (addItemButton) {
1908
+ addItemButton.addEventListener("click", () => {
1909
+ createItemRow();
1910
+ });
1911
+ }
1912
+
1913
+ if (downloadButton) {
1914
+ downloadButton.addEventListener("click", async () => {
1915
+ if (!lastInvoice || !currentBusiness) {
1916
+ alert("Brak faktury do pobrania. Wygeneruj ją najpierw.");
1917
+ return;
1918
+ }
1919
+ await generatePdf(currentBusiness, lastInvoice, currentLogo);
1920
+ });
1921
+ }
1922
+
1923
+ if (logoutButton) {
1924
+ logoutButton.addEventListener("click", () => {
1925
+ authToken = null;
1926
+ currentLogin = "";
1927
+ sessionStorage.removeItem("invoiceAuthToken");
1928
+ sessionStorage.removeItem("invoiceLogin");
1929
+ lastInvoice = null;
1930
+ currentBusiness = null;
1931
+ currentLogo = null;
1932
+ invoicesCache = [];
1933
+ exitInvoiceEdit();
1934
+ resetInvoiceForm();
1935
+ setBusinessFormVisibility(false);
1936
+ if (invoiceResult) {
1937
+ invoiceResult.classList.add("hidden");
1938
+ }
1939
+ updateLogoPreview();
1940
+ updateLoginLabel();
1941
+ renderInvoicesTable([]);
1942
+ updateSummaryCards({});
1943
+ updateSummaryChart({});
1944
+ closeRegisterPanel({ resetForm: true, focusTrigger: true });
1945
+ clearFeedback(registerFeedback);
1946
+ clearFeedback(loginFeedback);
1947
+ clearFeedback(businessFeedback);
1948
+ clearFeedback(logoFeedback);
1949
+ clearFeedback(dashboardFeedback);
1950
+ setAppState("auth");
1951
+ });
1952
+ }
1953
+
1954
+ appNavButtons.forEach((button) => {
1955
+ button.addEventListener("click", () => {
1956
+ setActiveView(button.dataset.view);
1957
+ });
1958
+ });
1959
+
1960
+ if (filterStartDate) {
1961
+ filterStartDate.addEventListener("change", applyInvoiceFilters);
1962
+ }
1963
+ if (filterEndDate) {
1964
+ filterEndDate.addEventListener("change", applyInvoiceFilters);
1965
+ }
1966
+ if (clearFiltersButton) {
1967
+ clearFiltersButton.addEventListener("click", () => {
1968
+ if (filterStartDate) {
1969
+ filterStartDate.value = "";
1970
+ }
1971
+ if (filterEndDate) {
1972
+ filterEndDate.value = "";
1973
+ }
1974
+ applyInvoiceFilters();
1975
+ });
1976
+ }
1977
+
1978
+ if (showRegisterButton) {
1979
+ showRegisterButton.addEventListener("click", () => {
1980
+ openRegisterPanel();
1981
+ });
1982
+ }
1983
+
1984
+ if (backToLoginButton) {
1985
+ backToLoginButton.addEventListener("click", () => {
1986
+ closeRegisterPanel({ resetForm: false, focusTrigger: true });
1987
+ });
1988
+ }
1989
+
1990
+ if (cancelRegisterButton) {
1991
+ cancelRegisterButton.addEventListener("click", () => {
1992
+ closeRegisterPanel({ resetForm: true, focusTrigger: true });
1993
+ });
1994
+ }
1995
+
1996
+ if (logoInput) {
1997
+ logoInput.addEventListener("change", (event) => {
1998
+ const file = event.target.files?.[0];
1999
+ if (!file) {
2000
+ return;
2001
+ }
2002
+ clearFeedback(logoFeedback);
2003
+ if (file.size > maxLogoSize) {
2004
+ showFeedback(logoFeedback, `Logo jest zbyt duze. Maksymalny rozmiar to ${(maxLogoSize / 1024).toFixed(0)} KB.`);
2005
+ logoInput.value = "";
2006
+ return;
2007
+ }
2008
+ const reader = new FileReader();
2009
+ reader.onload = async () => {
2010
+ try {
2011
+ const base64 = reader.result?.toString();
2012
+ if (!base64) {
2013
+ throw new Error("Nie udało się odczytać pliku.");
2014
+ }
2015
+ const response = await apiRequest(
2016
+ "/api/logo",
2017
+ {
2018
+ method: "POST",
2019
+ body: {
2020
+ filename: file.name,
2021
+ mime_type: file.type,
2022
+ content: base64,
2023
+ },
2024
+ },
2025
+ true
2026
+ );
2027
+ currentLogo = response.logo;
2028
+ updateLogoPreview();
2029
+ showFeedback(logoFeedback, "Logo zapisane.", "success");
2030
+ } catch (error) {
2031
+ showFeedback(logoFeedback, error.message || "Nie udało się zapisać logo.");
2032
+ } finally {
2033
+ logoInput.value = "";
2034
+ }
2035
+ };
2036
+ reader.onerror = () => {
2037
+ showFeedback(logoFeedback, "Nie udało się wczytać pliku logo.");
2038
+ logoInput.value = "";
2039
+ };
2040
+ reader.readAsDataURL(file);
2041
+ });
2042
+ }
2043
+
2044
+ if (removeLogoButton) {
2045
+ removeLogoButton.addEventListener("click", async () => {
2046
+ clearFeedback(logoFeedback);
2047
+ if (!currentLogo) {
2048
+ showFeedback(logoFeedback, "Brak logo do usunięcia.");
2049
+ return;
2050
+ }
2051
+ try {
2052
+ await apiRequest("/api/logo", { method: "DELETE" }, true);
2053
+ currentLogo = null;
2054
+ updateLogoPreview();
2055
+ showFeedback(logoFeedback, "Logo usunięte.", "success");
2056
+ } catch (error) {
2057
+ showFeedback(logoFeedback, error.message || "Nie udało się usunąć logo.");
2058
+ }
2059
+ });
2060
+ }
2061
+
2062
+ if (cancelEditInvoiceButton) {
2063
+ cancelEditInvoiceButton.addEventListener("click", () => {
2064
+ exitInvoiceEdit();
2065
+ resetInvoiceForm();
2066
+ });
2067
+ }
2068
+
2069
+ initialize().catch((error) => {
2070
+ console.error(error);
2071
+ showFeedback(registerFeedback, "Nie udało się uruchomić aplikacji.");
2072
+ });
requirements.txt CHANGED
@@ -1,6 +1 @@
1
  Flask>=2.3,<3.0
2
-
3
- SQLAlchemy
4
- psycopg2-binary
5
- uvicorn
6
- fastapi
 
1
  Flask>=2.3,<3.0
 
 
 
 
 
server.py CHANGED
@@ -1,16 +1,11 @@
 
 
1
  import hashlib
2
  import json
3
  import os
4
- from flask import Flask, render_template, request, redirect, url_for
5
- from sqlalchemy import create_engine, text
6
-
7
- from flask import render_template
8
-
9
- app = Flask(__name__)
10
- engine = create_engine(os.environ["DATABASE_URL"], pool_pre_ping=True)
11
-
12
  import uuid
13
- from datetime import datetime
14
  from decimal import Decimal, ROUND_HALF_UP, getcontext
15
  from pathlib import Path
16
  from typing import Any, Dict, List, Optional, Tuple
@@ -21,6 +16,9 @@ APP_ROOT = Path(__file__).parent.resolve()
21
  DATA_DIR = Path(os.environ.get("DATA_DIR", APP_ROOT))
22
  DATA_FILE = DATA_DIR / "web_invoice_store.json"
23
  INVOICE_HISTORY_LIMIT = 200
 
 
 
24
 
25
  VAT_RATES: Dict[str, Optional[Decimal]] = {
26
  "23": Decimal("0.23"),
@@ -31,7 +29,11 @@ VAT_RATES: Dict[str, Optional[Decimal]] = {
31
  "NP": None,
32
  }
33
 
34
- SESSION_TOKENS: Dict[str, datetime] = {}
 
 
 
 
35
 
36
  ALLOWED_STATIC = {
37
  "index.html",
@@ -41,27 +43,8 @@ ALLOWED_STATIC = {
41
  "Roboto-VariableFont_wdth,wght.ttf",
42
  }
43
 
44
- @app.route("/")
45
- def index():
46
- # Pobierz ostatnie notatki i pokaż w HTML
47
- with engine.begin() as conn:
48
- rows = conn.execute(text(
49
- "SELECT id, body, created_at FROM notes ORDER BY id DESC LIMIT 20"
50
- )).mappings().all()
51
- return render_template("index.html", notes=rows)
52
-
53
- if __name__ == "__main__": # Sprawdzamy, czy uruchamiamy główny skrypt
54
- port = int(os.environ.get("PORT", "7860")) # Wcięcie pod warunkiem
55
- app.run(host="0.0.0.0", port=port, debug=False) # Wcięcie pod warunkiem
56
-
57
-
58
- @app.post("/add")
59
- def add():
60
- body = request.form.get("body","").strip()
61
- if body:
62
- with engine.begin() as conn:
63
- conn.execute(text("INSERT INTO notes (body) VALUES (:b)"), {"b": body})
64
- return redirect(url_for("index"))
65
 
66
  getcontext().prec = 10
67
 
@@ -81,11 +64,92 @@ def hash_password(password: str) -> str:
81
  return hashlib.sha256(password.encode("utf-8")).hexdigest()
82
 
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  def load_store() -> Dict[str, Any]:
85
  if not DATA_FILE.exists():
86
- return {"business": None, "password_hash": None, "invoices": []}
87
  with DATA_FILE.open("r", encoding="utf-8") as handle:
88
- return json.load(handle)
 
 
 
 
89
 
90
 
91
  def save_store(data: Dict[str, Any]) -> None:
@@ -94,9 +158,58 @@ def save_store(data: Dict[str, Any]) -> None:
94
  json.dump(data, handle, ensure_ascii=False, indent=2)
95
 
96
 
97
- def ensure_configured(data: Dict[str, Any]) -> None:
98
- if not data.get("business") or not data.get("password_hash"):
99
- raise ValueError("Aplikacja nie zostala skonfigurowana.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
 
102
  def parse_iso_date(value: Optional[str]) -> Optional[str]:
@@ -121,9 +234,18 @@ def compute_invoice_items(items_payload: List[Dict[str, Any]]) -> Tuple[List[Dic
121
  if not name:
122
  raise ValueError("Kazda pozycja musi miec nazwe.")
123
 
124
- quantity = _decimal(raw.get("quantity", "0"))
125
- if quantity <= 0:
126
  raise ValueError("Ilosc musi byc wieksza od zera.")
 
 
 
 
 
 
 
 
 
127
 
128
  vat_code = str(raw.get("vat_code", "")).upper()
129
  if vat_code not in VAT_RATES:
@@ -144,16 +266,18 @@ def compute_invoice_items(items_payload: List[Dict[str, Any]]) -> Tuple[List[Dic
144
  unit_price_net = _quantize(unit_price_net)
145
  unit_price_gross = _quantize(unit_price_gross)
146
 
147
- net_total = _quantize(unit_price_net * quantity)
148
- vat_amount_total = _quantize(vat_amount * quantity if rate is not None else Decimal("0.00"))
149
- gross_total = _quantize(unit_price_gross * quantity)
 
150
 
151
  vat_label = "ZW" if vat_code in {"ZW", "0"} else ("NP" if vat_code == "NP" else f"{vat_code}%")
152
 
153
  computed_items.append(
154
  {
155
  "name": name,
156
- "quantity": str(_quantize(quantity)),
 
157
  "vat_code": vat_code,
158
  "vat_label": vat_label,
159
  "unit_price_net": str(unit_price_net),
@@ -188,7 +312,7 @@ def computed_summary_to_serializable(summary: Dict[str, Dict[str, Decimal]]) ->
188
  return serialized
189
 
190
 
191
- def compute_invoice(payload: Dict[str, Any], business: Dict[str, Any]) -> Dict[str, Any]:
192
  items_payload = payload.get("items", [])
193
  computed_items, summary = compute_invoice_items(items_payload)
194
 
@@ -196,10 +320,17 @@ def compute_invoice(payload: Dict[str, Any], business: Dict[str, Any]) -> Dict[s
196
  vat_sum = sum(Decimal(item["vat_amount"]) for item in computed_items)
197
  gross_sum = sum(Decimal(item["gross_total"]) for item in computed_items)
198
 
199
- issued_at = datetime.now()
200
- invoice_id = issued_at.strftime("FV-%Y%m%d-%H%M%S")
 
 
 
 
201
 
202
- sale_date = parse_iso_date(payload.get("sale_date")) or issued_at.strftime("%Y-%m-%d")
 
 
 
203
  client_payload = payload.get("client") or {}
204
  client = {
205
  "name": (client_payload.get("name") or "").strip(),
@@ -207,12 +338,14 @@ def compute_invoice(payload: Dict[str, Any], business: Dict[str, Any]) -> Dict[s
207
  "postal_code": (client_payload.get("postal_code") or "").strip(),
208
  "city": (client_payload.get("city") or "").strip(),
209
  "tax_id": (client_payload.get("tax_id") or "").strip(),
 
210
  }
211
 
212
  invoice = {
213
- "invoice_id": invoice_id,
214
- "issued_at": issued_at.strftime("%Y-%m-%d %H:%M"),
215
  "sale_date": sale_date,
 
216
  "items": computed_items,
217
  "summary": computed_summary_to_serializable(summary),
218
  "totals": {
@@ -227,9 +360,17 @@ def compute_invoice(payload: Dict[str, Any], business: Dict[str, Any]) -> Dict[s
227
  return invoice
228
 
229
 
230
- def create_token() -> str:
 
 
 
 
 
 
 
 
231
  token = uuid.uuid4().hex
232
- SESSION_TOKENS[token] = datetime.now()
233
  return token
234
 
235
 
@@ -241,10 +382,20 @@ def get_token() -> Optional[str]:
241
 
242
 
243
  def require_auth() -> str:
 
244
  token = get_token()
245
- if not token or token not in SESSION_TOKENS:
246
  raise PermissionError("Brak autoryzacji.")
247
- return token
 
 
 
 
 
 
 
 
 
248
 
249
 
250
  @app.route("/<path:path>")
@@ -260,17 +411,29 @@ def serve_static(path: str) -> Any:
260
  @app.route("/api/status", methods=["GET"])
261
  def api_status() -> Any:
262
  data = load_store()
263
- configured = bool(data.get("business") and data.get("password_hash"))
264
- return jsonify({"configured": configured})
 
 
 
 
 
265
 
266
 
267
  @app.route("/api/setup", methods=["POST"])
268
  def api_setup() -> Any:
269
  data = load_store()
270
- if data.get("password_hash"):
271
- return jsonify({"error": "Aplikacja zostala juz skonfigurowana."}), 400
272
-
273
  payload = request.get_json(force=True)
 
 
 
 
 
 
 
 
 
 
274
  required_fields = [
275
  "company_name",
276
  "owner_name",
@@ -279,62 +442,83 @@ def api_setup() -> Any:
279
  "city",
280
  "tax_id",
281
  "bank_account",
282
- "password",
283
  ]
284
 
285
  missing = [field for field in required_fields if not (payload.get(field) or "").strip()]
286
  if missing:
287
  return jsonify({"error": f"Brakuje pol: {', '.join(missing)}"}), 400
288
 
289
- if len(payload["password"]) < 4:
290
- return jsonify({"error": "Haslo musi miec co najmniej 4 znaki."}), 400
291
-
292
- data["business"] = {
293
- "company_name": payload["company_name"].strip(),
294
- "owner_name": payload["owner_name"].strip(),
295
- "address_line": payload["address_line"].strip(),
296
- "postal_code": payload["postal_code"].strip(),
297
- "city": payload["city"].strip(),
298
- "tax_id": payload["tax_id"].strip(),
299
- "bank_account": payload["bank_account"].strip(),
 
 
 
 
 
 
 
 
 
300
  }
301
- data["password_hash"] = hash_password(payload["password"])
302
- data.setdefault("invoices", [])
303
 
 
 
304
  save_store(data)
305
- return jsonify({"message": "Dane zapisane. Mozesz sie zalogowac."})
306
 
307
 
308
  @app.route("/api/login", methods=["POST"])
309
  def api_login() -> Any:
310
  payload = request.get_json(force=True)
 
311
  password = (payload.get("password") or "").strip()
 
 
312
  data = load_store()
313
 
314
- if not data.get("password_hash"):
315
- return jsonify({"error": "Aplikacja nie zostala skonfigurowana."}), 400
 
 
316
 
317
- if hash_password(password) != data["password_hash"]:
318
- return jsonify({"error": "Nieprawidlowe haslo."}), 401
 
 
 
319
 
320
- token = create_token()
321
- return jsonify({"token": token})
 
322
 
323
 
324
  @app.route("/api/business", methods=["GET", "PUT"])
325
  def api_business() -> Any:
326
  try:
327
- require_auth()
328
  except PermissionError:
329
  return jsonify({"error": "Brak autoryzacji."}), 401
330
 
331
  data = load_store()
 
 
 
 
 
332
  if request.method == "GET":
333
- ensure_configured(data)
334
- return jsonify({"business": data["business"]})
335
 
336
  payload = request.get_json(force=True)
337
- current = data.get("business") or {}
338
  updated = {
339
  "company_name": (payload.get("company_name") or current.get("company_name") or "").strip(),
340
  "owner_name": (payload.get("owner_name") or current.get("owner_name") or "").strip(),
@@ -349,7 +533,7 @@ def api_business() -> Any:
349
  if missing:
350
  return jsonify({"error": f"Wypelnij wszystkie pola: {', '.join(missing)}"}), 400
351
 
352
- data["business"] = updated
353
  save_store(data)
354
  return jsonify({"business": updated})
355
 
@@ -357,34 +541,268 @@ def api_business() -> Any:
357
  @app.route("/api/invoices", methods=["POST", "GET"])
358
  def api_invoices() -> Any:
359
  try:
360
- require_auth()
361
  except PermissionError:
362
  return jsonify({"error": "Brak autoryzacji."}), 401
363
 
364
  data = load_store()
365
- ensure_configured(data)
 
 
 
 
 
 
 
366
 
367
  if request.method == "GET":
368
- return jsonify({"invoices": data.get("invoices", [])})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
 
370
  payload = request.get_json(force=True)
371
  try:
372
- invoice = compute_invoice(payload, data["business"])
373
  except ValueError as error:
374
  return jsonify({"error": str(error)}), 400
375
 
376
- invoices = data.setdefault("invoices", [])
377
  invoices.append(invoice)
378
  if len(invoices) > INVOICE_HISTORY_LIMIT:
379
- data["invoices"] = invoices[-INVOICE_HISTORY_LIMIT:]
380
 
381
  save_store(data)
382
  return jsonify({"invoice": invoice})
383
 
384
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
  if __name__ == "__main__":
386
- port = int(os.environ.get("PORT", "7860"))
387
  app.run(host="0.0.0.0", port=port, debug=True)
388
-
389
- DATA_DIR = Path(os.environ.get("DATA_DIR", APP_ROOT))
390
- DATA_FILE = DATA_DIR / "web_invoice_store.json"
 
1
+ import base64
2
+ import binascii
3
  import hashlib
4
  import json
5
  import os
6
+ import re
 
 
 
 
 
 
 
7
  import uuid
8
+ from datetime import date, datetime, timedelta
9
  from decimal import Decimal, ROUND_HALF_UP, getcontext
10
  from pathlib import Path
11
  from typing import Any, Dict, List, Optional, Tuple
 
16
  DATA_DIR = Path(os.environ.get("DATA_DIR", APP_ROOT))
17
  DATA_FILE = DATA_DIR / "web_invoice_store.json"
18
  INVOICE_HISTORY_LIMIT = 200
19
+ MAX_LOGO_SIZE = 512 * 1024 # 512 KB
20
+ TOKEN_TTL = timedelta(hours=12)
21
+ ALLOWED_LOGO_MIME_TYPES = {"image/png", "image/jpeg"}
22
 
23
  VAT_RATES: Dict[str, Optional[Decimal]] = {
24
  "23": Decimal("0.23"),
 
29
  "NP": None,
30
  }
31
 
32
+ DEFAULT_UNIT = "szt."
33
+ ALLOWED_UNITS = {"szt.", "godz."}
34
+ PASSWORD_MIN_LENGTH = 4
35
+
36
+ SESSION_TOKENS: Dict[str, Dict[str, Any]] = {}
37
 
38
  ALLOWED_STATIC = {
39
  "index.html",
 
43
  "Roboto-VariableFont_wdth,wght.ttf",
44
  }
45
 
46
+
47
+ app = Flask(__name__, static_folder=str(APP_ROOT), static_url_path="")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
  getcontext().prec = 10
50
 
 
64
  return hashlib.sha256(password.encode("utf-8")).hexdigest()
65
 
66
 
67
+ EMAIL_PATTERN = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$")
68
+
69
+
70
+ def normalize_email(raw_email: str) -> Tuple[str, str]:
71
+ display_email = (raw_email or "").strip()
72
+ if not display_email:
73
+ raise ValueError("Email nie moze byc pusty.")
74
+ if not EMAIL_PATTERN.fullmatch(display_email):
75
+ raise ValueError("Podaj poprawny adres email.")
76
+ return display_email.lower(), display_email
77
+
78
+
79
+ def sanitize_filename(filename: Optional[str]) -> str:
80
+ if not filename:
81
+ return "logo"
82
+ name = str(filename).split("/")[-1].split("\\")[-1]
83
+ sanitized = re.sub(r"[^A-Za-z0-9._-]", "_", name).strip("._")
84
+ return sanitized or "logo"
85
+
86
+
87
+ def find_account_identifier(accounts: Dict[str, Any], identifier: str) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
88
+ key = (identifier or "").strip().lower()
89
+ if not key:
90
+ return None, None
91
+ account = accounts.get(key)
92
+ if account:
93
+ return key, account
94
+ for login_key, candidate in accounts.items():
95
+ candidate_login = (candidate.get("login") or "").strip().lower()
96
+ candidate_email = (candidate.get("email") or "").strip().lower()
97
+ if key in {candidate_login, candidate_email}:
98
+ return login_key, candidate
99
+ return None, None
100
+
101
+
102
+
103
+
104
+ def migrate_store_if_needed(data: Dict[str, Any]) -> Tuple[Dict[str, Any], bool]:
105
+ if "accounts" in data:
106
+ accounts = data.get("accounts") or {}
107
+ for login, account in accounts.items():
108
+ account.setdefault("login", login)
109
+ email = (account.get("email") or account.get("login") or "").strip()
110
+ account["email"] = email
111
+ account.setdefault("business", None)
112
+ account.setdefault("password_hash", None)
113
+ account.setdefault("invoices", [])
114
+ account.setdefault("logo", None)
115
+ account.setdefault("created_at", datetime.utcnow().isoformat(timespec="seconds"))
116
+ data["accounts"] = accounts
117
+ return data, False
118
+
119
+ legacy_business = data.get("business")
120
+ legacy_password = data.get("password_hash")
121
+ legacy_invoices = data.get("invoices", [])
122
+ legacy_logo = data.get("logo")
123
+
124
+ accounts: Dict[str, Any] = {}
125
+ legacy_login_hint = None
126
+ if legacy_password:
127
+ login_key = "admin"
128
+ accounts[login_key] = {
129
+ "login": login_key,
130
+ "password_hash": legacy_password,
131
+ "business": legacy_business,
132
+ "invoices": legacy_invoices,
133
+ "logo": legacy_logo,
134
+ "created_at": datetime.utcnow().isoformat(timespec="seconds"),
135
+ }
136
+ legacy_login_hint = login_key
137
+
138
+ migrated: Dict[str, Any] = {"accounts": accounts}
139
+ if legacy_login_hint:
140
+ migrated["legacy_login_hint"] = legacy_login_hint
141
+ return migrated, True
142
+
143
+
144
  def load_store() -> Dict[str, Any]:
145
  if not DATA_FILE.exists():
146
+ return {"accounts": {}}
147
  with DATA_FILE.open("r", encoding="utf-8") as handle:
148
+ data = json.load(handle)
149
+ normalized, migrated = migrate_store_if_needed(data)
150
+ if migrated:
151
+ save_store(normalized)
152
+ return normalized
153
 
154
 
155
  def save_store(data: Dict[str, Any]) -> None:
 
158
  json.dump(data, handle, ensure_ascii=False, indent=2)
159
 
160
 
161
+ def ensure_business_configured(account: Dict[str, Any]) -> None:
162
+ if not account.get("business"):
163
+ raise ValueError("Dane sprzedawcy nie zostaly uzupelnione.")
164
+
165
+
166
+ def ensure_account_defaults(account: Dict[str, Any], login_key: str) -> Dict[str, Any]:
167
+ account.setdefault("login", login_key)
168
+ email_value = (account.get("email") or account.get("login") or "").strip()
169
+ account["email"] = email_value
170
+ account.setdefault("business", None)
171
+ account.setdefault("password_hash", None)
172
+ invoices = account.setdefault("invoices", [])
173
+ if isinstance(invoices, list):
174
+ for invoice in invoices:
175
+ if not isinstance(invoice, dict):
176
+ continue
177
+ items = invoice.get("items")
178
+ if not isinstance(items, list):
179
+ continue
180
+ for item in items:
181
+ if not isinstance(item, dict):
182
+ continue
183
+ raw_quantity = str(item.get("quantity", "")).strip()
184
+ try:
185
+ quantity_decimal = _decimal(raw_quantity or "0")
186
+ except ValueError:
187
+ quantity_decimal = Decimal("0")
188
+ if quantity_decimal > 0:
189
+ quantity_integral = quantity_decimal.to_integral_value(rounding=ROUND_HALF_UP)
190
+ item["quantity"] = str(int(quantity_integral))
191
+ unit_value = (item.get("unit") or "").strip()
192
+ if unit_value not in ALLOWED_UNITS:
193
+ unit_value = DEFAULT_UNIT
194
+ item["unit"] = unit_value
195
+ account.setdefault("logo", None)
196
+ account.setdefault("created_at", datetime.utcnow().isoformat(timespec="seconds"))
197
+ return account
198
+
199
+
200
+ def get_accounts(data: Dict[str, Any]) -> Dict[str, Any]:
201
+ accounts = data.setdefault("accounts", {})
202
+ for login_key, account in list(accounts.items()):
203
+ accounts[login_key] = ensure_account_defaults(account, login_key)
204
+ return accounts
205
+
206
+
207
+ def get_account(data: Dict[str, Any], login_key: str) -> Dict[str, Any]:
208
+ accounts = get_accounts(data)
209
+ account = accounts.get(login_key)
210
+ if not account:
211
+ raise KeyError("Nie znaleziono konta.")
212
+ return account
213
 
214
 
215
  def parse_iso_date(value: Optional[str]) -> Optional[str]:
 
234
  if not name:
235
  raise ValueError("Kazda pozycja musi miec nazwe.")
236
 
237
+ quantity_decimal = _decimal(raw.get("quantity", "0"))
238
+ if quantity_decimal <= 0:
239
  raise ValueError("Ilosc musi byc wieksza od zera.")
240
+ quantity_integral = quantity_decimal.to_integral_value(rounding=ROUND_HALF_UP)
241
+ if quantity_decimal != quantity_integral:
242
+ raise ValueError("Ilosc musi byc liczba calkowita.")
243
+ quantity = int(quantity_integral)
244
+
245
+ unit_raw = str(raw.get("unit", "") or DEFAULT_UNIT).strip()
246
+ unit = unit_raw if unit_raw in ALLOWED_UNITS else None
247
+ if unit is None:
248
+ raise ValueError("Wybrano nieprawidlowa jednostke.")
249
 
250
  vat_code = str(raw.get("vat_code", "")).upper()
251
  if vat_code not in VAT_RATES:
 
266
  unit_price_net = _quantize(unit_price_net)
267
  unit_price_gross = _quantize(unit_price_gross)
268
 
269
+ quantity_decimal_value = Decimal(quantity)
270
+ net_total = _quantize(unit_price_net * quantity_decimal_value)
271
+ vat_amount_total = _quantize(vat_amount * quantity_decimal_value if rate is not None else Decimal("0.00"))
272
+ gross_total = _quantize(unit_price_gross * quantity_decimal_value)
273
 
274
  vat_label = "ZW" if vat_code in {"ZW", "0"} else ("NP" if vat_code == "NP" else f"{vat_code}%")
275
 
276
  computed_items.append(
277
  {
278
  "name": name,
279
+ "unit": unit,
280
+ "quantity": str(quantity),
281
  "vat_code": vat_code,
282
  "vat_label": vat_label,
283
  "unit_price_net": str(unit_price_net),
 
312
  return serialized
313
 
314
 
315
+ def compute_invoice(payload: Dict[str, Any], business: Dict[str, Any], *, invoice_id: Optional[str] = None, issued_at: Optional[str] = None) -> Dict[str, Any]:
316
  items_payload = payload.get("items", [])
317
  computed_items, summary = compute_invoice_items(items_payload)
318
 
 
320
  vat_sum = sum(Decimal(item["vat_amount"]) for item in computed_items)
321
  gross_sum = sum(Decimal(item["gross_total"]) for item in computed_items)
322
 
323
+ issued_timestamp = datetime.now()
324
+ if issued_at:
325
+ try:
326
+ issued_timestamp = datetime.strptime(issued_at, "%Y-%m-%d %H:%M")
327
+ except ValueError:
328
+ issued_timestamp = datetime.now()
329
 
330
+ generated_id = invoice_id or issued_timestamp.strftime("FV-%Y%m%d-%H%M%S")
331
+
332
+ sale_date = parse_iso_date(payload.get("sale_date")) or issued_timestamp.strftime("%Y-%m-%d")
333
+ payment_term = payload.get("payment_term")
334
  client_payload = payload.get("client") or {}
335
  client = {
336
  "name": (client_payload.get("name") or "").strip(),
 
338
  "postal_code": (client_payload.get("postal_code") or "").strip(),
339
  "city": (client_payload.get("city") or "").strip(),
340
  "tax_id": (client_payload.get("tax_id") or "").strip(),
341
+ "phone": (client_payload.get("phone") or "").strip(),
342
  }
343
 
344
  invoice = {
345
+ "invoice_id": generated_id,
346
+ "issued_at": issued_timestamp.strftime("%Y-%m-%d %H:%M"),
347
  "sale_date": sale_date,
348
+ "payment_term": payment_term,
349
  "items": computed_items,
350
  "summary": computed_summary_to_serializable(summary),
351
  "totals": {
 
360
  return invoice
361
 
362
 
363
+ def cleanup_tokens() -> None:
364
+ now = datetime.utcnow()
365
+ expired = [token for token, payload in SESSION_TOKENS.items() if now - payload["issued_at"] > TOKEN_TTL]
366
+ for token in expired:
367
+ SESSION_TOKENS.pop(token, None)
368
+
369
+
370
+ def create_token(login: str) -> str:
371
+ cleanup_tokens()
372
  token = uuid.uuid4().hex
373
+ SESSION_TOKENS[token] = {"login": login, "issued_at": datetime.utcnow()}
374
  return token
375
 
376
 
 
382
 
383
 
384
  def require_auth() -> str:
385
+ cleanup_tokens()
386
  token = get_token()
387
+ if not token:
388
  raise PermissionError("Brak autoryzacji.")
389
+ payload = SESSION_TOKENS.get(token)
390
+ if not payload:
391
+ raise PermissionError("Brak autoryzacji.")
392
+ payload["issued_at"] = datetime.utcnow()
393
+ return payload["login"]
394
+
395
+
396
+ @app.route("/")
397
+ def serve_index() -> Any:
398
+ return send_from_directory(app.static_folder, "index.html")
399
 
400
 
401
  @app.route("/<path:path>")
 
411
  @app.route("/api/status", methods=["GET"])
412
  def api_status() -> Any:
413
  data = load_store()
414
+ accounts = get_accounts(data)
415
+ response = {
416
+ "configured": bool(accounts),
417
+ "legacy_login_hint": data.get("legacy_login_hint"),
418
+ "max_logo_size": MAX_LOGO_SIZE,
419
+ }
420
+ return jsonify(response)
421
 
422
 
423
  @app.route("/api/setup", methods=["POST"])
424
  def api_setup() -> Any:
425
  data = load_store()
 
 
 
426
  payload = request.get_json(force=True)
427
+ try:
428
+ email_key, display_email = normalize_email(payload.get("email", ""))
429
+ except ValueError as error:
430
+ return jsonify({"error": str(error)}), 400
431
+
432
+ accounts = get_accounts(data)
433
+ existing_key, existing_account = find_account_identifier(accounts, display_email)
434
+ if existing_key and existing_account:
435
+ return jsonify({"error": "Podany adres email jest juz zajety."}), 400
436
+
437
  required_fields = [
438
  "company_name",
439
  "owner_name",
 
442
  "city",
443
  "tax_id",
444
  "bank_account",
 
445
  ]
446
 
447
  missing = [field for field in required_fields if not (payload.get(field) or "").strip()]
448
  if missing:
449
  return jsonify({"error": f"Brakuje pol: {', '.join(missing)}"}), 400
450
 
451
+ password = (payload.get("password") or "").strip()
452
+ if len(password) < PASSWORD_MIN_LENGTH:
453
+ return jsonify({"error": f"Haslo musi miec co najmniej {PASSWORD_MIN_LENGTH} znakow."}), 400
454
+
455
+ new_account = {
456
+ "login": display_email,
457
+ "email": display_email,
458
+ "password_hash": hash_password(password),
459
+ "business": {
460
+ "company_name": payload["company_name"].strip(),
461
+ "owner_name": payload["owner_name"].strip(),
462
+ "address_line": payload["address_line"].strip(),
463
+ "postal_code": payload["postal_code"].strip(),
464
+ "city": payload["city"].strip(),
465
+ "tax_id": payload["tax_id"].strip(),
466
+ "bank_account": payload["bank_account"].strip(),
467
+ },
468
+ "invoices": [],
469
+ "logo": None,
470
+ "created_at": datetime.utcnow().isoformat(timespec="seconds"),
471
  }
 
 
472
 
473
+ accounts[email_key] = new_account
474
+ data.pop("legacy_login_hint", None)
475
  save_store(data)
476
+ return jsonify({"message": "Konto utworzone. Mozesz sie zalogowac."})
477
 
478
 
479
  @app.route("/api/login", methods=["POST"])
480
  def api_login() -> Any:
481
  payload = request.get_json(force=True)
482
+ identifier_raw = (payload.get("email") or payload.get("login") or "").strip()
483
  password = (payload.get("password") or "").strip()
484
+ if not identifier_raw:
485
+ return jsonify({"error": "Podaj adres email."}), 400
486
  data = load_store()
487
 
488
+ accounts = get_accounts(data)
489
+ login_key, account = find_account_identifier(accounts, identifier_raw)
490
+ if not account:
491
+ return jsonify({"error": "Nieprawidlowy email lub haslo."}), 401
492
 
493
+ stored_hash = account.get("password_hash")
494
+ if not stored_hash:
495
+ return jsonify({"error": "Konto nie zostalo jeszcze skonfigurowane."}), 400
496
+ if hash_password(password) != stored_hash:
497
+ return jsonify({"error": "Nieprawidlowy email lub haslo."}), 401
498
 
499
+ token = create_token(login_key or (identifier_raw.lower()))
500
+ display_email = account.get("email") or account.get("login") or identifier_raw
501
+ return jsonify({"token": token, "login": account.get("login", display_email), "email": display_email})
502
 
503
 
504
  @app.route("/api/business", methods=["GET", "PUT"])
505
  def api_business() -> Any:
506
  try:
507
+ login_key = require_auth()
508
  except PermissionError:
509
  return jsonify({"error": "Brak autoryzacji."}), 401
510
 
511
  data = load_store()
512
+ try:
513
+ account = get_account(data, login_key)
514
+ except KeyError:
515
+ return jsonify({"error": "Nie znaleziono konta."}), 404
516
+
517
  if request.method == "GET":
518
+ return jsonify({"business": account.get("business")})
 
519
 
520
  payload = request.get_json(force=True)
521
+ current = account.get("business") or {}
522
  updated = {
523
  "company_name": (payload.get("company_name") or current.get("company_name") or "").strip(),
524
  "owner_name": (payload.get("owner_name") or current.get("owner_name") or "").strip(),
 
533
  if missing:
534
  return jsonify({"error": f"Wypelnij wszystkie pola: {', '.join(missing)}"}), 400
535
 
536
+ account["business"] = updated
537
  save_store(data)
538
  return jsonify({"business": updated})
539
 
 
541
  @app.route("/api/invoices", methods=["POST", "GET"])
542
  def api_invoices() -> Any:
543
  try:
544
+ login_key = require_auth()
545
  except PermissionError:
546
  return jsonify({"error": "Brak autoryzacji."}), 401
547
 
548
  data = load_store()
549
+ try:
550
+ account = get_account(data, login_key)
551
+ except KeyError:
552
+ return jsonify({"error": "Nie znaleziono konta."}), 404
553
+ try:
554
+ ensure_business_configured(account)
555
+ except ValueError as error:
556
+ return jsonify({"error": str(error)}), 400
557
 
558
  if request.method == "GET":
559
+ invoices = list(account.get("invoices", []))
560
+ start_param = request.args.get("start_date")
561
+ end_param = request.args.get("end_date")
562
+ start_date: Optional[date] = None
563
+ end_date: Optional[date] = None
564
+ if start_param:
565
+ try:
566
+ start_date = datetime.fromisoformat(start_param).date()
567
+ except ValueError:
568
+ return jsonify({"error": "Niepoprawny format daty poczatkowej (YYYY-MM-DD)."}), 400
569
+ if end_param:
570
+ try:
571
+ end_date = datetime.fromisoformat(end_param).date()
572
+ except ValueError:
573
+ return jsonify({"error": "Niepoprawny format daty koncowej (YYYY-MM-DD)."}), 400
574
+ if start_date and end_date and start_date > end_date:
575
+ return jsonify({"error": "Data poczatkowa nie moze byc pozniejsza niz data koncowa."}), 400
576
+
577
+ def issued_at_to_datetime(issued_at: str) -> Optional[datetime]:
578
+ try:
579
+ return datetime.strptime(issued_at, "%Y-%m-%d %H:%M")
580
+ except (TypeError, ValueError):
581
+ return None
582
+
583
+ filtered: List[Dict[str, Any]] = []
584
+ for invoice in invoices:
585
+ issued_at_str = invoice.get("issued_at")
586
+ issued_dt = issued_at_to_datetime(issued_at_str)
587
+ if issued_dt is None:
588
+ filtered.append(invoice)
589
+ continue
590
+ issued_date = issued_dt.date()
591
+ if start_date and issued_date < start_date:
592
+ continue
593
+ if end_date and issued_date > end_date:
594
+ continue
595
+ filtered.append(invoice)
596
+
597
+ filtered.sort(key=lambda item: item.get("issued_at", ""), reverse=True)
598
+ return jsonify({"invoices": filtered})
599
 
600
  payload = request.get_json(force=True)
601
  try:
602
+ invoice = compute_invoice(payload, account["business"])
603
  except ValueError as error:
604
  return jsonify({"error": str(error)}), 400
605
 
606
+ invoices = account.setdefault("invoices", [])
607
  invoices.append(invoice)
608
  if len(invoices) > INVOICE_HISTORY_LIMIT:
609
+ account["invoices"] = invoices[-INVOICE_HISTORY_LIMIT:]
610
 
611
  save_store(data)
612
  return jsonify({"invoice": invoice})
613
 
614
 
615
+ @app.route("/api/invoices/<invoice_id>", methods=["GET", "PUT", "DELETE"])
616
+ def api_invoice_detail(invoice_id: str) -> Any:
617
+ try:
618
+ login_key = require_auth()
619
+ except PermissionError:
620
+ return jsonify({"error": "Brak autoryzacji."}), 401
621
+
622
+ data = load_store()
623
+ try:
624
+ account = get_account(data, login_key)
625
+ except KeyError:
626
+ return jsonify({"error": "Nie znaleziono konta."}), 404
627
+
628
+ try:
629
+ ensure_business_configured(account)
630
+ except ValueError as error:
631
+ return jsonify({"error": str(error)}), 400
632
+
633
+ invoices = account.setdefault("invoices", [])
634
+ try:
635
+ index = next(index for index, inv in enumerate(invoices) if inv.get("invoice_id") == invoice_id)
636
+ except StopIteration:
637
+ return jsonify({"error": "Nie znaleziono faktury."}), 404
638
+
639
+ current_invoice = invoices[index]
640
+
641
+ if request.method == "GET":
642
+ return jsonify({"invoice": current_invoice})
643
+
644
+ if request.method == "DELETE":
645
+ invoices.pop(index)
646
+ save_store(data)
647
+ return jsonify({"message": "Faktura zostala usunieta."})
648
+
649
+ payload = request.get_json(force=True)
650
+ try:
651
+ updated_invoice = compute_invoice(
652
+ payload,
653
+ account["business"],
654
+ invoice_id=current_invoice.get("invoice_id"),
655
+ issued_at=current_invoice.get("issued_at"),
656
+ )
657
+ except ValueError as error:
658
+ return jsonify({"error": str(error)}), 400
659
+
660
+ invoices[index] = updated_invoice
661
+ save_store(data)
662
+ return jsonify({"invoice": updated_invoice})
663
+
664
+
665
+ @app.route("/api/logo", methods=["GET", "POST", "DELETE"])
666
+ def api_logo() -> Any:
667
+ try:
668
+ login_key = require_auth()
669
+ except PermissionError:
670
+ return jsonify({"error": "Brak autoryzacji."}), 401
671
+
672
+ data = load_store()
673
+ try:
674
+ account = get_account(data, login_key)
675
+ except KeyError:
676
+ return jsonify({"error": "Nie znaleziono konta."}), 404
677
+
678
+ if request.method == "GET":
679
+ logo = account.get("logo")
680
+ if not logo:
681
+ return jsonify({"logo": None})
682
+ encoded = logo.get("data")
683
+ mime_type = logo.get("mime_type")
684
+ data_url = None
685
+ if encoded and mime_type:
686
+ data_url = f"data:{mime_type};base64,{encoded}"
687
+ return jsonify(
688
+ {
689
+ "logo": {
690
+ "filename": logo.get("filename"),
691
+ "mime_type": mime_type,
692
+ "data": encoded,
693
+ "data_url": data_url,
694
+ "uploaded_at": logo.get("uploaded_at"),
695
+ }
696
+ }
697
+ )
698
+
699
+ if request.method == "DELETE":
700
+ account["logo"] = None
701
+ save_store(data)
702
+ return jsonify({"message": "Logo zostalo usuniete."})
703
+
704
+ payload = request.get_json(force=True)
705
+ raw_content = (payload.get("content") or payload.get("data") or "").strip()
706
+ if not raw_content:
707
+ return jsonify({"error": "Brak danych logo."}), 400
708
+
709
+ provided_mime = (payload.get("mime_type") or "").strip()
710
+ filename = sanitize_filename(payload.get("filename"))
711
+
712
+ if raw_content.startswith("data:"):
713
+ try:
714
+ header, encoded_content = raw_content.split(",", 1)
715
+ except ValueError:
716
+ return jsonify({"error": "Niepoprawny format danych logo."}), 400
717
+ header = header.strip()
718
+ if ";base64" not in header:
719
+ return jsonify({"error": "Niepoprawny format danych logo (oczekiwano base64)."}), 400
720
+ mime_type = header.split(";")[0].replace("data:", "", 1) or provided_mime
721
+ base64_content = encoded_content.strip()
722
+ else:
723
+ mime_type = provided_mime
724
+ base64_content = raw_content
725
+
726
+ mime_type = (mime_type or "").lower()
727
+ if mime_type not in ALLOWED_LOGO_MIME_TYPES:
728
+ return jsonify({"error": "Dozwolone formaty logo to PNG lub JPG."}), 400
729
+
730
+ try:
731
+ logo_bytes = base64.b64decode(base64_content, validate=True)
732
+ except (ValueError, binascii.Error):
733
+ return jsonify({"error": "Nie udalo sie odczytac danych logo (base64)."}), 400
734
+
735
+ if len(logo_bytes) > MAX_LOGO_SIZE:
736
+ return jsonify({"error": f"Logo jest zbyt duze (maksymalnie {MAX_LOGO_SIZE // 1024} KB)."}), 400
737
+
738
+ stored_logo = {
739
+ "filename": filename,
740
+ "mime_type": mime_type,
741
+ "data": base64.b64encode(logo_bytes).decode("ascii"),
742
+ "uploaded_at": datetime.utcnow().isoformat(timespec="seconds"),
743
+ }
744
+
745
+ account["logo"] = stored_logo
746
+ save_store(data)
747
+ return jsonify({"logo": stored_logo})
748
+
749
+
750
+ @app.route("/api/invoices/summary", methods=["GET"])
751
+ def api_invoice_summary() -> Any:
752
+ try:
753
+ login_key = require_auth()
754
+ except PermissionError:
755
+ return jsonify({"error": "Brak autoryzacji."}), 401
756
+
757
+ data = load_store()
758
+ try:
759
+ account = get_account(data, login_key)
760
+ except KeyError:
761
+ return jsonify({"error": "Nie znaleziono konta."}), 404
762
+
763
+ try:
764
+ ensure_business_configured(account)
765
+ except ValueError as error:
766
+ return jsonify({"error": str(error)}), 400
767
+
768
+ now = datetime.utcnow()
769
+ last_month_start = now - timedelta(days=30)
770
+ quarter_first_month = ((now.month - 1) // 3) * 3 + 1
771
+ quarter_start = now.replace(month=quarter_first_month, day=1, hour=0, minute=0, second=0, microsecond=0)
772
+ year_start = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
773
+
774
+ def parse_issued_at(value: Optional[str]) -> Optional[datetime]:
775
+ if not value:
776
+ return None
777
+ try:
778
+ return datetime.strptime(value, "%Y-%m-%d %H:%M")
779
+ except ValueError:
780
+ return None
781
+
782
+ def aggregate(start: datetime) -> Dict[str, Any]:
783
+ count = 0
784
+ gross_total = Decimal("0.00")
785
+ for invoice in account.get("invoices", []):
786
+ issued_dt = parse_issued_at(invoice.get("issued_at"))
787
+ if issued_dt is None or issued_dt < start:
788
+ continue
789
+ count += 1
790
+ gross_value = invoice.get("totals", {}).get("gross", "0")
791
+ try:
792
+ gross_total += _decimal(gross_value)
793
+ except ValueError:
794
+ continue
795
+ return {"count": count, "gross_total": str(_quantize(gross_total))}
796
+
797
+ summary = {
798
+ "last_month": aggregate(last_month_start),
799
+ "quarter": aggregate(quarter_start),
800
+ "year": aggregate(year_start),
801
+ }
802
+
803
+ return jsonify({"summary": summary})
804
+
805
+
806
  if __name__ == "__main__":
807
+ port = int(os.environ.get("PORT", "5000"))
808
  app.run(host="0.0.0.0", port=port, debug=True)
 
 
 
small_logotyp do strony.jpg ADDED
styles.css ADDED
@@ -0,0 +1,705 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #f5f5f5;
3
+ --panel-bg: #ffffff;
4
+ --text: #202124;
5
+ --muted: #5f6368;
6
+ --accent: #1a73e8;
7
+ --danger: #c5221f;
8
+ --border: #dadce0;
9
+ --radius: 10px;
10
+ --shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
11
+ font-family: "Roboto", "Segoe UI", Tahoma, sans-serif;
12
+ }
13
+
14
+ * {
15
+ box-sizing: border-box;
16
+ }
17
+
18
+ body {
19
+ margin: 0;
20
+ background: linear-gradient(180deg, #f7f9fc 0%, #eef1f6 100%);
21
+ color: var(--text);
22
+ }
23
+
24
+ .container {
25
+ max-width: 980px;
26
+ margin: 0 auto;
27
+ padding: 40px 20px 64px;
28
+ }
29
+
30
+ .header-section {
31
+ display: flex;
32
+ flex-direction: column;
33
+ align-items: center;
34
+ gap: 16px;
35
+ margin-bottom: 32px;
36
+ text-align: center;
37
+ }
38
+
39
+ .logo-container {
40
+ display: flex;
41
+ flex-direction: column;
42
+ align-items: center;
43
+ gap: 12px;
44
+ }
45
+
46
+ .logo {
47
+ max-width: 180px;
48
+ height: auto;
49
+ border-radius: 8px;
50
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
51
+ }
52
+
53
+ .app-title {
54
+ text-align: center;
55
+ font-size: 24px;
56
+ margin: 0;
57
+ font-weight: 600;
58
+ }
59
+
60
+ .app-description {
61
+ text-align: center;
62
+ font-size: 16px;
63
+ color: var(--muted);
64
+ line-height: 1.5;
65
+ margin: 0;
66
+ max-width: 600px;
67
+ }
68
+
69
+ .panel {
70
+ background: var(--panel-bg);
71
+ border-radius: var(--radius);
72
+ box-shadow: var(--shadow);
73
+ padding: 28px 32px;
74
+ margin-bottom: 32px;
75
+ border: 1px solid rgba(32, 33, 36, 0.08);
76
+ }
77
+
78
+ .auth-card {
79
+ border: 1px solid rgba(32, 33, 36, 0.12);
80
+ border-radius: var(--radius);
81
+ padding: 24px 28px;
82
+ background: #f9fbff;
83
+ display: grid;
84
+ gap: 18px;
85
+ max-width: 520px;
86
+ margin: 0 auto;
87
+ width: 100%;
88
+ }
89
+
90
+ .auth-login {
91
+ display: flex;
92
+ justify-content: center;
93
+ }
94
+
95
+ .login-card {
96
+ width: 100%;
97
+ }
98
+
99
+ #register-section {
100
+ display: flex;
101
+ justify-content: center;
102
+ }
103
+
104
+ #register-section .register-card {
105
+ max-width: 640px;
106
+ width: 100%;
107
+ }
108
+
109
+ .register-header {
110
+ display: flex;
111
+ justify-content: space-between;
112
+ align-items: center;
113
+ gap: 16px;
114
+ }
115
+
116
+ .auth-card h3 {
117
+ margin: 0;
118
+ }
119
+
120
+ .auth-card .form {
121
+ gap: 18px;
122
+ width: 100%;
123
+ }
124
+
125
+
126
+ .auth-actions {
127
+ margin-top: 4px;
128
+
129
+ display: flex;
130
+ flex-direction: column;
131
+ align-items: center;
132
+ justify-content: center;
133
+ gap: 10px;
134
+ font-size: 14px;
135
+ color: var(--muted);
136
+ text-align: center;
137
+ }
138
+
139
+ .ghost-button {
140
+ padding: 10px 20px;
141
+ border: 1px solid var(--accent);
142
+ border-radius: 8px;
143
+ background: transparent;
144
+ color: var(--accent);
145
+ font-weight: 600;
146
+ cursor: pointer;
147
+ transition: background 0.2s ease, color 0.2s ease;
148
+ }
149
+
150
+ .ghost-button:hover {
151
+ background: rgba(26, 115, 232, 0.12);
152
+ }
153
+
154
+ .form-divider {
155
+ border: none;
156
+ border-top: 1px solid var(--border);
157
+ margin: 16px 0 0;
158
+ }
159
+
160
+ .register-fields {
161
+ display: grid;
162
+ gap: 24px;
163
+ }
164
+
165
+ .register-credentials {
166
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
167
+ }
168
+
169
+ .register-company {
170
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
171
+ }
172
+
173
+ .hidden {
174
+ display: none;
175
+ }
176
+
177
+ .form {
178
+ display: grid;
179
+ gap: 20px;
180
+ }
181
+
182
+ .field-grid {
183
+ display: grid;
184
+ gap: 18px 24px;
185
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
186
+ }
187
+
188
+ label {
189
+ display: grid;
190
+ gap: 8px;
191
+ font-weight: 600;
192
+ font-size: 15px;
193
+ }
194
+
195
+ input,
196
+ textarea,
197
+ select {
198
+ padding: 12px 14px;
199
+ border-radius: 8px;
200
+ border: 1px solid var(--border);
201
+ font-size: 15px;
202
+ background: #fbfbff;
203
+ }
204
+
205
+ input:focus,
206
+ textarea:focus,
207
+ select:focus {
208
+ outline: 2px solid rgba(26, 115, 232, 0.35);
209
+ outline-offset: 1px;
210
+ border-color: var(--accent);
211
+ background: #ffffff;
212
+ }
213
+
214
+ textarea {
215
+ resize: vertical;
216
+ min-height: 96px;
217
+ }
218
+
219
+ #exemption-note-wrapper {
220
+ display: grid;
221
+ gap: 12px;
222
+ }
223
+
224
+ #exemption-note-wrapper textarea[readonly] {
225
+ background: #f4f6fb;
226
+ color: var(--muted);
227
+ }
228
+
229
+ button {
230
+ padding: 12px 20px;
231
+ border-radius: 8px;
232
+ border: none;
233
+ font-size: 15px;
234
+ font-weight: 600;
235
+ background: var(--accent);
236
+ color: white;
237
+ cursor: pointer;
238
+ transition: background 0.2s ease;
239
+ }
240
+
241
+ button:hover {
242
+ background: #0f5ec4;
243
+ }
244
+
245
+ .button {
246
+ display: inline-flex;
247
+ align-items: center;
248
+ justify-content: center;
249
+ gap: 8px;
250
+ padding: 12px 20px;
251
+ border-radius: 8px;
252
+ border: none;
253
+ font-size: 15px;
254
+ font-weight: 600;
255
+ background: var(--accent);
256
+ color: #ffffff;
257
+ cursor: pointer;
258
+ transition: background 0.2s ease;
259
+ }
260
+
261
+ .button.secondary {
262
+ background: rgba(26, 115, 232, 0.12);
263
+ color: var(--accent);
264
+ border: 1px solid rgba(26, 115, 232, 0.25);
265
+ }
266
+
267
+ .button.secondary:hover {
268
+ background: rgba(26, 115, 232, 0.2);
269
+ }
270
+
271
+ .button input[type="file"] {
272
+ display: none;
273
+ }
274
+
275
+ button:disabled {
276
+ opacity: 0.6;
277
+ cursor: not-allowed;
278
+ }
279
+
280
+ .link-button {
281
+ background: none;
282
+ color: var(--accent);
283
+ padding: 0;
284
+ }
285
+
286
+ .link-button:hover {
287
+ color: #0f5ec4;
288
+ background: none;
289
+ }
290
+
291
+ .hint {
292
+ color: var(--muted);
293
+ font-size: 13px;
294
+ margin: 0;
295
+ }
296
+
297
+ .feedback {
298
+ color: var(--muted);
299
+ min-height: 20px;
300
+ font-size: 14px;
301
+ }
302
+
303
+ .feedback:empty {
304
+ display: none;
305
+ }
306
+
307
+ .feedback.error {
308
+ color: var(--danger);
309
+ }
310
+
311
+ .feedback.success {
312
+ color: #188038;
313
+ }
314
+
315
+ .app-header {
316
+ display: flex;
317
+ justify-content: space-between;
318
+ align-items: center;
319
+ margin-bottom: 24px;
320
+ flex-wrap: wrap;
321
+ gap: 16px;
322
+ }
323
+
324
+ .app-subtitle {
325
+ margin: 4px 0 0;
326
+ color: var(--muted);
327
+ font-size: 14px;
328
+ }
329
+
330
+ .app-nav {
331
+ display: flex;
332
+ align-items: center;
333
+ gap: 8px;
334
+ flex-wrap: wrap;
335
+ }
336
+
337
+ .app-nav-button {
338
+ background: rgba(26, 115, 232, 0.12);
339
+ color: var(--accent);
340
+ border: 1px solid rgba(26, 115, 232, 0.25);
341
+ }
342
+
343
+ .app-nav-button:hover {
344
+ background: rgba(26, 115, 232, 0.2);
345
+ }
346
+
347
+ .app-nav-button.active {
348
+ background: var(--accent);
349
+ color: #ffffff;
350
+ }
351
+
352
+ .app-nav-button.active:hover {
353
+ background: #0f5ec4;
354
+ }
355
+
356
+ .business-section {
357
+ border: 1px solid rgba(32, 33, 36, 0.08);
358
+ border-radius: var(--radius);
359
+ padding: 20px 24px;
360
+ background: #fbfcff;
361
+ margin-bottom: 24px;
362
+ }
363
+
364
+ .app-view {
365
+ display: grid;
366
+ gap: 24px;
367
+ }
368
+
369
+ .business-section-header {
370
+ display: flex;
371
+ justify-content: space-between;
372
+ align-items: center;
373
+ margin-bottom: 12px;
374
+ }
375
+
376
+ .business-actions {
377
+ display: flex;
378
+ gap: 12px;
379
+ flex-wrap: wrap;
380
+ align-items: center;
381
+ }
382
+
383
+ .business-display {
384
+ font-size: 15px;
385
+ line-height: 1.4;
386
+ }
387
+
388
+ .business-display-grid {
389
+ display: grid;
390
+ gap: 12px;
391
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
392
+ }
393
+
394
+ .business-display-item {
395
+ display: flex;
396
+ flex-direction: column;
397
+ gap: 4px;
398
+ }
399
+
400
+ .business-display-item strong {
401
+ font-weight: 600;
402
+ }
403
+
404
+ .logo-preview {
405
+ margin-top: 16px;
406
+ border: 1px solid var(--border);
407
+ border-radius: 8px;
408
+ padding: 12px;
409
+ display: grid;
410
+ gap: 8px;
411
+ max-width: 260px;
412
+ background: #ffffff;
413
+ }
414
+
415
+ .logo-preview-label {
416
+ font-size: 12px;
417
+ text-transform: uppercase;
418
+ letter-spacing: 0.05em;
419
+ color: var(--muted);
420
+ font-weight: 600;
421
+ }
422
+
423
+ .logo-preview img {
424
+ max-width: 100%;
425
+ max-height: 120px;
426
+ object-fit: contain;
427
+ }
428
+
429
+ .form-actions {
430
+ display: flex;
431
+ align-items: center;
432
+ gap: 16px;
433
+ }
434
+
435
+ .items-section {
436
+ border: 1px solid rgba(32, 33, 36, 0.08);
437
+ border-radius: var(--radius);
438
+ padding: 20px 24px;
439
+ background: #ffffff;
440
+ display: grid;
441
+ gap: 18px;
442
+ }
443
+
444
+ .items-header {
445
+ display: flex;
446
+ justify-content: space-between;
447
+ align-items: center;
448
+ }
449
+
450
+ .items-table-wrapper {
451
+ overflow-x: auto;
452
+ }
453
+
454
+ .items-table {
455
+ width: 100%;
456
+ border-collapse: collapse;
457
+ font-size: 14px;
458
+ }
459
+
460
+ .items-table th,
461
+ .items-table td {
462
+ border: 1px solid var(--border);
463
+ padding: 10px 12px;
464
+ text-align: left;
465
+ }
466
+
467
+ .items-table th {
468
+ background: #f1f3f7;
469
+ font-weight: 600;
470
+ text-transform: uppercase;
471
+ letter-spacing: 0.02em;
472
+ }
473
+
474
+ .items-table input,
475
+ .items-table select {
476
+ width: 100%;
477
+ padding: 8px 10px;
478
+ border-radius: 6px;
479
+ border: 1px solid var(--border);
480
+ background: #ffffff;
481
+ }
482
+
483
+ .items-table .remove-item {
484
+ color: var(--danger);
485
+ background: none;
486
+ padding: 0;
487
+ }
488
+
489
+ .items-table .remove-item:hover {
490
+ text-decoration: underline;
491
+ }
492
+
493
+ .totals {
494
+ display: flex;
495
+ flex-wrap: wrap;
496
+ gap: 16px;
497
+ font-weight: 600;
498
+ margin-top: 8px;
499
+ padding: 12px 16px;
500
+ border-radius: 8px;
501
+ background: #f7f9ff;
502
+ }
503
+
504
+ .rate-summary {
505
+ display: grid;
506
+ gap: 10px;
507
+ margin-top: 8px;
508
+ }
509
+
510
+ .rate-summary-item {
511
+ display: flex;
512
+ flex-wrap: wrap;
513
+ gap: 12px;
514
+ font-weight: 600;
515
+ padding: 8px 12px;
516
+ border: 1px dashed rgba(26, 115, 232, 0.25);
517
+ border-radius: 6px;
518
+ background: #ffffff;
519
+ }
520
+
521
+ .invoice-preview {
522
+ display: grid;
523
+ gap: 24px;
524
+ }
525
+
526
+ .invoice-preview-meta {
527
+ display: flex;
528
+ flex-wrap: wrap;
529
+ gap: 24px;
530
+ font-size: 14px;
531
+ }
532
+
533
+ .invoice-preview-meta span {
534
+ display: inline-flex;
535
+ align-items: center;
536
+ gap: 6px;
537
+ }
538
+
539
+ .invoice-preview-header {
540
+ display: flex;
541
+ flex-wrap: wrap;
542
+ gap: 24px;
543
+ }
544
+
545
+ .invoice-preview-card {
546
+ flex: 1 1 280px;
547
+ border: 1px solid var(--border);
548
+ border-radius: var(--radius);
549
+ padding: 16px 20px;
550
+ background: #f9fafc;
551
+ }
552
+
553
+ .invoice-preview-card h4 {
554
+ margin: 0 0 12px;
555
+ font-size: 14px;
556
+ text-transform: uppercase;
557
+ letter-spacing: 0.05em;
558
+ }
559
+
560
+ .invoice-preview-card p {
561
+ margin: 4px 0;
562
+ font-size: 14px;
563
+ }
564
+
565
+ .invoice-preview table {
566
+ width: 100%;
567
+ border-collapse: collapse;
568
+ font-size: 14px;
569
+ }
570
+
571
+ .invoice-preview th,
572
+ .invoice-preview td {
573
+ border: 1px solid var(--border);
574
+ padding: 10px 12px;
575
+ text-align: left;
576
+ }
577
+
578
+ .invoice-preview th {
579
+ background: #f1f3f7;
580
+ font-weight: 600;
581
+ }
582
+
583
+ .invoice-preview-summary {
584
+ display: flex;
585
+ justify-content: flex-end;
586
+ flex-wrap: wrap;
587
+ gap: 16px;
588
+ font-weight: 600;
589
+ font-size: 15px;
590
+ }
591
+
592
+ .invoice-preview-note {
593
+ padding: 12px 16px;
594
+ border-left: 3px solid var(--accent);
595
+ background: #f4f8ff;
596
+ font-size: 14px;
597
+ }
598
+
599
+ .dashboard-header {
600
+ display: flex;
601
+ flex-wrap: wrap;
602
+ align-items: center;
603
+ justify-content: space-between;
604
+ gap: 16px;
605
+ }
606
+
607
+ .filters {
608
+ display: flex;
609
+ flex-wrap: wrap;
610
+ gap: 16px;
611
+ align-items: flex-end;
612
+ }
613
+
614
+ .dashboard-summary {
615
+ display: grid;
616
+ gap: 16px;
617
+ margin: 24px 0;
618
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
619
+ }
620
+
621
+ .summary-card {
622
+ border: 1px solid rgba(32, 33, 36, 0.08);
623
+ border-radius: var(--radius);
624
+ background: #fbfcff;
625
+ padding: 16px;
626
+ display: grid;
627
+ gap: 6px;
628
+ }
629
+
630
+ .summary-label {
631
+ font-size: 13px;
632
+ font-weight: 600;
633
+ color: var(--muted);
634
+ text-transform: uppercase;
635
+ letter-spacing: 0.05em;
636
+ }
637
+
638
+ .summary-count {
639
+ font-size: 20px;
640
+ font-weight: 700;
641
+ }
642
+
643
+ .summary-amount {
644
+ font-size: 16px;
645
+ font-weight: 600;
646
+ color: var(--accent);
647
+ }
648
+
649
+ .dashboard-chart {
650
+ border: 1px solid rgba(32, 33, 36, 0.08);
651
+ border-radius: var(--radius);
652
+ background: #ffffff;
653
+ padding: 16px;
654
+ }
655
+
656
+ .dashboard-table .items-table th,
657
+ .dashboard-table .items-table td {
658
+ white-space: nowrap;
659
+ }
660
+
661
+ .dashboard-table .items-table td:last-child {
662
+ width: 160px;
663
+ }
664
+
665
+ .table-actions {
666
+ display: flex;
667
+ flex-wrap: wrap;
668
+ gap: 8px;
669
+ }
670
+
671
+ #invoices-empty {
672
+ margin-top: 12px;
673
+ text-align: center;
674
+ }
675
+
676
+ @media (max-width: 640px) {
677
+ .panel {
678
+ padding: 20px;
679
+ }
680
+
681
+ .header-section {
682
+ flex-direction: column;
683
+ text-align: center;
684
+ gap: 16px;
685
+ }
686
+
687
+ .logo {
688
+ max-width: 150px;
689
+ }
690
+
691
+ .app-title {
692
+ font-size: 20px;
693
+ }
694
+
695
+ .app-header {
696
+ flex-direction: column;
697
+ gap: 12px;
698
+ align-items: flex-start;
699
+ }
700
+
701
+ .business-section,
702
+ .items-section {
703
+ padding: 16px;
704
+ }
705
+ }
web_invoice_store.json CHANGED
The diff for this file is too large to render. See raw diff