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