.gitattributes CHANGED
@@ -34,4 +34,3 @@ 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
37
- 1.png 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
 
0f5c994848b44107af0395713ad69da0-free.png DELETED
Binary file (6.1 kB)
 
1.png DELETED

Git LFS Details

  • SHA256: cd2c1f2405363a43e9fc9ce9e40a88b148dc8be3eb7031066d89e90be57c7a6d
  • Pointer size: 131 Bytes
  • Size of remote file: 115 kB
Dockerfile CHANGED
@@ -1,12 +1,31 @@
1
- FROM python:3.11-slim
2
- WORKDIR /app
3
- COPY requirements.txt .
4
- RUN pip install --no-cache-dir -r requirements.txt
5
- COPY . .
6
- RUN mkdir -p /data \
7
- && chmod -R 777 /data
8
- COPY web_invoice_store.json /data/web_invoice_store.json
9
- RUN chmod 666 /data/web_invoice_store.json
10
- ENV DATA_DIR=/data
11
- ENV PORT=7860
12
- CMD ["python", "server.py"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"]
README.md CHANGED
@@ -9,56 +9,87 @@ pinned: false
9
 
10
  # Generator faktur
11
 
12
- Projekt oferuje dwa sposoby tworzenia faktur: prostą aplikację CLI oraz rozbudowany panel webowy z backendem Flask i estetycznym PDF-em.
13
 
14
- ## Co w środku?
15
- - **CLI** szybkie narzędzie terminalowe do pojedynczych dokumentów.
16
- - **Frontend + backend** – logowanie, edycja danych firmy, inteligentny formularz nabywcy, historia faktur i eleganckie PDF-y z logo.
17
 
18
- ---
19
 
20
- ## Aplikacja webowa
21
 
22
- ### Wymagania
23
- - Python 3.8+
24
- - `pip install -r requirements.txt`
25
- - (opcjonalnie) `NEON_DATABASE_URL` – gdy ustawisz połączenie z bazą PostgreSQL/Neon, klienci i faktury są zapisywani w bazie zamiast w pliku.
 
26
 
27
- ### Uruchomienie
28
  ```powershell
29
- python server.py
30
- # aplikacja działa na http://localhost:5000
31
  ```
32
 
33
- ### Funkcje
34
- - onboarding z konfiguracją firmy i logo,
35
- - logowanie z tokenem w `sessionStorage`,
36
- - edycja sprzedawcy i zarządzanie logo (PNG/JPG do 512 KB),
37
- - formularz nabywcy z wyszukiwarką (po nazwie lub NIP-ie, wyniki tylko z konta zalogowanego użytkownika),
38
- - dynamiczna tabela pozycji licząca netto/VAT/brutto oraz obsługę stawek 23/8/5/0/ZW/NP,
39
- - sekcja zwolnienia podatkowego i automatyczna nota prawna,
40
- - dashboard z filtrami dat, wykresem i historią ostatnich 200 faktur,
41
- - eksport PDF spójny z UI (logo nad sprzedawcą, kapsułki z sumami, czytelna tabela).
42
 
43
- > PDF-y generuje [jsPDF](https://cdnjs.com/libraries/jspdf) ładowany z CDN przeglądarka musi mieć dostęp do internetu.
44
 
45
- ### Reset danych
46
- - tryb plikowy: usuń `web_invoice_store.json`, aby przejść onboarding ponownie,
47
- - tryb Neon: wyczyść tabele w bazie lub użyj nowej bazy.
48
 
49
- ---
 
 
50
 
51
- ## Aplikacja CLI
 
 
52
 
53
- Lekki kreator w terminalu zapisujący dokumenty do katalogu `invoices/` i historię w `invoice_data.json`.
 
 
 
 
 
 
 
 
 
54
 
55
- ### Uruchomienie
56
  ```powershell
57
- python invoice_app.py
58
  ```
59
- 1. Przy pierwszym starcie wpisz dane firmy i ustaw hasło.
60
- 2. Przy kolejnych startach logujesz się, uzupełniasz dane pozycji i (opcjonalnie) klienta.
61
- 3. Utworzona faktura trafia do `invoices/`, a historia do pliku JSON.
62
 
63
- ### Reset CLI
64
- Usuń katalog `invoices/` oraz plik `invoice_data.json`, aby rozpocząć od nowa (historia zostanie utracona).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  # Generator faktur
11
 
12
+ Repozytorium zawiera dwa sposoby wystawiania faktur:
13
 
14
+ 1. prosta aplikacje CLI,
15
+ 2. rozbudowany frontend/ backend, ktory generuje faktury w PDF.
 
16
 
17
+ ## 1. Aplikacja CLI
18
 
19
+ Niewielkie narzedzie w Pythonie, ktore pomaga zebrac podstawowe dane i wystawic fakture z pojedyncza pozycja (usluga lub towar). Dane firmy zapisywane sa lokalnie w `invoice_data.json`, a kazda wygenerowana faktura trafia do katalogu `invoices/`.
20
 
21
+ ## Wymagania
22
+
23
+ - Python 3.8 lub nowszy
24
+
25
+ ## Pierwsze uruchomienie
26
 
 
27
  ```powershell
28
+ python invoice_app.py
 
29
  ```
30
 
31
+ 1. Podaj dane firmy (nazwa, adres, NIP, numer konta).
32
+ 2. Ustaw haslo, ktore bedzie wymagane przy kolejnych uruchomieniach.
 
 
 
 
 
 
 
33
 
34
+ Po zakonczeniu konfiguracji uruchom aplikacje ponownie, aby sie zalogowac i wystawic pierwsza fakture.
35
 
36
+ ## Wystawianie faktury
 
 
37
 
38
+ ```powershell
39
+ python invoice_app.py
40
+ ```
41
 
42
+ 1. Zaloguj sie haslem ustawionym wczesniej.
43
+ 2. Podaj opis uslugi/towaru, ilosc oraz cene jednostkowa.
44
+ 3. Opcjonalnie wpisz dane klienta.
45
 
46
+ Aplikacja automatycznie policzy kwote netto i zapisze fakture w katalogu `invoices/` jako plik tekstowy (np. `invoices/FV-20240101-120000.txt`). Dane faktury sa rowniez dopisywane do pliku `invoice_data.json`, dzieki czemu latwo przechowywac historie.
47
+
48
+ ## 2. Aplikacja webowa (frontend + backend)
49
+
50
+ Interfejs przegladarkowy z serwerem REST (Flask), ktory przechowuje dane firmy oraz historie faktur w pliku `web_invoice_store.json`.
51
+
52
+ ### Wymagania
53
+
54
+ - Python 3.8+
55
+ - Zainstalowany pakiet `Flask`
56
 
 
57
  ```powershell
58
+ python -m pip install -r requirements.txt
59
  ```
 
 
 
60
 
61
+ ### Uruchomienie
62
+
63
+ 1. Start serwera API oraz statycznego frontendu:
64
+
65
+ ```powershell
66
+ python server.py
67
+ ```
68
+
69
+ 2. W przegladarce odwiedz `http://localhost:5000/`.
70
+
71
+ ### Funkcje webowego generatora
72
+
73
+ - Pierwsze uruchomienie:
74
+ - konfiguracja danych sprzedawcy (nazwa, adres, kod pocztowy, miejscowosc, NIP, numer konta, haslo),
75
+ - dane przechowywane lokalnie na serwerze w `web_invoice_store.json`.
76
+ - Logowanie chronione haslem (token przechowywany w `sessionStorage` przegladarki).
77
+ - Panel po zalogowaniu:
78
+ - wyswietlenie danych sprzedawcy oraz mozliwosc edycji bezpośrednio z poziomu UI,
79
+ - formularz danych nabywcy z rozszerzonym adresem (ulica, kod pocztowy, miejscowosc, NIP),
80
+ - pole daty sprzedazy/wykonania uslugi (niezalezne od daty wystawienia),
81
+ - pozycje faktury z dynamiczna tabela: wprowadzamy cene brutto, wybieramy stawke VAT (23/8/5/0/ZW/NP), aplikacja automatycznie liczy cene netto, wartosc netto, VAT i brutto,
82
+ - obsluga pozycji zwolnionych (ZW) wraz z polem na podstawe prawna zwolnienia (wyswietlana na fakturze),
83
+ - podsumowanie stawek (np. 23% – X netto / VAT – Y, ZW – Z netto / VAT – 0) zamiast pojedynczej sumy netto,
84
+ - generowanie podgladu faktury oraz eksport do PDF w formacie A4 (NABYWCA po lewej, SPRZEDAWCA po prawej, tabela zgodna z nazewnictwem ustawy: „Cena jedn. netto”, „Wartosc netto (pozycja)”, „Stawka VAT”, „Kwota VAT (pozycja)”, „Wartosc brutto”).
85
+ - Historia faktur zapisywana jest po stronie serwera (ostatnie 200 dokumentow).
86
+
87
+ > **Uwaga:** do wygenerowania pliku PDF wykorzystywana jest biblioteka [jsPDF](https://cdnjs.com/libraries/jspdf) ladowana z CDN. Przegladarka musi miec dostep do internetu, aby pobrac skrypt.
88
+
89
+ ### Reset danych webowych
90
+
91
+ Usun plik `web_invoice_store.json`, aby uruchomic konfiguracje od nowa (spowoduje to utrate historii faktur).
92
+
93
+ ## Reset danych CLI
94
+
95
+ Jezeli chcesz rozpoczac konfiguracje od nowa, usun pliki `invoice_data.json` i katalog `invoices/` (uwaga: spowoduje to utrate historii wystawionych faktur).
db.py DELETED
@@ -1,285 +0,0 @@
1
- import os
2
- import hashlib
3
- from contextlib import contextmanager
4
- from typing import Any, Dict, List, Optional, Sequence
5
-
6
- import psycopg2
7
- from psycopg2.extras import RealDictCursor
8
-
9
- DATABASE_URL = os.environ.get("NEON_DATABASE_URL")
10
- PRIVATE_TAX_ID_PREFIX = "__PRIVATE__:"
11
-
12
- if not DATABASE_URL:
13
- raise RuntimeError(
14
- "Brak zmiennej NEON_DATABASE_URL. Ustaw sekret w Hugging Face lub "
15
- "ustaw zmienną środowiskową lokalnie."
16
- )
17
-
18
-
19
-
20
- @contextmanager
21
- def db_conn():
22
- conn = psycopg2.connect(DATABASE_URL, cursor_factory=RealDictCursor)
23
- try:
24
- yield conn
25
- conn.commit()
26
- except Exception:
27
- conn.rollback()
28
- raise
29
- finally:
30
- conn.close()
31
-
32
-
33
- def fetch_one(query: str, params: Sequence[Any]) -> Optional[Dict[str, Any]]:
34
- with db_conn() as conn, conn.cursor() as cur:
35
- cur.execute(query, params)
36
- return cur.fetchone()
37
-
38
-
39
- def fetch_all(query: str, params: Sequence[Any] = ()) -> List[Dict[str, Any]]:
40
- with db_conn() as conn, conn.cursor() as cur:
41
- cur.execute(query, params)
42
- return cur.fetchall()
43
-
44
-
45
- def execute(query: str, params: Sequence[Any]) -> None:
46
- with db_conn() as conn, conn.cursor() as cur:
47
- cur.execute(query, params)
48
-
49
-
50
- def create_account(login: str, email: str, password_hash: str) -> int:
51
- with db_conn() as conn, conn.cursor() as cur:
52
- cur.execute(
53
- """
54
- INSERT INTO accounts (login, password_hash)
55
- VALUES (%s, %s)
56
- RETURNING id
57
- """,
58
- (login, password_hash),
59
- )
60
- account_id = cur.fetchone()["id"]
61
- cur.execute(
62
- """
63
- INSERT INTO business_profiles (account_id, company_name, owner_name,
64
- address_line, postal_code, city, tax_id, bank_account)
65
- VALUES (%s, '', '', '', '', '', '', '')
66
- """,
67
- (account_id,),
68
- )
69
- return account_id
70
-
71
-
72
- def update_business(account_id: int, data: Dict[str, str]) -> None:
73
- execute(
74
- """
75
- UPDATE business_profiles
76
- SET company_name = %s,
77
- owner_name = %s,
78
- address_line = %s,
79
- postal_code = %s,
80
- city = %s,
81
- tax_id = %s,
82
- bank_account = %s
83
- WHERE account_id = %s
84
- """,
85
- (
86
- data["company_name"],
87
- data["owner_name"],
88
- data["address_line"],
89
- data["postal_code"],
90
- data["city"],
91
- data["tax_id"],
92
- data["bank_account"],
93
- account_id,
94
- ),
95
- )
96
-
97
-
98
-
99
- def fetch_business_logo(account_id: int) -> Optional[Dict[str, Optional[str]]]:
100
- row = fetch_one(
101
- """
102
- SELECT logo_mime_type, logo_data_base64
103
- FROM business_profiles
104
- WHERE account_id = %s
105
- """,
106
- (account_id,),
107
- )
108
- if not row:
109
- return None
110
- mime_type = row.get("logo_mime_type")
111
- data_base64 = row.get("logo_data_base64")
112
- if not mime_type or not data_base64:
113
- return None
114
- return {"mime_type": mime_type, "data": data_base64}
115
-
116
-
117
- def update_business_logo(account_id: int, mime: Optional[str], data_base64: Optional[str]) -> None:
118
- execute(
119
- """
120
- UPDATE business_profiles
121
- SET logo_mime_type = %s,
122
- logo_data_base64 = %s
123
- WHERE account_id = %s
124
- """,
125
- (mime, data_base64, account_id),
126
- )
127
-
128
- def upsert_client(account_id: int, payload: Dict[str, str]) -> int:
129
- tax_id = (payload.get("tax_id") or "").strip()
130
- if tax_id:
131
- stored_tax_id = tax_id
132
- else:
133
- identity = "|".join(
134
- [
135
- str(account_id),
136
- (payload.get("name") or "").strip().lower(),
137
- (payload.get("address_line") or "").strip().lower(),
138
- (payload.get("postal_code") or "").strip().lower(),
139
- (payload.get("city") or "").strip().lower(),
140
- (payload.get("phone") or "").strip().lower(),
141
- ]
142
- )
143
- stored_tax_id = f"{PRIVATE_TAX_ID_PREFIX}{hashlib.sha1(identity.encode('utf-8')).hexdigest()[:20]}"
144
-
145
- row = fetch_one(
146
- """
147
- SELECT id FROM clients
148
- WHERE account_id = %s AND tax_id = %s
149
- """,
150
- (account_id, stored_tax_id),
151
- )
152
- if row:
153
- client_id = row["id"]
154
- execute(
155
- """
156
- UPDATE clients
157
- SET name = %s,
158
- address_line = %s,
159
- postal_code = %s,
160
- city = %s,
161
- phone = %s
162
- WHERE id = %s
163
- """,
164
- (
165
- payload["name"],
166
- payload["address_line"],
167
- payload["postal_code"],
168
- payload["city"],
169
- payload.get("phone"),
170
- client_id,
171
- ),
172
- )
173
- return client_id
174
-
175
- with db_conn() as conn, conn.cursor() as cur:
176
- cur.execute(
177
- """
178
- INSERT INTO clients (account_id, name, address_line, postal_code, city, tax_id, phone)
179
- VALUES (%s, %s, %s, %s, %s, %s, %s)
180
- RETURNING id
181
- """,
182
- (
183
- account_id,
184
- payload["name"],
185
- payload["address_line"],
186
- payload["postal_code"],
187
- payload["city"],
188
- stored_tax_id,
189
- payload.get("phone"),
190
- ),
191
- )
192
- return cur.fetchone()["id"]
193
-
194
-
195
- def search_clients(account_id: int, term: str, limit: int = 10) -> List[Dict[str, Any]]:
196
- query = (term or "").strip().lower()
197
- like = f"%{query}%"
198
- return fetch_all(
199
- """
200
- SELECT name,
201
- CASE WHEN tax_id LIKE %s THEN '' ELSE tax_id END AS tax_id,
202
- address_line, postal_code, city, phone
203
- FROM clients
204
- WHERE account_id = %s
205
- AND (
206
- %s = '' OR
207
- LOWER(COALESCE(name, '')) LIKE %s OR
208
- LOWER(COALESCE(tax_id, '')) LIKE %s
209
- )
210
- ORDER BY LOWER(COALESCE(name, tax_id, '')) ASC
211
- LIMIT %s
212
- """,
213
- (f"{PRIVATE_TAX_ID_PREFIX}%", account_id, query, like, like, limit),
214
- )
215
-
216
-
217
- def insert_invoice(account_id: int, client_id: int, invoice: Dict[str, Any]) -> int:
218
- with db_conn() as conn, conn.cursor() as cur:
219
- cur.execute(
220
- """
221
- INSERT INTO invoices (account_id, client_id, invoice_number, issued_at,
222
- sale_date, payment_term_days, exemption_note,
223
- total_net, total_vat, total_gross)
224
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
225
- RETURNING id
226
- """,
227
- (
228
- account_id,
229
- client_id,
230
- invoice["invoice_id"],
231
- invoice["issued_at"],
232
- invoice["sale_date"],
233
- invoice.get("payment_term", 14),
234
- invoice.get("exemption_note"),
235
- invoice["totals"]["net"],
236
- invoice["totals"]["vat"],
237
- invoice["totals"]["gross"],
238
- ),
239
- )
240
- invoice_id = cur.fetchone()["id"]
241
-
242
- cur.executemany(
243
- """
244
- INSERT INTO invoice_items (invoice_id, line_no, name, quantity, unit,
245
- vat_code, vat_label, unit_price_net,
246
- unit_price_gross, net_total, vat_amount, gross_total)
247
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
248
- """,
249
- [
250
- (
251
- invoice_id,
252
- idx + 1,
253
- item["name"],
254
- item["quantity"],
255
- item.get("unit"),
256
- item.get("vat_code"),
257
- item.get("vat_label"),
258
- item["unit_price_net"],
259
- item["unit_price_gross"],
260
- item["net_total"],
261
- item["vat_amount"],
262
- item["gross_total"],
263
- )
264
- for idx, item in enumerate(invoice["items"])
265
- ],
266
- )
267
-
268
- cur.executemany(
269
- """
270
- INSERT INTO invoice_vat_summary (invoice_id, vat_label, net_total, vat_total, gross_total)
271
- VALUES (%s, %s, %s, %s, %s)
272
- """,
273
- [
274
- (
275
- invoice_id,
276
- row["vat_label"],
277
- row["net_total"],
278
- row["vat_total"],
279
- row["gross_total"],
280
- )
281
- for row in invoice["summary"]
282
- ],
283
- )
284
-
285
- return invoice_id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
entrypoint.sh CHANGED
@@ -1,27 +1,27 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
-
4
- # /data jest montowane przez HF; jeśli nie mamy uprawnień, pomiń
5
- if [ ! -d /data ]; then
6
- echo "Info: /data not available (will be mounted by Spaces)."
7
- fi
8
-
9
- : "${DATABASE_URL:?ERROR: DATABASE_URL is not set}"
10
-
11
- python - <<'PY'
12
- import os
13
- from sqlalchemy import create_engine, text
14
- engine = create_engine(os.environ["DATABASE_URL"], pool_pre_ping=True)
15
- with engine.begin() as conn:
16
- conn.execute(text("""
17
- CREATE TABLE IF NOT EXISTS notes (
18
- id SERIAL PRIMARY KEY,
19
- body TEXT NOT NULL,
20
- created_at TIMESTAMPTZ DEFAULT now()
21
- )
22
- """))
23
- PY
24
-
25
- # Start aplikacji – dopasuj do swojej
26
- exec python server.py
27
- # (albo: exec python server.py)
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # /data jest montowane przez HF; jeśli nie mamy uprawnień, pomiń
5
+ if [ ! -d /data ]; then
6
+ echo "Info: /data not available (will be mounted by Spaces)."
7
+ fi
8
+
9
+ : "${DATABASE_URL:?ERROR: DATABASE_URL is not set}"
10
+
11
+ python - <<'PY'
12
+ import os
13
+ from sqlalchemy import create_engine, text
14
+ engine = create_engine(os.environ["DATABASE_URL"], pool_pre_ping=True)
15
+ with engine.begin() as conn:
16
+ conn.execute(text("""
17
+ CREATE TABLE IF NOT EXISTS notes (
18
+ id SERIAL PRIMARY KEY,
19
+ body TEXT NOT NULL,
20
+ created_at TIMESTAMPTZ DEFAULT now()
21
+ )
22
+ """))
23
+ PY
24
+
25
+ # Start aplikacji – dopasuj do swojej
26
+ exec python server.py
27
+ # (albo: exec python server.py)
gitattributes DELETED
@@ -1,36 +0,0 @@
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 DELETED
@@ -1,398 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="pl"><head>
3
-
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
-
12
-
13
- </head><body>
14
- <main class="container">
15
- <div class="brand-banner">
16
- <img src="small_logotyp do strony.jpg" alt="Logotyp FakturON!" class="brand-logo">
17
- </div>
18
-
19
- <section id="hero-panel" class="panel hero-panel">
20
- <div class="hero-columns">
21
- <div class="hero-content">
22
- <p class="eyebrow">Bezpłatny panel faktur</p>
23
- <h1 class="app-title">Generator faktur</h1>
24
- <p class="app-description">
25
- Proste narzędzie do wystawiania i analizowania faktur: logujesz się, uzupełniasz dane kontrahenta,
26
- zapisujesz dokument i od razu widzisz efekty w dashboardzie. Zero opłat, zero limitów.
27
- </p>
28
- <p class="hero-lead">
29
- Cały proces zamyka się w jednym widoku – formularze, podgląd i historia korzystają z tej samej siatki,
30
- dzięki czemu wyglądają identycznie na desktopie i na telefonie.
31
- </p>
32
- <ul class="header-highlights">
33
- <li>
34
- <strong>Zawsze bezpłatnie</strong>
35
- <span>Generator działa bez limitów dokumentów i bez pakietów abonamentowych.</span>
36
- </li>
37
- <li>
38
- <strong>Szybkie wystawianie</strong>
39
- <span>W jednym formularzu zbierasz dane sprzedawcy, nabywcy i pozycje, a podgląd generuje się automatycznie.</span>
40
- </li>
41
- <li>
42
- <strong>Spójny wygląd</strong>
43
- <span>Każdy etap korzysta z tej samej siatki, więc łatwo pracować na desktopie i na telefonie.</span>
44
- </li>
45
- <li>
46
- <strong>Dashboard i historia</strong>
47
- <span>Po zalogowaniu od razu widać statystyki i listę faktur gotowych do pobrania.</span>
48
- </li>
49
- </ul>
50
- </div>
51
- <section id="auth-section" class="auth-panel">
52
- <div class="auth-panel-header">
53
- <p class="eyebrow">Logowanie</p>
54
- <h2 class="auth-headline">Wejdź do generatora</h2>
55
- <p class="auth-copy">Zaloguj się i prowadź całą obsługę faktur w jednym darmowym panelu.</p>
56
- </div>
57
- <div class="auth-login">
58
- <div class="auth-card login-card">
59
- <h3>Zaloguj się</h3>
60
- <form id="login-form" class="form">
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="current-password" required>
68
- </label>
69
- <button type="submit">Zaloguj</button>
70
- <hr class="form-divider">
71
- </form>
72
- <p id="login-feedback" class="feedback" aria-live="polite"></p>
73
- <div class="auth-actions">
74
- <span>Nie masz konta?</span>
75
- <button id="show-register-button" type="button" class="ghost-button">Stwórz konto</button>
76
- </div>
77
- <p id="legacy-login-hint" class="hint hidden"></p>
78
- </div>
79
- </div>
80
- </section>
81
- </div>
82
- </section>
83
-
84
- <section id="register-section" class="panel hidden">
85
- <div class="auth-card register-card">
86
- <div class="register-header">
87
- <h3>Załóż konto</h3>
88
- <button id="back-to-login" type="button" class="link-button">Wróć do logowania</button>
89
- </div>
90
- <form id="register-form" class="form">
91
- <div class="register-fields">
92
- <div class="field-grid register-credentials">
93
- <label>
94
- Email
95
- <input type="email" name="email" autocomplete="email" required>
96
- </label>
97
- <label>
98
- Hasło
99
- <input type="password" name="password" autocomplete="new-password" required>
100
- </label>
101
- <label>
102
- Powtórz hasło
103
- <input type="password" name="confirm_password" autocomplete="new-password" required>
104
- </label>
105
- </div>
106
-
107
- <div class="field-grid register-company">
108
- <label>
109
- Nazwa firmy
110
- <input type="text" name="company_name" required>
111
- </label>
112
- <label>
113
- Imię i nazwisko właściciela
114
- <input type="text" name="owner_name" required>
115
- </label>
116
- <label>
117
- Ulica i numer
118
- <input type="text" name="address_line" required>
119
- </label>
120
- <label>
121
- Kod pocztowy
122
- <input type="text" name="postal_code" required>
123
- </label>
124
- <label>
125
- Miejscowość
126
- <input type="text" name="city" required>
127
- </label>
128
- <label>
129
- NIP
130
- <input type="text" name="tax_id" required>
131
- </label>
132
- <label>
133
- Numer konta bankowego
134
- <input type="text" name="bank_account" required>
135
- </label>
136
- </div>
137
- </div>
138
- <div class="form-actions">
139
- <button type="submit">Utwórz konto</button>
140
- <button id="cancel-register" type="button" class="link-button">Anuluj</button>
141
- </div>
142
- </form>
143
- <p id="register-feedback" class="feedback"></p>
144
- <p class="hint">Wskazowka: dane firmy zaktualizujesz pozniej w sekcji "Dane sprzedawcy".</p>
145
- </div>
146
- </section>
147
-
148
- <section id="app-section" class="panel hidden">
149
- <header class="app-header">
150
- <div>
151
- <h2>Panel faktur</h2>
152
- </div>
153
- <div id="login-badge" class="login-badge hidden" aria-live="polite">
154
- <span class="badge-label">Zalogowany jako</span>
155
- <span id="current-login-label" class="badge-value"></span>
156
- </div>
157
- <nav class="app-nav">
158
- <button type="button" class="app-nav-button active" data-view="invoice-builder">Nowa faktura</button>
159
- <button type="button" class="app-nav-button" data-view="dashboard">Dashboard</button>
160
- </nav>
161
- <button id="logout-button" type="button" class="link-button">Wyloguj</button>
162
- </header>
163
-
164
- <section id="invoice-builder-section" class="app-view">
165
- <section class="business-section">
166
- <div class="business-section-header">
167
- <h3>Dane sprzedawcy</h3>
168
- <div class="business-actions">
169
- <button id="toggle-business-form" type="button" class="pill-button">Edycja danych</button>
170
- <label for="logo-input" class="pill-button secondary">
171
- <input id="logo-input" type="file" accept="image/png,image/jpeg" hidden>
172
- Wgraj logo
173
- </label>
174
- <button id="remove-logo-button" type="button" class="pill-button danger hidden">Usuń logo</button>
175
- </div>
176
- </div>
177
- <div id="business-display" class="business-display"></div>
178
- <div id="logo-preview" class="logo-preview hidden">
179
- <span class="logo-preview-label">Logo sprzedawcy</span>
180
- <img id="logo-preview-image" alt="Logo firmy">
181
- </div>
182
- <p id="logo-feedback" class="feedback"></p>
183
- <form id="business-form" class="form hidden">
184
- <div class="field-grid">
185
- <label>
186
- Nazwa firmy
187
- <input type="text" name="company_name" required>
188
- </label>
189
- <label>
190
- Imię i nazwisko właściciela
191
- <input type="text" name="owner_name" required>
192
- </label>
193
- <label>
194
- Ulica i numer
195
- <input type="text" name="address_line" required>
196
- </label>
197
- <label>
198
- Kod pocztowy
199
- <input type="text" name="postal_code" required>
200
- </label>
201
- <label>
202
- Miejscowość
203
- <input type="text" name="city" required>
204
- </label>
205
- <label>
206
- NIP
207
- <input type="text" name="tax_id" required>
208
- </label>
209
- <label>
210
- Numer konta bankowego
211
- <input type="text" name="bank_account" required>
212
- </label>
213
- </div>
214
- <div class="form-actions">
215
- <button type="submit">Zapisz</button>
216
- <button id="cancel-business-update" type="button" class="link-button">Anuluj</button>
217
- </div>
218
- <p id="business-feedback" class="feedback"></p>
219
- </form>
220
- </section>
221
-
222
- <form id="invoice-form" class="form">
223
- <fieldset>
224
- <legend>Informacje o fakturze</legend>
225
- <div class="field-grid">
226
- <label>
227
- Typ dokumentu
228
- <select name="documentType" id="document-type">
229
- <option value="standard">Faktura VAT</option>
230
- <option value="personal_paid">Faktura imienna - zapłacona</option>
231
- </select>
232
- </label>
233
- <label>
234
- Data sprzedaży / wykonania usługi
235
- <input type="date" name="saleDate">
236
- </label>
237
- <label id="payment-term-field">
238
- Termin płatności (dni)
239
- <input type="number" name="paymentTerm" min="1" step="1" value="14">
240
- </label>
241
- </div>
242
- <p id="paid-document-hint" class="hint hidden">Ten dokument będzie oznaczony jako opłacony i może służyć do sprzedaży imiennej zamiast paragonu.</p>
243
- </fieldset>
244
-
245
- <fieldset>
246
- <legend>Dane nabywcy</legend>
247
- <div class="client-lookup">
248
- <label for="client-search">
249
- Szybkie wyszukiwanie nabywcy
250
- <input type="text" id="client-search" placeholder="Wpisz NIP lub nazwę klienta">
251
- </label>
252
- <div id="client-suggestions" class="client-suggestions hidden" role="listbox"></div>
253
- </div>
254
- <div class="field-grid">
255
- <label>
256
- Nazwa / Imię i nazwisko
257
- <input type="text" name="clientName">
258
- </label>
259
- <label>
260
- NIP
261
- <input type="text" name="clientTaxId">
262
- </label>
263
- <label>
264
- Ulica i numer
265
- <input type="text" name="clientAddress">
266
- </label>
267
- <label>
268
- Kod pocztowy
269
- <input type="text" name="clientPostalCode">
270
- </label>
271
- <label>
272
- Miejscowość
273
- <input type="text" name="clientCity">
274
- </label>
275
- <label>
276
- Numer telefonu
277
- <input type="tel" name="clientPhone">
278
- </label>
279
- </div>
280
- </fieldset>
281
-
282
- <section class="items-section">
283
- <header class="items-header">
284
- <h3>Pozycje faktury</h3>
285
- <button type="button" id="add-item-button">Dodaj pozycję</button>
286
- </header>
287
- <div class="items-table-wrapper">
288
- <table class="items-table">
289
- <thead>
290
- <tr>
291
- <th>Nazwa towaru/usługi</th>
292
- <th>Ilość</th>
293
- <th>Jednostka</th>
294
- <th>Cena jedn. brutto (PLN)</th>
295
- <th>Stawka VAT</th>
296
- <th>Wartość brutto (PLN)</th>
297
- <th></th>
298
- </tr>
299
- </thead>
300
- <tbody id="items-body"></tbody>
301
- </table>
302
- </div>
303
- </section>
304
-
305
- <div id="totals-container" class="totals">
306
- <span id="total-net">Suma netto: 0.00 PLN</span>
307
- <span id="total-vat">Kwota VAT: 0.00 PLN</span>
308
- <span id="total-gross">Suma brutto: 0.00 PLN</span>
309
- </div>
310
-
311
- <section id="rate-summary" class="rate-summary"></section>
312
-
313
- <div id="exemption-note-wrapper" class="hidden">
314
- <label for="exemption-reason">Powód zastosowania stawki ZW/0%</label>
315
- <select id="exemption-reason" class="form-select">
316
- <option value="">Wybierz podstawę zwolnienia...</option>
317
- </select>
318
- <label for="exemption-note" id="exemption-note-label">Podstawa prawna zwolnienia</label>
319
- <textarea id="exemption-note" rows="3" placeholder="np. Art. 43 ust. 1 pkt 19 ustawy o VAT"></textarea>
320
- </div>
321
-
322
- <div class="form-actions">
323
- <button type="submit" id="save-invoice-button">Generuj fakturę</button>
324
- <button id="cancel-edit-invoice" type="button" class="link-button hidden">Anuluj edycję</button>
325
- </div>
326
- </form>
327
-
328
- <section id="invoice-result" class="panel hidden">
329
- <h3>Podgląd faktury</h3>
330
- <div id="invoice-output" class="invoice-preview"></div>
331
- <button id="download-button" type="button">Pobierz jako plik PDF</button>
332
- </section>
333
- </section>
334
-
335
- <section id="dashboard-section" class="app-view hidden">
336
- <header class="dashboard-header">
337
- <div class="filters">
338
- <label>
339
- Od
340
- <input type="date" id="filter-start-date">
341
- </label>
342
- <label>
343
- Do
344
- <input type="date" id="filter-end-date">
345
- </label>
346
- <button type="button" id="clear-filters" class="button secondary">Wyczyść</button>
347
- </div>
348
- <p id="dashboard-feedback" class="feedback"></p>
349
- </header>
350
-
351
- <section class="dashboard-summary">
352
- <div class="summary-card">
353
- <span class="summary-label">Ostatnie 30 dni</span>
354
- <span id="summary-month-count" class="summary-count">0 faktur</span>
355
- <span id="summary-month-amount" class="summary-amount">0.00 PLN</span>
356
- </div>
357
- <div class="summary-card">
358
- <span class="summary-label">Bieżący kwartał</span>
359
- <span id="summary-quarter-count" class="summary-count">0 faktur</span>
360
- <span id="summary-quarter-amount" class="summary-amount">0.00 PLN</span>
361
- </div>
362
- <div class="summary-card">
363
- <span class="summary-label">Bieżący rok</span>
364
- <span id="summary-year-count" class="summary-count">0 faktur</span>
365
- <span id="summary-year-amount" class="summary-amount">0.00 PLN</span>
366
- </div>
367
- </section>
368
-
369
- <section class="dashboard-chart">
370
- <canvas id="invoices-chart" aria-label="Podsumowanie faktur"></canvas>
371
- </section>
372
-
373
- <section class="dashboard-table">
374
- <div class="items-table-wrapper">
375
- <table class="items-table">
376
- <thead>
377
- <tr>
378
- <th>Numer</th>
379
- <th>Data wystawienia</th>
380
- <th>Nabywca</th>
381
- <th>Suma brutto</th>
382
- <th>Akcje</th>
383
- </tr>
384
- </thead>
385
- <tbody id="invoices-table-body"></tbody>
386
- </table>
387
- <p id="invoices-empty" class="hint hidden">Brak faktur do wyświetlenia.</p>
388
- </div>
389
- </section>
390
- </section>
391
- </section>
392
- </main>
393
-
394
- <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js" defer></script>
395
- <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.6/dist/chart.umd.min.js" defer></script>
396
- <script src="main.js" defer></script>
397
- </body>
398
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
invoice_app.py CHANGED
@@ -1,190 +1,190 @@
1
- import getpass
2
- import hashlib
3
- import json
4
- import os
5
- from datetime import datetime
6
-
7
- DATA_FILE = "invoice_data.json"
8
- INVOICE_FOLDER = "invoices"
9
-
10
-
11
- def hash_password(password: str) -> str:
12
- return hashlib.sha256(password.encode("utf-8")).hexdigest()
13
-
14
-
15
- def prompt_non_empty(prompt: str) -> str:
16
- while True:
17
- value = input(prompt).strip()
18
- if value:
19
- return value
20
- print("Pole nie moze byc puste.")
21
-
22
-
23
- def prompt_positive_float(prompt: str) -> float:
24
- while True:
25
- raw_value = input(prompt).replace(",", ".").strip()
26
- try:
27
- value = float(raw_value)
28
- except ValueError:
29
- print("Wprowadz liczbe.")
30
- continue
31
- if value <= 0:
32
- print("Wartosc musi byc wieksza od zera.")
33
- continue
34
- return value
35
-
36
-
37
- def load_data():
38
- if not os.path.exists(DATA_FILE):
39
- return None
40
- with open(DATA_FILE, "r", encoding="utf-8") as handle:
41
- return json.load(handle)
42
-
43
-
44
- def save_data(data) -> None:
45
- with open(DATA_FILE, "w", encoding="utf-8") as handle:
46
- json.dump(data, handle, indent=2, ensure_ascii=False)
47
-
48
-
49
- def run_setup():
50
- print("=== Konfiguracja konta przedsiebiorcy ===")
51
- business = {
52
- "company_name": prompt_non_empty("Nazwa firmy: "),
53
- "owner_name": prompt_non_empty("Imie i nazwisko wlasciciela: "),
54
- "address": prompt_non_empty("Adres: "),
55
- "tax_id": prompt_non_empty("NIP: "),
56
- "bank_account": prompt_non_empty("Numer konta bankowego: "),
57
- }
58
-
59
- print("\nUstaw haslo do logowania.")
60
- while True:
61
- password = getpass.getpass("Haslo: ")
62
- confirm = getpass.getpass("Powtorz haslo: ")
63
- if not password:
64
- print("Haslo nie moze byc puste.")
65
- continue
66
- if password != confirm:
67
- print("Hasla nie sa identyczne. Sprobuj ponownie.")
68
- continue
69
- break
70
-
71
- data = {
72
- "business": business,
73
- "password_hash": hash_password(password),
74
- "invoices": [],
75
- }
76
- save_data(data)
77
- print("\nDane zapisane. Uruchom aplikacje ponownie, aby zalogowac sie i wystawiac faktury.")
78
-
79
-
80
- def authenticate(data) -> bool:
81
- for attempt in range(3):
82
- password = getpass.getpass("Haslo: ")
83
- if hash_password(password) == data["password_hash"]:
84
- return True
85
- print("Nieprawidlowe haslo.")
86
- return False
87
-
88
-
89
- def prompt_client_details():
90
- answer = input("Dodac dane klienta? (T/N): ").strip().lower()
91
- if answer not in ("t", "tak"):
92
- return {}
93
- print("\n=== Dane klienta ===")
94
- client = {
95
- "name": prompt_non_empty("Nazwa / Imie i nazwisko: "),
96
- "address": prompt_non_empty("Adres: "),
97
- "tax_id": input("NIP (opcjonalnie): ").strip(),
98
- }
99
- return client
100
-
101
-
102
- def format_invoice_text(invoice_id: str, business: dict, invoice: dict) -> str:
103
- lines = [
104
- f"Faktura: {invoice_id}",
105
- f"Data wystawienia: {invoice['issued_at']}",
106
- "",
107
- "=== Sprzedawca ===",
108
- f"Nazwa: {business['company_name']}",
109
- f"Wlasciciel: {business['owner_name']}",
110
- f"Adres: {business['address']}",
111
- f"NIP: {business['tax_id']}",
112
- f"Konto bankowe: {business['bank_account']}",
113
- "",
114
- "=== Nabywca ===",
115
- ]
116
-
117
- client = invoice.get("client", {})
118
- if client:
119
- lines.extend(
120
- [
121
- f"Nazwa: {client.get('name', '')}",
122
- f"Adres: {client.get('address', '')}",
123
- f"NIP: {client.get('tax_id', '') or '---'}",
124
- ]
125
- )
126
- else:
127
- lines.append("Brak danych klienta (pole opcjonalne).")
128
-
129
- lines.extend(
130
- [
131
- "",
132
- "=== Pozycja ===",
133
- f"Opis: {invoice['item_description']}",
134
- f"Ilosc: {invoice['quantity']}",
135
- f"Cena jednostkowa: {invoice['unit_price']:.2f} PLN",
136
- f"Wartosc netto: {invoice['net_total']:.2f} PLN",
137
- ]
138
- )
139
- return "\n".join(lines)
140
-
141
-
142
- def create_invoice(data):
143
- print("\n=== Wystaw fakture ===")
144
- description = prompt_non_empty("Opis uslugi / towaru: ")
145
- quantity = prompt_positive_float("Ilosc: ")
146
- unit_price = prompt_positive_float("Cena jednostkowa (PLN): ")
147
- client = prompt_client_details()
148
-
149
- issued_at = datetime.now().strftime("%Y-%m-%d %H:%M")
150
- invoice_id = datetime.now().strftime("FV-%Y%m%d-%H%M%S")
151
- net_total = quantity * unit_price
152
-
153
- invoice = {
154
- "invoice_id": invoice_id,
155
- "issued_at": issued_at,
156
- "item_description": description,
157
- "quantity": quantity,
158
- "unit_price": unit_price,
159
- "net_total": net_total,
160
- "client": client,
161
- }
162
-
163
- os.makedirs(INVOICE_FOLDER, exist_ok=True)
164
- invoice_path = os.path.join(INVOICE_FOLDER, f"{invoice_id}.txt")
165
- with open(invoice_path, "w", encoding="utf-8") as handle:
166
- handle.write(format_invoice_text(invoice_id, data["business"], invoice))
167
-
168
- data["invoices"].append(invoice)
169
- save_data(data)
170
-
171
- print(f"\nFaktura zapisana do {invoice_path}")
172
- print(f"Suma do zaplaty: {net_total:.2f} PLN")
173
-
174
-
175
- def main():
176
- data = load_data()
177
- if data is None:
178
- run_setup()
179
- return
180
-
181
- print("=== Logowanie ===")
182
- if not authenticate(data):
183
- print("Zbyt wiele nieudanych prob logowania. Zakonczono.")
184
- return
185
-
186
- create_invoice(data)
187
-
188
-
189
- if __name__ == "__main__":
190
- main()
 
1
+ import getpass
2
+ import hashlib
3
+ import json
4
+ import os
5
+ from datetime import datetime
6
+
7
+ DATA_FILE = "invoice_data.json"
8
+ INVOICE_FOLDER = "invoices"
9
+
10
+
11
+ def hash_password(password: str) -> str:
12
+ return hashlib.sha256(password.encode("utf-8")).hexdigest()
13
+
14
+
15
+ def prompt_non_empty(prompt: str) -> str:
16
+ while True:
17
+ value = input(prompt).strip()
18
+ if value:
19
+ return value
20
+ print("Pole nie moze byc puste.")
21
+
22
+
23
+ def prompt_positive_float(prompt: str) -> float:
24
+ while True:
25
+ raw_value = input(prompt).replace(",", ".").strip()
26
+ try:
27
+ value = float(raw_value)
28
+ except ValueError:
29
+ print("Wprowadz liczbe.")
30
+ continue
31
+ if value <= 0:
32
+ print("Wartosc musi byc wieksza od zera.")
33
+ continue
34
+ return value
35
+
36
+
37
+ def load_data():
38
+ if not os.path.exists(DATA_FILE):
39
+ return None
40
+ with open(DATA_FILE, "r", encoding="utf-8") as handle:
41
+ return json.load(handle)
42
+
43
+
44
+ def save_data(data) -> None:
45
+ with open(DATA_FILE, "w", encoding="utf-8") as handle:
46
+ json.dump(data, handle, indent=2, ensure_ascii=False)
47
+
48
+
49
+ def run_setup():
50
+ print("=== Konfiguracja konta przedsiebiorcy ===")
51
+ business = {
52
+ "company_name": prompt_non_empty("Nazwa firmy: "),
53
+ "owner_name": prompt_non_empty("Imie i nazwisko wlasciciela: "),
54
+ "address": prompt_non_empty("Adres: "),
55
+ "tax_id": prompt_non_empty("NIP: "),
56
+ "bank_account": prompt_non_empty("Numer konta bankowego: "),
57
+ }
58
+
59
+ print("\nUstaw haslo do logowania.")
60
+ while True:
61
+ password = getpass.getpass("Haslo: ")
62
+ confirm = getpass.getpass("Powtorz haslo: ")
63
+ if not password:
64
+ print("Haslo nie moze byc puste.")
65
+ continue
66
+ if password != confirm:
67
+ print("Hasla nie sa identyczne. Sprobuj ponownie.")
68
+ continue
69
+ break
70
+
71
+ data = {
72
+ "business": business,
73
+ "password_hash": hash_password(password),
74
+ "invoices": [],
75
+ }
76
+ save_data(data)
77
+ print("\nDane zapisane. Uruchom aplikacje ponownie, aby zalogowac sie i wystawiac faktury.")
78
+
79
+
80
+ def authenticate(data) -> bool:
81
+ for attempt in range(3):
82
+ password = getpass.getpass("Haslo: ")
83
+ if hash_password(password) == data["password_hash"]:
84
+ return True
85
+ print("Nieprawidlowe haslo.")
86
+ return False
87
+
88
+
89
+ def prompt_client_details():
90
+ answer = input("Dodac dane klienta? (T/N): ").strip().lower()
91
+ if answer not in ("t", "tak"):
92
+ return {}
93
+ print("\n=== Dane klienta ===")
94
+ client = {
95
+ "name": prompt_non_empty("Nazwa / Imie i nazwisko: "),
96
+ "address": prompt_non_empty("Adres: "),
97
+ "tax_id": input("NIP (opcjonalnie): ").strip(),
98
+ }
99
+ return client
100
+
101
+
102
+ def format_invoice_text(invoice_id: str, business: dict, invoice: dict) -> str:
103
+ lines = [
104
+ f"Faktura: {invoice_id}",
105
+ f"Data wystawienia: {invoice['issued_at']}",
106
+ "",
107
+ "=== Sprzedawca ===",
108
+ f"Nazwa: {business['company_name']}",
109
+ f"Wlasciciel: {business['owner_name']}",
110
+ f"Adres: {business['address']}",
111
+ f"NIP: {business['tax_id']}",
112
+ f"Konto bankowe: {business['bank_account']}",
113
+ "",
114
+ "=== Nabywca ===",
115
+ ]
116
+
117
+ client = invoice.get("client", {})
118
+ if client:
119
+ lines.extend(
120
+ [
121
+ f"Nazwa: {client.get('name', '')}",
122
+ f"Adres: {client.get('address', '')}",
123
+ f"NIP: {client.get('tax_id', '') or '---'}",
124
+ ]
125
+ )
126
+ else:
127
+ lines.append("Brak danych klienta (pole opcjonalne).")
128
+
129
+ lines.extend(
130
+ [
131
+ "",
132
+ "=== Pozycja ===",
133
+ f"Opis: {invoice['item_description']}",
134
+ f"Ilosc: {invoice['quantity']}",
135
+ f"Cena jednostkowa: {invoice['unit_price']:.2f} PLN",
136
+ f"Wartosc netto: {invoice['net_total']:.2f} PLN",
137
+ ]
138
+ )
139
+ return "\n".join(lines)
140
+
141
+
142
+ def create_invoice(data):
143
+ print("\n=== Wystaw fakture ===")
144
+ description = prompt_non_empty("Opis uslugi / towaru: ")
145
+ quantity = prompt_positive_float("Ilosc: ")
146
+ unit_price = prompt_positive_float("Cena jednostkowa (PLN): ")
147
+ client = prompt_client_details()
148
+
149
+ issued_at = datetime.now().strftime("%Y-%m-%d %H:%M")
150
+ invoice_id = datetime.now().strftime("FV-%Y%m%d-%H%M%S")
151
+ net_total = quantity * unit_price
152
+
153
+ invoice = {
154
+ "invoice_id": invoice_id,
155
+ "issued_at": issued_at,
156
+ "item_description": description,
157
+ "quantity": quantity,
158
+ "unit_price": unit_price,
159
+ "net_total": net_total,
160
+ "client": client,
161
+ }
162
+
163
+ os.makedirs(INVOICE_FOLDER, exist_ok=True)
164
+ invoice_path = os.path.join(INVOICE_FOLDER, f"{invoice_id}.txt")
165
+ with open(invoice_path, "w", encoding="utf-8") as handle:
166
+ handle.write(format_invoice_text(invoice_id, data["business"], invoice))
167
+
168
+ data["invoices"].append(invoice)
169
+ save_data(data)
170
+
171
+ print(f"\nFaktura zapisana do {invoice_path}")
172
+ print(f"Suma do zaplaty: {net_total:.2f} PLN")
173
+
174
+
175
+ def main():
176
+ data = load_data()
177
+ if data is None:
178
+ run_setup()
179
+ return
180
+
181
+ print("=== Logowanie ===")
182
+ if not authenticate(data):
183
+ print("Zbyt wiele nieudanych prob logowania. Zakonczono.")
184
+ return
185
+
186
+ create_invoice(data)
187
+
188
+
189
+ if __name__ == "__main__":
190
+ main()
logotyp do strony.png DELETED
Binary file (62.2 kB)
 
main.js DELETED
@@ -1,2411 +0,0 @@
1
- const APP_PATHNAME =
2
- typeof window !== "undefined" && window.location && typeof window.location.pathname === "string"
3
- ? window.location.pathname.replace(/\/$/, "")
4
- : "";
5
-
6
- const VAT_OPTIONS = [
7
- { value: "23", label: "23%" },
8
- { value: "8", label: "8%" },
9
- { value: "5", label: "5%" },
10
- { value: "0", label: "0% (ZW)" },
11
- { value: "ZW", label: "ZW - zwolnione" },
12
- { value: "NP", label: "NP - poza zakresem" },
13
- ];
14
-
15
- const VAT_RATE_VALUES = {
16
- "23": 0.23,
17
- "8": 0.08,
18
- "5": 0.05,
19
- "0": 0,
20
- ZW: 0,
21
- NP: 0,
22
- };
23
-
24
- const UNIT_OPTIONS = [
25
- { value: "szt.", label: "szt." },
26
- { value: "godz.", label: "godz." },
27
- ];
28
-
29
- const DEFAULT_UNIT = UNIT_OPTIONS[0].value;
30
-
31
- const DOCUMENT_TYPES = {
32
- standard: {
33
- label: "Faktura VAT",
34
- pdfTitle: "Faktura",
35
- prefix: "FV",
36
- },
37
- personal_paid: {
38
- label: "Faktura imienna",
39
- pdfTitle: "Faktura imienna",
40
- prefix: "FI",
41
- },
42
- };
43
-
44
- const EXEMPTION_REASONS = [
45
- {
46
- value: "art_43_1_19",
47
- label: "Art. 43 ust. 1 pkt 19 ustawy o VAT - usługi medyczne",
48
- note: "Art. 43 ust. 1 pkt 19 ustawy o VAT - usługi w zakresie opieki medycznej.",
49
- },
50
- {
51
- value: "art_43_1_18",
52
- label: "Art. 43 ust. 1 pkt 18 ustawy o VAT - usługi edukacyjne",
53
- note: "Art. 43 ust. 1 pkt 18 ustawy o VAT - usługi edukacyjne w formach przewidzianych w przepisach.",
54
- },
55
- {
56
- value: "art_43_1_37",
57
- label: "Art. 43 ust. 1 pkt 37 ustawy o VAT - usługi finansowe",
58
- note: "Art. 43 ust. 1 pkt 37 ustawy o VAT - usługi finansowe i pośrednictwa finansowego.",
59
- },
60
- {
61
- value: "art_113",
62
- label: "Art. 113 ust. 1 i 9 ustawy o VAT - zwolnienie podmiotowe",
63
- note: "Art. 113 ust. 1 i 9 ustawy o VAT - zwolnienie podmiotowe do 200 000 PLN obrotu.",
64
- },
65
- {
66
- value: "par_3_ust_1_pkt_1",
67
- label: "Par. 3 ust. 1 pkt 1 rozporządzenia MF z 20.12.2013 r.",
68
- note: "Par. 3 ust. 1 pkt 1 rozporządzenia MF z 20.12.2013 r. - dostawa towarów używanych.",
69
- },
70
- {
71
- value: "custom",
72
- label: "Inne (wpisz własny opis)",
73
- note: "",
74
- },
75
- ];
76
-
77
- const EXEMPTION_REASON_LOOKUP = new Map(EXEMPTION_REASONS.map((reason) => [reason.value, reason]));
78
-
79
- const heroPanel = document.getElementById("hero-panel");
80
- const authSection = document.getElementById("auth-section");
81
- const appSection = document.getElementById("app-section");
82
-
83
- const registerForm = document.getElementById("register-form");
84
- const loginForm = document.getElementById("login-form");
85
- const loginSubmitButton = loginForm ? loginForm.querySelector('button[type="submit"]') : null;
86
- const loginSubmitButtonDefaultText = loginSubmitButton && loginSubmitButton.textContent ? loginSubmitButton.textContent.trim() || "Zaloguj" : "Zaloguj";
87
- const invoiceForm = document.getElementById("invoice-form");
88
- const businessForm = document.getElementById("business-form");
89
-
90
- const registerFeedback = document.getElementById("register-feedback");
91
- const loginFeedback = document.getElementById("login-feedback");
92
- const businessFeedback = document.getElementById("business-feedback");
93
- const logoFeedback = document.getElementById("logo-feedback");
94
- const registerSection = document.getElementById("register-section");
95
- const showRegisterButton = document.getElementById("show-register-button");
96
- const backToLoginButton = document.getElementById("back-to-login");
97
- const cancelRegisterButton = document.getElementById("cancel-register");
98
- const clientSearchInput = document.getElementById("client-search");
99
- const clientSuggestionsContainer = document.getElementById("client-suggestions");
100
- const loginBadge = document.getElementById("login-badge");
101
-
102
- const businessDisplay = document.getElementById("business-display");
103
- const toggleBusinessFormButton = document.getElementById("toggle-business-form");
104
- const cancelBusinessUpdateButton = document.getElementById("cancel-business-update");
105
- const currentLoginLabel = document.getElementById("current-login-label");
106
-
107
- const itemsBody = document.getElementById("items-body");
108
- const addItemButton = document.getElementById("add-item-button");
109
- const documentTypeSelect = document.getElementById("document-type");
110
- const paymentTermField = document.getElementById("payment-term-field");
111
- const paidDocumentHint = document.getElementById("paid-document-hint");
112
-
113
- const totalNetLabel = document.getElementById("total-net");
114
- const totalVatLabel = document.getElementById("total-vat");
115
- const totalGrossLabel = document.getElementById("total-gross");
116
- const rateSummaryContainer = document.getElementById("rate-summary");
117
-
118
- const exemptionNoteWrapper = document.getElementById("exemption-note-wrapper");
119
- const exemptionReasonSelect = document.getElementById("exemption-reason");
120
- const exemptionNoteInput = document.getElementById("exemption-note");
121
-
122
- const invoiceResult = document.getElementById("invoice-result");
123
- const invoiceOutput = document.getElementById("invoice-output");
124
- const downloadButton = document.getElementById("download-button");
125
- const logoutButton = document.getElementById("logout-button");
126
- const cancelEditInvoiceButton = document.getElementById("cancel-edit-invoice");
127
- const saveInvoiceButton = document.getElementById("save-invoice-button");
128
-
129
- const invoiceBuilderSection = document.getElementById("invoice-builder-section");
130
- const dashboardSection = document.getElementById("dashboard-section");
131
- const appNavButtons = Array.from(document.querySelectorAll(".app-nav-button"));
132
-
133
- const invoicesTableBody = document.getElementById("invoices-table-body");
134
- const invoicesEmpty = document.getElementById("invoices-empty");
135
- const dashboardFeedback = document.getElementById("dashboard-feedback");
136
-
137
- const filterStartDate = document.getElementById("filter-start-date");
138
- const filterEndDate = document.getElementById("filter-end-date");
139
- const clearFiltersButton = document.getElementById("clear-filters");
140
-
141
- const summaryMonthCount = document.getElementById("summary-month-count");
142
- const summaryMonthAmount = document.getElementById("summary-month-amount");
143
- const summaryQuarterCount = document.getElementById("summary-quarter-count");
144
- const summaryQuarterAmount = document.getElementById("summary-quarter-amount");
145
- const summaryYearCount = document.getElementById("summary-year-count");
146
- const summaryYearAmount = document.getElementById("summary-year-amount");
147
-
148
- const logoInput = document.getElementById("logo-input");
149
- const logoPreview = document.getElementById("logo-preview");
150
- const logoPreviewImage = document.getElementById("logo-preview-image");
151
- const removeLogoButton = document.getElementById("remove-logo-button");
152
- const legacyLoginHint = document.getElementById("legacy-login-hint");
153
- const invoicesChartCanvas = document.getElementById("invoices-chart");
154
-
155
- let authToken = sessionStorage.getItem("invoiceAuthToken") || null;
156
- let currentLogin = sessionStorage.getItem("invoiceLogin") || "";
157
- let currentBusiness = null;
158
- let currentLogo = null;
159
- let lastInvoice = null;
160
- let invoicesCache = [];
161
- let editingInvoiceId = null;
162
- let activeView = "invoice-builder";
163
- let invoicesChart = null;
164
- let maxLogoSize = 512 * 1024;
165
- let pdfFontPromise = null;
166
- let pdfFontBase64 = null;
167
- let customExemptionNote = "";
168
- let clientLookupTimeout = null;
169
-
170
- function setVisibility(element, visible) {
171
- if (!element) {
172
- return;
173
- }
174
- if (visible) {
175
- element.classList.remove("hidden");
176
- element.style.removeProperty("display");
177
- } else {
178
- element.classList.add("hidden");
179
- element.style.display = "none";
180
- }
181
- }
182
-
183
- function setAppState(state) {
184
- if (state === "app") {
185
- setVisibility(authSection, false);
186
- setVisibility(registerSection, false);
187
- setVisibility(appSection, true);
188
- setVisibility(heroPanel, false);
189
- } else {
190
- setVisibility(authSection, true);
191
- setVisibility(registerSection, false);
192
- setVisibility(appSection, false);
193
- setVisibility(heroPanel, true);
194
- }
195
- }
196
-
197
- function openRegisterPanel() {
198
- if (!registerSection) {
199
- return;
200
- }
201
- setVisibility(authSection, false);
202
- setVisibility(registerSection, true);
203
- setVisibility(appSection, false);
204
- clearFeedback(registerFeedback);
205
- clearFeedback(loginFeedback);
206
- if (registerForm) {
207
- const emailInput = registerForm.elements.email;
208
- if (emailInput) {
209
- emailInput.focus();
210
- }
211
- }
212
- const scrollTarget = registerSection.querySelector(".register-card") || registerSection;
213
- const scrollIntoView = () => scrollTarget.scrollIntoView({ behavior: "smooth", block: "start" });
214
- if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
215
- window.requestAnimationFrame(scrollIntoView);
216
- } else if (typeof requestAnimationFrame === "function") {
217
- requestAnimationFrame(scrollIntoView);
218
- } else {
219
- scrollIntoView();
220
- }
221
- }
222
-
223
- function closeRegisterPanel({ resetForm = true, focusTrigger = false } = {}) {
224
- if (!registerSection) {
225
- return;
226
- }
227
- setVisibility(registerSection, false);
228
- setVisibility(authSection, true);
229
- setVisibility(appSection, false);
230
- clearFeedback(registerFeedback);
231
- clearFeedback(loginFeedback);
232
- if (resetForm && registerForm) {
233
- registerForm.reset();
234
- }
235
- if (focusTrigger) {
236
- if (showRegisterButton) {
237
- showRegisterButton.focus();
238
- }
239
- const scrollTarget = authSection ? authSection.querySelector(".login-card") || authSection : null;
240
- if (scrollTarget) {
241
- const scrollToLogin = () => scrollTarget.scrollIntoView({ behavior: "smooth", block: "start" });
242
- if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
243
- window.requestAnimationFrame(scrollToLogin);
244
- } else if (typeof requestAnimationFrame === "function") {
245
- requestAnimationFrame(scrollToLogin);
246
- } else {
247
- scrollToLogin();
248
- }
249
- }
250
- }
251
- }
252
-
253
- function clearFeedback(element) {
254
- if (!element) {
255
- return;
256
- }
257
- element.textContent = "";
258
- element.classList.remove("error", "success");
259
- }
260
-
261
- function showFeedback(element, message, type = "error") {
262
- if (!element) {
263
- return;
264
- }
265
- element.textContent = message;
266
- element.classList.remove("error", "success");
267
- if (type) {
268
- element.classList.add(type);
269
- }
270
- }
271
-
272
- function parseNumber(value) {
273
- if (typeof value === "number") {
274
- return Number.isFinite(value) ? value : 0;
275
- }
276
- if (!value) {
277
- return 0;
278
- }
279
- const normalized = value.toString().replace(",", ".");
280
- const parsed = Number.parseFloat(normalized);
281
- return Number.isFinite(parsed) ? parsed : 0;
282
- }
283
-
284
- function parseIntegerString(value) {
285
- if (value === null || value === undefined) {
286
- return Number.NaN;
287
- }
288
- const normalized = value.toString().trim();
289
- if (!normalized) {
290
- return 0;
291
- }
292
- const parsed = Number.parseFloat(normalized.replace(",", "."));
293
- if (!Number.isFinite(parsed) || Math.floor(parsed) !== parsed) {
294
- return Number.NaN;
295
- }
296
- return parsed;
297
- }
298
-
299
- function formatQuantity(value) {
300
- const parsed = parseIntegerString(value);
301
- if (Number.isNaN(parsed)) {
302
- return "0";
303
- }
304
- return parsed.toString();
305
- }
306
-
307
- function formatCurrency(value) {
308
- const number = parseNumber(value);
309
- return `${number.toFixed(2)} PLN`;
310
- }
311
-
312
- function getDocumentType(invoice = {}) {
313
- const rawType = invoice.document_type || invoice.documentType || "";
314
- if (DOCUMENT_TYPES[rawType]) {
315
- return rawType;
316
- }
317
- if (invoice.payment_status === "paid" || (invoice.payment_term !== null && invoice.payment_term !== undefined && Number(invoice.payment_term) === 0)) {
318
- return "personal_paid";
319
- }
320
- const number = invoice.invoice_id || "";
321
- if (number.startsWith(`${DOCUMENT_TYPES.personal_paid.prefix}-`)) {
322
- return "personal_paid";
323
- }
324
- return "standard";
325
- }
326
-
327
- function getDocumentLabel(invoice = {}) {
328
- return DOCUMENT_TYPES[getDocumentType(invoice)]?.label || DOCUMENT_TYPES.standard.label;
329
- }
330
-
331
- function isPaidInvoice(invoice = {}) {
332
- return (
333
- invoice.payment_status === "paid" ||
334
- getDocumentType(invoice) === "personal_paid" ||
335
- (invoice.payment_term !== null && invoice.payment_term !== undefined && Number(invoice.payment_term) === 0)
336
- );
337
- }
338
-
339
- function syncDocumentTypeControls() {
340
- const selectedType = documentTypeSelect?.value || "standard";
341
- const isPersonalPaid = selectedType === "personal_paid";
342
- setVisibility(paymentTermField, !isPersonalPaid);
343
- setVisibility(paidDocumentHint, isPersonalPaid);
344
- const paymentTermInput = invoiceForm?.elements.paymentTerm;
345
- if (!paymentTermInput) {
346
- return;
347
- }
348
- paymentTermInput.disabled = isPersonalPaid;
349
- if (isPersonalPaid) {
350
- paymentTermInput.value = "";
351
- } else if (!paymentTermInput.value) {
352
- paymentTermInput.value = "14";
353
- }
354
- }
355
-
356
- function vatLabelFromCode(code) {
357
- if (code === "ZW" || code === "0") {
358
- return "ZW";
359
- }
360
- if (code === "NP") {
361
- return "NP";
362
- }
363
- return `${code}%`;
364
- }
365
-
366
- function requiresExemption(code) {
367
- return code === "ZW" || code === "0";
368
- }
369
-
370
- function populateExemptionReasons() {
371
- if (!exemptionReasonSelect || exemptionReasonSelect.dataset.initialized === "true") {
372
- return;
373
- }
374
- const existingValues = new Set(Array.from(exemptionReasonSelect.options).map((option) => option.value));
375
- EXEMPTION_REASONS.forEach((reason) => {
376
- if (existingValues.has(reason.value)) {
377
- return;
378
- }
379
- const option = document.createElement("option");
380
- option.value = reason.value;
381
- option.textContent = reason.label;
382
- exemptionReasonSelect.appendChild(option);
383
- });
384
- exemptionReasonSelect.dataset.initialized = "true";
385
- }
386
-
387
- function applyExemptionReasonSelection({ preserveCustom = false } = {}) {
388
- if (!exemptionReasonSelect || !exemptionNoteInput) {
389
- return;
390
- }
391
- const selectedValue = exemptionReasonSelect.value;
392
- const selectedReason = EXEMPTION_REASON_LOOKUP.get(selectedValue);
393
-
394
- // Ukryj pole "Podstawa prawna zwolnienia" jeśli nie wybrano opcji "Inne..."
395
- const exemptionNoteLabel = document.getElementById("exemption-note-label");
396
- if (exemptionNoteLabel) {
397
- if (selectedValue === "custom") {
398
- exemptionNoteLabel.style.display = "block";
399
- exemptionNoteInput.style.display = "block";
400
- } else {
401
- exemptionNoteLabel.style.display = "none";
402
- exemptionNoteInput.style.display = "none";
403
- }
404
- }
405
-
406
- if (!selectedReason) {
407
- if (!preserveCustom) {
408
- exemptionNoteInput.readOnly = false;
409
- exemptionNoteInput.value = "";
410
- }
411
- return;
412
- }
413
- if (selectedValue === "custom") {
414
- exemptionNoteInput.readOnly = false;
415
- if (!preserveCustom) {
416
- exemptionNoteInput.value = customExemptionNote;
417
- }
418
- return;
419
- }
420
- exemptionNoteInput.readOnly = true;
421
- exemptionNoteInput.value = selectedReason.note;
422
- }
423
-
424
- function findExemptionReasonByNote(note) {
425
- if (!note) {
426
- return null;
427
- }
428
- const normalized = note.trim().toLowerCase();
429
- return (
430
- EXEMPTION_REASONS.find(
431
- (reason) =>
432
- reason.value !== "custom" && reason.note && reason.note.trim().toLowerCase() === normalized
433
- ) || null
434
- );
435
- }
436
-
437
- function syncExemptionControlsWithNote(note) {
438
- if (!exemptionNoteInput) {
439
- return;
440
- }
441
- const trimmed = (note || "").trim();
442
- exemptionNoteInput.readOnly = false;
443
- if (!exemptionReasonSelect) {
444
- exemptionNoteInput.value = trimmed;
445
- return;
446
- }
447
- if (!trimmed) {
448
- customExemptionNote = "";
449
- exemptionReasonSelect.value = "";
450
- exemptionNoteInput.value = "";
451
- return;
452
- }
453
- const matchedReason = findExemptionReasonByNote(trimmed);
454
- if (matchedReason && matchedReason.value !== "custom") {
455
- exemptionReasonSelect.value = matchedReason.value;
456
- applyExemptionReasonSelection({ preserveCustom: true });
457
- } else {
458
- customExemptionNote = trimmed;
459
- exemptionReasonSelect.value = "custom";
460
- exemptionNoteInput.readOnly = false;
461
- exemptionNoteInput.value = trimmed;
462
- }
463
- }
464
-
465
- function updateExemptionVisibility(exemptionNeeded) {
466
- if (!exemptionNoteWrapper || !exemptionNoteInput) {
467
- return;
468
- }
469
- if (exemptionNeeded) {
470
- populateExemptionReasons();
471
- setVisibility(exemptionNoteWrapper, true);
472
- applyExemptionReasonSelection({ preserveCustom: true });
473
- return;
474
- }
475
- setVisibility(exemptionNoteWrapper, false);
476
- if (exemptionReasonSelect) {
477
- exemptionReasonSelect.value = "";
478
- }
479
- customExemptionNote = "";
480
- exemptionNoteInput.readOnly = false;
481
- exemptionNoteInput.value = "";
482
- }
483
-
484
- function formatInvoicesCount(count) {
485
- const value = Number.parseInt(count, 10) || 0;
486
- const absolute = Math.abs(value);
487
- const mod10 = absolute % 10;
488
- const mod100 = absolute % 100;
489
- let suffix = "faktur";
490
- if (mod10 === 1 && mod100 !== 11) {
491
- suffix = "faktura";
492
- } else if ([2, 3, 4].includes(mod10) && ![12, 13, 14].includes(mod100)) {
493
- suffix = "faktury";
494
- }
495
- return `${value} ${suffix}`;
496
- }
497
-
498
- function parseInvoiceIssuedAt(invoice) {
499
- if (!invoice || !invoice.issued_at) {
500
- return null;
501
- }
502
- const normalized = invoice.issued_at.replace(" ", "T");
503
- const parsed = new Date(normalized);
504
- return Number.isNaN(parsed.getTime()) ? null : parsed;
505
- }
506
-
507
- function parseDateInput(value) {
508
- if (!value) {
509
- return null;
510
- }
511
- const parts = value.split("-").map((part) => Number.parseInt(part, 10));
512
- if (parts.length !== 3 || parts.some(Number.isNaN)) {
513
- return null;
514
- }
515
- return new Date(parts[0], parts[1] - 1, parts[2]);
516
- }
517
-
518
- function setActiveView(view) {
519
- activeView = view === "dashboard" ? "dashboard" : "invoice-builder";
520
- setVisibility(invoiceBuilderSection, activeView === "invoice-builder");
521
- setVisibility(dashboardSection, activeView === "dashboard");
522
- const showDashboard = activeView === "dashboard";
523
- appNavButtons.forEach((button) => {
524
- button.classList.toggle("active", button.dataset.view === activeView);
525
- });
526
- if (showDashboard) {
527
- applyInvoiceFilters();
528
- }
529
- }
530
-
531
- function updateLoginLabel() {
532
- if (!currentLoginLabel) {
533
- return;
534
- }
535
- if (!currentLogin) {
536
- currentLoginLabel.textContent = "";
537
- if (loginBadge) {
538
- loginBadge.classList.add("hidden");
539
- }
540
- return;
541
- }
542
- currentLoginLabel.textContent = currentLogin;
543
- if (loginBadge) {
544
- loginBadge.classList.remove("hidden");
545
- }
546
- }
547
-
548
- function updateLogoPreview() {
549
- if (currentLogo && currentLogo.data && currentLogo.mime_type) {
550
- const dataUrl = currentLogo.data_url || `data:${currentLogo.mime_type};base64,${currentLogo.data}`;
551
- logoPreviewImage.src = dataUrl;
552
- logoPreview.classList.remove("hidden");
553
- removeLogoButton.classList.remove("hidden");
554
- } else {
555
- logoPreviewImage.removeAttribute("src");
556
- logoPreview.classList.add("hidden");
557
- removeLogoButton.classList.add("hidden");
558
- }
559
- }
560
-
561
- function renderInvoicesTable(invoices) {
562
- invoicesTableBody.innerHTML = "";
563
- if (!Array.isArray(invoices) || invoices.length === 0) {
564
- invoicesEmpty.classList.remove("hidden");
565
- return;
566
- }
567
-
568
- invoicesEmpty.classList.add("hidden");
569
- invoices.forEach((invoice) => {
570
- const row = document.createElement("tr");
571
-
572
- const numberCell = document.createElement("td");
573
- numberCell.textContent = `${invoice.invoice_id || "---"} (${getDocumentLabel(invoice)})`;
574
- row.appendChild(numberCell);
575
-
576
- const issuedCell = document.createElement("td");
577
- issuedCell.textContent = invoice.issued_at || "-";
578
- row.appendChild(issuedCell);
579
-
580
- const clientCell = document.createElement("td");
581
- const clientName = invoice.client?.name || "";
582
- const clientCity = invoice.client?.city || "";
583
- clientCell.textContent = clientName ? `${clientName}${clientCity ? ` (${clientCity})` : ""}` : "-";
584
- row.appendChild(clientCell);
585
-
586
- const grossCell = document.createElement("td");
587
- grossCell.textContent = formatCurrency(invoice.totals?.gross ?? 0);
588
- row.appendChild(grossCell);
589
-
590
- const actionsCell = document.createElement("td");
591
- const actionsWrapper = document.createElement("div");
592
- actionsWrapper.className = "table-actions";
593
-
594
- const editButton = document.createElement("button");
595
- editButton.type = "button";
596
- editButton.textContent = "Edytuj";
597
- editButton.addEventListener("click", () => {
598
- startInvoiceEdit(invoice.invoice_id);
599
- });
600
-
601
- const pdfButton = document.createElement("button");
602
- pdfButton.type = "button";
603
- pdfButton.className = "button secondary";
604
- pdfButton.dataset.download = invoice.invoice_id;
605
- pdfButton.textContent = "PDF";
606
-
607
- const deleteButton = document.createElement("button");
608
- deleteButton.type = "button";
609
- deleteButton.className = "button secondary";
610
- deleteButton.textContent = "Usuń";
611
- deleteButton.addEventListener("click", async () => {
612
- clearFeedback(dashboardFeedback);
613
- const shouldDelete = window.confirm(`Usuńac fakturę ${invoice.invoice_id}?`);
614
- if (!shouldDelete) {
615
- return;
616
- }
617
- await deleteInvoice(invoice.invoice_id);
618
- });
619
-
620
- actionsWrapper.appendChild(editButton);
621
- actionsWrapper.appendChild(pdfButton);
622
- actionsWrapper.appendChild(deleteButton);
623
- actionsCell.appendChild(actionsWrapper);
624
- row.appendChild(actionsCell);
625
-
626
- invoicesTableBody.appendChild(row);
627
- });
628
- }
629
-
630
- function applyInvoiceFilters() {
631
- if (!Array.isArray(invoicesCache)) {
632
- renderInvoicesTable([]);
633
- return;
634
- }
635
-
636
- let filtered = invoicesCache.slice();
637
- const startDate = parseDateInput(filterStartDate?.value);
638
- const endDate = parseDateInput(filterEndDate?.value);
639
-
640
- if (startDate) {
641
- const startTime = startDate.getTime();
642
- filtered = filtered.filter((invoice) => {
643
- const issued = parseInvoiceIssuedAt(invoice);
644
- return !issued || issued.getTime() >= startTime;
645
- });
646
- }
647
-
648
- if (endDate) {
649
- const endBoundary = new Date(endDate);
650
- endBoundary.setHours(23, 59, 59, 999);
651
- const endTime = endBoundary.getTime();
652
- filtered = filtered.filter((invoice) => {
653
- const issued = parseInvoiceIssuedAt(invoice);
654
- return !issued || issued.getTime() <= endTime;
655
- });
656
- }
657
-
658
- filtered.sort((a, b) => (b.issued_at || "").localeCompare(a.issued_at || ""));
659
- renderInvoicesTable(filtered);
660
- }
661
-
662
- if (invoicesTableBody) {
663
- invoicesTableBody.addEventListener("click", async (event) => {
664
- const target = event.target;
665
- if (!(target instanceof HTMLElement)) {
666
- return;
667
- }
668
- const pdfTrigger = target.closest("[data-download]");
669
- if (pdfTrigger) {
670
- const invoiceId = pdfTrigger.getAttribute("data-download");
671
- if (!invoiceId) {
672
- return;
673
- }
674
- const invoiceData = invoicesCache.find((invoice) => invoice.invoice_id === invoiceId);
675
- if (!invoiceData || !currentBusiness) {
676
- showFeedback(dashboardFeedback, "Nie udało się przygotować PDF. Odśwież dane i spróbuj ponownie.");
677
- return;
678
- }
679
- try {
680
- await generatePdf(currentBusiness, invoiceData, currentLogo);
681
- } catch (error) {
682
- console.error(error);
683
- showFeedback(dashboardFeedback, "Nie udało się wygenerować PDF-a.");
684
- }
685
- }
686
- });
687
- }
688
-
689
- async function refreshInvoices() {
690
- if (!authToken) {
691
- invoicesCache = [];
692
- renderInvoicesTable([]);
693
- return;
694
- }
695
- clearFeedback(dashboardFeedback);
696
- try {
697
- const data = await apiRequest("/api/invoices", {}, true);
698
- invoicesCache = Array.isArray(data.invoices) ? data.invoices.slice() : [];
699
- invoicesCache.sort((a, b) => (b.issued_at || "").localeCompare(a.issued_at || ""));
700
- applyInvoiceFilters();
701
- } catch (error) {
702
- console.error(error);
703
- showFeedback(dashboardFeedback, error.message || "Nie udało się pobrać faktur.");
704
- }
705
- }
706
-
707
- function updateSummaryCards(summary) {
708
- const monthSummary = summary?.last_month || { count: 0, gross_total: 0 };
709
- const quarterSummary = summary?.quarter || { count: 0, gross_total: 0 };
710
- const yearSummary = summary?.year || { count: 0, gross_total: 0 };
711
-
712
- summaryMonthCount.textContent = formatInvoicesCount(monthSummary.count);
713
- summaryQuarterCount.textContent = formatInvoicesCount(quarterSummary.count);
714
- summaryYearCount.textContent = formatInvoicesCount(yearSummary.count);
715
-
716
- summaryMonthAmount.textContent = formatCurrency(monthSummary.gross_total ?? 0);
717
- summaryQuarterAmount.textContent = formatCurrency(quarterSummary.gross_total ?? 0);
718
- summaryYearAmount.textContent = formatCurrency(yearSummary.gross_total ?? 0);
719
- }
720
-
721
- function updateSummaryChart(summary) {
722
- if (!invoicesChartCanvas || typeof window.Chart === "undefined") {
723
- return;
724
- }
725
-
726
- const labels = ["Ostatnie 30 dni", "Bieżący kwartał", "Bieżący rok"];
727
- const counts = [
728
- Number.parseInt(summary?.last_month?.count ?? 0, 10) || 0,
729
- Number.parseInt(summary?.quarter?.count ?? 0, 10) || 0,
730
- Number.parseInt(summary?.year?.count ?? 0, 10) || 0,
731
- ];
732
- const amounts = [
733
- parseNumber(summary?.last_month?.gross_total ?? 0),
734
- parseNumber(summary?.quarter?.gross_total ?? 0),
735
- parseNumber(summary?.year?.gross_total ?? 0),
736
- ];
737
-
738
- const chartData = {
739
- labels,
740
- datasets: [
741
- {
742
- label: "Liczba faktur",
743
- data: counts,
744
- backgroundColor: "rgba(26, 115, 232, 0.65)",
745
- yAxisID: "count",
746
- borderRadius: 6,
747
- },
748
- {
749
- label: "Suma brutto (PLN)",
750
- data: amounts,
751
- type: "line",
752
- fill: false,
753
- borderColor: "rgba(26, 115, 232, 0.65)",
754
- backgroundColor: "rgba(26, 115, 232, 0.35)",
755
- tension: 0.3,
756
- yAxisID: "amount",
757
- },
758
- ],
759
- };
760
-
761
- const options = {
762
- responsive: true,
763
- maintainAspectRatio: false,
764
- scales: {
765
- count: {
766
- beginAtZero: true,
767
- position: "left",
768
- ticks: {
769
- precision: 0,
770
- stepSize: 1,
771
- },
772
- },
773
- amount: {
774
- beginAtZero: true,
775
- position: "right",
776
- grid: {
777
- drawOnChartArea: false,
778
- },
779
- ticks: {
780
- callback: (value) => `${new Intl.NumberFormat("pl-PL", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value)} PLN`,
781
- },
782
- },
783
- },
784
- plugins: {
785
- legend: {
786
- position: "bottom",
787
- },
788
- tooltip: {
789
- callbacks: {
790
- label(context) {
791
- if (context.dataset.yAxisID === "amount") {
792
- return `${context.dataset.label}: ${new Intl.NumberFormat("pl-PL", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(context.parsed.y)} PLN`;
793
- }
794
- return `${context.dataset.label}: ${context.parsed.y}`;
795
- },
796
- },
797
- },
798
- },
799
- };
800
-
801
- if (!invoicesChart) {
802
- invoicesChart = new window.Chart(invoicesChartCanvas, {
803
- type: "bar",
804
- data: chartData,
805
- options,
806
- });
807
- } else {
808
- invoicesChart.data = chartData;
809
- invoicesChart.options = options;
810
- invoicesChart.update();
811
- }
812
- }
813
-
814
- async function refreshSummary() {
815
- if (!authToken) {
816
- updateSummaryCards({});
817
- updateSummaryChart({});
818
- return;
819
- }
820
- clearFeedback(dashboardFeedback);
821
- try {
822
- const data = await apiRequest("/api/invoices/summary", {}, true);
823
- updateSummaryCards(data.summary);
824
- updateSummaryChart(data.summary);
825
- } catch (error) {
826
- console.error(error);
827
- showFeedback(dashboardFeedback, error.message || "Nie udało się pobrać podsumowania.");
828
- }
829
- }
830
-
831
- async function deleteInvoice(invoiceId) {
832
- if (!invoiceId) {
833
- return;
834
- }
835
- try {
836
- await apiRequest(`/api/invoices/${encodeURIComponent(invoiceId)}`, { method: "DELETE" }, true);
837
- invoicesCache = invoicesCache.filter((invoice) => invoice.invoice_id !== invoiceId);
838
- applyInvoiceFilters();
839
- await refreshSummary();
840
- } catch (error) {
841
- console.error(error);
842
- showFeedback(dashboardFeedback, error.message || "Nie udało się usunąć faktury.");
843
- }
844
- }
845
-
846
- function startInvoiceEdit(invoiceId) {
847
- if (!invoiceId) {
848
- return;
849
- }
850
- const invoice = invoicesCache.find((item) => item.invoice_id === invoiceId);
851
- if (!invoice) {
852
- showFeedback(dashboardFeedback, "Nie znaleziono wybranej faktury.");
853
- return;
854
- }
855
-
856
- editingInvoiceId = invoiceId;
857
- saveInvoiceButton.textContent = "Zapisz zmiany";
858
- cancelEditInvoiceButton.classList.remove("hidden");
859
- setActiveView("invoice-builder");
860
-
861
- resetInvoiceForm();
862
- if (invoiceForm.elements.documentType) {
863
- invoiceForm.elements.documentType.value = getDocumentType(invoice);
864
- syncDocumentTypeControls();
865
- }
866
- invoiceForm.elements.saleDate.value = invoice.sale_date || "";
867
- invoiceForm.elements.paymentTerm.value = isPaidInvoice(invoice) ? "" : (invoice.payment_term || 14);
868
-
869
- if (invoice.client) {
870
- setClientFormValues(invoice.client);
871
- }
872
-
873
- itemsBody.innerHTML = "";
874
- if (Array.isArray(invoice.items) && invoice.items.length > 0) {
875
- invoice.items.forEach((item) => {
876
- createItemRow({
877
- name: item.name,
878
- quantity: item.quantity,
879
- unit_price_gross: item.unit_price_gross ?? item.gross_total,
880
- vat_code: item.vat_code,
881
- unit: item.unit,
882
- });
883
- });
884
- } else {
885
- createItemRow();
886
- }
887
-
888
- const note = invoice.exemption_note || "";
889
- syncExemptionControlsWithNote(note);
890
- const requiresNote = Array.isArray(invoice.items)
891
- ? invoice.items.some((item) => requiresExemption(item.vat_code))
892
- : false;
893
- updateExemptionVisibility(requiresNote);
894
-
895
- lastInvoice = invoice;
896
- }
897
-
898
- function exitInvoiceEdit() {
899
- editingInvoiceId = null;
900
- saveInvoiceButton.textContent = "Generuj fakturę";
901
- cancelEditInvoiceButton.classList.add("hidden");
902
- }
903
-
904
- function buildApiUrl(path = "") {
905
- if (!path) {
906
- return APP_PATHNAME || "/";
907
- }
908
- if (/^https?:\/\//i.test(path)) {
909
- return path;
910
- }
911
- return path.startsWith("/")
912
- ? `${APP_PATHNAME}${path}` || "/"
913
- : `${APP_PATHNAME}/${path}`.replace(/\/{2,}/g, "/");
914
- }
915
-
916
- async function apiRequest(path, { method = "GET", body, headers = {} } = {}, requireAuth = false) {
917
- const options = {
918
- method,
919
- headers: {
920
- "Content-Type": "application/json",
921
- ...headers,
922
- },
923
- };
924
-
925
- if (body !== undefined) {
926
- options.body = JSON.stringify(body);
927
- }
928
-
929
- if (requireAuth) {
930
- if (!authToken) {
931
- throw new Error("Brak tokenu autoryzacyjnego.");
932
- }
933
- options.headers.Authorization = `Bearer ${authToken}`;
934
- }
935
-
936
- const url = buildApiUrl(path);
937
- const response = await fetch(url, options);
938
- const isJson = response.headers.get("content-type")?.includes("application/json");
939
- const data = isJson ? await response.json() : {};
940
-
941
- if (response.status === 401) {
942
- authToken = null;
943
- currentLogin = "";
944
- sessionStorage.removeItem("invoiceAuthToken");
945
- sessionStorage.removeItem("invoiceLogin");
946
- setAppState("auth");
947
- throw new Error(data.error || "Sesja wygasła. Zaloguj się ponownie.");
948
- }
949
-
950
- if (!response.ok) {
951
- throw new Error(data.error || "Wystapil błąd podczas komunikacji z serwerem.");
952
- }
953
-
954
- return data;
955
- }
956
-
957
- function renderBusinessDisplay(business) {
958
- if (!business) {
959
- businessDisplay.textContent = "Brak zapisanych danych firmy.";
960
- return;
961
- }
962
-
963
- const fallback = (value) => {
964
- if (!value) {
965
- return "---";
966
- }
967
- const trimmed = value.toString().trim();
968
- return trimmed || "---";
969
- };
970
-
971
- const companyName = fallback(business.company_name);
972
- const ownerName = fallback(business.owner_name);
973
- const addressLine = fallback(business.address_line);
974
- const location = fallback([business.postal_code, business.city].filter(Boolean).join(" "));
975
- const taxLine = `NIP: ${fallback(business.tax_id)}`;
976
- const bankLine = `Konto: ${fallback(business.bank_account)}`;
977
-
978
- businessDisplay.innerHTML = `
979
- <div class="business-display-grid">
980
- <div class="business-display-item business-display-item--name">
981
- <strong>${companyName}</strong>
982
- <span>${ownerName}</span>
983
- </div>
984
- <div class="business-display-item">
985
- <span>${addressLine}</span>
986
- <span>${location}</span>
987
- </div>
988
- <div class="business-display-item">
989
- <span>${taxLine}</span>
990
- <span>${bankLine}</span>
991
- </div>
992
- </div>
993
- `;
994
- }
995
-
996
- function fillBusinessForm(business) {
997
- if (!business) {
998
- return;
999
- }
1000
- businessForm.elements.company_name.value = business.company_name || "";
1001
- businessForm.elements.owner_name.value = business.owner_name || "";
1002
- businessForm.elements.address_line.value = business.address_line || "";
1003
- businessForm.elements.postal_code.value = business.postal_code || "";
1004
- businessForm.elements.city.value = business.city || "";
1005
- businessForm.elements.tax_id.value = business.tax_id || "";
1006
- businessForm.elements.bank_account.value = business.bank_account || "";
1007
- }
1008
-
1009
- function setBusinessFormVisibility(visible, { preserveFeedback = false } = {}) {
1010
- setVisibility(businessForm, visible);
1011
- if (toggleBusinessFormButton) {
1012
- toggleBusinessFormButton.textContent = visible ? "Ukryj formularz" : "Edycja danych";
1013
- }
1014
- if (!visible && !preserveFeedback) {
1015
- clearFeedback(businessFeedback);
1016
- }
1017
- }
1018
-
1019
- function setClientFormValues(client = {}) {
1020
- if (!invoiceForm) {
1021
- return;
1022
- }
1023
- invoiceForm.elements.clientName.value = client.name || "";
1024
- invoiceForm.elements.clientTaxId.value = client.tax_id || "";
1025
- invoiceForm.elements.clientAddress.value = client.address_line || "";
1026
- invoiceForm.elements.clientPostalCode.value = client.postal_code || "";
1027
- invoiceForm.elements.clientCity.value = client.city || "";
1028
- invoiceForm.elements.clientPhone.value = client.phone || "";
1029
- }
1030
-
1031
- function hideClientSuggestions() {
1032
- if (!clientSuggestionsContainer) {
1033
- return;
1034
- }
1035
- clientSuggestionsContainer.classList.add("hidden");
1036
- clientSuggestionsContainer.innerHTML = "";
1037
- }
1038
-
1039
- function selectClientFromLookup(client) {
1040
- setClientFormValues(client);
1041
- if (clientSearchInput) {
1042
- const summary = [client.name, client.tax_id].filter(Boolean).join(" • ");
1043
- clientSearchInput.value = summary || client.name || client.tax_id || "";
1044
- }
1045
- hideClientSuggestions();
1046
- }
1047
-
1048
- function renderClientSuggestions(clients) {
1049
- if (!clientSuggestionsContainer) {
1050
- return;
1051
- }
1052
- clientSuggestionsContainer.innerHTML = "";
1053
- if (!Array.isArray(clients) || clients.length === 0) {
1054
- const empty = document.createElement("p");
1055
- empty.className = "client-suggestions-empty";
1056
- empty.textContent = "Brak dopasowanych klientów.";
1057
- clientSuggestionsContainer.appendChild(empty);
1058
- clientSuggestionsContainer.classList.remove("hidden");
1059
- return;
1060
- }
1061
- const fragment = document.createDocumentFragment();
1062
- clients.forEach((client) => {
1063
- const button = document.createElement("button");
1064
- button.type = "button";
1065
- button.className = "client-suggestion";
1066
- button.setAttribute("role", "option");
1067
- button.innerHTML = `
1068
- <strong>${client.name || "Bez nazwy"}</strong>
1069
- <span>${[client.tax_id, client.city].filter(Boolean).join(" • ")}</span>
1070
- `;
1071
- button.addEventListener("click", () => {
1072
- selectClientFromLookup(client);
1073
- });
1074
- fragment.appendChild(button);
1075
- });
1076
- clientSuggestionsContainer.appendChild(fragment);
1077
- clientSuggestionsContainer.classList.remove("hidden");
1078
- }
1079
-
1080
- async function requestClientSuggestions(term) {
1081
- const query = (term || "").trim();
1082
- if (!clientSuggestionsContainer || !clientSearchInput) {
1083
- return;
1084
- }
1085
- if (!authToken || query.length < 2) {
1086
- hideClientSuggestions();
1087
- return;
1088
- }
1089
- try {
1090
- const data = await apiRequest(`/api/clients?q=${encodeURIComponent(query)}`, {}, true);
1091
- renderClientSuggestions(data.clients || []);
1092
- } catch (error) {
1093
- console.error(error);
1094
- hideClientSuggestions();
1095
- }
1096
- }
1097
-
1098
- function handleClientSearchInput(event) {
1099
- const term = event.target.value || "";
1100
- if (clientLookupTimeout) {
1101
- window.clearTimeout(clientLookupTimeout);
1102
- }
1103
- if (!term.trim()) {
1104
- hideClientSuggestions();
1105
- return;
1106
- }
1107
- clientLookupTimeout = window.setTimeout(() => {
1108
- requestClientSuggestions(term);
1109
- }, 250);
1110
- }
1111
-
1112
- function vatSelectElement(initialValue = "23") {
1113
- const select = document.createElement("select");
1114
- select.className = "item-vat";
1115
- VAT_OPTIONS.forEach((option) => {
1116
- const element = document.createElement("option");
1117
- element.value = option.value;
1118
- element.textContent = option.label;
1119
- select.appendChild(element);
1120
- });
1121
- select.value = VAT_OPTIONS.some((option) => option.value === initialValue) ? initialValue : "23";
1122
- return select;
1123
- }
1124
-
1125
- function unitSelectElement(initialValue = DEFAULT_UNIT) {
1126
- const select = document.createElement("select");
1127
- select.className = "item-unit";
1128
- UNIT_OPTIONS.forEach((option) => {
1129
- const element = document.createElement("option");
1130
- element.value = option.value;
1131
- element.textContent = option.label;
1132
- select.appendChild(element);
1133
- });
1134
- select.value = UNIT_OPTIONS.some((option) => option.value === initialValue) ? initialValue : DEFAULT_UNIT;
1135
- return select;
1136
- }
1137
-
1138
- function createItemRow(initialValues = {}) {
1139
- const row = document.createElement("tr");
1140
-
1141
- const nameCell = document.createElement("td");
1142
- const nameInput = document.createElement("input");
1143
- nameInput.type = "text";
1144
- nameInput.className = "item-name";
1145
- nameInput.placeholder = "Nazwa towaru lub usługi";
1146
- if (initialValues.name) {
1147
- nameInput.value = initialValues.name;
1148
- }
1149
- nameCell.appendChild(nameInput);
1150
-
1151
- const quantityCell = document.createElement("td");
1152
- const quantityInput = document.createElement("input");
1153
- quantityInput.type = "number";
1154
- quantityInput.className = "item-quantity";
1155
- quantityInput.min = "1";
1156
- quantityInput.step = "1";
1157
- quantityInput.inputMode = "numeric";
1158
- const parsedQuantity = parseIntegerString(initialValues.quantity);
1159
- const safeQuantity = Number.isNaN(parsedQuantity) || parsedQuantity <= 0 ? 1 : parsedQuantity;
1160
- quantityInput.value = String(safeQuantity);
1161
- quantityCell.appendChild(quantityInput);
1162
-
1163
- const unitCell = document.createElement("td");
1164
- const unitSelect = unitSelectElement(initialValues.unit);
1165
- unitCell.appendChild(unitSelect);
1166
-
1167
- const unitGrossCell = document.createElement("td");
1168
- const unitGrossInput = document.createElement("input");
1169
- unitGrossInput.type = "number";
1170
- unitGrossInput.className = "item-gross";
1171
- unitGrossInput.min = "0.01";
1172
- unitGrossInput.step = "0.01";
1173
- unitGrossInput.placeholder = "Brutto";
1174
- if (initialValues.unit_price_gross) {
1175
- unitGrossInput.value = initialValues.unit_price_gross;
1176
- }
1177
- unitGrossCell.appendChild(unitGrossInput);
1178
-
1179
- const vatCell = document.createElement("td");
1180
- const vatSelect = vatSelectElement(initialValues.vat_code);
1181
- vatCell.appendChild(vatSelect);
1182
-
1183
- const totalCell = document.createElement("td");
1184
- totalCell.className = "item-total";
1185
- totalCell.textContent = "0.00 PLN";
1186
-
1187
- const actionsCell = document.createElement("td");
1188
- const removeButton = document.createElement("button");
1189
- removeButton.type = "button";
1190
- removeButton.className = "remove-item";
1191
- removeButton.textContent = "Usuń";
1192
- actionsCell.appendChild(removeButton);
1193
-
1194
- row.appendChild(nameCell);
1195
- row.appendChild(quantityCell);
1196
- row.appendChild(unitCell);
1197
- row.appendChild(unitGrossCell);
1198
- row.appendChild(vatCell);
1199
- row.appendChild(totalCell);
1200
- row.appendChild(actionsCell);
1201
-
1202
- const handleChange = () => updateTotals();
1203
- nameInput.addEventListener("input", handleChange);
1204
- quantityInput.addEventListener("input", () => {
1205
- const sanitized = quantityInput.value.replace(/[^0-9]/g, "");
1206
- quantityInput.value = sanitized;
1207
- handleChange();
1208
- });
1209
- quantityInput.addEventListener("blur", () => {
1210
- const parsed = parseIntegerString(quantityInput.value);
1211
- quantityInput.value = Number.isNaN(parsed) || parsed <= 0 ? "1" : String(parsed);
1212
- handleChange();
1213
- });
1214
- unitGrossInput.addEventListener("input", handleChange);
1215
- vatSelect.addEventListener("change", handleChange);
1216
- unitSelect.addEventListener("change", handleChange);
1217
-
1218
- removeButton.addEventListener("click", () => {
1219
- if (itemsBody.children.length === 1) {
1220
- nameInput.value = "";
1221
- quantityInput.value = "1";
1222
- unitGrossInput.value = "";
1223
- vatSelect.value = "23";
1224
- unitSelect.value = DEFAULT_UNIT;
1225
- updateTotals();
1226
- return;
1227
- }
1228
- row.remove();
1229
- updateTotals();
1230
- });
1231
-
1232
- itemsBody.appendChild(row);
1233
- updateTotals();
1234
- }
1235
-
1236
- function calculateRowTotals(row) {
1237
- const name = row.querySelector(".item-name")?.value.trim() ?? "";
1238
- const quantityRaw = row.querySelector(".item-quantity")?.value;
1239
- const quantityParsed = parseIntegerString(quantityRaw);
1240
- const quantityValid = Number.isInteger(quantityParsed) && quantityParsed > 0;
1241
- const quantity = quantityValid ? quantityParsed : 0;
1242
- const unitGross = parseNumber(row.querySelector(".item-gross")?.value);
1243
- const vatCode = row.querySelector(".item-vat")?.value ?? "23";
1244
- const rate = VAT_RATE_VALUES[vatCode] ?? 0;
1245
- const unit = row.querySelector(".item-unit")?.value || DEFAULT_UNIT;
1246
- const unitLabel = UNIT_OPTIONS.some((option) => option.value === unit) ? unit : DEFAULT_UNIT;
1247
-
1248
- const hasValues = name || quantity > 0 || unitGross > 0;
1249
- if (!hasValues) {
1250
- return {
1251
- valid: false,
1252
- vatCode,
1253
- vatLabel: vatLabelFromCode(vatCode),
1254
- requiresExemption: requiresExemption(vatCode),
1255
- quantity,
1256
- unitGross,
1257
- unitNet: 0,
1258
- netTotal: 0,
1259
- vatAmount: 0,
1260
- grossTotal: 0,
1261
- unit: unitLabel,
1262
- };
1263
- }
1264
-
1265
- if (!quantityValid || unitGross <= 0) {
1266
- return {
1267
- valid: false,
1268
- vatCode,
1269
- vatLabel: vatLabelFromCode(vatCode),
1270
- requiresExemption: requiresExemption(vatCode),
1271
- quantity,
1272
- unitGross,
1273
- unitNet: 0,
1274
- netTotal: 0,
1275
- vatAmount: 0,
1276
- grossTotal: quantity * unitGross,
1277
- unit: unitLabel,
1278
- };
1279
- }
1280
-
1281
- const grossTotal = quantity * unitGross;
1282
- const netTotal = rate > 0 ? grossTotal / (1 + rate) : grossTotal;
1283
- const vatAmount = grossTotal - netTotal;
1284
- const unitNet = netTotal / quantity;
1285
-
1286
- return {
1287
- valid: true,
1288
- vatCode,
1289
- vatLabel: vatLabelFromCode(vatCode),
1290
- requiresExemption: requiresExemption(vatCode),
1291
- quantity,
1292
- unitGross,
1293
- unitNet,
1294
- netTotal,
1295
- vatAmount,
1296
- grossTotal,
1297
- unit: unitLabel,
1298
- };
1299
- }
1300
-
1301
- function updateTotals() {
1302
- let totalNet = 0;
1303
- let totalVat = 0;
1304
- let totalGross = 0;
1305
- const summary = new Map();
1306
- let exemptionNeeded = false;
1307
-
1308
- const rows = Array.from(itemsBody.querySelectorAll("tr"));
1309
- rows.forEach((row) => {
1310
- const totals = calculateRowTotals(row);
1311
- if (totals.requiresExemption) {
1312
- exemptionNeeded = true;
1313
- }
1314
- const totalCell = row.querySelector(".item-total");
1315
- totalCell.textContent = formatCurrency(totals.grossTotal);
1316
-
1317
- if (!totals.valid) {
1318
- return;
1319
- }
1320
-
1321
- totalNet += totals.netTotal;
1322
- totalVat += totals.vatAmount;
1323
- totalGross += totals.grossTotal;
1324
-
1325
- const existing = summary.get(totals.vatLabel) || { net: 0, vat: 0, gross: 0 };
1326
- existing.net += totals.netTotal;
1327
- existing.vat += totals.vatAmount;
1328
- existing.gross += totals.grossTotal;
1329
- summary.set(totals.vatLabel, existing);
1330
- });
1331
-
1332
- totalNetLabel.textContent = `Suma netto: ${totalNet.toFixed(2)} PLN`;
1333
- totalVatLabel.textContent = `Kwota VAT: ${totalVat.toFixed(2)} PLN`;
1334
- totalGrossLabel.textContent = `Suma brutto: ${totalGross.toFixed(2)} PLN`;
1335
- renderRateSummary(summary);
1336
-
1337
- updateExemptionVisibility(exemptionNeeded);
1338
- }
1339
-
1340
- function renderRateSummary(summary) {
1341
- if (!summary || summary.size === 0) {
1342
- rateSummaryContainer.innerHTML = "";
1343
- return;
1344
- }
1345
-
1346
- const entries = Array.from(summary.entries()).sort(([a], [b]) => a.localeCompare(b));
1347
- const markup = entries
1348
- .map(
1349
- ([label, totals]) =>
1350
- `<div class="rate-summary-item">
1351
- <span>${label}</span>
1352
- <span>Netto: ${totals.net.toFixed(2)} PLN</span>
1353
- <span>VAT: ${totals.vat.toFixed(2)} PLN</span>
1354
- <span>Brutto: ${totals.gross.toFixed(2)} PLN</span>
1355
- </div>`
1356
- )
1357
- .join("");
1358
- rateSummaryContainer.innerHTML = `<h4>Podsumowanie stawek</h4>${markup}`;
1359
- }
1360
-
1361
- function collectInvoicePayload() {
1362
- const items = [];
1363
- const rows = Array.from(itemsBody.querySelectorAll("tr"));
1364
-
1365
- rows.forEach((row) => {
1366
- const name = row.querySelector(".item-name")?.value.trim() ?? "";
1367
- const quantityRaw = row.querySelector(".item-quantity")?.value;
1368
- const quantityParsed = parseIntegerString(quantityRaw);
1369
- const quantityValid = Number.isInteger(quantityParsed) && quantityParsed > 0;
1370
- const quantity = quantityValid ? quantityParsed : 0;
1371
- const unitGross = parseNumber(row.querySelector(".item-gross")?.value);
1372
- const vatCode = row.querySelector(".item-vat")?.value ?? "23";
1373
- const unitValue = row.querySelector(".item-unit")?.value || DEFAULT_UNIT;
1374
- const unit = UNIT_OPTIONS.some((option) => option.value === unitValue) ? unitValue : DEFAULT_UNIT;
1375
-
1376
- const hasValues = name || quantity > 0 || unitGross > 0;
1377
- if (!hasValues) {
1378
- return;
1379
- }
1380
-
1381
- if (!name) {
1382
- throw new Error("Każda pozycja musi mieć nazwę.");
1383
- }
1384
- if (!quantityValid) {
1385
- throw new Error("Ilość musi byc dodatnia liczba calkowita.");
1386
- }
1387
- if (unitGross <= 0) {
1388
- throw new Error("Cena brutto musi być większa od zera.");
1389
- }
1390
-
1391
- items.push({
1392
- name,
1393
- quantity,
1394
- unit,
1395
- unit_price_gross: unitGross.toFixed(2),
1396
- vat_code: vatCode,
1397
- });
1398
- });
1399
-
1400
- if (items.length === 0) {
1401
- throw new Error("Dodaj przynajmniej jedną pozycję.");
1402
- }
1403
-
1404
- const documentType = documentTypeSelect?.value === "personal_paid" ? "personal_paid" : "standard";
1405
- const saleDate = invoiceForm.elements.saleDate.value || null;
1406
- const paymentTerm = documentType === "personal_paid" ? 0 : (parseInt(invoiceForm.elements.paymentTerm.value) || 14);
1407
- const requiresExemptionNote = items.some((item) => item.vat_code === "ZW" || item.vat_code === "0");
1408
- let exemptionNote = "";
1409
- if (requiresExemptionNote) {
1410
- const noteFromTextarea = exemptionNoteInput.value.trim();
1411
- if (exemptionReasonSelect) {
1412
- const selectedReason = EXEMPTION_REASON_LOOKUP.get(exemptionReasonSelect.value);
1413
- if (selectedReason && selectedReason.value !== "custom") {
1414
- exemptionNote = selectedReason.note;
1415
- } else {
1416
- exemptionNote = noteFromTextarea;
1417
- }
1418
- } else {
1419
- exemptionNote = noteFromTextarea;
1420
- }
1421
- if (!exemptionNote) {
1422
- throw new Error("Wybierz lub wpisz podstawę zwolnienia dla pozycji ze stawka ZW/0%.");
1423
- }
1424
- }
1425
- const client = {
1426
- name: (invoiceForm.elements.clientName.value || "").trim(),
1427
- tax_id: (invoiceForm.elements.clientTaxId.value || "").trim(),
1428
- address_line: (invoiceForm.elements.clientAddress.value || "").trim(),
1429
- postal_code: (invoiceForm.elements.clientPostalCode.value || "").trim(),
1430
- city: (invoiceForm.elements.clientCity.value || "").trim(),
1431
- phone: (invoiceForm.elements.clientPhone.value || "").trim(),
1432
- };
1433
-
1434
- if (documentType === "personal_paid" && !client.name) {
1435
- throw new Error("Podaj imię i nazwisko nabywcy dla faktury imiennej.");
1436
- }
1437
-
1438
- return {
1439
- document_type: documentType,
1440
- payment_status: documentType === "personal_paid" ? "paid" : "unpaid",
1441
- sale_date: saleDate,
1442
- payment_term: paymentTerm,
1443
- client,
1444
- items,
1445
- exemption_note: exemptionNote,
1446
- };
1447
- }
1448
-
1449
- function renderInvoicePreview(invoice) {
1450
- if (!invoice || !currentBusiness) {
1451
- invoiceOutput.innerHTML = "<p>Brak danych faktury.</p>";
1452
- return;
1453
- }
1454
-
1455
- const client = invoice.client || {};
1456
- const hasClientData = client.name || client.address_line || client.postal_code || client.city || client.tax_id;
1457
- const paid = isPaidInvoice(invoice);
1458
-
1459
- const itemsRows = (invoice.items || [])
1460
- .map((item) => {
1461
- const quantityDisplay = formatQuantity(item.quantity);
1462
- const unitDisplay = UNIT_OPTIONS.some((option) => option.value === item.unit) ? item.unit : DEFAULT_UNIT;
1463
- return `
1464
- <tr>
1465
- <td>${item.name}</td>
1466
- <td>${quantityDisplay}</td>
1467
- <td>${unitDisplay}</td>
1468
- <td>${formatCurrency(item.unit_price_net)}</td>
1469
- <td>${formatCurrency(item.net_total)}</td>
1470
- <td>${item.vat_label}</td>
1471
- <td>${formatCurrency(item.vat_amount)}</td>
1472
- <td>${formatCurrency(item.gross_total)}</td>
1473
- </tr>`;
1474
- })
1475
- .join("");
1476
-
1477
- const summaryRows = (invoice.summary || [])
1478
- .map(
1479
- (entry) =>
1480
- `<div class="rate-summary-item">
1481
- <span>${entry.vat_label}</span>
1482
- <span>Netto: ${formatCurrency(entry.net_total)}</span>
1483
- <span>VAT: ${formatCurrency(entry.vat_total)}</span>
1484
- <span>Brutto: ${formatCurrency(entry.gross_total)}</span>
1485
- </div>`
1486
- )
1487
- .join("");
1488
-
1489
- invoiceOutput.innerHTML = `
1490
- <div class="invoice-preview-meta">
1491
- <span><strong>Dokument:</strong> ${getDocumentLabel(invoice)}</span>
1492
- <span><strong>Numer:</strong> ${invoice.invoice_id}</span>
1493
- <span><strong>Data wystawienia:</strong> ${invoice.issued_at}</span>
1494
- <span><strong>Data sprzedaży:</strong> ${invoice.sale_date}</span>
1495
- ${paid ? `<span><strong>Status płatności:</strong> Zapłacono</span>` : ''}
1496
- ${!paid && invoice.payment_term ? `<span><strong>Termin płatności:</strong> ${invoice.payment_term} dni</span>` : ''}
1497
- </div>
1498
- <div class="invoice-preview-header">
1499
- <div class="invoice-preview-card">
1500
- <h4>Nabywca</h4>
1501
- ${
1502
- hasClientData
1503
- ? `
1504
- <p>${client.name || "---"}</p>
1505
- <p>${client.address_line || "---"}</p>
1506
- <p>${client.postal_code || ""} ${client.city || ""}</p>
1507
- <p>${client.tax_id ? `NIP: ${client.tax_id}` : ""}</p>
1508
- ${client.phone ? `<p>Tel: ${client.phone}</p>` : ''}
1509
- `
1510
- : "<p>Brak danych nabywcy.</p>"
1511
- }
1512
- </div>
1513
- <div class="invoice-preview-card">
1514
- <h4>Sprzedawca</h4>
1515
- <p>${currentBusiness.company_name}</p>
1516
- <p>${currentBusiness.owner_name}</p>
1517
- <p>${currentBusiness.address_line}</p>
1518
- <p>${currentBusiness.postal_code} ${currentBusiness.city}</p>
1519
- <p>NIP: ${currentBusiness.tax_id}</p>
1520
- <p>Konto: ${currentBusiness.bank_account}</p>
1521
- </div>
1522
- </div>
1523
- <table>
1524
- <thead>
1525
- <tr>
1526
- <th>Nazwa</th>
1527
- <th>Ilość</th>
1528
- <th>Jednostka</th>
1529
- <th>Cena jedn. netto</th>
1530
- <th>Wartość netto (pozycja)</th>
1531
- <th>Stawka VAT</th>
1532
- <th>Kwota VAT (pozycja)</th>
1533
- <th>Wartość brutto</th>
1534
- </tr>
1535
- </thead>
1536
- <tbody>${itemsRows}</tbody>
1537
- </table>
1538
- <div class="rate-summary">
1539
- <h4>Podsumowanie stawek</h4>
1540
- ${summaryRows}
1541
- </div>
1542
- <div class="invoice-preview-summary">
1543
- <span>Netto: ${formatCurrency(invoice.totals.net)}</span>
1544
- <span>VAT: ${formatCurrency(invoice.totals.vat)}</span>
1545
- <span>Brutto: ${formatCurrency(invoice.totals.gross)}</span>
1546
- </div>
1547
- ${
1548
- invoice.exemption_note
1549
- ? `<div class="invoice-preview-note"><strong>Podstawa prawna zwolnienia:</strong> ${invoice.exemption_note}</div>`
1550
- : ""
1551
- }
1552
- `;
1553
- }
1554
-
1555
- function drawPartyBox(doc, title, lines, x, y, width, options = {}) {
1556
- const lineHeight = 5;
1557
- const wrappedLines = lines.flatMap((line) => doc.splitTextToSize(line, width));
1558
- const boxHeight = wrappedLines.length * lineHeight + 18;
1559
- const bgColor = options.bgColor || PDF_COLORS.surface;
1560
- const plain = options.plain || false;
1561
-
1562
- if (!plain) {
1563
- doc.setDrawColor(...PDF_COLORS.border);
1564
- doc.setFillColor(...bgColor);
1565
- doc.roundedRect(x - 4, y - 10, width + 8, boxHeight, 2.5, 2.5, "FD");
1566
- }
1567
-
1568
- doc.setFontSize(11);
1569
- doc.setTextColor(...PDF_COLORS.muted);
1570
- doc.text(title.toUpperCase(), x, y - 2);
1571
- doc.setFontSize(10);
1572
- doc.setTextColor(...PDF_COLORS.text);
1573
-
1574
- let cursor = y + 4;
1575
- wrappedLines.forEach((line) => {
1576
- doc.text(line, x, cursor);
1577
- cursor += lineHeight;
1578
- });
1579
-
1580
- return y - 10 + boxHeight;
1581
- }
1582
-
1583
- function arrayBufferToBase64(buffer) {
1584
- const bytes = new Uint8Array(buffer);
1585
- const chunkSize = 0x8000;
1586
- let binary = "";
1587
- for (let offset = 0; offset < bytes.length; offset += chunkSize) {
1588
- const chunk = bytes.subarray(offset, Math.min(offset + chunkSize, bytes.length));
1589
- binary += String.fromCharCode.apply(null, chunk);
1590
- }
1591
- return btoa(binary);
1592
- }
1593
-
1594
- const PDF_FONT_FILE = "Roboto-VariableFont_wdth,wght.ttf";
1595
- const PDF_FONT_NAME = "RobotoPolish";
1596
- const PDF_COLORS = {
1597
- accent: [37, 99, 235],
1598
- accentMuted: [226, 236, 255],
1599
- text: [16, 24, 40],
1600
- muted: [102, 112, 133],
1601
- border: [215, 222, 236],
1602
- surface: [249, 251, 255],
1603
- };
1604
-
1605
- async function ensurePdfFont() {
1606
- if (pdfFontPromise) {
1607
- return pdfFontPromise;
1608
- }
1609
-
1610
- if (!window.jspdf || !window.jspdf.jsPDF) {
1611
- throw new Error("Biblioteka jsPDF nie została załadowana.");
1612
- }
1613
-
1614
- const { jsPDF } = window.jspdf;
1615
- const loadBase64 = async () => {
1616
- if (typeof window !== "undefined" && window.PDF_FONT_BASE64) {
1617
- return window.PDF_FONT_BASE64;
1618
- }
1619
- const response = await fetch(`/${encodeURIComponent(PDF_FONT_FILE)}`);
1620
- if (!response.ok) {
1621
- throw new Error(`Nie udało się pobrać czcionki Roboto (status ${response.status}).`);
1622
- }
1623
- const buffer = await response.arrayBuffer();
1624
- return arrayBufferToBase64(buffer);
1625
- };
1626
-
1627
- pdfFontPromise = loadBase64().then((data) => {
1628
- pdfFontBase64 = data;
1629
- return data;
1630
- });
1631
-
1632
- return pdfFontPromise;
1633
- }
1634
-
1635
- async function generatePdf(business, invoice, logo) {
1636
- if (!window.jspdf || !window.jspdf.jsPDF) {
1637
- alert("Biblioteka jsPDF nie została załadowana. Sprawdź połączenie z internetem.");
1638
- return;
1639
- }
1640
-
1641
- let fontBase64;
1642
- try {
1643
- fontBase64 = await ensurePdfFont();
1644
- } catch (error) {
1645
- alert(error.message || "Nie udało się przygotować czcionki do PDF.");
1646
- return;
1647
- }
1648
-
1649
- const { jsPDF } = window.jspdf;
1650
- const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
1651
- const marginX = 18;
1652
- let cursorY = 20;
1653
- const pageWidth = doc.internal.pageSize.getWidth();
1654
-
1655
- if (!doc.getFontList()[PDF_FONT_NAME]) {
1656
- const embeddedFont = pdfFontBase64 || fontBase64;
1657
- doc.addFileToVFS(PDF_FONT_FILE, embeddedFont);
1658
- doc.addFont(PDF_FONT_FILE, PDF_FONT_NAME, "normal");
1659
- }
1660
-
1661
- doc.setFont(PDF_FONT_NAME, "normal");
1662
- doc.setTextColor(...PDF_COLORS.text);
1663
- doc.setFontSize(18);
1664
- doc.text(DOCUMENT_TYPES[getDocumentType(invoice)]?.pdfTitle || "Faktura", marginX, cursorY + 2);
1665
- doc.setFontSize(13);
1666
- doc.text(invoice.invoice_id, marginX, cursorY + 10);
1667
- doc.setFontSize(10);
1668
- doc.setTextColor(...PDF_COLORS.muted);
1669
- const metaLines = [
1670
- `Data wystawienia: ${invoice.issued_at}`,
1671
- `Data sprzedaży: ${invoice.sale_date}`,
1672
- ];
1673
- if (isPaidInvoice(invoice)) {
1674
- metaLines.push("Status płatności: zapłacono");
1675
- } else if (invoice.payment_term) {
1676
- metaLines.push(`Termin płatności: ${invoice.payment_term} dni`);
1677
- }
1678
- metaLines.forEach((line, index) => {
1679
- doc.text(line, marginX, cursorY + 18 + index * 5);
1680
- });
1681
-
1682
- cursorY += 18 + metaLines.length * 5 + 6;
1683
- const columnWidth = 85;
1684
- const sellerX = marginX + columnWidth + 12;
1685
-
1686
- const clientLines = invoice.client && (invoice.client.name || invoice.client.address_line || invoice.client.city || invoice.client.tax_id || invoice.client.phone)
1687
- ? [
1688
- invoice.client.name || "---",
1689
- invoice.client.address_line || "",
1690
- `${(invoice.client.postal_code || "").trim()} ${(invoice.client.city || "").trim()}`.trim(),
1691
- invoice.client.tax_id ? `NIP: ${invoice.client.tax_id}` : "",
1692
- invoice.client.phone ? `Tel: ${invoice.client.phone}` : "",
1693
- ].filter((line) => line && line.trim())
1694
- : ["Brak danych nabywcy"];
1695
-
1696
- const sellerLines = [
1697
- business.company_name,
1698
- business.owner_name,
1699
- business.address_line,
1700
- `${business.postal_code} ${business.city}`.trim(),
1701
- `NIP: ${business.tax_id}`,
1702
- `Konto: ${business.bank_account}`,
1703
- ];
1704
-
1705
- let logoBottom = cursorY;
1706
- if (logo && logo.data && logo.mime_type) {
1707
- const format = logo.mime_type === "image/png" ? "PNG" : "JPEG";
1708
- const dataUrl = logo.data_url || `data:${logo.mime_type};base64,${logo.data}`;
1709
- try {
1710
- let logoWidth = 40;
1711
- let logoHeight = 16;
1712
- if (doc.getImageProperties) {
1713
- const props = doc.getImageProperties(dataUrl);
1714
- if (props?.width && props?.height) {
1715
- const ratio = props.height / props.width;
1716
- logoHeight = logoWidth * ratio;
1717
- if (logoHeight > 20) {
1718
- logoHeight = 20;
1719
- logoWidth = logoHeight / ratio;
1720
- }
1721
- }
1722
- }
1723
- const logoX = sellerX;
1724
- const logoY = Math.max(cursorY - logoHeight - 12, 18);
1725
- doc.addImage(dataUrl, format, logoX, logoY, logoWidth, logoHeight);
1726
- logoBottom = logoY + logoHeight;
1727
- } catch (error) {
1728
- console.warn("Nie udało się dodać logo nad sprzedawcą:", error);
1729
- }
1730
- }
1731
-
1732
- const buyerBottom = drawPartyBox(doc, "NABYWCA", clientLines, marginX, cursorY, columnWidth, { plain: true });
1733
- const sellerBottom = drawPartyBox(doc, "SPRZEDAWCA", sellerLines, sellerX, cursorY, columnWidth, {
1734
- plain: true,
1735
- });
1736
- cursorY = Math.max(buyerBottom, sellerBottom, logoBottom) + 12;
1737
-
1738
- const tableColumns = [
1739
- { key: "name", label: "Nazwa", width: 44 },
1740
- { key: "quantity", label: "Ilość", width: 14 },
1741
- { key: "unit", label: "Jednostka", width: 14 },
1742
- { key: "unitNet", label: "Cena jedn. netto", width: 23 },
1743
- { key: "netTotal", label: "Wartość netto", width: 23 },
1744
- { key: "vatLabel", label: "Stawka VAT", width: 14 },
1745
- { key: "vatAmount", label: "Kwota VAT", width: 21 },
1746
- { key: "grossTotal", label: "Wartość brutto", width: 21 },
1747
- ];
1748
- const lineHeight = 5;
1749
- const headerLineHeight = 4.2;
1750
- tableColumns.forEach((column) => {
1751
- column.headerLines = doc.splitTextToSize(column.label, column.width - 4);
1752
- });
1753
- const headerHeight = Math.max(...tableColumns.map((column) => column.headerLines.length * headerLineHeight + 3));
1754
-
1755
- const tableWidth = tableColumns.reduce((sum, column) => sum + column.width, 0);
1756
-
1757
- doc.setFillColor(...PDF_COLORS.accentMuted);
1758
- doc.setDrawColor(...PDF_COLORS.border);
1759
- doc.rect(marginX, cursorY, tableWidth, headerHeight, "F");
1760
- doc.rect(marginX, cursorY, tableWidth, headerHeight);
1761
- let offsetX = marginX;
1762
- doc.setFontSize(10);
1763
- doc.setTextColor(...PDF_COLORS.text);
1764
- const rightAlignedColumns = new Set(["quantity", "unit", "unitNet", "netTotal", "vatAmount", "grossTotal"]);
1765
- tableColumns.forEach((column) => {
1766
- doc.rect(offsetX, cursorY, column.width, headerHeight);
1767
- column.headerLines.forEach((line, index) => {
1768
- const textY = cursorY + 4 + index * headerLineHeight;
1769
- const textX = rightAlignedColumns.has(column.key) ? offsetX + column.width - 2 : offsetX + 2;
1770
- doc.text((line || "").trim(), textX, textY, {
1771
- align: rightAlignedColumns.has(column.key) ? "right" : "left",
1772
- });
1773
- });
1774
- offsetX += column.width;
1775
- });
1776
- cursorY += headerHeight;
1777
-
1778
- const withPercent = (value) => {
1779
- if (!value) {
1780
- return "-";
1781
- }
1782
- if (/%$/.test(value)) {
1783
- return value;
1784
- }
1785
- if (/^\d+(\.\d+)?$/.test(value)) {
1786
- return `${value}%`;
1787
- }
1788
- return value;
1789
- };
1790
-
1791
- const invoiceItems = Array.isArray(invoice.items) ? invoice.items : [];
1792
- invoiceItems.forEach((item, rowIndex) => {
1793
- const quantity = formatQuantity(item.quantity);
1794
- const unitLabel = UNIT_OPTIONS.some((option) => option.value === item.unit) ? item.unit : DEFAULT_UNIT;
1795
- const unitNet = formatCurrency(item.unit_price_net);
1796
- const netTotal = formatCurrency(item.net_total);
1797
- const vatAmount = formatCurrency(item.vat_amount);
1798
- const grossTotal = formatCurrency(item.gross_total);
1799
-
1800
- const wrapText = (text, width) => {
1801
- const available = Math.max(width - 4, 6);
1802
- return doc
1803
- .splitTextToSize(text ?? "", available)
1804
- .map((line) => line.trim());
1805
- };
1806
-
1807
- const columnData = tableColumns.map((column) => {
1808
- switch (column.key) {
1809
- case "name":
1810
- return wrapText(item.name, column.width - 4);
1811
- case "quantity":
1812
- return wrapText(quantity, column.width);
1813
- case "unit":
1814
- return wrapText(unitLabel, column.width);
1815
- case "unitNet":
1816
- return wrapText(unitNet, column.width);
1817
- case "netTotal":
1818
- return wrapText(netTotal, column.width);
1819
- case "vatLabel":
1820
- return wrapText(withPercent(item.vat_label), column.width);
1821
- case "vatAmount":
1822
- return wrapText(vatAmount, column.width);
1823
- case "grossTotal":
1824
- return wrapText(grossTotal, column.width);
1825
- default:
1826
- return wrapText("", column.width);
1827
- }
1828
- });
1829
-
1830
- const rowHeight = Math.max(...columnData.map((data) => data.length)) * lineHeight + 2;
1831
- offsetX = marginX;
1832
- if (rowIndex % 2 === 1) {
1833
- doc.setFillColor(248, 250, 253);
1834
- doc.rect(marginX, cursorY, tableWidth, rowHeight, "F");
1835
- }
1836
- tableColumns.forEach((column, index) => {
1837
- doc.rect(offsetX, cursorY, column.width, rowHeight);
1838
- const lines = columnData[index];
1839
- lines.forEach((line, lineIndex) => {
1840
- const textY = cursorY + (lineIndex + 1) * lineHeight;
1841
- const content = (line || "").trim();
1842
- const alignRight = rightAlignedColumns.has(column.key);
1843
- const textX = alignRight ? offsetX + column.width - 2 : offsetX + 2;
1844
- doc.text(content, textX, textY, { align: alignRight ? "right" : "left" });
1845
- });
1846
- offsetX += column.width;
1847
- });
1848
-
1849
- cursorY += rowHeight;
1850
- });
1851
-
1852
- cursorY += 10;
1853
- doc.setFontSize(11);
1854
- doc.text("Podsumowanie stawek", marginX, cursorY);
1855
- cursorY += 6;
1856
-
1857
- const summaryEntries = Array.isArray(invoice.summary) ? invoice.summary : [];
1858
- doc.setFontSize(10);
1859
- doc.setTextColor(...PDF_COLORS.text);
1860
- summaryEntries.forEach((entry) => {
1861
- const summaryLine = `${withPercent(entry.vat_label)} | Netto: ${formatCurrency(entry.net_total)} VAT: ${formatCurrency(entry.vat_total)} Brutto: ${formatCurrency(entry.gross_total)}`;
1862
- const wrapped = doc.splitTextToSize(summaryLine, 170);
1863
- wrapped.forEach((line) => {
1864
- doc.text((line || "").trim(), marginX, cursorY);
1865
- cursorY += lineHeight;
1866
- });
1867
- cursorY += 2;
1868
- });
1869
- cursorY += 6;
1870
-
1871
- const totals = [
1872
- { label: "Netto", value: formatCurrency(invoice.totals.net), variant: "muted" },
1873
- { label: "VAT", value: formatCurrency(invoice.totals.vat), variant: "muted" },
1874
- { label: "Brutto", value: formatCurrency(invoice.totals.gross), variant: "accent" },
1875
- ];
1876
- const chipWidth = 54;
1877
- const chipHeight = 20;
1878
- doc.setFontSize(10);
1879
- totals.forEach((chip, index) => {
1880
- const x = marginX + index * (chipWidth + 12);
1881
- if (chip.variant === "accent") {
1882
- doc.setFillColor(...PDF_COLORS.accent);
1883
- doc.setTextColor(255, 255, 255);
1884
- } else {
1885
- doc.setFillColor(...PDF_COLORS.surface);
1886
- doc.setTextColor(...PDF_COLORS.muted);
1887
- }
1888
- doc.roundedRect(x, cursorY, chipWidth, chipHeight, 4, 4, "F");
1889
- doc.text(chip.label.toUpperCase(), x + 3, cursorY + 6);
1890
- doc.setFontSize(chip.variant === "accent" ? 12 : 11);
1891
- if (chip.variant === "accent") {
1892
- doc.setTextColor(255, 255, 255);
1893
- } else {
1894
- doc.setTextColor(...PDF_COLORS.text);
1895
- }
1896
- doc.text(chip.value, x + 3, cursorY + 14);
1897
- doc.setFontSize(10);
1898
- });
1899
- cursorY += chipHeight + 12;
1900
-
1901
- if (invoice.exemption_note) {
1902
- doc.setFontSize(10);
1903
- doc.setTextColor(...PDF_COLORS.text);
1904
- const noteLines = doc.splitTextToSize(`Podstawa prawna zwolnienia: ${invoice.exemption_note}`, 170);
1905
- doc.text(noteLines, marginX, cursorY);
1906
- }
1907
-
1908
- doc.save(`${invoice.invoice_id}.pdf`);
1909
- }
1910
-
1911
- async function loadBusinessData() {
1912
- const data = await apiRequest("/api/business", {}, true);
1913
- currentBusiness = data.business;
1914
- renderBusinessDisplay(currentBusiness);
1915
- fillBusinessForm(currentBusiness);
1916
- setBusinessFormVisibility(false);
1917
- }
1918
-
1919
- async function loadLogo() {
1920
- try {
1921
- const data = await apiRequest("/api/logo", {}, true);
1922
- currentLogo = data.logo || null;
1923
- } catch (error) {
1924
- console.error("Nie udało się pobrać logo:", error);
1925
- currentLogo = null;
1926
- }
1927
- updateLogoPreview();
1928
- }
1929
-
1930
- function resetInvoiceForm() {
1931
- invoiceForm.reset();
1932
- if (invoiceForm.elements.documentType) {
1933
- invoiceForm.elements.documentType.value = "standard";
1934
- }
1935
- syncDocumentTypeControls();
1936
- customExemptionNote = "";
1937
- updateExemptionVisibility(false);
1938
- itemsBody.innerHTML = "";
1939
- createItemRow();
1940
- const now = new Date();
1941
- const year = now.getFullYear();
1942
- const month = String(now.getMonth() + 1).padStart(2, "0");
1943
- const day = String(now.getDate()).padStart(2, "0");
1944
- if (invoiceForm.elements.saleDate) {
1945
- invoiceForm.elements.saleDate.value = `${year}-${month}-${day}`;
1946
- }
1947
- updateTotals();
1948
- }
1949
-
1950
- async function bootstrapApp() {
1951
- try {
1952
- await loadBusinessData();
1953
- await loadLogo();
1954
- exitInvoiceEdit();
1955
- resetInvoiceForm();
1956
- invoiceResult.classList.add("hidden");
1957
- lastInvoice = null;
1958
- await refreshInvoices();
1959
- await refreshSummary();
1960
- updateLoginLabel();
1961
- setAppState("app");
1962
- activeView = "invoice-builder";
1963
- setActiveView(activeView);
1964
- } catch (error) {
1965
- console.error(error);
1966
- authToken = null;
1967
- currentLogin = "";
1968
- sessionStorage.removeItem("invoiceAuthToken");
1969
- sessionStorage.removeItem("invoiceLogin");
1970
- showFeedback(loginFeedback, error.message || "Nie udało się pobrać danych konta.");
1971
- setAppState("auth");
1972
- }
1973
- }
1974
-
1975
- async function initialize() {
1976
- exitInvoiceEdit();
1977
- resetInvoiceForm();
1978
- updateLogoPreview();
1979
- updateSummaryCards({});
1980
- updateSummaryChart({});
1981
- setActiveView("invoice-builder");
1982
- setAppState("auth");
1983
- closeRegisterPanel({ resetForm: true, focusTrigger: false });
1984
- clearFeedback(registerFeedback);
1985
- clearFeedback(loginFeedback);
1986
- if (legacyLoginHint) {
1987
- legacyLoginHint.classList.add("hidden");
1988
- legacyLoginHint.textContent = "";
1989
- }
1990
- if (authToken) {
1991
- await bootstrapApp().catch((error) => {
1992
- console.error(error);
1993
- showFeedback(registerFeedback, "Nie uda?o si? nawi?za? po??czenia z serwerem.");
1994
- });
1995
- }
1996
- }
1997
-
1998
-
1999
- if (registerForm && registerFeedback && loginFeedback) {
2000
- registerForm.addEventListener("submit", async (event) => {
2001
- event.preventDefault();
2002
- clearFeedback(registerFeedback);
2003
- clearFeedback(loginFeedback);
2004
-
2005
- const formData = new FormData(registerForm);
2006
- const emailValue = formData.get("email")?.toString().trim() ?? "";
2007
- const password = formData.get("password")?.toString() ?? "";
2008
- const confirmPassword = formData.get("confirm_password")?.toString() ?? "";
2009
-
2010
- if (!emailValue) {
2011
- showFeedback(registerFeedback, "Podaj adres email.");
2012
- return;
2013
- }
2014
- if (password !== confirmPassword) {
2015
- showFeedback(registerFeedback, "Hasła musza byc identyczne.");
2016
- return;
2017
- }
2018
-
2019
- if (password.trim().length < 4) {
2020
- showFeedback(registerFeedback, "Hasło musi miec co najmniej 4 znaki.");
2021
- return;
2022
- }
2023
-
2024
- const payload = {
2025
- email: emailValue,
2026
- password,
2027
- confirm_password: confirmPassword,
2028
- company_name: formData.get("company_name")?.toString().trim(),
2029
- owner_name: formData.get("owner_name")?.toString().trim(),
2030
- address_line: formData.get("address_line")?.toString().trim(),
2031
- postal_code: formData.get("postal_code")?.toString().trim(),
2032
- city: formData.get("city")?.toString().trim(),
2033
- tax_id: formData.get("tax_id")?.toString().trim(),
2034
- bank_account: formData.get("bank_account")?.toString().trim(),
2035
- };
2036
-
2037
- try {
2038
- await apiRequest("/api/register", { method: "POST", body: payload });
2039
- showFeedback(registerFeedback, "Konto utworzone. Możesz sie zalogowac.", "success");
2040
- if (loginForm && loginForm.elements.email) {
2041
- loginForm.elements.email.value = emailValue;
2042
- }
2043
- registerForm.reset();
2044
- setTimeout(() => {
2045
- closeRegisterPanel({ resetForm: true, focusTrigger: false });
2046
- clearFeedback(registerFeedback);
2047
- clearFeedback(loginFeedback);
2048
- showFeedback(loginFeedback, "Konto utworzone. Zaloguj się haslem.", "success");
2049
- if (loginForm) {
2050
- const passwordInput = loginForm.elements.password;
2051
- if (passwordInput) {
2052
- passwordInput.focus();
2053
- }
2054
- }
2055
- }, 1600);
2056
- } catch (error) {
2057
- showFeedback(registerFeedback, error.message || "Nie udało się utworzyć konta.");
2058
- }
2059
- });
2060
- }
2061
-
2062
- if (loginForm && loginFeedback) {
2063
- const setLoginSubmittingState = (isSubmitting) => {
2064
- if (!loginSubmitButton) {
2065
- return;
2066
- }
2067
- if (isSubmitting) {
2068
- loginSubmitButton.disabled = true;
2069
- loginSubmitButton.setAttribute("data-loading", "true");
2070
- loginSubmitButton.textContent = "Logowanie...";
2071
- } else {
2072
- loginSubmitButton.disabled = false;
2073
- loginSubmitButton.textContent = loginSubmitButtonDefaultText;
2074
- loginSubmitButton.removeAttribute("data-loading");
2075
- }
2076
- };
2077
-
2078
- loginForm.addEventListener("submit", async (event) => {
2079
- event.preventDefault();
2080
- clearFeedback(loginFeedback);
2081
-
2082
- const emailElement = loginForm.elements.email;
2083
- const emailValue = emailElement ? emailElement.value.trim() : "";
2084
- const password = loginForm.elements.password.value;
2085
-
2086
- if (!emailValue) {
2087
- showFeedback(loginFeedback, "Podaj adres email.");
2088
- return;
2089
- }
2090
-
2091
- if (!password) {
2092
- showFeedback(loginFeedback, "Podaj hasło.");
2093
- return;
2094
- }
2095
-
2096
- setLoginSubmittingState(true);
2097
-
2098
- try {
2099
- const response = await apiRequest("/api/login", { method: "POST", body: { email: emailValue, password } });
2100
- authToken = response.token;
2101
- currentLogin = response.email || response.login || emailValue;
2102
- sessionStorage.setItem("invoiceAuthToken", authToken);
2103
- sessionStorage.setItem("invoiceLogin", currentLogin);
2104
- loginForm.reset();
2105
- await bootstrapApp();
2106
- } catch (error) {
2107
- const errorMessage = error instanceof Error ? error.message : String(error || "");
2108
- let feedbackMessage = errorMessage || "Logowanie nie powiodło się.";
2109
- if (/nieprawidlowy (login|email) lub hasło/i.test(errorMessage)) {
2110
- feedbackMessage = "Podany email lub hasło są nieprawidłowe. Utwórz konto, jeśli jeszcze go nie masz.";
2111
- } else if (/brak autoryzacji/i.test(errorMessage) || /brak tokenu autoryzacyjnego/i.test(errorMessage)) {
2112
- feedbackMessage = "Sesja wygasła. Zaloguj się ponownie.";
2113
- } else if (/failed to fetch|networkerror/i.test(errorMessage) || error instanceof TypeError) {
2114
- feedbackMessage = "Nie udało się nawiązać połączenia z serwerem. Sprawdź, czy aplikacja serwerowa jest uruchomiona.";
2115
- }
2116
- showFeedback(loginFeedback, feedbackMessage);
2117
- } finally {
2118
- setLoginSubmittingState(false);
2119
- }
2120
- });
2121
- }
2122
-
2123
- if (toggleBusinessFormButton && businessForm && businessFeedback) {
2124
- toggleBusinessFormButton.addEventListener("click", () => {
2125
- const isVisible = !businessForm.classList.contains("hidden");
2126
- if (!isVisible) {
2127
- setBusinessFormVisibility(true, { preserveFeedback: true });
2128
- } else {
2129
- setBusinessFormVisibility(false);
2130
- }
2131
- });
2132
- }
2133
-
2134
- if (cancelBusinessUpdateButton && businessForm && businessFeedback && toggleBusinessFormButton) {
2135
- cancelBusinessUpdateButton.addEventListener("click", () => {
2136
- setBusinessFormVisibility(false);
2137
- });
2138
- }
2139
-
2140
- if (businessForm && businessFeedback) {
2141
- businessForm.addEventListener("submit", async (event) => {
2142
- event.preventDefault();
2143
- clearFeedback(businessFeedback);
2144
-
2145
- const formData = new FormData(businessForm);
2146
- const payload = {
2147
- company_name: formData.get("company_name")?.toString().trim(),
2148
- owner_name: formData.get("owner_name")?.toString().trim(),
2149
- address_line: formData.get("address_line")?.toString().trim(),
2150
- postal_code: formData.get("postal_code")?.toString().trim(),
2151
- city: formData.get("city")?.toString().trim(),
2152
- tax_id: formData.get("tax_id")?.toString().trim(),
2153
- bank_account: formData.get("bank_account")?.toString().trim(),
2154
- };
2155
-
2156
- try {
2157
- await apiRequest("/api/business", { method: "POST", body: payload }, true);
2158
- await loadBusinessData();
2159
- setBusinessFormVisibility(false, { preserveFeedback: true });
2160
- showFeedback(businessFeedback, "Dane sprzedawcy zaktualizowane.", "success");
2161
- setTimeout(() => clearFeedback(businessFeedback), 2000);
2162
- } catch (error) {
2163
- showFeedback(businessFeedback, error.message || "Nie udało się zaktualizować danych.");
2164
- }
2165
- });
2166
- }
2167
-
2168
- if (exemptionReasonSelect) {
2169
- populateExemptionReasons();
2170
- let previousReasonValue = exemptionReasonSelect.value;
2171
- applyExemptionReasonSelection({ preserveCustom: true });
2172
- exemptionReasonSelect.addEventListener("change", () => {
2173
- if (previousReasonValue === "custom" && exemptionNoteInput) {
2174
- customExemptionNote = exemptionNoteInput.value.trim();
2175
- }
2176
- previousReasonValue = exemptionReasonSelect.value;
2177
- applyExemptionReasonSelection();
2178
- if (exemptionReasonSelect.value === "custom" && exemptionNoteInput) {
2179
- exemptionNoteInput.focus();
2180
- }
2181
- });
2182
- }
2183
-
2184
- if (exemptionNoteInput) {
2185
- exemptionNoteInput.addEventListener("input", () => {
2186
- if (exemptionReasonSelect && exemptionReasonSelect.value === "custom") {
2187
- customExemptionNote = exemptionNoteInput.value;
2188
- }
2189
- });
2190
- }
2191
-
2192
- if (documentTypeSelect) {
2193
- documentTypeSelect.addEventListener("change", syncDocumentTypeControls);
2194
- syncDocumentTypeControls();
2195
- }
2196
-
2197
- if (invoiceForm) {
2198
- invoiceForm.addEventListener("submit", async (event) => {
2199
- event.preventDefault();
2200
- try {
2201
- const payload = collectInvoicePayload();
2202
- let response;
2203
- if (editingInvoiceId) {
2204
- response = await apiRequest(`/api/invoices/${encodeURIComponent(editingInvoiceId)}`, { method: "PUT", body: payload }, true);
2205
- exitInvoiceEdit();
2206
- } else {
2207
- response = await apiRequest("/api/invoices", { method: "POST", body: payload }, true);
2208
- }
2209
- lastInvoice = response.invoice;
2210
- renderInvoicePreview(lastInvoice);
2211
- if (invoiceResult) {
2212
- invoiceResult.classList.remove("hidden");
2213
- }
2214
- await refreshInvoices();
2215
- await refreshSummary();
2216
- resetInvoiceForm();
2217
- } catch (error) {
2218
- alert(error.message || "Nie udało się zapisać faktury.");
2219
- }
2220
- });
2221
- }
2222
-
2223
- if (addItemButton) {
2224
- addItemButton.addEventListener("click", () => {
2225
- createItemRow();
2226
- });
2227
- }
2228
-
2229
- if (downloadButton) {
2230
- downloadButton.addEventListener("click", async () => {
2231
- if (!lastInvoice || !currentBusiness) {
2232
- alert("Brak faktury do pobrania. Wygeneruj ją najpierw.");
2233
- return;
2234
- }
2235
- await generatePdf(currentBusiness, lastInvoice, currentLogo);
2236
- });
2237
- }
2238
-
2239
- if (logoutButton) {
2240
- logoutButton.addEventListener("click", () => {
2241
- authToken = null;
2242
- currentLogin = "";
2243
- sessionStorage.removeItem("invoiceAuthToken");
2244
- sessionStorage.removeItem("invoiceLogin");
2245
- lastInvoice = null;
2246
- currentBusiness = null;
2247
- currentLogo = null;
2248
- invoicesCache = [];
2249
- exitInvoiceEdit();
2250
- resetInvoiceForm();
2251
- setBusinessFormVisibility(false);
2252
- if (invoiceResult) {
2253
- invoiceResult.classList.add("hidden");
2254
- }
2255
- updateLogoPreview();
2256
- updateLoginLabel();
2257
- renderInvoicesTable([]);
2258
- updateSummaryCards({});
2259
- updateSummaryChart({});
2260
- closeRegisterPanel({ resetForm: true, focusTrigger: true });
2261
- clearFeedback(registerFeedback);
2262
- clearFeedback(loginFeedback);
2263
- clearFeedback(businessFeedback);
2264
- clearFeedback(logoFeedback);
2265
- clearFeedback(dashboardFeedback);
2266
- setAppState("auth");
2267
- });
2268
- }
2269
-
2270
- appNavButtons.forEach((button) => {
2271
- button.addEventListener("click", () => {
2272
- setActiveView(button.dataset.view);
2273
- });
2274
- });
2275
-
2276
- if (filterStartDate) {
2277
- filterStartDate.addEventListener("change", applyInvoiceFilters);
2278
- }
2279
- if (filterEndDate) {
2280
- filterEndDate.addEventListener("change", applyInvoiceFilters);
2281
- }
2282
- if (clearFiltersButton) {
2283
- clearFiltersButton.addEventListener("click", () => {
2284
- if (filterStartDate) {
2285
- filterStartDate.value = "";
2286
- }
2287
- if (filterEndDate) {
2288
- filterEndDate.value = "";
2289
- }
2290
- applyInvoiceFilters();
2291
- });
2292
- }
2293
-
2294
- if (showRegisterButton) {
2295
- showRegisterButton.addEventListener("click", () => {
2296
- openRegisterPanel();
2297
- });
2298
- }
2299
-
2300
- if (backToLoginButton) {
2301
- backToLoginButton.addEventListener("click", () => {
2302
- closeRegisterPanel({ resetForm: false, focusTrigger: true });
2303
- });
2304
- }
2305
-
2306
- if (cancelRegisterButton) {
2307
- cancelRegisterButton.addEventListener("click", () => {
2308
- closeRegisterPanel({ resetForm: true, focusTrigger: true });
2309
- });
2310
- }
2311
-
2312
- if (logoInput) {
2313
- logoInput.addEventListener("change", (event) => {
2314
- const file = event.target.files?.[0];
2315
- if (!file) {
2316
- return;
2317
- }
2318
- clearFeedback(logoFeedback);
2319
- if (file.size > maxLogoSize) {
2320
- showFeedback(logoFeedback, `Logo jest zbyt duze. Maksymalny rozmiar to ${(maxLogoSize / 1024).toFixed(0)} KB.`);
2321
- logoInput.value = "";
2322
- return;
2323
- }
2324
- const reader = new FileReader();
2325
- reader.onload = async () => {
2326
- try {
2327
- const base64 = reader.result?.toString();
2328
- if (!base64) {
2329
- throw new Error("Nie udało się odczytać pliku.");
2330
- }
2331
- const response = await apiRequest(
2332
- "/api/logo",
2333
- {
2334
- method: "POST",
2335
- body: {
2336
- filename: file.name,
2337
- mime_type: file.type,
2338
- content: base64,
2339
- },
2340
- },
2341
- true
2342
- );
2343
- currentLogo = response.logo;
2344
- updateLogoPreview();
2345
- showFeedback(logoFeedback, "Logo zapisane.", "success");
2346
- } catch (error) {
2347
- showFeedback(logoFeedback, error.message || "Nie udało się zapisać logo.");
2348
- } finally {
2349
- logoInput.value = "";
2350
- }
2351
- };
2352
- reader.onerror = () => {
2353
- showFeedback(logoFeedback, "Nie udało się wczytać pliku logo.");
2354
- logoInput.value = "";
2355
- };
2356
- reader.readAsDataURL(file);
2357
- });
2358
- }
2359
-
2360
- if (removeLogoButton) {
2361
- removeLogoButton.addEventListener("click", async () => {
2362
- clearFeedback(logoFeedback);
2363
- if (!currentLogo) {
2364
- showFeedback(logoFeedback, "Brak logo do usunięcia.");
2365
- return;
2366
- }
2367
- try {
2368
- await apiRequest("/api/logo", { method: "DELETE" }, true);
2369
- currentLogo = null;
2370
- updateLogoPreview();
2371
- showFeedback(logoFeedback, "Logo usunięte.", "success");
2372
- } catch (error) {
2373
- showFeedback(logoFeedback, error.message || "Nie udało się usunąć logo.");
2374
- }
2375
- });
2376
- }
2377
-
2378
- if (cancelEditInvoiceButton) {
2379
- cancelEditInvoiceButton.addEventListener("click", () => {
2380
- exitInvoiceEdit();
2381
- resetInvoiceForm();
2382
- });
2383
- }
2384
-
2385
- if (clientSearchInput) {
2386
- clientSearchInput.addEventListener("input", handleClientSearchInput);
2387
- clientSearchInput.addEventListener("focus", () => {
2388
- if ((clientSearchInput.value || "").trim().length >= 2) {
2389
- requestClientSuggestions(clientSearchInput.value);
2390
- }
2391
- });
2392
- }
2393
-
2394
- document.addEventListener("click", (event) => {
2395
- if (!clientSuggestionsContainer || !clientSearchInput) {
2396
- return;
2397
- }
2398
- if (
2399
- clientSuggestionsContainer.contains(event.target) ||
2400
- clientSearchInput === event.target ||
2401
- clientSearchInput.contains(event.target)
2402
- ) {
2403
- return;
2404
- }
2405
- hideClientSuggestions();
2406
- });
2407
-
2408
- initialize().catch((error) => {
2409
- console.error(error);
2410
- showFeedback(registerFeedback, "Nie udało się uruchomić aplikacji.");
2411
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -1,2 +1,6 @@
1
- Flask==3.0.2
2
- psycopg2-binary==2.9.9
 
 
 
 
 
1
+ Flask>=2.3,<3.0
2
+
3
+ SQLAlchemy
4
+ psycopg2-binary
5
+ uvicorn
6
+ fastapi
server.py CHANGED
@@ -1,74 +1,75 @@
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, timezone
9
- from decimal import Decimal, ROUND_HALF_UP, getcontext
10
- from pathlib import Path
11
- from typing import Any, Dict, List, Optional, Tuple
12
-
13
- from flask import Flask, jsonify, request, send_from_directory
14
-
15
- from db import (
16
- create_account,
17
- execute,
18
- fetch_all,
19
- fetch_one,
20
- fetch_business_logo,
21
- insert_invoice,
22
- search_clients,
23
- update_business,
24
- update_business_logo,
25
- upsert_client,
26
- )
27
-
28
- APP_ROOT = Path(__file__).parent.resolve()
29
- DATA_DIR = Path(os.environ.get("DATA_DIR", APP_ROOT))
30
- DATA_FILE = DATA_DIR / "web_invoice_store.json"
31
- INVOICE_HISTORY_LIMIT = 200
32
- MAX_LOGO_SIZE = 512 * 1024 # 512 KB
33
- TOKEN_TTL = timedelta(hours=12)
34
- ALLOWED_LOGO_MIME_TYPES = {"image/png", "image/jpeg"}
35
- PRIVATE_TAX_ID_PREFIX = "__PRIVATE__:"
36
-
37
- DATABASE_AVAILABLE = bool(os.environ.get("NEON_DATABASE_URL"))
38
-
39
- VAT_RATES: Dict[str, Optional[Decimal]] = {
40
- "23": Decimal("0.23"),
41
- "8": Decimal("0.08"),
42
- "5": Decimal("0.05"),
43
- "0": Decimal("0.00"),
44
- "ZW": None,
45
- "NP": None,
46
- }
47
-
48
- DEFAULT_UNIT = "szt."
49
- ALLOWED_UNITS = {"szt.", "godz."}
50
- PASSWORD_MIN_LENGTH = 4
51
-
52
- SESSION_TOKENS: Dict[str, Dict[str, Any]] = {}
53
-
54
- ALLOWED_STATIC = {
55
- "index.html",
56
- "styles.css",
57
- "main.js",
58
- "favicon.ico",
59
- "Roboto-VariableFont_wdth,wght.ttf",
60
- }
61
-
62
-
63
- app = Flask(__name__, static_folder=str(APP_ROOT), static_url_path="")
64
-
65
- getcontext().prec = 10
66
-
67
-
68
- def _quantize(value: Decimal) -> Decimal:
69
- return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
70
-
71
-
 
72
  def _decimal(value: Any) -> Decimal:
73
  try:
74
  return Decimal(str(value))
@@ -76,135 +77,201 @@ def _decimal(value: Any) -> Decimal:
76
  raise ValueError(f"Niepoprawna wartosc liczby: {value}") from error
77
 
78
 
79
- def _format_decimal_str(value: Any, default: str = "0.00") -> str:
80
- if value in (None, ""):
81
- return default
82
- if isinstance(value, Decimal):
83
- return str(_quantize(value))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  try:
85
- return str(_quantize(Decimal(str(value))))
86
- except Exception:
87
- return str(value)
 
88
 
89
 
90
- def hash_password(password: str) -> str:
91
- return hashlib.sha256(password.encode("utf-8")).hexdigest()
92
-
93
-
94
- EMAIL_PATTERN = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$")
95
-
96
-
97
- def normalize_email(raw_email: str) -> Tuple[str, str]:
98
- display_email = (raw_email or "").strip()
99
- if not display_email:
100
- raise ValueError("Email nie moze byc pusty.")
101
- if not EMAIL_PATTERN.fullmatch(display_email):
102
- raise ValueError("Podaj poprawny adres email.")
103
- return display_email.lower(), display_email
104
-
105
-
106
- def sanitize_filename(filename: Optional[str]) -> str:
107
- if not filename:
108
- return "logo"
109
- name = str(filename).split("/")[-1].split("\\")[-1]
110
- sanitized = re.sub(r"[^A-Za-z0-9._-]", "_", name).strip("._")
111
- return sanitized or "logo"
112
-
113
-
114
- def find_account_identifier(accounts: Dict[str, Any], identifier: str) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
115
- key = (identifier or "").strip().lower()
116
- if not key:
117
- return None, None
118
- account = accounts.get(key)
119
- if account:
120
- return key, account
121
- for login_key, candidate in accounts.items():
122
- candidate_login = (candidate.get("login") or "").strip().lower()
123
- candidate_email = (candidate.get("email") or "").strip().lower()
124
- if key in {candidate_login, candidate_email}:
125
- return login_key, candidate
126
- return None, None
127
-
128
-
129
- def load_store() -> Dict[str, Any]:
130
- if not DATA_FILE.exists():
131
- return {"accounts": {}}
132
- try:
133
- with DATA_FILE.open("r", encoding="utf-8") as handle:
134
- data = json.load(handle)
135
- except json.JSONDecodeError:
136
- raise ValueError("Plik z danymi jest uszkodzony.")
137
- return data
138
-
139
-
140
- def save_store(data: Dict[str, Any]) -> None:
141
- DATA_DIR.mkdir(parents=True, exist_ok=True)
142
- tmp_path = DATA_FILE.with_suffix(".tmp")
143
- with tmp_path.open("w", encoding="utf-8") as handle:
144
- json.dump(data, handle, ensure_ascii=False, indent=2)
145
- tmp_path.replace(DATA_FILE)
146
-
147
-
148
- def get_account(data: Dict[str, Any], login_key: str) -> Dict[str, Any]:
149
- accounts = data.get("accounts") or {}
150
- account = accounts.get(login_key)
151
- if not account:
152
- raise KeyError("Nie znaleziono konta.")
153
- return account
154
-
155
-
156
- def get_account_row(login_key: str) -> Dict[str, Any]:
157
- row = fetch_one("SELECT id, login FROM accounts WHERE login = %s", (login_key,))
158
- if not row:
159
- raise KeyError("Nie znaleziono konta.")
160
- return row
161
-
162
-
163
- def get_business_profile(account_id: int) -> Optional[Dict[str, Any]]:
164
- return fetch_one(
165
- """
166
- SELECT company_name, owner_name, address_line, postal_code,
167
- city, tax_id, bank_account
168
- FROM business_profiles
169
- WHERE account_id = %s
170
- """,
171
- (account_id,),
172
- )
173
-
174
-
175
- def require_auth() -> str:
176
- auth_header = request.headers.get("Authorization")
177
- if not auth_header or not auth_header.startswith("Bearer "):
178
- raise PermissionError("Brak tokenu.")
179
- token = auth_header.split(" ", 1)[1].strip()
180
- session = SESSION_TOKENS.get(token)
181
- if not session:
182
- raise PermissionError("Nieprawidlowy token.")
183
- if session["expires_at"] < datetime.utcnow():
184
- SESSION_TOKENS.pop(token, None)
185
- raise PermissionError("Token wygasl.")
186
- return session["login_key"]
187
-
188
-
189
- @app.route("/")
190
- def index() -> Any:
191
- return send_from_directory(app.static_folder, "index.html")
192
-
193
-
194
- @app.route("/<path:filename>")
195
- def static_files(filename: str) -> Any:
196
- if filename not in ALLOWED_STATIC:
197
- return jsonify({"error": "Nie ma takiego zasobu."}), 404
198
- return send_from_directory(app.static_folder, filename)
199
-
200
-
201
- @app.route("/api/register", methods=["POST"])
202
- def api_register() -> Any:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  payload = request.get_json(force=True)
204
- email = payload.get("email")
205
- password = payload.get("password")
206
- confirm = payload.get("confirm_password")
207
- business_fields = [
208
  "company_name",
209
  "owner_name",
210
  "address_line",
@@ -212,830 +279,112 @@ def api_register() -> Any:
212
  "city",
213
  "tax_id",
214
  "bank_account",
 
215
  ]
216
- business_data: Dict[str, str] = {}
217
-
218
- for field in business_fields:
219
- value = (payload.get(field) or "").strip()
220
- if not value:
221
- return jsonify({"error": f"Pole {field} jest wymagane."}), 400
222
- business_data[field] = value
223
-
224
- if password != confirm:
225
- return jsonify({"error": "Hasla musza byc identyczne."}), 400
226
- if len(password or "") < PASSWORD_MIN_LENGTH:
227
- return jsonify({"error": "Haslo jest za krotkie."}), 400
228
-
229
- login_key, display_email = normalize_email(email)
230
- password_hash = hash_password(password)
231
-
232
- if DATABASE_AVAILABLE:
233
- if fetch_one("SELECT 1 FROM accounts WHERE login = %s", (login_key,)):
234
- return jsonify({"error": "Konto o podanym emailu juz istnieje."}), 400
235
- account_id = create_account(login_key, display_email, password_hash)
236
- update_business(account_id, business_data)
237
- return jsonify({"message": "Konto zostalo utworzone."})
238
 
239
- data = load_store()
240
- accounts = data.setdefault("accounts", {})
241
- if login_key in accounts:
242
- return jsonify({"error": "Konto o podanym emailu juz istnieje."}), 400
243
-
244
- accounts[login_key] = {
245
- "login": login_key,
246
- "email": display_email,
247
- "password_hash": password_hash,
248
- "business": business_data,
249
- "invoices": [],
250
- "logo": None,
251
- "created_at": datetime.utcnow().isoformat(timespec="seconds"),
 
 
252
  }
 
 
 
253
  save_store(data)
254
- return jsonify({"message": "Konto zostalo utworzone."})
255
-
256
-
257
- @app.route("/api/login", methods=["POST"])
258
- def api_login() -> Any:
259
- payload = request.get_json(force=True)
260
- identifier = payload.get("identifier") or payload.get("email")
261
- password = payload.get("password")
262
- if not identifier or not password:
263
- return jsonify({"error": "Podaj email/login i haslo."}), 400
264
-
265
- login_key, _ = normalize_email(identifier)
266
-
267
- if DATABASE_AVAILABLE:
268
- row = fetch_one(
269
- "SELECT id, password_hash FROM accounts WHERE login = %s",
270
- (login_key,),
271
- )
272
- if not row or row["password_hash"] != hash_password(password):
273
- return jsonify({"error": "Niepoprawne dane logowania."}), 401
274
- token = uuid.uuid4().hex
275
- SESSION_TOKENS[token] = {
276
- "login_key": login_key,
277
- "account_id": row["id"],
278
- "expires_at": datetime.utcnow() + TOKEN_TTL,
279
- }
280
- return jsonify({"token": token, "login": login_key})
281
-
282
- data = load_store()
283
- accounts = data.get("accounts") or {}
284
- login_key, account = find_account_identifier(accounts, login_key)
285
- if not account or account.get("password_hash") != hash_password(password):
286
- return jsonify({"error": "Niepoprawne dane logowania."}), 401
287
- token = uuid.uuid4().hex
288
- SESSION_TOKENS[token] = {
289
- "login_key": login_key,
290
- "expires_at": datetime.utcnow() + TOKEN_TTL,
291
- }
292
- return jsonify({"token": token, "login": account.get("login", login_key)})
293
-
294
-
295
- @app.route("/api/logout", methods=["POST"])
296
- def api_logout() -> Any:
297
- token = request.headers.get("Authorization", "").replace("Bearer ", "")
298
- SESSION_TOKENS.pop(token, None)
299
- return jsonify({"message": "Wylogowano."})
300
-
301
-
302
- @app.route("/api/business", methods=["GET", "POST"])
303
- def api_business() -> Any:
304
- try:
305
- login_key = require_auth()
306
- except PermissionError:
307
- return jsonify({"error": "Brak autoryzacji."}), 401
308
-
309
- data = load_store()
310
- account = data.get("accounts", {}).get(login_key)
311
- account_row = None
312
- if DATABASE_AVAILABLE:
313
- try:
314
- account_row = get_account_row(login_key)
315
- except KeyError:
316
- return jsonify({"error": "Nie znaleziono konta."}), 404
317
-
318
- if request.method == "GET":
319
- if DATABASE_AVAILABLE:
320
- profile = fetch_one(
321
- """
322
- SELECT company_name, owner_name, address_line, postal_code,
323
- city, tax_id, bank_account
324
- FROM business_profiles
325
- WHERE account_id = %s
326
- """,
327
- (account_row["id"],),
328
- )
329
- return jsonify({"business": profile})
330
- if not account:
331
- return jsonify({"business": None})
332
- return jsonify({"business": account.get("business")})
333
-
334
- payload = request.get_json(force=True)
335
- required_fields = [
336
- "company_name",
337
- "owner_name",
338
- "address_line",
339
- "postal_code",
340
- "city",
341
- "tax_id",
342
- "bank_account",
343
- ]
344
- for field in required_fields:
345
- if not (payload.get(field) or "").strip():
346
- return jsonify({"error": f"Pole {field} jest wymagane."}), 400
347
-
348
- if DATABASE_AVAILABLE:
349
- update_business(account_row["id"], payload)
350
- return jsonify({"message": "Dane sprzedawcy zostaly zaktualizowane."})
351
-
352
- if not account:
353
- return jsonify({"error": "Nie znaleziono konta."}), 404
354
- account["business"] = payload
355
- save_store(data)
356
- return jsonify({"message": "Dane sprzedawcy zostaly zaktualizowane."})
357
 
358
 
359
- @app.route("/api/clients", methods=["GET"])
360
- def api_clients() -> Any:
361
- try:
362
- login_key = require_auth()
363
- except PermissionError:
364
- return jsonify({"error": "Brak autoryzacji."}), 401
365
 
366
- if not DATABASE_AVAILABLE:
367
- return jsonify({"clients": []})
368
 
369
- try:
370
- account_row = get_account_row(login_key)
371
- except KeyError:
372
- return jsonify({"error": "Nie znaleziono konta."}), 404
373
 
374
- search_term = (request.args.get("q") or "").strip()
375
- limit_param = request.args.get("limit", "10")
376
- try:
377
- limit_value = int(limit_param)
378
- except ValueError:
379
- limit_value = 10
380
- limit_value = max(1, min(25, limit_value))
381
- clients = search_clients(account_row["id"], search_term, limit_value)
382
- return jsonify({"clients": clients})
383
-
384
-
385
- @app.route("/api/logo", methods=["GET", "POST", "DELETE"])
386
- def api_logo() -> Any:
387
- try:
388
- login_key = require_auth()
389
- except PermissionError:
390
- return jsonify({"error": "Brak autoryzacji."}), 401
391
-
392
- account_row = None
393
- account = None
394
- data = None
395
-
396
- if DATABASE_AVAILABLE:
397
- try:
398
- account_row = get_account_row(login_key)
399
- except KeyError:
400
- return jsonify({"error": "Nie znaleziono konta."}), 404
401
- else:
402
- data = load_store()
403
- try:
404
- account = get_account(data, login_key)
405
- except KeyError:
406
- return jsonify({"error": "Nie znaleziono konta."}), 404
407
-
408
- if request.method == "GET":
409
- if DATABASE_AVAILABLE:
410
- logo_row = fetch_business_logo(account_row["id"])
411
- if not logo_row:
412
- return jsonify({"logo": None})
413
- mime_type = logo_row["mime_type"]
414
- encoded = logo_row["data"]
415
- data_url = f"data:{mime_type};base64,{encoded}" if mime_type and encoded else None
416
- return jsonify(
417
- {
418
- "logo": {
419
- "filename": None,
420
- "mime_type": mime_type,
421
- "data": encoded,
422
- "data_url": data_url,
423
- "uploaded_at": None,
424
- }
425
- }
426
- )
427
- logo = account.get("logo") if account else None
428
- if not logo:
429
- return jsonify({"logo": None})
430
- encoded = logo.get("data")
431
- mime_type = logo.get("mime_type")
432
- data_url = None
433
- if encoded and mime_type:
434
- data_url = f"data:{mime_type};base64,{encoded}"
435
- return jsonify(
436
- {
437
- "logo": {
438
- "filename": logo.get("filename"),
439
- "mime_type": mime_type,
440
- "data": encoded,
441
- "data_url": data_url,
442
- "uploaded_at": logo.get("uploaded_at"),
443
- }
444
- }
445
- )
446
-
447
- if request.method == "DELETE":
448
- if DATABASE_AVAILABLE:
449
- update_business_logo(account_row["id"], None, None)
450
- return jsonify({"message": "Logo zostalo usuniete."})
451
- if not account or data is None:
452
- return jsonify({"error": "Nie znaleziono konta."}), 404
453
- account["logo"] = None
454
- save_store(data)
455
- return jsonify({"message": "Logo zostalo usuniete."})
456
-
457
- payload = request.get_json(force=True)
458
- raw_content = (payload.get("content") or payload.get("data") or "").strip()
459
- if not raw_content:
460
- return jsonify({"error": "Brak danych logo."}), 400
461
-
462
- provided_mime = (payload.get("mime_type") or "").strip()
463
- filename = sanitize_filename(payload.get("filename"))
464
-
465
- if raw_content.startswith("data:"):
466
- try:
467
- header, encoded_content = raw_content.split(",", 1)
468
- except ValueError:
469
- return jsonify({"error": "Niepoprawny format danych logo."}), 400
470
- header = header.strip()
471
- if ";base64" not in header:
472
- return jsonify({"error": "Niepoprawny format danych logo (oczekiwano base64)."}), 400
473
- mime_type = header.split(";")[0].replace("data:", "", 1) or provided_mime
474
- base64_content = encoded_content.strip()
475
- else:
476
- mime_type = provided_mime
477
- base64_content = raw_content
478
-
479
- mime_type = (mime_type or "").lower()
480
- if mime_type not in ALLOWED_LOGO_MIME_TYPES:
481
- return jsonify({"error": "Dozwolone formaty logo to PNG lub JPG."}), 400
482
-
483
- try:
484
- logo_bytes = base64.b64decode(base64_content, validate=True)
485
- except (ValueError, binascii.Error):
486
- return jsonify({"error": "Nie udalo sie odczytac danych logo (base64)."}), 400
487
-
488
- if len(logo_bytes) > MAX_LOGO_SIZE:
489
- return jsonify({"error": f"Logo jest zbyt duze (maksymalnie {MAX_LOGO_SIZE // 1024} KB)."}), 400
490
-
491
- encoded_logo = base64.b64encode(logo_bytes).decode("ascii")
492
- stored_logo = {
493
- "filename": filename,
494
- "mime_type": mime_type,
495
- "data": encoded_logo,
496
- "uploaded_at": datetime.utcnow().isoformat(timespec="seconds"),
497
- }
498
-
499
- if DATABASE_AVAILABLE:
500
- update_business_logo(account_row["id"], stored_logo["mime_type"], stored_logo["data"])
501
- return jsonify({"logo": stored_logo})
502
-
503
- account["logo"] = stored_logo
504
- save_store(data)
505
- return jsonify({"logo": stored_logo})
506
-
507
- def normalize_phone(phone: Optional[str]) -> Optional[str]:
508
- if not phone:
509
- return None
510
- digits = re.sub(r"[^\d+]", "", phone)
511
- return digits or None
512
-
513
-
514
- def public_tax_id(value: Optional[str]) -> str:
515
- tax_id = (value or "").strip()
516
- if tax_id.startswith(PRIVATE_TAX_ID_PREFIX):
517
- return ""
518
- return tax_id
519
-
520
-
521
- def validate_client(payload: Dict[str, Any]) -> Dict[str, str]:
522
- client_payload = payload.get("client") or {}
523
- client = {
524
- "name": (client_payload.get("name") or payload.get("clientName") or "").strip(),
525
- "tax_id": (client_payload.get("tax_id") or payload.get("clientTaxId") or "").strip(),
526
- "address_line": (client_payload.get("address_line") or payload.get("clientAddress") or "").strip(),
527
- "postal_code": (client_payload.get("postal_code") or payload.get("clientPostalCode") or "").strip(),
528
- "city": (client_payload.get("city") or payload.get("clientCity") or "").strip(),
529
- "phone": normalize_phone(client_payload.get("phone") or payload.get("clientPhone")),
530
- }
531
- return client
532
-
533
-
534
- def normalize_document_type(payload: Dict[str, Any]) -> str:
535
- document_type = (payload.get("document_type") or payload.get("documentType") or "standard").strip()
536
- if document_type == "personal_paid":
537
- return "personal_paid"
538
- return "standard"
539
-
540
-
541
- def build_invoice(payload: Dict[str, Any], business: Dict[str, Any], client: Dict[str, str]) -> Dict[str, Any]:
542
- now = datetime.now()
543
- document_type = normalize_document_type(payload)
544
- payment_status = "paid" if document_type == "personal_paid" else "unpaid"
545
- invoice_prefix = "FI" if document_type == "personal_paid" else "FV"
546
- invoice_id = f"{invoice_prefix}-{now.strftime('%Y%m%d-%H%M%S')}"
547
- issued_at = now.strftime("%Y-%m-%d %H:%M")
548
- sale_date = payload.get("sale_date") or payload.get("saleDate") or date.today().isoformat()
549
- payment_term_raw = payload.get("payment_term")
550
- if payment_term_raw is None:
551
- payment_term_raw = payload.get("paymentTerm")
552
- if payment_term_raw in (None, ""):
553
- payment_term = 0 if document_type == "personal_paid" else 14
554
- else:
555
- payment_term = int(payment_term_raw)
556
- if document_type == "personal_paid":
557
- payment_term = 0
558
- if not client.get("name"):
559
- raise ValueError("Podaj imie i nazwisko nabywcy dla faktury imiennej.")
560
- items = payload.get("items") or []
561
-
562
- normalized_items: List[Dict[str, Any]] = []
563
- for item in items:
564
- name = (item.get("name") or "").strip()
565
- if not name:
566
- raise ValueError("Nazwa pozycji nie moze byc pusta.")
567
- quantity = _quantize(_decimal(item.get("quantity") or "0"))
568
- if quantity <= Decimal("0"):
569
- raise ValueError("Ilosc musi byc dodatnia.")
570
- unit = item.get("unit") or DEFAULT_UNIT
571
- vat_code = str(item.get("vat_code") or item.get("vat") or item.get("vatCode") or "23")
572
- if vat_code not in VAT_RATES:
573
- raise ValueError("Niepoprawna stawka VAT.")
574
- unit_price_raw = item.get("unit_price_gross")
575
- if unit_price_raw in (None, ""):
576
- unit_price_raw = item.get("unitPrice") or item.get("unit_price") or item.get("price")
577
- unit_price_gross = _quantize(_decimal(unit_price_raw or "0"))
578
- if unit_price_gross <= Decimal("0"):
579
- raise ValueError("Cena musi byc dodatnia.")
580
- vat_rate = VAT_RATES[vat_code]
581
- if vat_rate is None:
582
- unit_price_net = unit_price_gross
583
- vat_amount = Decimal("0.00")
584
- else:
585
- unit_price_net = _quantize(unit_price_gross / (Decimal("1.0") + vat_rate))
586
- vat_amount = _quantize(unit_price_gross - unit_price_net)
587
- net_total = _quantize(unit_price_net * quantity)
588
- vat_total = _quantize(vat_amount * quantity)
589
- gross_total = _quantize(unit_price_gross * quantity)
590
- normalized_items.append(
591
- {
592
- "name": name,
593
- "quantity": str(quantity),
594
- "unit": unit,
595
- "vat_code": vat_code,
596
- "vat_label": item.get("vatLabel") or vat_code,
597
- "unit_price_net": str(unit_price_net),
598
- "unit_price_gross": str(unit_price_gross),
599
- "net_total": str(net_total),
600
- "vat_amount": str(vat_amount),
601
- "gross_total": str(gross_total),
602
- }
603
- )
604
-
605
- totals = {"net": Decimal("0"), "vat": Decimal("0"), "gross": Decimal("0")}
606
- summary: Dict[str, Dict[str, Decimal]] = {}
607
- for item in normalized_items:
608
- totals["net"] += Decimal(item["net_total"])
609
- totals["vat"] += Decimal(item["vat_amount"])
610
- totals["gross"] += Decimal(item["gross_total"])
611
- label = item["vat_label"]
612
- summary.setdefault(label, {"net_total": Decimal("0"), "vat_total": Decimal("0"), "gross_total": Decimal("0")})
613
- summary[label]["net_total"] += Decimal(item["net_total"])
614
- summary[label]["vat_total"] += Decimal(item["vat_amount"])
615
- summary[label]["gross_total"] += Decimal(item["gross_total"])
616
-
617
- totals = {key: str(_quantize(value)) for key, value in totals.items()}
618
- summary_list = [
619
- {
620
- "vat_label": label,
621
- "net_total": str(_quantize(values["net_total"])),
622
- "vat_total": str(_quantize(values["vat_total"])),
623
- "gross_total": str(_quantize(values["gross_total"])),
624
- }
625
- for label, values in summary.items()
626
- ]
627
-
628
- exemption_note = (payload.get("exemption_note") or payload.get("exemptionNote") or "").strip()
629
-
630
- return {
631
- "invoice_id": invoice_id,
632
- "document_type": document_type,
633
- "payment_status": payment_status,
634
- "issued_at": issued_at,
635
- "sale_date": sale_date,
636
- "payment_term": payment_term,
637
- "items": normalized_items,
638
- "summary": summary_list,
639
- "totals": totals,
640
- "client": client,
641
- "business": business,
642
- "exemption_note": exemption_note,
643
- }
644
-
645
-
646
- @app.route("/api/invoices", methods=["GET", "POST"])
647
- def api_invoices() -> Any:
648
  try:
649
- login_key = require_auth()
650
  except PermissionError:
651
  return jsonify({"error": "Brak autoryzacji."}), 401
652
 
 
653
  if request.method == "GET":
654
- if DATABASE_AVAILABLE:
655
- try:
656
- account_row = get_account_row(login_key)
657
- except KeyError:
658
- return jsonify({"error": "Nie znaleziono konta."}), 404
659
- invoice_rows = fetch_all(
660
- """
661
- SELECT i.id,
662
- i.invoice_number,
663
- i.issued_at,
664
- i.sale_date,
665
- i.payment_term_days,
666
- i.exemption_note,
667
- i.total_net,
668
- i.total_vat,
669
- i.total_gross,
670
- c.name AS client_name,
671
- c.address_line AS client_address,
672
- c.postal_code AS client_postal_code,
673
- c.city AS client_city,
674
- c.tax_id AS client_tax_id,
675
- c.phone AS client_phone
676
- FROM invoices AS i
677
- LEFT JOIN clients AS c ON c.id = i.client_id
678
- WHERE i.account_id = %s
679
- ORDER BY i.issued_at DESC
680
- LIMIT %s
681
- """,
682
- (account_row["id"], INVOICE_HISTORY_LIMIT),
683
- )
684
- if not invoice_rows:
685
- return jsonify({"invoices": []})
686
-
687
- invoice_ids = [row["id"] for row in invoice_rows]
688
- items_map: Dict[int, List[Dict[str, Any]]] = {row_id: [] for row_id in invoice_ids}
689
- summary_map: Dict[int, List[Dict[str, str]]] = {row_id: [] for row_id in invoice_ids}
690
-
691
- if invoice_ids:
692
- item_rows = fetch_all(
693
- """
694
- SELECT invoice_id, line_no, name, quantity, unit,
695
- vat_code, vat_label, unit_price_net,
696
- unit_price_gross, net_total, vat_amount, gross_total
697
- FROM invoice_items
698
- WHERE invoice_id = ANY(%s)
699
- ORDER BY line_no
700
- """,
701
- (invoice_ids,),
702
- )
703
- for item in item_rows:
704
- items_map.setdefault(item["invoice_id"], []).append(
705
- {
706
- "name": item["name"],
707
- "quantity": _format_decimal_str(item.get("quantity"), "0.00"),
708
- "unit": item.get("unit") or DEFAULT_UNIT,
709
- "vat_code": item.get("vat_code"),
710
- "vat_label": item.get("vat_label") or item.get("vat_code"),
711
- "unit_price_net": _format_decimal_str(item.get("unit_price_net")),
712
- "unit_price_gross": _format_decimal_str(item.get("unit_price_gross")),
713
- "net_total": _format_decimal_str(item.get("net_total")),
714
- "vat_amount": _format_decimal_str(item.get("vat_amount")),
715
- "gross_total": _format_decimal_str(item.get("gross_total")),
716
- }
717
- )
718
-
719
- summary_rows = fetch_all(
720
- """
721
- SELECT invoice_id, vat_label, net_total, vat_total, gross_total
722
- FROM invoice_vat_summary
723
- WHERE invoice_id = ANY(%s)
724
- ORDER BY vat_label
725
- """,
726
- (invoice_ids,),
727
- )
728
- for entry in summary_rows:
729
- summary_map.setdefault(entry["invoice_id"], []).append(
730
- {
731
- "vat_label": entry.get("vat_label"),
732
- "net_total": _format_decimal_str(entry.get("net_total")),
733
- "vat_total": _format_decimal_str(entry.get("vat_total")),
734
- "gross_total": _format_decimal_str(entry.get("gross_total")),
735
- }
736
- )
737
-
738
- business_profile = get_business_profile(account_row["id"])
739
- invoices: List[Dict[str, Any]] = []
740
- for row in invoice_rows:
741
- issued_at_value = row.get("issued_at")
742
- sale_date_value = row.get("sale_date")
743
- invoice_number = row.get("invoice_number") or ""
744
- payment_term_days = row.get("payment_term_days")
745
- is_personal_paid = invoice_number.startswith("FI-") or payment_term_days == 0
746
- if isinstance(issued_at_value, datetime):
747
- issued_at = issued_at_value.strftime("%Y-%m-%d %H:%M")
748
- else:
749
- issued_at = issued_at_value
750
- if hasattr(sale_date_value, "isoformat"):
751
- sale_date = sale_date_value.isoformat()
752
- else:
753
- sale_date = sale_date_value
754
- client = None
755
- if row.get("client_name"):
756
- client = {
757
- "name": row.get("client_name"),
758
- "address_line": row.get("client_address"),
759
- "postal_code": row.get("client_postal_code"),
760
- "city": row.get("client_city"),
761
- "tax_id": public_tax_id(row.get("client_tax_id")),
762
- "phone": row.get("client_phone"),
763
- }
764
- invoices.append(
765
- {
766
- "invoice_id": invoice_number,
767
- "document_type": "personal_paid" if is_personal_paid else "standard",
768
- "payment_status": "paid" if is_personal_paid else "unpaid",
769
- "issued_at": issued_at,
770
- "sale_date": sale_date,
771
- "payment_term": payment_term_days,
772
- "exemption_note": row.get("exemption_note"),
773
- "items": items_map.get(row["id"], []),
774
- "summary": summary_map.get(row["id"], []),
775
- "totals": {
776
- "net": _format_decimal_str(row.get("total_net")),
777
- "vat": _format_decimal_str(row.get("total_vat")),
778
- "gross": _format_decimal_str(row.get("total_gross")),
779
- },
780
- "client": client,
781
- "business": business_profile,
782
- }
783
- )
784
- return jsonify({"invoices": invoices})
785
-
786
- data = load_store()
787
- try:
788
- account = get_account(data, login_key)
789
- except KeyError:
790
- return jsonify({"error": "Nie znaleziono konta."}), 404
791
- invoices = account.get("invoices", [])[:INVOICE_HISTORY_LIMIT]
792
- return jsonify({"invoices": invoices})
793
 
794
  payload = request.get_json(force=True)
 
 
 
 
 
 
 
 
 
 
795
 
796
- if DATABASE_AVAILABLE:
797
- try:
798
- account_row = get_account_row(login_key)
799
- except KeyError:
800
- return jsonify({"error": "Nie znaleziono konta."}), 404
801
- business = get_business_profile(account_row["id"])
802
- if not business:
803
- return jsonify({"error": "Ustaw dane sprzedawcy przed dodaniem faktury."}), 400
804
-
805
- client = validate_client(payload)
806
- try:
807
- invoice = build_invoice(payload, business, client)
808
- except ValueError as error:
809
- return jsonify({"error": str(error)}), 400
810
-
811
- client_id = upsert_client(
812
- account_row["id"],
813
- {
814
- "name": client["name"],
815
- "address_line": client["address_line"],
816
- "postal_code": client["postal_code"],
817
- "city": client["city"],
818
- "tax_id": client["tax_id"],
819
- "phone": client.get("phone"),
820
- },
821
- )
822
- insert_invoice(account_row["id"], client_id, invoice)
823
- return jsonify({"message": "Faktura zostala zapisana.", "invoice": invoice})
824
-
825
- data = load_store()
826
- try:
827
- account = get_account(data, login_key)
828
- except KeyError:
829
- return jsonify({"error": "Nie znaleziono konta."}), 404
830
-
831
- business = account.get("business")
832
- if not business:
833
- return jsonify({"error": "Ustaw dane sprzedawcy przed dodaniem faktury."}), 400
834
-
835
- client = validate_client(payload)
836
- try:
837
- invoice = build_invoice(payload, business, client)
838
- except ValueError as error:
839
- return jsonify({"error": str(error)}), 400
840
 
841
- invoices = account.setdefault("invoices", [])
842
- invoices.insert(0, invoice)
843
- account["invoices"] = invoices[:INVOICE_HISTORY_LIMIT]
844
  save_store(data)
845
- return jsonify({"message": "Faktura zostala zapisana.", "invoice": invoice})
846
 
847
 
848
- @app.route("/api/invoices/<invoice_id>", methods=["PUT", "DELETE"])
849
- def api_invoice_detail(invoice_id: str) -> Any:
850
  try:
851
- login_key = require_auth()
852
  except PermissionError:
853
  return jsonify({"error": "Brak autoryzacji."}), 401
854
 
855
- if DATABASE_AVAILABLE:
856
- try:
857
- account_row = get_account_row(login_key)
858
- except KeyError:
859
- return jsonify({"error": "Nie znaleziono konta."}), 404
860
-
861
- invoice_row = fetch_one(
862
- """
863
- SELECT id, issued_at
864
- FROM invoices
865
- WHERE account_id = %s AND invoice_number = %s
866
- """,
867
- (account_row["id"], invoice_id),
868
- )
869
- if not invoice_row:
870
- return jsonify({"error": "Nie znaleziono faktury."}), 404
871
-
872
- if request.method == "DELETE":
873
- execute("DELETE FROM invoice_items WHERE invoice_id = %s", (invoice_row["id"],))
874
- execute("DELETE FROM invoice_vat_summary WHERE invoice_id = %s", (invoice_row["id"],))
875
- execute("DELETE FROM invoices WHERE id = %s", (invoice_row["id"],))
876
- return jsonify({"message": "Faktura zostala usunieta."})
877
-
878
- payload = request.get_json(force=True)
879
- business = get_business_profile(account_row["id"])
880
- if not business:
881
- return jsonify({"error": "Ustaw dane sprzedawcy przed dodaniem faktury."}), 400
882
-
883
- client = validate_client(payload)
884
- try:
885
- invoice = build_invoice(payload, business, client)
886
- except ValueError as error:
887
- return jsonify({"error": str(error)}), 400
888
-
889
- invoice["invoice_id"] = invoice_id
890
- existing_issued_at = invoice_row.get("issued_at")
891
- if isinstance(existing_issued_at, datetime):
892
- invoice["issued_at"] = existing_issued_at.strftime("%Y-%m-%d %H:%M")
893
- elif existing_issued_at:
894
- invoice["issued_at"] = str(existing_issued_at)
895
-
896
- client_id = upsert_client(
897
- account_row["id"],
898
- {
899
- "name": client["name"],
900
- "address_line": client["address_line"],
901
- "postal_code": client["postal_code"],
902
- "city": client["city"],
903
- "tax_id": client["tax_id"],
904
- "phone": client.get("phone"),
905
- },
906
- )
907
-
908
- execute("DELETE FROM invoice_items WHERE invoice_id = %s", (invoice_row["id"],))
909
- execute("DELETE FROM invoice_vat_summary WHERE invoice_id = %s", (invoice_row["id"],))
910
- execute("DELETE FROM invoices WHERE id = %s", (invoice_row["id"],))
911
- insert_invoice(account_row["id"], client_id, invoice)
912
- return jsonify({"message": "Faktura zostala zaktualizowana.", "invoice": invoice})
913
-
914
  data = load_store()
915
- try:
916
- account = get_account(data, login_key)
917
- except KeyError:
918
- return jsonify({"error": "Nie znaleziono konta."}), 404
919
-
920
- invoices = account.get("invoices", [])
921
- invoice_index = next(
922
- (idx for idx, entry in enumerate(invoices) if entry.get("invoice_id") == invoice_id),
923
- None,
924
- )
925
- if invoice_index is None:
926
- return jsonify({"error": "Nie znaleziono faktury."}), 404
927
-
928
- if request.method == "DELETE":
929
- invoices.pop(invoice_index)
930
- save_store(data)
931
- return jsonify({"message": "Faktura zostala usunieta."})
932
 
933
- payload = request.get_json(force=True)
934
- business = account.get("business")
935
- if not business:
936
- return jsonify({"error": "Ustaw dane sprzedawcy przed dodaniem faktury."}), 400
937
 
938
- client = validate_client(payload)
939
  try:
940
- invoice = build_invoice(payload, business, client)
941
  except ValueError as error:
942
  return jsonify({"error": str(error)}), 400
943
 
944
- invoice["invoice_id"] = invoice_id
945
- existing_invoice = invoices[invoice_index]
946
- if existing_invoice.get("issued_at"):
947
- invoice["issued_at"] = existing_invoice.get("issued_at")
948
- invoices[invoice_index] = invoice
949
- save_store(data)
950
- return jsonify({"message": "Faktura zostala zaktualizowana.", "invoice": invoice})
951
-
952
-
953
- @app.route("/api/invoices/summary", methods=["GET"])
954
- def api_invoice_summary() -> Any:
955
- try:
956
- login_key = require_auth()
957
- except PermissionError:
958
- return jsonify({"error": "Brak autoryzacji."}), 401
959
 
960
- now = datetime.utcnow()
961
- last_month_start = now - timedelta(days=30)
962
- quarter_first_month = ((now.month - 1) // 3) * 3 + 1
963
- quarter_start = now.replace(month=quarter_first_month, day=1, hour=0, minute=0, second=0, microsecond=0)
964
- year_start = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
965
-
966
- def normalize_issued_at(value: Any) -> Optional[datetime]:
967
- if isinstance(value, datetime):
968
- tzinfo = value.tzinfo
969
- if tzinfo is not None and tzinfo.utcoffset(value) is not None:
970
- return value.astimezone(timezone.utc).replace(tzinfo=None)
971
- return value
972
- if isinstance(value, str):
973
- candidate = value.strip()
974
- if not candidate:
975
- return None
976
- for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
977
- try:
978
- return datetime.strptime(candidate, fmt)
979
- except ValueError:
980
- continue
981
- return None
982
 
983
- def aggregate_from_rows(rows: List[Dict[str, Any]], start: datetime) -> Dict[str, Any]:
984
- count = 0
985
- gross_total = Decimal("0.00")
986
- for row in rows:
987
- issued_dt = normalize_issued_at(row.get("issued_at"))
988
- if issued_dt is None or issued_dt < start:
989
- continue
990
- try:
991
- gross_total += _decimal(row.get("total_gross") or "0")
992
- except ValueError:
993
- continue
994
- count += 1
995
- return {"count": count, "gross_total": str(_quantize(gross_total))}
996
-
997
- if DATABASE_AVAILABLE:
998
- try:
999
- account_row = get_account_row(login_key)
1000
- except KeyError:
1001
- return jsonify({"error": "Nie znaleziono konta."}), 404
1002
- rows = fetch_all(
1003
- """
1004
- SELECT issued_at, total_gross
1005
- FROM invoices
1006
- WHERE account_id = %s
1007
- """,
1008
- (account_row["id"],),
1009
- )
1010
- summary = {
1011
- "last_month": aggregate_from_rows(rows, last_month_start),
1012
- "quarter": aggregate_from_rows(rows, quarter_start),
1013
- "year": aggregate_from_rows(rows, year_start),
1014
- }
1015
- return jsonify({"summary": summary})
1016
 
1017
- data = load_store()
1018
- try:
1019
- account = get_account(data, login_key)
1020
- except KeyError:
1021
- return jsonify({"error": "Nie znaleziono konta."}), 404
1022
- invoices = account.get("invoices", [])
1023
- rows = [
1024
- {
1025
- "issued_at": invoice.get("issued_at"),
1026
- "total_gross": (invoice.get("totals") or {}).get("gross", "0"),
1027
- }
1028
- for invoice in invoices
1029
- ]
1030
 
1031
- summary = {
1032
- "last_month": aggregate_from_rows(rows, last_month_start),
1033
- "quarter": aggregate_from_rows(rows, quarter_start),
1034
- "year": aggregate_from_rows(rows, year_start),
1035
- }
1036
- return jsonify({"summary": summary})
1037
-
1038
-
1039
- if __name__ == "__main__":
1040
- port = int(os.environ.get("PORT", "5000"))
1041
- app.run(host="0.0.0.0", port=port, debug=True)
 
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
17
+
18
+ from flask import Flask, jsonify, request, send_from_directory
19
+
20
+ 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"),
27
+ "8": Decimal("0.08"),
28
+ "5": Decimal("0.05"),
29
+ "0": Decimal("0.00"),
30
+ "ZW": None,
31
+ "NP": None,
32
+ }
33
+
34
+ SESSION_TOKENS: Dict[str, datetime] = {}
35
+
36
+ ALLOWED_STATIC = {
37
+ "index.html",
38
+ "styles.css",
39
+ "main.js",
40
+ "favicon.ico",
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
+
68
+
69
+ def _quantize(value: Decimal) -> Decimal:
70
+ return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
71
+
72
+
73
  def _decimal(value: Any) -> Decimal:
74
  try:
75
  return Decimal(str(value))
 
77
  raise ValueError(f"Niepoprawna wartosc liczby: {value}") from error
78
 
79
 
80
+ 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:
92
+ DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
93
+ with DATA_FILE.open("w", encoding="utf-8") as handle:
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]:
103
+ if not value:
104
+ return None
105
  try:
106
+ parsed = datetime.fromisoformat(value)
107
+ return parsed.strftime("%Y-%m-%d")
108
+ except ValueError:
109
+ return None
110
 
111
 
112
+ def compute_invoice_items(items_payload: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], Dict[str, Dict[str, Decimal]]]:
113
+ if not items_payload:
114
+ raise ValueError("Dodaj przynajmniej jedna pozycje.")
115
+
116
+ computed_items: List[Dict[str, Any]] = []
117
+ summary: Dict[str, Dict[str, Decimal]] = {}
118
+
119
+ for raw in items_payload:
120
+ name = (raw.get("name") or "").strip()
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:
130
+ raise ValueError(f"Nieznana stawka VAT: {vat_code}")
131
+
132
+ unit_price_gross = _decimal(raw.get("unit_price_gross", "0"))
133
+ if unit_price_gross <= 0:
134
+ raise ValueError("Cena brutto musi byc wieksza od zera.")
135
+
136
+ rate = VAT_RATES[vat_code]
137
+ if rate is None:
138
+ unit_price_net = unit_price_gross
139
+ vat_amount = Decimal("0.00")
140
+ else:
141
+ unit_price_net = unit_price_gross / (Decimal("1.00") + rate)
142
+ vat_amount = unit_price_gross - unit_price_net
143
+
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),
160
+ "unit_price_gross": str(unit_price_gross),
161
+ "net_total": str(net_total),
162
+ "vat_amount": str(vat_amount_total),
163
+ "gross_total": str(gross_total),
164
+ }
165
+ )
166
+
167
+ summary_key = vat_label
168
+ bucket = summary.setdefault(summary_key, {"net": Decimal("0.00"), "vat": Decimal("0.00"), "gross": Decimal("0.00")})
169
+ bucket["net"] += net_total
170
+ bucket["vat"] += vat_amount_total
171
+ bucket["gross"] += gross_total
172
+
173
+ return computed_items, summary
174
+
175
+
176
+ def computed_summary_to_serializable(summary: Dict[str, Dict[str, Decimal]]) -> List[Dict[str, str]]:
177
+ serialized = []
178
+ for vat_label, values in summary.items():
179
+ serialized.append(
180
+ {
181
+ "vat_label": vat_label,
182
+ "net_total": str(_quantize(values["net"])),
183
+ "vat_total": str(_quantize(values["vat"])),
184
+ "gross_total": str(_quantize(values["gross"])),
185
+ }
186
+ )
187
+ serialized.sort(key=lambda item: item["vat_label"])
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
+
195
+ net_sum = sum(Decimal(item["net_total"]) for item in computed_items)
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(),
206
+ "address_line": (client_payload.get("address_line") or "").strip(),
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": {
219
+ "net": str(_quantize(net_sum)),
220
+ "vat": str(_quantize(vat_sum)),
221
+ "gross": str(_quantize(gross_sum)),
222
+ },
223
+ "client": client,
224
+ "exemption_note": (payload.get("exemption_note") or "").strip(),
225
+ }
226
+
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
+
236
+ def get_token() -> Optional[str]:
237
+ header = request.headers.get("Authorization", "")
238
+ if not header.startswith("Bearer "):
239
+ return None
240
+ return header.split(" ", 1)[1].strip()
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>")
251
+ def serve_static(path: str) -> Any:
252
+ if path not in ALLOWED_STATIC:
253
+ return jsonify({"error": "Nie znaleziono pliku."}), 404
254
+ target = Path(app.static_folder) / path
255
+ if target.is_file():
256
+ return send_from_directory(app.static_folder, path)
257
+ return jsonify({"error": "Nie znaleziono pliku."}), 404
258
+
259
+
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",
277
  "address_line",
 
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(),
341
+ "address_line": (payload.get("address_line") or current.get("address_line") or "").strip(),
342
+ "postal_code": (payload.get("postal_code") or current.get("postal_code") or "").strip(),
343
+ "city": (payload.get("city") or current.get("city") or "").strip(),
344
+ "tax_id": (payload.get("tax_id") or current.get("tax_id") or "").strip(),
345
+ "bank_account": (payload.get("bank_account") or current.get("bank_account") or "").strip(),
346
+ }
347
 
348
+ missing = [field for field, value in updated.items() if not value]
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
 
356
 
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"
 
 
 
 
 
 
 
 
 
small_logotyp do strony.jpg DELETED
Binary file (4.51 kB)
 
static/css/styles.css ADDED
@@ -0,0 +1,365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: "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
+ h1 {
31
+ text-align: center;
32
+ font-size: 32px;
33
+ margin-bottom: 32px;
34
+ }
35
+
36
+ .panel {
37
+ background: var(--panel-bg);
38
+ border-radius: var(--radius);
39
+ box-shadow: var(--shadow);
40
+ padding: 28px 32px;
41
+ margin-bottom: 32px;
42
+ border: 1px solid rgba(32, 33, 36, 0.08);
43
+ }
44
+
45
+ .panel h2 {
46
+ margin-top: 0;
47
+ }
48
+
49
+ .hidden {
50
+ display: none;
51
+ }
52
+
53
+ .form {
54
+ display: grid;
55
+ gap: 20px;
56
+ }
57
+
58
+ .field-grid {
59
+ display: grid;
60
+ gap: 18px 24px;
61
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
62
+ }
63
+
64
+ label {
65
+ display: grid;
66
+ gap: 8px;
67
+ font-weight: 600;
68
+ font-size: 15px;
69
+ }
70
+
71
+ input,
72
+ textarea,
73
+ select {
74
+ padding: 12px 14px;
75
+ border-radius: 8px;
76
+ border: 1px solid var(--border);
77
+ font-size: 15px;
78
+ background: #fbfbff;
79
+ }
80
+
81
+ input:focus,
82
+ textarea:focus,
83
+ select:focus {
84
+ outline: 2px solid rgba(26, 115, 232, 0.35);
85
+ outline-offset: 1px;
86
+ border-color: var(--accent);
87
+ background: #ffffff;
88
+ }
89
+
90
+ textarea {
91
+ resize: vertical;
92
+ min-height: 96px;
93
+ }
94
+
95
+ button {
96
+ padding: 12px 20px;
97
+ border-radius: 8px;
98
+ border: none;
99
+ font-size: 15px;
100
+ font-weight: 600;
101
+ background: var(--accent);
102
+ color: white;
103
+ cursor: pointer;
104
+ transition: background 0.2s ease;
105
+ }
106
+
107
+ button:hover {
108
+ background: #0f5ec4;
109
+ }
110
+
111
+ button:disabled {
112
+ opacity: 0.6;
113
+ cursor: not-allowed;
114
+ }
115
+
116
+ .link-button {
117
+ background: none;
118
+ color: var(--accent);
119
+ padding: 0;
120
+ }
121
+
122
+ .link-button:hover {
123
+ color: #0f5ec4;
124
+ background: none;
125
+ }
126
+
127
+ .hint {
128
+ color: var(--muted);
129
+ font-size: 13px;
130
+ margin: 0;
131
+ }
132
+
133
+ .feedback {
134
+ color: var(--muted);
135
+ min-height: 20px;
136
+ font-size: 14px;
137
+ }
138
+
139
+ .feedback.error {
140
+ color: var(--danger);
141
+ }
142
+
143
+ .feedback.success {
144
+ color: #188038;
145
+ }
146
+
147
+ .invoice-header {
148
+ display: flex;
149
+ justify-content: space-between;
150
+ align-items: center;
151
+ margin-bottom: 24px;
152
+ }
153
+
154
+ .business-section {
155
+ border: 1px solid rgba(32, 33, 36, 0.08);
156
+ border-radius: var(--radius);
157
+ padding: 20px 24px;
158
+ background: #fbfcff;
159
+ margin-bottom: 24px;
160
+ }
161
+
162
+ .business-section-header {
163
+ display: flex;
164
+ justify-content: space-between;
165
+ align-items: center;
166
+ margin-bottom: 12px;
167
+ }
168
+
169
+ .business-display {
170
+ display: grid;
171
+ gap: 6px;
172
+ font-size: 15px;
173
+ line-height: 1.4;
174
+ }
175
+
176
+ .business-display strong {
177
+ font-weight: 600;
178
+ }
179
+
180
+ .form-actions {
181
+ display: flex;
182
+ align-items: center;
183
+ gap: 16px;
184
+ }
185
+
186
+ .items-section {
187
+ border: 1px solid rgba(32, 33, 36, 0.08);
188
+ border-radius: var(--radius);
189
+ padding: 20px 24px;
190
+ background: #ffffff;
191
+ display: grid;
192
+ gap: 18px;
193
+ }
194
+
195
+ .items-header {
196
+ display: flex;
197
+ justify-content: space-between;
198
+ align-items: center;
199
+ }
200
+
201
+ .items-table-wrapper {
202
+ overflow-x: auto;
203
+ }
204
+
205
+ .items-table {
206
+ width: 100%;
207
+ border-collapse: collapse;
208
+ font-size: 14px;
209
+ }
210
+
211
+ .items-table th,
212
+ .items-table td {
213
+ border: 1px solid var(--border);
214
+ padding: 10px 12px;
215
+ text-align: left;
216
+ }
217
+
218
+ .items-table th {
219
+ background: #f1f3f7;
220
+ font-weight: 600;
221
+ text-transform: uppercase;
222
+ letter-spacing: 0.02em;
223
+ }
224
+
225
+ .items-table input,
226
+ .items-table select {
227
+ width: 100%;
228
+ padding: 8px 10px;
229
+ border-radius: 6px;
230
+ border: 1px solid var(--border);
231
+ background: #ffffff;
232
+ }
233
+
234
+ .items-table .remove-item {
235
+ color: var(--danger);
236
+ background: none;
237
+ padding: 0;
238
+ }
239
+
240
+ .items-table .remove-item:hover {
241
+ text-decoration: underline;
242
+ }
243
+
244
+ .totals {
245
+ display: flex;
246
+ flex-wrap: wrap;
247
+ gap: 16px;
248
+ font-weight: 600;
249
+ margin-top: 8px;
250
+ padding: 12px 16px;
251
+ border-radius: 8px;
252
+ background: #f7f9ff;
253
+ }
254
+
255
+ .rate-summary {
256
+ display: grid;
257
+ gap: 10px;
258
+ margin-top: 8px;
259
+ }
260
+
261
+ .rate-summary-item {
262
+ display: flex;
263
+ flex-wrap: wrap;
264
+ gap: 12px;
265
+ font-weight: 600;
266
+ padding: 8px 12px;
267
+ border: 1px dashed rgba(26, 115, 232, 0.25);
268
+ border-radius: 6px;
269
+ background: #ffffff;
270
+ }
271
+
272
+ .invoice-preview {
273
+ display: grid;
274
+ gap: 24px;
275
+ }
276
+
277
+ .invoice-preview-meta {
278
+ display: flex;
279
+ flex-wrap: wrap;
280
+ gap: 24px;
281
+ font-size: 14px;
282
+ }
283
+
284
+ .invoice-preview-meta span {
285
+ display: inline-flex;
286
+ align-items: center;
287
+ gap: 6px;
288
+ }
289
+
290
+ .invoice-preview-header {
291
+ display: flex;
292
+ flex-wrap: wrap;
293
+ gap: 24px;
294
+ }
295
+
296
+ .invoice-preview-card {
297
+ flex: 1 1 280px;
298
+ border: 1px solid var(--border);
299
+ border-radius: var(--radius);
300
+ padding: 16px 20px;
301
+ background: #f9fafc;
302
+ }
303
+
304
+ .invoice-preview-card h4 {
305
+ margin: 0 0 12px;
306
+ font-size: 14px;
307
+ text-transform: uppercase;
308
+ letter-spacing: 0.05em;
309
+ }
310
+
311
+ .invoice-preview-card p {
312
+ margin: 4px 0;
313
+ font-size: 14px;
314
+ }
315
+
316
+ .invoice-preview table {
317
+ width: 100%;
318
+ border-collapse: collapse;
319
+ font-size: 14px;
320
+ }
321
+
322
+ .invoice-preview th,
323
+ .invoice-preview td {
324
+ border: 1px solid var(--border);
325
+ padding: 10px 12px;
326
+ text-align: left;
327
+ }
328
+
329
+ .invoice-preview th {
330
+ background: #f1f3f7;
331
+ font-weight: 600;
332
+ }
333
+
334
+ .invoice-preview-summary {
335
+ display: flex;
336
+ justify-content: flex-end;
337
+ flex-wrap: wrap;
338
+ gap: 16px;
339
+ font-weight: 600;
340
+ font-size: 15px;
341
+ }
342
+
343
+ .invoice-preview-note {
344
+ padding: 12px 16px;
345
+ border-left: 3px solid var(--accent);
346
+ background: #f4f8ff;
347
+ font-size: 14px;
348
+ }
349
+
350
+ @media (max-width: 640px) {
351
+ .panel {
352
+ padding: 20px;
353
+ }
354
+
355
+ .invoice-header {
356
+ flex-direction: column;
357
+ gap: 12px;
358
+ align-items: flex-start;
359
+ }
360
+
361
+ .business-section,
362
+ .items-section {
363
+ padding: 16px;
364
+ }
365
+ }
static/js/main.js ADDED
@@ -0,0 +1,984 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 setupSection = document.getElementById("setup-section");
20
+ const loginSection = document.getElementById("login-section");
21
+ const appSection = document.getElementById("app-section");
22
+
23
+ const setupForm = document.getElementById("setup-form");
24
+ const loginForm = document.getElementById("login-form");
25
+ const invoiceForm = document.getElementById("invoice-form");
26
+ const businessForm = document.getElementById("business-form");
27
+
28
+ const setupFeedback = document.getElementById("setup-feedback");
29
+ const loginFeedback = document.getElementById("login-feedback");
30
+ const businessFeedback = document.getElementById("business-feedback");
31
+
32
+ const businessDisplay = document.getElementById("business-display");
33
+ const toggleBusinessFormButton = document.getElementById("toggle-business-form");
34
+ const cancelBusinessUpdateButton = document.getElementById("cancel-business-update");
35
+
36
+ const itemsBody = document.getElementById("items-body");
37
+ const addItemButton = document.getElementById("add-item-button");
38
+
39
+ const totalNetLabel = document.getElementById("total-net");
40
+ const totalVatLabel = document.getElementById("total-vat");
41
+ const totalGrossLabel = document.getElementById("total-gross");
42
+ const rateSummaryContainer = document.getElementById("rate-summary");
43
+
44
+ const exemptionNoteWrapper = document.getElementById("exemption-note-wrapper");
45
+ const exemptionNoteInput = document.getElementById("exemption-note");
46
+
47
+ const invoiceResult = document.getElementById("invoice-result");
48
+ const invoiceOutput = document.getElementById("invoice-output");
49
+ const downloadButton = document.getElementById("download-button");
50
+ const logoutButton = document.getElementById("logout-button");
51
+
52
+ let authToken = sessionStorage.getItem("invoiceAuthToken") || null;
53
+ let currentBusiness = null;
54
+ let lastInvoice = null;
55
+ let pdfFontPromise = null;
56
+ let pdfFontBase64 = null;
57
+
58
+ function setState(state) {
59
+ setupSection.classList.add("hidden");
60
+ loginSection.classList.add("hidden");
61
+ appSection.classList.add("hidden");
62
+
63
+ if (state === "setup") {
64
+ setupSection.classList.remove("hidden");
65
+ } else if (state === "login") {
66
+ loginSection.classList.remove("hidden");
67
+ } else if (state === "app") {
68
+ appSection.classList.remove("hidden");
69
+ }
70
+ }
71
+
72
+ function clearFeedback(element) {
73
+ element.textContent = "";
74
+ element.classList.remove("error", "success");
75
+ }
76
+
77
+ function showFeedback(element, message, type = "error") {
78
+ element.textContent = message;
79
+ element.classList.remove("error", "success");
80
+ if (type) {
81
+ element.classList.add(type);
82
+ }
83
+ }
84
+
85
+ function parseNumber(value) {
86
+ if (typeof value === "number") {
87
+ return Number.isFinite(value) ? value : 0;
88
+ }
89
+ if (!value) {
90
+ return 0;
91
+ }
92
+ const normalized = value.toString().replace(",", ".");
93
+ const parsed = Number.parseFloat(normalized);
94
+ return Number.isFinite(parsed) ? parsed : 0;
95
+ }
96
+
97
+ function formatCurrency(value) {
98
+ const number = parseNumber(value);
99
+ return `${number.toFixed(2)} PLN`;
100
+ }
101
+
102
+ function vatLabelFromCode(code) {
103
+ if (code === "ZW" || code === "0") {
104
+ return "ZW";
105
+ }
106
+ if (code === "NP") {
107
+ return "NP";
108
+ }
109
+ return `${code}%`;
110
+ }
111
+
112
+ function requiresExemption(code) {
113
+ return code === "ZW" || code === "0";
114
+ }
115
+
116
+ async function apiRequest(path, { method = "GET", body, headers = {} } = {}, requireAuth = false) {
117
+ const options = {
118
+ method,
119
+ headers: {
120
+ "Content-Type": "application/json",
121
+ ...headers,
122
+ },
123
+ };
124
+
125
+ if (body !== undefined) {
126
+ options.body = JSON.stringify(body);
127
+ }
128
+
129
+ if (requireAuth) {
130
+ if (!authToken) {
131
+ throw new Error("Brak tokenu autoryzacyjnego.");
132
+ }
133
+ options.headers.Authorization = `Bearer ${authToken}`;
134
+ }
135
+
136
+ const response = await fetch(path, options);
137
+ const isJson = response.headers.get("content-type")?.includes("application/json");
138
+ const data = isJson ? await response.json() : {};
139
+
140
+ if (response.status === 401) {
141
+ authToken = null;
142
+ sessionStorage.removeItem("invoiceAuthToken");
143
+ setState("login");
144
+ throw new Error(data.error || "Sesja wygasla. Zaloguj sie ponownie.");
145
+ }
146
+
147
+ if (!response.ok) {
148
+ throw new Error(data.error || "Wystapil blad podczas komunikacji z serwerem.");
149
+ }
150
+
151
+ return data;
152
+ }
153
+
154
+ function renderBusinessDisplay(business) {
155
+ if (!business) {
156
+ businessDisplay.textContent = "Brak zapisanych danych firmy.";
157
+ return;
158
+ }
159
+
160
+ businessDisplay.innerHTML = `
161
+ <p><strong>${business.company_name}</strong></p>
162
+ <p>${business.owner_name}</p>
163
+ <p>${business.address_line}</p>
164
+ <p>${business.postal_code} ${business.city}</p>
165
+ <p>NIP: ${business.tax_id}</p>
166
+ <p>Konto: ${business.bank_account}</p>
167
+ `;
168
+ }
169
+
170
+ function fillBusinessForm(business) {
171
+ if (!business) {
172
+ return;
173
+ }
174
+ businessForm.elements.company_name.value = business.company_name || "";
175
+ businessForm.elements.owner_name.value = business.owner_name || "";
176
+ businessForm.elements.address_line.value = business.address_line || "";
177
+ businessForm.elements.postal_code.value = business.postal_code || "";
178
+ businessForm.elements.city.value = business.city || "";
179
+ businessForm.elements.tax_id.value = business.tax_id || "";
180
+ businessForm.elements.bank_account.value = business.bank_account || "";
181
+ }
182
+
183
+ function vatSelectElement(initialValue = "23") {
184
+ const select = document.createElement("select");
185
+ select.className = "item-vat";
186
+ VAT_OPTIONS.forEach((option) => {
187
+ const element = document.createElement("option");
188
+ element.value = option.value;
189
+ element.textContent = option.label;
190
+ select.appendChild(element);
191
+ });
192
+ select.value = VAT_OPTIONS.some((option) => option.value === initialValue) ? initialValue : "23";
193
+ return select;
194
+ }
195
+
196
+ function createItemRow(initialValues = {}) {
197
+ const row = document.createElement("tr");
198
+
199
+ const nameCell = document.createElement("td");
200
+ const nameInput = document.createElement("input");
201
+ nameInput.type = "text";
202
+ nameInput.className = "item-name";
203
+ nameInput.placeholder = "Nazwa towaru lub uslugi";
204
+ if (initialValues.name) {
205
+ nameInput.value = initialValues.name;
206
+ }
207
+ nameCell.appendChild(nameInput);
208
+
209
+ const quantityCell = document.createElement("td");
210
+ const quantityInput = document.createElement("input");
211
+ quantityInput.type = "number";
212
+ quantityInput.className = "item-quantity";
213
+ quantityInput.min = "0.01";
214
+ quantityInput.step = "0.01";
215
+ quantityInput.value = initialValues.quantity ?? "1";
216
+ quantityCell.appendChild(quantityInput);
217
+
218
+ const unitGrossCell = document.createElement("td");
219
+ const unitGrossInput = document.createElement("input");
220
+ unitGrossInput.type = "number";
221
+ unitGrossInput.className = "item-gross";
222
+ unitGrossInput.min = "0.01";
223
+ unitGrossInput.step = "0.01";
224
+ unitGrossInput.placeholder = "Brutto";
225
+ if (initialValues.unit_price_gross) {
226
+ unitGrossInput.value = initialValues.unit_price_gross;
227
+ }
228
+ unitGrossCell.appendChild(unitGrossInput);
229
+
230
+ const vatCell = document.createElement("td");
231
+ const vatSelect = vatSelectElement(initialValues.vat_code);
232
+ vatCell.appendChild(vatSelect);
233
+
234
+ const totalCell = document.createElement("td");
235
+ totalCell.className = "item-total";
236
+ totalCell.textContent = "0.00 PLN";
237
+
238
+ const actionsCell = document.createElement("td");
239
+ const removeButton = document.createElement("button");
240
+ removeButton.type = "button";
241
+ removeButton.className = "remove-item";
242
+ removeButton.textContent = "Usun";
243
+ actionsCell.appendChild(removeButton);
244
+
245
+ row.appendChild(nameCell);
246
+ row.appendChild(quantityCell);
247
+ row.appendChild(unitGrossCell);
248
+ row.appendChild(vatCell);
249
+ row.appendChild(totalCell);
250
+ row.appendChild(actionsCell);
251
+
252
+ const handleChange = () => updateTotals();
253
+ nameInput.addEventListener("input", handleChange);
254
+ quantityInput.addEventListener("input", handleChange);
255
+ unitGrossInput.addEventListener("input", handleChange);
256
+ vatSelect.addEventListener("change", handleChange);
257
+
258
+ removeButton.addEventListener("click", () => {
259
+ if (itemsBody.children.length === 1) {
260
+ nameInput.value = "";
261
+ quantityInput.value = "1";
262
+ unitGrossInput.value = "";
263
+ vatSelect.value = "23";
264
+ updateTotals();
265
+ return;
266
+ }
267
+ row.remove();
268
+ updateTotals();
269
+ });
270
+
271
+ itemsBody.appendChild(row);
272
+ updateTotals();
273
+ }
274
+
275
+ function calculateRowTotals(row) {
276
+ const name = row.querySelector(".item-name")?.value.trim() ?? "";
277
+ const quantity = parseNumber(row.querySelector(".item-quantity")?.value);
278
+ const unitGross = parseNumber(row.querySelector(".item-gross")?.value);
279
+ const vatCode = row.querySelector(".item-vat")?.value ?? "23";
280
+ const rate = VAT_RATE_VALUES[vatCode] ?? 0;
281
+
282
+ const hasValues = name || quantity > 0 || unitGross > 0;
283
+ if (!hasValues) {
284
+ return {
285
+ valid: false,
286
+ vatCode,
287
+ vatLabel: vatLabelFromCode(vatCode),
288
+ requiresExemption: requiresExemption(vatCode),
289
+ quantity,
290
+ unitGross,
291
+ unitNet: 0,
292
+ netTotal: 0,
293
+ vatAmount: 0,
294
+ grossTotal: 0,
295
+ };
296
+ }
297
+
298
+ if (quantity <= 0 || unitGross <= 0) {
299
+ return {
300
+ valid: false,
301
+ vatCode,
302
+ vatLabel: vatLabelFromCode(vatCode),
303
+ requiresExemption: requiresExemption(vatCode),
304
+ quantity,
305
+ unitGross,
306
+ unitNet: 0,
307
+ netTotal: 0,
308
+ vatAmount: 0,
309
+ grossTotal: quantity * unitGross,
310
+ };
311
+ }
312
+
313
+ const grossTotal = quantity * unitGross;
314
+ const netTotal = rate > 0 ? grossTotal / (1 + rate) : grossTotal;
315
+ const vatAmount = grossTotal - netTotal;
316
+ const unitNet = netTotal / quantity;
317
+
318
+ return {
319
+ valid: true,
320
+ vatCode,
321
+ vatLabel: vatLabelFromCode(vatCode),
322
+ requiresExemption: requiresExemption(vatCode),
323
+ quantity,
324
+ unitGross,
325
+ unitNet,
326
+ netTotal,
327
+ vatAmount,
328
+ grossTotal,
329
+ };
330
+ }
331
+
332
+ function updateTotals() {
333
+ let totalNet = 0;
334
+ let totalVat = 0;
335
+ let totalGross = 0;
336
+ const summary = new Map();
337
+ let exemptionNeeded = false;
338
+
339
+ const rows = Array.from(itemsBody.querySelectorAll("tr"));
340
+ rows.forEach((row) => {
341
+ const totals = calculateRowTotals(row);
342
+ if (totals.requiresExemption) {
343
+ exemptionNeeded = true;
344
+ }
345
+ const totalCell = row.querySelector(".item-total");
346
+ totalCell.textContent = formatCurrency(totals.grossTotal);
347
+
348
+ if (!totals.valid) {
349
+ return;
350
+ }
351
+
352
+ totalNet += totals.netTotal;
353
+ totalVat += totals.vatAmount;
354
+ totalGross += totals.grossTotal;
355
+
356
+ const existing = summary.get(totals.vatLabel) || { net: 0, vat: 0, gross: 0 };
357
+ existing.net += totals.netTotal;
358
+ existing.vat += totals.vatAmount;
359
+ existing.gross += totals.grossTotal;
360
+ summary.set(totals.vatLabel, existing);
361
+ });
362
+
363
+ totalNetLabel.textContent = `Suma netto: ${totalNet.toFixed(2)} PLN`;
364
+ totalVatLabel.textContent = `Kwota VAT: ${totalVat.toFixed(2)} PLN`;
365
+ totalGrossLabel.textContent = `Suma brutto: ${totalGross.toFixed(2)} PLN`;
366
+ renderRateSummary(summary);
367
+
368
+ if (exemptionNeeded) {
369
+ exemptionNoteWrapper.classList.remove("hidden");
370
+ } else {
371
+ exemptionNoteWrapper.classList.add("hidden");
372
+ exemptionNoteInput.value = "";
373
+ }
374
+ }
375
+
376
+ function renderRateSummary(summary) {
377
+ if (!summary || summary.size === 0) {
378
+ rateSummaryContainer.innerHTML = "";
379
+ return;
380
+ }
381
+
382
+ const entries = Array.from(summary.entries()).sort(([a], [b]) => a.localeCompare(b));
383
+ const markup = entries
384
+ .map(
385
+ ([label, totals]) =>
386
+ `<div class="rate-summary-item">
387
+ <span>${label}</span>
388
+ <span>Netto: ${totals.net.toFixed(2)} PLN</span>
389
+ <span>VAT: ${totals.vat.toFixed(2)} PLN</span>
390
+ <span>Brutto: ${totals.gross.toFixed(2)} PLN</span>
391
+ </div>`
392
+ )
393
+ .join("");
394
+ rateSummaryContainer.innerHTML = `<h4>Podsumowanie stawek</h4>${markup}`;
395
+ }
396
+
397
+ function collectInvoicePayload() {
398
+ const items = [];
399
+ const rows = Array.from(itemsBody.querySelectorAll("tr"));
400
+
401
+ rows.forEach((row) => {
402
+ const name = row.querySelector(".item-name")?.value.trim() ?? "";
403
+ const quantity = parseNumber(row.querySelector(".item-quantity")?.value);
404
+ const unitGross = parseNumber(row.querySelector(".item-gross")?.value);
405
+ const vatCode = row.querySelector(".item-vat")?.value ?? "23";
406
+
407
+ const hasValues = name || quantity > 0 || unitGross > 0;
408
+ if (!hasValues) {
409
+ return;
410
+ }
411
+
412
+ if (!name) {
413
+ throw new Error("Kazda pozycja musi miec nazwe.");
414
+ }
415
+ if (quantity <= 0) {
416
+ throw new Error("Ilosc musi byc wieksza od zera.");
417
+ }
418
+ if (unitGross <= 0) {
419
+ throw new Error("Cena brutto musi byc wieksza od zera.");
420
+ }
421
+
422
+ items.push({
423
+ name,
424
+ quantity: quantity.toFixed(2),
425
+ unit_price_gross: unitGross.toFixed(2),
426
+ vat_code: vatCode,
427
+ });
428
+ });
429
+
430
+ if (items.length === 0) {
431
+ throw new Error("Dodaj przynajmniej jedna pozycje.");
432
+ }
433
+
434
+ const saleDate = invoiceForm.elements.saleDate.value || null;
435
+ const exemptionNote = exemptionNoteInput.value.trim();
436
+ const requiresExemptionNote = items.some((item) => item.vat_code === "ZW" || item.vat_code === "0");
437
+ if (requiresExemptionNote && !exemptionNote) {
438
+ throw new Error("Podaj podstawe prawna zwolnienia dla pozycji rozliczanych jako ZW.");
439
+ }
440
+
441
+ const client = {
442
+ name: (invoiceForm.elements.clientName.value || "").trim(),
443
+ tax_id: (invoiceForm.elements.clientTaxId.value || "").trim(),
444
+ address_line: (invoiceForm.elements.clientAddress.value || "").trim(),
445
+ postal_code: (invoiceForm.elements.clientPostalCode.value || "").trim(),
446
+ city: (invoiceForm.elements.clientCity.value || "").trim(),
447
+ };
448
+
449
+ return {
450
+ sale_date: saleDate,
451
+ client,
452
+ items,
453
+ exemption_note: exemptionNote,
454
+ };
455
+ }
456
+
457
+ function renderInvoicePreview(invoice) {
458
+ if (!invoice || !currentBusiness) {
459
+ invoiceOutput.innerHTML = "<p>Brak danych faktury.</p>";
460
+ return;
461
+ }
462
+
463
+ const client = invoice.client || {};
464
+ const hasClientData = client.name || client.address_line || client.postal_code || client.city || client.tax_id;
465
+
466
+ const itemsRows = invoice.items
467
+ .map(
468
+ (item) => `
469
+ <tr>
470
+ <td>${item.name}</td>
471
+ <td>${parseNumber(item.quantity).toFixed(2)}</td>
472
+ <td>${formatCurrency(item.unit_price_net)}</td>
473
+ <td>${formatCurrency(item.net_total)}</td>
474
+ <td>${item.vat_label}</td>
475
+ <td>${formatCurrency(item.vat_amount)}</td>
476
+ <td>${formatCurrency(item.gross_total)}</td>
477
+ </tr>`
478
+ )
479
+ .join("");
480
+
481
+ const summaryRows = (invoice.summary || [])
482
+ .map(
483
+ (entry) =>
484
+ `<div class="rate-summary-item">
485
+ <span>${entry.vat_label}</span>
486
+ <span>Netto: ${formatCurrency(entry.net_total)}</span>
487
+ <span>VAT: ${formatCurrency(entry.vat_total)}</span>
488
+ <span>Brutto: ${formatCurrency(entry.gross_total)}</span>
489
+ </div>`
490
+ )
491
+ .join("");
492
+
493
+ invoiceOutput.innerHTML = `
494
+ <div class="invoice-preview-meta">
495
+ <span><strong>Numer:</strong> ${invoice.invoice_id}</span>
496
+ <span><strong>Data wystawienia:</strong> ${invoice.issued_at}</span>
497
+ <span><strong>Data sprzedazy:</strong> ${invoice.sale_date}</span>
498
+ </div>
499
+ <div class="invoice-preview-header">
500
+ <div class="invoice-preview-card">
501
+ <h4>Nabywca</h4>
502
+ ${
503
+ hasClientData
504
+ ? `
505
+ <p>${client.name || "---"}</p>
506
+ <p>${client.address_line || "---"}</p>
507
+ <p>${client.postal_code || ""} ${client.city || ""}</p>
508
+ <p>${client.tax_id ? `NIP: ${client.tax_id}` : ""}</p>
509
+ `
510
+ : "<p>Brak danych nabywcy.</p>"
511
+ }
512
+ </div>
513
+ <div class="invoice-preview-card">
514
+ <h4>Sprzedawca</h4>
515
+ <p>${currentBusiness.company_name}</p>
516
+ <p>${currentBusiness.owner_name}</p>
517
+ <p>${currentBusiness.address_line}</p>
518
+ <p>${currentBusiness.postal_code} ${currentBusiness.city}</p>
519
+ <p>NIP: ${currentBusiness.tax_id}</p>
520
+ <p>Konto: ${currentBusiness.bank_account}</p>
521
+ </div>
522
+ </div>
523
+ <table>
524
+ <thead>
525
+ <tr>
526
+ <th>Nazwa</th>
527
+ <th>Ilość</th>
528
+ <th>Cena jedn. netto</th>
529
+ <th>Wartość netto (pozycja)</th>
530
+ <th>Stawka VAT</th>
531
+ <th>Kwota VAT (pozycja)</th>
532
+ <th>Wartość brutto</th>
533
+ </tr>
534
+ </thead>
535
+ <tbody>${itemsRows}</tbody>
536
+ </table>
537
+ <div class="rate-summary">
538
+ <h4>Podsumowanie stawek</h4>
539
+ ${summaryRows}
540
+ </div>
541
+ <div class="invoice-preview-summary">
542
+ <span>Netto: ${formatCurrency(invoice.totals.net)}</span>
543
+ <span>VAT: ${formatCurrency(invoice.totals.vat)}</span>
544
+ <span>Brutto: ${formatCurrency(invoice.totals.gross)}</span>
545
+ </div>
546
+ ${
547
+ invoice.exemption_note
548
+ ? `<div class="invoice-preview-note"><strong>Podstawa prawna zwolnienia:</strong> ${invoice.exemption_note}</div>`
549
+ : ""
550
+ }
551
+ `;
552
+ }
553
+
554
+ function drawPartyBox(doc, title, lines, x, y, width) {
555
+ const lineHeight = 5;
556
+ const wrappedLines = lines.flatMap((line) => doc.splitTextToSize(line, width));
557
+ const boxHeight = wrappedLines.length * lineHeight + 14;
558
+
559
+ doc.roundedRect(x - 4, y - 8, width + 8, boxHeight, 2, 2);
560
+ doc.setFontSize(11);
561
+ doc.text(title, x, y);
562
+ doc.setFontSize(10);
563
+
564
+ let cursor = y + 5;
565
+ wrappedLines.forEach((line) => {
566
+ doc.text(line, x, cursor);
567
+ cursor += lineHeight;
568
+ });
569
+
570
+ return y - 8 + boxHeight;
571
+ }
572
+
573
+ function arrayBufferToBase64(buffer) {
574
+ const bytes = new Uint8Array(buffer);
575
+ const chunkSize = 0x8000;
576
+ let binary = "";
577
+ for (let offset = 0; offset < bytes.length; offset += chunkSize) {
578
+ const chunk = bytes.subarray(offset, Math.min(offset + chunkSize, bytes.length));
579
+ binary += String.fromCharCode.apply(null, chunk);
580
+ }
581
+ return btoa(binary);
582
+ }
583
+
584
+ const PDF_FONT_FILE = "Roboto-VariableFont_wdth,wght.ttf";
585
+ const PDF_FONT_NAME = "RobotoPolish";
586
+
587
+ async function ensurePdfFont() {
588
+ if (pdfFontPromise) {
589
+ return pdfFontPromise;
590
+ }
591
+
592
+ if (!window.jspdf || !window.jspdf.jsPDF) {
593
+ throw new Error("Biblioteka jsPDF nie zostala zaladowana.");
594
+ }
595
+
596
+ const { jsPDF } = window.jspdf;
597
+ const loadBase64 = async () => {
598
+ if (typeof window !== "undefined" && window.PDF_FONT_BASE64) {
599
+ return window.PDF_FONT_BASE64;
600
+ }
601
+ const response = await fetch(`/${encodeURIComponent(PDF_FONT_FILE)}`);
602
+ if (!response.ok) {
603
+ throw new Error(`Nie udalo sie pobrac czcionki Roboto (status ${response.status}).`);
604
+ }
605
+ const buffer = await response.arrayBuffer();
606
+ return arrayBufferToBase64(buffer);
607
+ };
608
+
609
+ pdfFontPromise = loadBase64().then((data) => {
610
+ pdfFontBase64 = data;
611
+ return data;
612
+ });
613
+
614
+ return pdfFontPromise;
615
+ }
616
+
617
+ async function generatePdf(business, invoice) {
618
+ if (!window.jspdf || !window.jspdf.jsPDF) {
619
+ alert("Biblioteka jsPDF nie zostala zaladowana. Sprawdz polaczenie z internetem.");
620
+ return;
621
+ }
622
+
623
+ let fontBase64;
624
+ try {
625
+ fontBase64 = await ensurePdfFont();
626
+ } catch (error) {
627
+ alert(error.message || "Nie udalo sie przygotowac czcionki do PDF.");
628
+ return;
629
+ }
630
+
631
+ const { jsPDF } = window.jspdf;
632
+ const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
633
+ const marginX = 18;
634
+ let cursorY = 20;
635
+
636
+ if (!doc.getFontList()[PDF_FONT_NAME]) {
637
+ const embeddedFont = pdfFontBase64 || fontBase64;
638
+ doc.addFileToVFS(PDF_FONT_FILE, embeddedFont);
639
+ doc.addFont(PDF_FONT_FILE, PDF_FONT_NAME, "normal");
640
+ }
641
+
642
+ doc.setFont(PDF_FONT_NAME, "normal");
643
+ doc.setFontSize(16);
644
+ doc.text(`Faktura ${invoice.invoice_id}`, marginX, cursorY);
645
+ doc.setFontSize(10);
646
+ doc.text(`Data wystawienia: ${invoice.issued_at}`, marginX, cursorY + 6);
647
+ doc.text(`Data sprzedaży: ${invoice.sale_date}`, marginX, cursorY + 12);
648
+
649
+ cursorY += 22;
650
+ const columnWidth = 85;
651
+ const sellerX = marginX + columnWidth + 12;
652
+
653
+ const clientLines = invoice.client && (invoice.client.name || invoice.client.address_line || invoice.client.city || invoice.client.tax_id)
654
+ ? [
655
+ invoice.client.name || "---",
656
+ invoice.client.address_line || "",
657
+ `${(invoice.client.postal_code || "").trim()} ${(invoice.client.city || "").trim()}`.trim(),
658
+ invoice.client.tax_id ? `NIP: ${invoice.client.tax_id}` : "",
659
+ ].filter((line) => line && line.trim())
660
+ : ["Brak danych nabywcy"];
661
+
662
+ const sellerLines = [
663
+ business.company_name,
664
+ business.owner_name,
665
+ business.address_line,
666
+ `${business.postal_code} ${business.city}`.trim(),
667
+ `NIP: ${business.tax_id}`,
668
+ `Konto: ${business.bank_account}`,
669
+ ];
670
+
671
+ const buyerBottom = drawPartyBox(doc, "NABYWCA", clientLines, marginX, cursorY, columnWidth);
672
+ const sellerBottom = drawPartyBox(doc, "SPRZEDAWCA", sellerLines, sellerX, cursorY, columnWidth);
673
+ cursorY = Math.max(buyerBottom, sellerBottom) + 12;
674
+
675
+ const tableColumns = [
676
+ { key: "name", label: "Nazwa", width: 52 },
677
+ { key: "quantity", label: "Ilość", width: 16 },
678
+ { key: "unitNet", label: "Cena jedn. netto", width: 24 },
679
+ { key: "netTotal", label: "Wartość netto", width: 24 },
680
+ { key: "vatLabel", label: "Stawka VAT", width: 15 },
681
+ { key: "vatAmount", label: "Kwota VAT", width: 22 },
682
+ { key: "grossTotal", label: "Wartość brutto", width: 21 },
683
+ ];
684
+ const tableWidth = tableColumns.reduce((sum, col) => sum + col.width, 0);
685
+ const lineHeight = 5;
686
+ const headerLineHeight = 4.2;
687
+ tableColumns.forEach((column) => {
688
+ column.headerLines = doc.splitTextToSize(column.label, column.width - 4);
689
+ });
690
+ const headerHeight = Math.max(...tableColumns.map((column) => column.headerLines.length * headerLineHeight + 3));
691
+
692
+ doc.setFillColor(241, 243, 247);
693
+ doc.rect(marginX, cursorY, tableWidth, headerHeight, "F");
694
+ doc.rect(marginX, cursorY, tableWidth, headerHeight);
695
+ let offsetX = marginX;
696
+ doc.setFontSize(10);
697
+ tableColumns.forEach((column) => {
698
+ doc.rect(offsetX, cursorY, column.width, headerHeight);
699
+ column.headerLines.forEach((line, index) => {
700
+ const textY = cursorY + 4 + index * headerLineHeight;
701
+ doc.text((line || "").trim(), offsetX + 2, textY);
702
+ });
703
+ offsetX += column.width;
704
+ });
705
+ cursorY += headerHeight;
706
+
707
+ invoice.items.forEach((item) => {
708
+ const quantity = parseNumber(item.quantity).toFixed(2);
709
+ const unitNet = formatCurrency(item.unit_price_net);
710
+ const netTotal = formatCurrency(item.net_total);
711
+ const vatAmount = formatCurrency(item.vat_amount);
712
+ const grossTotal = formatCurrency(item.gross_total);
713
+
714
+ const wrapText = (text, width) =>
715
+ doc
716
+ .splitTextToSize(text ?? "", width)
717
+ .map((line) => line.trim());
718
+
719
+ const columnData = tableColumns.map((column) => {
720
+ switch (column.key) {
721
+ case "name":
722
+ return wrapText(item.name, column.width - 4);
723
+ case "quantity":
724
+ return [quantity];
725
+ case "unitNet":
726
+ return [unitNet];
727
+ case "netTotal":
728
+ return [netTotal];
729
+ case "vatLabel":
730
+ return [item.vat_label];
731
+ case "vatAmount":
732
+ return [vatAmount];
733
+ case "grossTotal":
734
+ return [grossTotal];
735
+ default:
736
+ return [""];
737
+ }
738
+ });
739
+
740
+ const rowHeight = Math.max(...columnData.map((data) => data.length)) * lineHeight + 2;
741
+ offsetX = marginX;
742
+ tableColumns.forEach((column, index) => {
743
+ doc.rect(offsetX, cursorY, column.width, rowHeight);
744
+ const lines = columnData[index];
745
+ lines.forEach((line, lineIndex) => {
746
+ const textY = cursorY + (lineIndex + 1) * lineHeight;
747
+ const content = (line || "").trim();
748
+ doc.text(content, offsetX + 2, textY);
749
+ });
750
+ offsetX += column.width;
751
+ });
752
+
753
+ cursorY += rowHeight;
754
+ });
755
+
756
+ cursorY += 10;
757
+ doc.setFontSize(11);
758
+ doc.text("Podsumowanie stawek:", marginX, cursorY);
759
+ cursorY += 6;
760
+
761
+ const summaryEntries = Array.isArray(invoice.summary) ? invoice.summary : [];
762
+ summaryEntries.forEach((entry) => {
763
+ const summaryLine = `${entry.vat_label} – Netto: ${formatCurrency(entry.net_total)} / VAT: ${formatCurrency(entry.vat_total)} / Brutto: ${formatCurrency(entry.gross_total)}`;
764
+ const wrapped = doc.splitTextToSize(summaryLine, 170);
765
+ wrapped.forEach((line) => {
766
+ doc.text((line || "").trim(), marginX, cursorY);
767
+ cursorY += lineHeight;
768
+ });
769
+ });
770
+
771
+ cursorY += 6;
772
+ doc.setFontSize(12);
773
+ doc.text(`Suma netto: ${formatCurrency(invoice.totals.net)}`, marginX, cursorY);
774
+ doc.text(`Suma VAT: ${formatCurrency(invoice.totals.vat)}`, marginX, cursorY + 6);
775
+ doc.text(`Suma brutto: ${formatCurrency(invoice.totals.gross)}`, marginX, cursorY + 12);
776
+ cursorY += 20;
777
+
778
+ if (invoice.exemption_note) {
779
+ doc.setFontSize(10);
780
+ const noteLines = doc.splitTextToSize(`Podstawa prawna zwolnienia: ${invoice.exemption_note}`, 170);
781
+ doc.text(noteLines, marginX, cursorY);
782
+ }
783
+
784
+ doc.save(`${invoice.invoice_id}.pdf`);
785
+ }
786
+
787
+ async function loadBusinessData() {
788
+ const data = await apiRequest("/api/business", {}, true);
789
+ currentBusiness = data.business;
790
+ renderBusinessDisplay(currentBusiness);
791
+ fillBusinessForm(currentBusiness);
792
+ }
793
+
794
+ function resetInvoiceForm() {
795
+ invoiceForm.reset();
796
+ exemptionNoteInput.value = "";
797
+ exemptionNoteWrapper.classList.add("hidden");
798
+ itemsBody.innerHTML = "";
799
+ createItemRow();
800
+ const today = new Date().toISOString().slice(0, 10);
801
+ invoiceForm.elements.saleDate.value = today;
802
+ }
803
+
804
+ async function bootstrapApp() {
805
+ try {
806
+ await loadBusinessData();
807
+ setState("app");
808
+ } catch (error) {
809
+ console.error(error);
810
+ authToken = null;
811
+ sessionStorage.removeItem("invoiceAuthToken");
812
+ showFeedback(loginFeedback, error.message || "Nie udalo sie pobrac danych firmy.");
813
+ setState("login");
814
+ }
815
+ }
816
+
817
+ async function initialize() {
818
+ resetInvoiceForm();
819
+ try {
820
+ const status = await apiRequest("/api/status");
821
+ if (!status.configured) {
822
+ setState("setup");
823
+ return;
824
+ }
825
+
826
+ if (authToken) {
827
+ await bootstrapApp();
828
+ } else {
829
+ setState("login");
830
+ }
831
+ } catch (error) {
832
+ console.error(error);
833
+ setState("setup");
834
+ showFeedback(setupFeedback, "Nie udalo sie nawiazac polaczenia z serwerem.");
835
+ }
836
+ }
837
+
838
+ setupForm.addEventListener("submit", async (event) => {
839
+ event.preventDefault();
840
+ clearFeedback(setupFeedback);
841
+
842
+ const formData = new FormData(setupForm);
843
+ const password = formData.get("password")?.toString() ?? "";
844
+ const confirmPassword = formData.get("confirm_password")?.toString() ?? "";
845
+
846
+ if (password !== confirmPassword) {
847
+ showFeedback(setupFeedback, "Hasla musza byc identyczne.");
848
+ return;
849
+ }
850
+
851
+ if (password.trim().length < 4) {
852
+ showFeedback(setupFeedback, "Haslo musi miec co najmniej 4 znaki.");
853
+ return;
854
+ }
855
+
856
+ const payload = {
857
+ company_name: formData.get("company_name")?.toString().trim(),
858
+ owner_name: formData.get("owner_name")?.toString().trim(),
859
+ address_line: formData.get("address_line")?.toString().trim(),
860
+ postal_code: formData.get("postal_code")?.toString().trim(),
861
+ city: formData.get("city")?.toString().trim(),
862
+ tax_id: formData.get("tax_id")?.toString().trim(),
863
+ bank_account: formData.get("bank_account")?.toString().trim(),
864
+ password,
865
+ };
866
+
867
+ try {
868
+ await apiRequest("/api/setup", { method: "POST", body: payload });
869
+ showFeedback(setupFeedback, "Dane zapisane. Mozesz sie zalogowac.", "success");
870
+ setTimeout(() => {
871
+ setState("login");
872
+ clearFeedback(setupFeedback);
873
+ setupForm.reset();
874
+ }, 1500);
875
+ } catch (error) {
876
+ showFeedback(setupFeedback, error.message || "Nie udalo sie zapisac danych.");
877
+ }
878
+ });
879
+
880
+ loginForm.addEventListener("submit", async (event) => {
881
+ event.preventDefault();
882
+ clearFeedback(loginFeedback);
883
+
884
+ const password = loginForm.elements.password.value;
885
+ if (!password) {
886
+ showFeedback(loginFeedback, "Podaj haslo.");
887
+ return;
888
+ }
889
+
890
+ try {
891
+ const response = await apiRequest("/api/login", { method: "POST", body: { password } });
892
+ authToken = response.token;
893
+ sessionStorage.setItem("invoiceAuthToken", authToken);
894
+ loginForm.reset();
895
+ await bootstrapApp();
896
+ } catch (error) {
897
+ showFeedback(loginFeedback, error.message || "Logowanie nie powiodlo sie.");
898
+ }
899
+ });
900
+
901
+ toggleBusinessFormButton.addEventListener("click", () => {
902
+ const isHidden = businessForm.classList.contains("hidden");
903
+ if (isHidden) {
904
+ fillBusinessForm(currentBusiness);
905
+ businessForm.classList.remove("hidden");
906
+ toggleBusinessFormButton.textContent = "Ukryj formularz";
907
+ } else {
908
+ businessForm.classList.add("hidden");
909
+ toggleBusinessFormButton.textContent = "Edytuj dane";
910
+ clearFeedback(businessFeedback);
911
+ }
912
+ });
913
+
914
+ cancelBusinessUpdateButton.addEventListener("click", () => {
915
+ businessForm.classList.add("hidden");
916
+ toggleBusinessFormButton.textContent = "Edytuj dane";
917
+ clearFeedback(businessFeedback);
918
+ });
919
+
920
+ businessForm.addEventListener("submit", async (event) => {
921
+ event.preventDefault();
922
+ clearFeedback(businessFeedback);
923
+
924
+ const formData = new FormData(businessForm);
925
+ const payload = {
926
+ company_name: formData.get("company_name")?.toString().trim(),
927
+ owner_name: formData.get("owner_name")?.toString().trim(),
928
+ address_line: formData.get("address_line")?.toString().trim(),
929
+ postal_code: formData.get("postal_code")?.toString().trim(),
930
+ city: formData.get("city")?.toString().trim(),
931
+ tax_id: formData.get("tax_id")?.toString().trim(),
932
+ bank_account: formData.get("bank_account")?.toString().trim(),
933
+ };
934
+
935
+ try {
936
+ const data = await apiRequest("/api/business", { method: "PUT", body: payload }, true);
937
+ currentBusiness = data.business;
938
+ renderBusinessDisplay(currentBusiness);
939
+ showFeedback(businessFeedback, "Dane sprzedawcy zaktualizowane.", "success");
940
+ setTimeout(() => clearFeedback(businessFeedback), 2000);
941
+ } catch (error) {
942
+ showFeedback(businessFeedback, error.message || "Nie udalo sie zaktualizowac danych.");
943
+ }
944
+ });
945
+
946
+ invoiceForm.addEventListener("submit", async (event) => {
947
+ event.preventDefault();
948
+ try {
949
+ const payload = collectInvoicePayload();
950
+ const response = await apiRequest("/api/invoices", { method: "POST", body: payload }, true);
951
+ lastInvoice = response.invoice;
952
+ renderInvoicePreview(lastInvoice);
953
+ invoiceResult.classList.remove("hidden");
954
+ resetInvoiceForm();
955
+ } catch (error) {
956
+ alert(error.message || "Nie udalo sie wygenerowac faktury.");
957
+ }
958
+ });
959
+
960
+ addItemButton.addEventListener("click", () => {
961
+ createItemRow();
962
+ });
963
+
964
+ downloadButton.addEventListener("click", async () => {
965
+ if (!lastInvoice || !currentBusiness) {
966
+ alert("Brak faktury do pobrania. Wygeneruj ja najpierw.");
967
+ return;
968
+ }
969
+ await generatePdf(currentBusiness, lastInvoice);
970
+ });
971
+
972
+ logoutButton.addEventListener("click", () => {
973
+ authToken = null;
974
+ sessionStorage.removeItem("invoiceAuthToken");
975
+ lastInvoice = null;
976
+ currentBusiness = null;
977
+ invoiceResult.classList.add("hidden");
978
+ setState("login");
979
+ });
980
+
981
+ initialize().catch((error) => {
982
+ console.error(error);
983
+ showFeedback(setupFeedback, "Nie udalo sie uruchomic aplikacji.");
984
+ });
styles.css DELETED
@@ -1,1041 +0,0 @@
1
- :root {
2
- --bg: #e9eef5;
3
- --panel-bg: #ffffff;
4
- --surface: #f7f9ff;
5
- --surface-alt: #eef3ff;
6
- --text: #101828;
7
- --muted: #667085;
8
- --accent: #2563eb;
9
- --accent-dark: #1d4ed8;
10
- --accent-soft: rgba(37, 99, 235, 0.1);
11
- --danger: #dc2626;
12
- --border: #dfe4ee;
13
- --radius: 16px;
14
- --radius-sm: 10px;
15
- --shadow: 0 25px 60px rgba(15, 23, 42, 0.08);
16
- font-family: "Roboto", "Segoe UI", Tahoma, sans-serif;
17
- }
18
-
19
- *,
20
- *::before,
21
- *::after {
22
- box-sizing: border-box;
23
- }
24
-
25
- body {
26
- margin: 0;
27
- font-family: "Roboto", "Segoe UI", Tahoma, sans-serif;
28
- color: var(--text);
29
- line-height: 1.6;
30
- background: radial-gradient(circle at top, #f5f7ff 0%, #e7edf8 60%, #dee5f1 100%);
31
- min-height: 100vh;
32
- -webkit-font-smoothing: antialiased;
33
- }
34
-
35
- .container {
36
- max-width: 1200px;
37
- margin: 0 auto;
38
- padding: 48px 32px 80px;
39
- display: grid;
40
- gap: 32px;
41
- }
42
-
43
- .brand-banner {
44
- display: flex;
45
- justify-content: center;
46
- align-items: center;
47
- padding: 8px 0 16px;
48
- }
49
-
50
- .brand-logo {
51
- max-width: 220px;
52
- width: 100%;
53
- height: auto;
54
- border-radius: 16px;
55
- box-shadow: 0 12px 35px rgba(15, 23, 42, 0.18);
56
- }
57
-
58
- .hero-panel {
59
- background: linear-gradient(135deg, #fdfbff 0%, #f5f7ff 50%, #eef3ff 100%);
60
- }
61
-
62
- .hero-columns {
63
- display: grid;
64
- grid-template-columns: minmax(0, 1.2fr) minmax(300px, 0.8fr);
65
- gap: 40px;
66
- align-items: stretch;
67
- }
68
-
69
- .hero-content {
70
- display: grid;
71
- gap: 8px;
72
- }
73
-
74
- .hero-lead {
75
- margin: 0;
76
- color: var(--text);
77
- font-size: 16px;
78
- }
79
-
80
- .eyebrow {
81
- font-size: 13px;
82
- text-transform: uppercase;
83
- letter-spacing: 0.12em;
84
- color: var(--accent);
85
- margin: 0;
86
- }
87
-
88
- .app-title {
89
- font-size: 24px;
90
- margin: 0;
91
- font-weight: 700;
92
- line-height: 1.15;
93
- display: inline-block;
94
- width: fit-content;
95
- }
96
-
97
- .app-title::after {
98
- content: "";
99
- display: block;
100
- width: 100%;
101
- height: 3px;
102
- background: var(--accent);
103
- margin-top: 8px;
104
- border-radius: 999px;
105
- }
106
-
107
- .app-description {
108
- font-size: 16px;
109
- color: var(--muted);
110
- margin: 0;
111
- max-width: 520px;
112
- }
113
-
114
- .header-highlights {
115
- list-style: none;
116
- padding: 0;
117
- margin: 12px 0 0;
118
- display: grid;
119
- gap: 16px;
120
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
121
- }
122
-
123
- .header-highlights li {
124
- padding: 18px;
125
- border-radius: var(--radius-sm);
126
- border: 1px solid rgba(16, 24, 40, 0.08);
127
- background: #ffffff;
128
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
129
- display: grid;
130
- gap: 6px;
131
- }
132
-
133
- .header-highlights strong {
134
- font-size: 15px;
135
- color: var(--accent);
136
- }
137
-
138
- .header-highlights span {
139
- font-size: 14px;
140
- color: var(--muted);
141
- }
142
-
143
- .panel {
144
- background: var(--panel-bg);
145
- border-radius: var(--radius);
146
- box-shadow: var(--shadow);
147
- padding: 36px;
148
- border: 1px solid rgba(15, 23, 42, 0.08);
149
- }
150
-
151
- .auth-card {
152
- border: 1px solid rgba(16, 24, 40, 0.08);
153
- border-radius: var(--radius);
154
- padding: 28px 32px;
155
- background: #ffffff;
156
- display: grid;
157
- gap: 20px;
158
- max-width: 520px;
159
- margin: 0 auto;
160
- width: 100%;
161
- box-shadow: 0 20px 35px rgba(15, 23, 42, 0.08);
162
- }
163
-
164
- .auth-panel {
165
- display: grid;
166
- gap: 24px;
167
- height: 100%;
168
- background: rgba(255, 255, 255, 0.8);
169
- border-radius: var(--radius);
170
- padding: 24px 28px;
171
- border: 2px solid rgba(37, 99, 235, 0.4);
172
- box-shadow: 0 20px 35px rgba(15, 23, 42, 0.1);
173
- }
174
-
175
- .auth-panel-header {
176
- display: grid;
177
- gap: 8px;
178
- }
179
-
180
- .auth-headline {
181
- margin: 0;
182
- font-size: 22px;
183
- }
184
-
185
- .auth-copy {
186
- margin: 0;
187
- color: var(--muted);
188
- font-size: 15px;
189
- }
190
-
191
- .auth-login {
192
- display: flex;
193
- justify-content: center;
194
- }
195
-
196
- .login-card {
197
- width: 100%;
198
- }
199
-
200
- #register-section {
201
- display: flex;
202
- justify-content: center;
203
- }
204
-
205
- #register-section .register-card {
206
- max-width: 640px;
207
- width: 100%;
208
- }
209
-
210
- .register-header {
211
- display: flex;
212
- justify-content: space-between;
213
- align-items: center;
214
- gap: 16px;
215
- }
216
-
217
- .auth-card h3 {
218
- margin: 0;
219
- }
220
-
221
- .auth-card .form {
222
- gap: 18px;
223
- width: 100%;
224
- }
225
-
226
-
227
- .auth-actions {
228
- margin-top: 4px;
229
- display: flex;
230
- flex-wrap: wrap;
231
- align-items: center;
232
- justify-content: space-between;
233
- gap: 12px;
234
- font-size: 14px;
235
- color: var(--muted);
236
- }
237
-
238
- .ghost-button {
239
- padding: 12px 20px;
240
- border: 1px solid rgba(37, 99, 235, 0.35);
241
- border-radius: var(--radius-sm);
242
- background: transparent;
243
- color: var(--accent);
244
- font-weight: 600;
245
- cursor: pointer;
246
- transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
247
- }
248
-
249
- .ghost-button:hover {
250
- background: rgba(37, 99, 235, 0.08);
251
- }
252
-
253
- .form-divider {
254
- border: none;
255
- border-top: 1px solid var(--border);
256
- margin: 16px 0 0;
257
- }
258
-
259
- .register-fields {
260
- display: grid;
261
- gap: 28px;
262
- }
263
-
264
- .register-credentials {
265
- grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
266
- }
267
-
268
- .register-company {
269
- grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
270
- }
271
-
272
- .hidden {
273
- display: none;
274
- }
275
-
276
- .form {
277
- display: grid;
278
- gap: 24px;
279
- width: 100%;
280
- }
281
-
282
- .client-lookup {
283
- position: relative;
284
- display: grid;
285
- gap: 8px;
286
- }
287
-
288
- .client-lookup label {
289
- font-size: 13px;
290
- font-weight: 600;
291
- color: var(--muted);
292
- }
293
-
294
- .client-lookup input {
295
- width: 100%;
296
- }
297
-
298
- .client-suggestions {
299
- position: absolute;
300
- top: 100%;
301
- left: 0;
302
- right: 0;
303
- margin-top: 6px;
304
- background: #ffffff;
305
- border-radius: var(--radius-sm);
306
- border: 1px solid var(--border);
307
- box-shadow: 0 15px 35px rgba(15, 23, 42, 0.15);
308
- z-index: 5;
309
- max-height: 320px;
310
- overflow-y: auto;
311
- display: grid;
312
- }
313
-
314
- .client-suggestion {
315
- display: flex;
316
- flex-direction: column;
317
- align-items: flex-start;
318
- gap: 2px;
319
- padding: 10px 14px;
320
- border: none;
321
- border-bottom: 1px solid rgba(15, 23, 42, 0.08);
322
- background: none;
323
- cursor: pointer;
324
- text-align: left;
325
- font-size: 14px;
326
- }
327
-
328
- .client-suggestion:last-child {
329
- border-bottom: none;
330
- }
331
-
332
- .client-suggestion strong {
333
- font-size: 14px;
334
- color: var(--text);
335
- }
336
-
337
- .client-suggestion span {
338
- font-size: 12px;
339
- color: var(--muted);
340
- }
341
-
342
- .client-suggestions-empty {
343
- padding: 12px 14px;
344
- font-size: 13px;
345
- color: var(--muted);
346
- }
347
-
348
- .field-grid {
349
- display: grid;
350
- gap: 20px 28px;
351
- grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
352
- }
353
-
354
- fieldset {
355
- border: 1px solid rgba(16, 24, 40, 0.1);
356
- border-radius: var(--radius);
357
- padding: 24px 28px 28px;
358
- display: grid;
359
- gap: 18px;
360
- background: var(--surface);
361
- margin: 0;
362
- }
363
-
364
- legend {
365
- font-weight: 700;
366
- font-size: 13px;
367
- text-transform: uppercase;
368
- letter-spacing: 0.08em;
369
- color: var(--muted);
370
- padding: 0 8px;
371
- }
372
-
373
- label {
374
- display: grid;
375
- gap: 8px;
376
- font-weight: 600;
377
- font-size: 14px;
378
- }
379
-
380
- input,
381
- textarea,
382
- select {
383
- padding: 12px 16px;
384
- border-radius: var(--radius-sm);
385
- border: 1px solid var(--border);
386
- font-size: 15px;
387
- background: #ffffff;
388
- min-height: 48px;
389
- transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
390
- }
391
-
392
- input:focus,
393
- textarea:focus,
394
- select:focus {
395
- outline: none;
396
- border-color: var(--accent);
397
- box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25);
398
- background: #ffffff;
399
- }
400
-
401
- textarea {
402
- resize: vertical;
403
- min-height: 120px;
404
- }
405
-
406
- #exemption-note-wrapper {
407
- display: grid;
408
- gap: 12px;
409
- padding: 20px;
410
- border: 1px dashed rgba(16, 24, 40, 0.15);
411
- border-radius: var(--radius-sm);
412
- background: #ffffff;
413
- }
414
-
415
- #exemption-note-wrapper textarea[readonly] {
416
- background: #f4f6fb;
417
- color: var(--muted);
418
- }
419
-
420
- button {
421
- padding: 13px 28px;
422
- border-radius: var(--radius-sm);
423
- border: none;
424
- font-size: 15px;
425
- font-weight: 600;
426
- background: var(--accent);
427
- color: #ffffff;
428
- cursor: pointer;
429
- transition: background 0.2s ease, transform 0.15s ease, box-shadow 0.15s ease;
430
- box-shadow: 0 15px 30px rgba(37, 99, 235, 0.25);
431
- }
432
-
433
- button:hover {
434
- background: var(--accent-dark);
435
- transform: translateY(-1px);
436
- box-shadow: 0 20px 35px rgba(37, 99, 235, 0.3);
437
- }
438
-
439
- button:focus-visible {
440
- outline: 3px solid rgba(37, 99, 235, 0.35);
441
- outline-offset: 2px;
442
- }
443
-
444
- .button {
445
- display: inline-flex;
446
- align-items: center;
447
- justify-content: center;
448
- gap: 8px;
449
- padding: 13px 24px;
450
- border-radius: var(--radius-sm);
451
- border: none;
452
- font-size: 15px;
453
- font-weight: 600;
454
- background: var(--accent);
455
- color: #ffffff;
456
- cursor: pointer;
457
- transition: background 0.2s ease, transform 0.15s ease, box-shadow 0.15s ease;
458
- box-shadow: 0 10px 25px rgba(37, 99, 235, 0.2);
459
- }
460
-
461
- .button.secondary {
462
- background: rgba(37, 99, 235, 0.12);
463
- color: var(--accent);
464
- border: 1px solid rgba(37, 99, 235, 0.25);
465
- box-shadow: none;
466
- }
467
-
468
- .button.secondary:hover {
469
- background: rgba(37, 99, 235, 0.2);
470
- }
471
-
472
- .button input[type="file"] {
473
- display: none;
474
- }
475
-
476
- button:disabled {
477
- opacity: 0.55;
478
- cursor: not-allowed;
479
- transform: none;
480
- box-shadow: none;
481
- }
482
-
483
- .link-button {
484
- background: none;
485
- color: var(--accent);
486
- padding: 0;
487
- border-radius: 0;
488
- box-shadow: none;
489
- transform: none;
490
- font-weight: 600;
491
- transition: color 0.2s ease;
492
- }
493
-
494
- .link-button:hover {
495
- color: var(--accent-dark);
496
- background: none;
497
- box-shadow: none;
498
- transform: none;
499
- }
500
-
501
- .hint {
502
- color: var(--muted);
503
- font-size: 13px;
504
- margin: 0;
505
- }
506
-
507
- .feedback {
508
- color: var(--muted);
509
- min-height: 20px;
510
- font-size: 14px;
511
- }
512
-
513
- .feedback:empty {
514
- display: none;
515
- }
516
-
517
- .feedback.error {
518
- color: var(--danger);
519
- }
520
-
521
- .feedback.success {
522
- color: #188038;
523
- }
524
-
525
- .app-header {
526
- display: flex;
527
- justify-content: space-between;
528
- align-items: center;
529
- flex-wrap: wrap;
530
- gap: 16px;
531
- padding-bottom: 12px;
532
- border-bottom: 1px solid rgba(15, 23, 42, 0.08);
533
- }
534
-
535
- .login-badge {
536
- display: inline-flex;
537
- align-items: center;
538
- gap: 6px;
539
- padding: 8px 14px;
540
- border-radius: 999px;
541
- border: 1px solid rgba(37, 99, 235, 0.3);
542
- background: rgba(37, 99, 235, 0.08);
543
- font-size: 13px;
544
- color: var(--muted);
545
- }
546
-
547
- .badge-label {
548
- text-transform: uppercase;
549
- letter-spacing: 0.08em;
550
- font-weight: 600;
551
- font-size: 11px;
552
- color: var(--muted);
553
- }
554
-
555
- .badge-value {
556
- font-weight: 700;
557
- color: var(--accent);
558
- }
559
-
560
- .app-nav {
561
- display: inline-flex;
562
- flex-wrap: wrap;
563
- gap: 6px;
564
- padding: 6px;
565
- background: var(--surface);
566
- border-radius: 999px;
567
- border: 1px solid rgba(15, 23, 42, 0.08);
568
- }
569
-
570
- .app-nav-button {
571
- background: transparent;
572
- color: var(--muted);
573
- border: none;
574
- padding: 10px 20px;
575
- border-radius: 999px;
576
- box-shadow: none;
577
- transform: none;
578
- transition: background 0.2s ease, color 0.2s ease;
579
- }
580
-
581
- .app-nav-button:hover {
582
- background: rgba(37, 99, 235, 0.08);
583
- color: var(--text);
584
- box-shadow: none;
585
- transform: none;
586
- }
587
-
588
- .app-nav-button.active {
589
- background: #ffffff;
590
- color: var(--accent);
591
- box-shadow: 0 12px 25px rgba(15, 23, 42, 0.12);
592
- }
593
-
594
- .app-nav-button.active:hover {
595
- background: #ffffff;
596
- }
597
-
598
- .app-view {
599
- display: grid;
600
- gap: 32px;
601
- }
602
-
603
- .business-section {
604
- border: 1px solid rgba(15, 23, 42, 0.08);
605
- border-radius: var(--radius);
606
- padding: 28px 32px;
607
- background: linear-gradient(135deg, #ffffff 0%, #f8faff 100%);
608
- display: grid;
609
- gap: 18px;
610
- }
611
-
612
- .business-section-header {
613
- display: flex;
614
- justify-content: space-between;
615
- align-items: center;
616
- margin-bottom: 12px;
617
- flex-wrap: wrap;
618
- gap: 12px;
619
- }
620
-
621
- .business-actions {
622
- display: flex;
623
- gap: 12px;
624
- flex-wrap: wrap;
625
- align-items: center;
626
- }
627
-
628
- .pill-button {
629
- display: inline-flex;
630
- align-items: center;
631
- justify-content: center;
632
- border: 1px solid rgba(37, 99, 235, 0.35);
633
- background: rgba(37, 99, 235, 0.08);
634
- color: var(--accent);
635
- padding: 8px 16px;
636
- border-radius: 999px;
637
- font-size: 14px;
638
- font-weight: 600;
639
- cursor: pointer;
640
- transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
641
- min-height: 38px;
642
- height: 38px;
643
- }
644
-
645
- .pill-button:hover,
646
- .pill-button:focus-visible {
647
- background: rgba(37, 99, 235, 0.15);
648
- }
649
-
650
- .pill-button.secondary {
651
- background: #ffffff;
652
- }
653
-
654
- .pill-button.danger {
655
- border-color: rgba(220, 38, 38, 0.4);
656
- color: var(--danger);
657
- background: rgba(220, 38, 38, 0.08);
658
- }
659
-
660
- .pill-button.danger:hover,
661
- .pill-button.danger:focus-visible {
662
- background: rgba(220, 38, 38, 0.15);
663
- }
664
-
665
- .pill-button input[type="file"] {
666
- display: none;
667
- }
668
-
669
- .business-display {
670
- font-size: 15px;
671
- line-height: 1.4;
672
- }
673
-
674
- .business-display-grid {
675
- display: grid;
676
- gap: 12px;
677
- grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
678
- }
679
-
680
- .business-display-item {
681
- display: flex;
682
- flex-direction: column;
683
- gap: 4px;
684
- }
685
-
686
- .business-display-item strong {
687
- font-weight: 600;
688
- }
689
-
690
- .logo-preview {
691
- margin-top: 16px;
692
- border: 1px solid var(--border);
693
- border-radius: var(--radius-sm);
694
- padding: 16px;
695
- display: grid;
696
- gap: 10px;
697
- max-width: 280px;
698
- background: #ffffff;
699
- box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
700
- }
701
-
702
- .logo-preview-label {
703
- font-size: 12px;
704
- text-transform: uppercase;
705
- letter-spacing: 0.05em;
706
- color: var(--muted);
707
- font-weight: 700;
708
- }
709
-
710
- .logo-preview img {
711
- max-width: 100%;
712
- max-height: 120px;
713
- object-fit: contain;
714
- }
715
-
716
- .form-actions {
717
- display: flex;
718
- align-items: center;
719
- flex-wrap: wrap;
720
- gap: 16px;
721
- }
722
-
723
- .items-section {
724
- border: 1px solid rgba(15, 23, 42, 0.08);
725
- border-radius: var(--radius);
726
- padding: 24px 28px;
727
- background: #ffffff;
728
- display: grid;
729
- gap: 20px;
730
- }
731
-
732
- .items-header {
733
- display: flex;
734
- justify-content: space-between;
735
- align-items: center;
736
- flex-wrap: wrap;
737
- gap: 16px;
738
- }
739
-
740
- .items-table-wrapper {
741
- overflow-x: auto;
742
- border-radius: var(--radius);
743
- border: 1px solid var(--border);
744
- background: #ffffff;
745
- }
746
-
747
- .items-table {
748
- width: 100%;
749
- border-collapse: separate;
750
- border-spacing: 0;
751
- font-size: 14px;
752
- }
753
-
754
- .items-table th,
755
- .items-table td {
756
- padding: 12px 14px;
757
- text-align: left;
758
- border-bottom: 1px solid rgba(15, 23, 42, 0.08);
759
- }
760
-
761
- .items-table tr:last-child td {
762
- border-bottom: none;
763
- }
764
-
765
- .items-table th {
766
- background: var(--surface);
767
- font-weight: 600;
768
- text-transform: uppercase;
769
- letter-spacing: 0.05em;
770
- font-size: 12px;
771
- color: var(--muted);
772
- }
773
-
774
- .items-table tbody tr:nth-child(even) {
775
- background: rgba(37, 99, 235, 0.03);
776
- }
777
-
778
- .items-table tbody tr:hover {
779
- background: rgba(37, 99, 235, 0.08);
780
- }
781
-
782
- .items-table input,
783
- .items-table select {
784
- width: 100%;
785
- min-height: 44px;
786
- }
787
-
788
- .items-table .remove-item {
789
- color: var(--danger);
790
- background: none;
791
- padding: 0;
792
- box-shadow: none;
793
- }
794
-
795
- .items-table .remove-item:hover {
796
- text-decoration: underline;
797
- background: none;
798
- }
799
-
800
- .totals {
801
- display: grid;
802
- grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
803
- gap: 16px;
804
- font-weight: 600;
805
- margin-top: 8px;
806
- padding: 0;
807
- }
808
-
809
- .totals span {
810
- display: block;
811
- padding: 14px 16px;
812
- border-radius: var(--radius-sm);
813
- border: 1px solid var(--border);
814
- background: var(--surface);
815
- text-align: center;
816
- }
817
-
818
- .rate-summary {
819
- display: grid;
820
- gap: 10px;
821
- margin-top: 8px;
822
- }
823
-
824
- .rate-summary-item {
825
- display: flex;
826
- flex-wrap: wrap;
827
- gap: 12px;
828
- font-weight: 600;
829
- padding: 12px 16px;
830
- border: 1px dashed rgba(37, 99, 235, 0.4);
831
- border-radius: var(--radius-sm);
832
- background: #ffffff;
833
- }
834
-
835
- .invoice-preview {
836
- display: grid;
837
- gap: 24px;
838
- }
839
-
840
- .invoice-preview-meta {
841
- display: flex;
842
- flex-wrap: wrap;
843
- gap: 24px;
844
- font-size: 14px;
845
- }
846
-
847
- .invoice-preview-meta span {
848
- display: inline-flex;
849
- align-items: center;
850
- gap: 6px;
851
- }
852
-
853
- .invoice-preview-header {
854
- display: flex;
855
- flex-wrap: wrap;
856
- gap: 24px;
857
- }
858
-
859
- .invoice-preview-card {
860
- flex: 1 1 280px;
861
- border: 1px solid var(--border);
862
- border-radius: var(--radius);
863
- padding: 16px 20px;
864
- background: #f9fafc;
865
- }
866
-
867
- .invoice-preview-card h4 {
868
- margin: 0 0 12px;
869
- font-size: 14px;
870
- text-transform: uppercase;
871
- letter-spacing: 0.05em;
872
- }
873
-
874
- .invoice-preview-card p {
875
- margin: 4px 0;
876
- font-size: 14px;
877
- }
878
-
879
- .invoice-preview table {
880
- width: 100%;
881
- border-collapse: collapse;
882
- font-size: 14px;
883
- }
884
-
885
- .invoice-preview th,
886
- .invoice-preview td {
887
- border: 1px solid var(--border);
888
- padding: 10px 12px;
889
- text-align: left;
890
- }
891
-
892
- .invoice-preview th {
893
- background: #f1f3f7;
894
- font-weight: 600;
895
- }
896
-
897
- .invoice-preview-summary {
898
- display: flex;
899
- justify-content: flex-end;
900
- flex-wrap: wrap;
901
- gap: 16px;
902
- font-weight: 600;
903
- font-size: 15px;
904
- }
905
-
906
- .invoice-preview-note {
907
- padding: 12px 16px;
908
- border-left: 3px solid var(--accent);
909
- background: #f4f8ff;
910
- font-size: 14px;
911
- }
912
-
913
- .dashboard-header {
914
- display: flex;
915
- flex-wrap: wrap;
916
- align-items: center;
917
- justify-content: space-between;
918
- gap: 16px;
919
- border-bottom: 1px solid rgba(15, 23, 42, 0.08);
920
- padding-bottom: 12px;
921
- }
922
-
923
- .filters {
924
- display: flex;
925
- flex-wrap: wrap;
926
- gap: 16px;
927
- align-items: flex-end;
928
- }
929
-
930
- .dashboard-summary {
931
- display: grid;
932
- gap: 18px;
933
- margin: 24px 0;
934
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
935
- }
936
-
937
- .summary-card {
938
- border: 1px solid rgba(15, 23, 42, 0.08);
939
- border-radius: var(--radius);
940
- background: var(--surface);
941
- padding: 20px;
942
- display: grid;
943
- gap: 8px;
944
- box-shadow: 0 12px 25px rgba(15, 23, 42, 0.08);
945
- }
946
-
947
- .summary-label {
948
- font-size: 13px;
949
- font-weight: 600;
950
- color: var(--muted);
951
- text-transform: uppercase;
952
- letter-spacing: 0.05em;
953
- }
954
-
955
- .summary-count {
956
- font-size: 22px;
957
- font-weight: 700;
958
- }
959
-
960
- .summary-amount {
961
- font-size: 18px;
962
- font-weight: 600;
963
- color: var(--accent);
964
- }
965
-
966
- .dashboard-chart {
967
- border: 1px solid rgba(15, 23, 42, 0.08);
968
- border-radius: var(--radius);
969
- background: #ffffff;
970
- padding: 24px;
971
- box-shadow: 0 20px 40px rgba(15, 23, 42, 0.08);
972
- }
973
-
974
- .dashboard-table .items-table th,
975
- .dashboard-table .items-table td {
976
- white-space: nowrap;
977
- }
978
-
979
- .dashboard-table .items-table td:last-child {
980
- width: 160px;
981
- }
982
-
983
- .table-actions {
984
- display: flex;
985
- flex-wrap: wrap;
986
- gap: 8px;
987
- }
988
-
989
- #invoices-empty {
990
- margin-top: 12px;
991
- text-align: center;
992
- padding: 12px 0;
993
- }
994
-
995
- @media (max-width: 1024px) {
996
- .container {
997
- padding: 40px 24px 64px;
998
- }
999
- .hero-columns {
1000
- grid-template-columns: 1fr;
1001
- }
1002
- }
1003
-
1004
- @media (max-width: 900px) {
1005
- .hero-columns {
1006
- grid-template-columns: 1fr;
1007
- }
1008
- }
1009
-
1010
- @media (max-width: 640px) {
1011
- .container {
1012
- padding: 32px 16px 56px;
1013
- }
1014
-
1015
- .panel {
1016
- padding: 24px;
1017
- }
1018
-
1019
- .brand-logo {
1020
- max-width: 160px;
1021
- }
1022
-
1023
- .app-title {
1024
- font-size: 24px;
1025
- }
1026
-
1027
- .auth-panel {
1028
- grid-template-columns: 1fr;
1029
- }
1030
-
1031
- .app-header {
1032
- flex-direction: column;
1033
- gap: 12px;
1034
- align-items: flex-start;
1035
- }
1036
-
1037
- .business-section,
1038
- .items-section {
1039
- padding: 20px;
1040
- }
1041
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
templates/index.html ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
8
+ </head>
9
+ <body>
10
+ <main class="container">
11
+ <h1>Generator faktur</h1>
12
+
13
+ <section id="setup-section" class="panel hidden">
14
+ <h2>Konfiguracja danych firmy</h2>
15
+ <form id="setup-form" class="form">
16
+ <div class="field-grid">
17
+ <label>
18
+ Nazwa firmy
19
+ <input type="text" name="company_name" required>
20
+ </label>
21
+ <label>
22
+ Imie i nazwisko wlasciciela
23
+ <input type="text" name="owner_name" required>
24
+ </label>
25
+ <label>
26
+ Ulica i numer
27
+ <input type="text" name="address_line" required>
28
+ </label>
29
+ <label>
30
+ Kod pocztowy
31
+ <input type="text" name="postal_code" required>
32
+ </label>
33
+ <label>
34
+ Miejscowosc
35
+ <input type="text" name="city" required>
36
+ </label>
37
+ <label>
38
+ NIP
39
+ <input type="text" name="tax_id" required>
40
+ </label>
41
+ <label>
42
+ Numer konta bankowego
43
+ <input type="text" name="bank_account" required>
44
+ </label>
45
+ <label>
46
+ Haslo
47
+ <input type="password" name="password" required>
48
+ </label>
49
+ <label>
50
+ Powtorz haslo
51
+ <input type="password" name="confirm_password" required>
52
+ </label>
53
+ </div>
54
+ <button type="submit">Zapisz dane</button>
55
+ <p class="hint">Dane przechowywane sa na serwerze lokalnym.</p>
56
+ </form>
57
+ <p id="setup-feedback" class="feedback"></p>
58
+ </section>
59
+
60
+ <section id="login-section" class="panel hidden">
61
+ <h2>Logowanie</h2>
62
+ <form id="login-form" class="form">
63
+ <label>
64
+ Haslo
65
+ <input type="password" name="password" required>
66
+ </label>
67
+ <button type="submit">Zaloguj</button>
68
+ </form>
69
+ <p id="login-feedback" class="feedback"></p>
70
+ </section>
71
+
72
+ <section id="app-section" class="panel hidden">
73
+ <header class="invoice-header">
74
+ <h2>Panel faktur</h2>
75
+ <button id="logout-button" type="button" class="link-button">Wyloguj</button>
76
+ </header>
77
+
78
+ <section class="business-section">
79
+ <div class="business-section-header">
80
+ <h3>Dane sprzedawcy</h3>
81
+ <button id="toggle-business-form" type="button" class="link-button">Edytuj dane</button>
82
+ </div>
83
+ <div id="business-display" class="business-display"></div>
84
+ <form id="business-form" class="form hidden">
85
+ <div class="field-grid">
86
+ <label>
87
+ Nazwa firmy
88
+ <input type="text" name="company_name" required>
89
+ </label>
90
+ <label>
91
+ Imie i nazwisko wlasciciela
92
+ <input type="text" name="owner_name" required>
93
+ </label>
94
+ <label>
95
+ Ulica i numer
96
+ <input type="text" name="address_line" required>
97
+ </label>
98
+ <label>
99
+ Kod pocztowy
100
+ <input type="text" name="postal_code" required>
101
+ </label>
102
+ <label>
103
+ Miejscowosc
104
+ <input type="text" name="city" required>
105
+ </label>
106
+ <label>
107
+ NIP
108
+ <input type="text" name="tax_id" required>
109
+ </label>
110
+ <label>
111
+ Numer konta bankowego
112
+ <input type="text" name="bank_account" required>
113
+ </label>
114
+ </div>
115
+ <div class="form-actions">
116
+ <button type="submit">Zapisz</button>
117
+ <button id="cancel-business-update" type="button" class="link-button">Anuluj</button>
118
+ </div>
119
+ <p id="business-feedback" class="feedback"></p>
120
+ </form>
121
+ </section>
122
+
123
+ <form id="invoice-form" class="form">
124
+ <fieldset>
125
+ <legend>Informacje o fakturze</legend>
126
+ <label>
127
+ Data sprzedazy / wykonania uslugi
128
+ <input type="date" name="saleDate">
129
+ </label>
130
+ </fieldset>
131
+
132
+ <fieldset>
133
+ <legend>Dane nabywcy</legend>
134
+ <div class="field-grid">
135
+ <label>
136
+ Nazwa / Imie i nazwisko
137
+ <input type="text" name="clientName">
138
+ </label>
139
+ <label>
140
+ NIP
141
+ <input type="text" name="clientTaxId">
142
+ </label>
143
+ <label>
144
+ Ulica i numer
145
+ <input type="text" name="clientAddress">
146
+ </label>
147
+ <label>
148
+ Kod pocztowy
149
+ <input type="text" name="clientPostalCode">
150
+ </label>
151
+ <label>
152
+ Miejscowosc
153
+ <input type="text" name="clientCity">
154
+ </label>
155
+ </div>
156
+ </fieldset>
157
+
158
+ <section class="items-section">
159
+ <header class="items-header">
160
+ <h3>Pozycje faktury</h3>
161
+ <button type="button" id="add-item-button">Dodaj pozycje</button>
162
+ </header>
163
+ <div class="items-table-wrapper">
164
+ <table class="items-table">
165
+ <thead>
166
+ <tr>
167
+ <th>Nazwa towaru/uslugi</th>
168
+ <th>Ilosc</th>
169
+ <th>Cena jedn. brutto (PLN)</th>
170
+ <th>Stawka VAT</th>
171
+ <th>Wartosc brutto (PLN)</th>
172
+ <th></th>
173
+ </tr>
174
+ </thead>
175
+ <tbody id="items-body"></tbody>
176
+ </table>
177
+ </div>
178
+ </section>
179
+
180
+ <div id="totals-container" class="totals">
181
+ <span id="total-net">Suma netto: 0.00 PLN</span>
182
+ <span id="total-vat">Kwota VAT: 0.00 PLN</span>
183
+ <span id="total-gross">Suma brutto: 0.00 PLN</span>
184
+ </div>
185
+
186
+ <section id="rate-summary" class="rate-summary"></section>
187
+
188
+ <div id="exemption-note-wrapper" class="hidden">
189
+ <label>
190
+ Podstawa prawna zwolnienia (stosowana dla pozycji ZW)
191
+ <textarea id="exemption-note" rows="3" placeholder="np. Art. 43 ust. 1 pkt 19 ustawy o VAT"></textarea>
192
+ </label>
193
+ </div>
194
+
195
+ <button type="submit">Generuj fakture</button>
196
+ </form>
197
+
198
+ <section id="invoice-result" class="panel hidden">
199
+ <h3>Podglad faktury</h3>
200
+ <div id="invoice-output" class="invoice-preview"></div>
201
+ <button id="download-button" type="button">Pobierz jako plik PDF</button>
202
+ </section>
203
+ </section>
204
+ </main>
205
+
206
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js" defer></script>
207
+ <script src="{{ url_for('static', filename='js/main.js') }}" defer></script>
208
+ </body>
209
+ </html>
web_invoice_store.json CHANGED
The diff for this file is too large to render. See raw diff