Antoni09 commited on
Commit
9c4e7c0
·
verified ·
1 Parent(s): 4d09f4d

Upload 10 files

Browse files
Files changed (10) hide show
  1. Dockerfile +11 -11
  2. README.md +36 -67
  3. db.py +33 -13
  4. entrypoint.sh +27 -27
  5. index.html +109 -68
  6. invoice_app.py +190 -190
  7. main.js +415 -202
  8. requirements.txt +2 -2
  9. server.py +34 -7
  10. styles.css +904 -568
Dockerfile CHANGED
@@ -1,12 +1,12 @@
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
+ 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"]
README.md CHANGED
@@ -9,87 +9,56 @@ pinned: false
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).
 
 
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).
db.py CHANGED
@@ -123,11 +123,11 @@ def update_business_logo(account_id: int, mime: Optional[str], data_base64: Opti
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
  )
@@ -170,14 +170,34 @@ def upsert_client(account_id: int, payload: Dict[str, str]) -> int:
170
  payload["tax_id"],
171
  payload.get("phone"),
172
  ),
173
- )
174
- return cur.fetchone()["id"]
175
-
176
-
177
- def insert_invoice(account_id: int, client_id: int, invoice: Dict[str, Any]) -> int:
178
- with db_conn() as conn, conn.cursor() as cur:
179
- cur.execute(
180
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  INSERT INTO invoices (account_id, client_id, invoice_number, issued_at,
182
  sale_date, payment_term_days, exemption_note,
183
  total_net, total_vat, total_gross)
 
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
  )
 
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)
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)
index.html CHANGED
@@ -10,44 +10,75 @@
10
  <link rel="stylesheet" href="styles.css">
11
  </head>
12
  <body>
13
- <main class="container">
14
- <div class="header-section">
15
- <div class="logo-container">
16
- <img src="small_logotyp do strony.jpg" alt="Logotyp FakturON!" class="logo">
17
- <h1 class="app-title">Generator faktur</h1>
18
- </div>
19
- <p class="app-description">
20
- Strona internetowa, gdzie możesz zupełnie bezpłatnie wystawić fakturę, edytować ją,
21
- przeglądać swoje faktury oraz śledzić przychody.
22
- </p>
23
- </div>
24
-
25
- <section id="auth-section" class="panel">
26
-
27
- <div class="auth-login">
28
- <div class="auth-card login-card">
29
- <h3>Zaloguj się</h3>
30
- <form id="login-form" class="form">
31
- <label>
32
- Email
33
- <input type="email" name="email" autocomplete="email" required>
34
- </label>
35
- <label>
36
- Hasło
37
- <input type="password" name="password" autocomplete="current-password" required>
38
- </label>
39
- <button type="submit">Zaloguj</button>
40
- <hr class="form-divider">
41
- </form>
42
- <p id="login-feedback" class="feedback" aria-live="polite"></p>
43
- <div class="auth-actions">
44
- <span>Nie masz konta?</span>
45
- <button id="show-register-button" type="button" class="ghost-button">Stwórz konto</button>
46
- </div>
47
- <p id="legacy-login-hint" class="hint hidden"></p>
48
- </div>
49
- </div>
50
- </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
  <section id="register-section" class="panel hidden">
53
  <div class="auth-card register-card">
@@ -108,37 +139,40 @@
108
  <button id="cancel-register" type="button" class="link-button">Anuluj</button>
109
  </div>
110
  </form>
111
- <p id="register-feedback" class="feedback"></p>
112
- <p class="hint">Dane konta przechowywane lokalnie na serwerze.</p>
113
- </div>
114
- </section>
115
 
116
  <section id="app-section" class="panel hidden">
117
- <header class="app-header">
118
- <div>
119
- <h2>Panel faktur</h2>
120
- <p id="current-login-label" class="app-subtitle"></p>
121
- </div>
122
- <nav class="app-nav">
123
- <button type="button" class="app-nav-button active" data-view="invoice-builder">Nowa faktura</button>
124
- <button type="button" class="app-nav-button" data-view="dashboard">Dashboard</button>
125
- </nav>
126
- <button id="logout-button" type="button" class="link-button">Wyloguj</button>
 
 
 
127
  </header>
128
 
129
  <section id="invoice-builder-section" class="app-view">
130
  <section class="business-section">
131
  <div class="business-section-header">
132
  <h3>Dane sprzedawcy</h3>
133
- <div class="business-actions">
134
- <button id="toggle-business-form" type="button" class="link-button">Edycja danych</button>
135
- <label for="logo-input" class="button secondary">
136
- <input id="logo-input" type="file" accept="image/png,image/jpeg" hidden>
137
- Wgraj logo
138
- </label>
139
- <button id="remove-logo-button" type="button" class="link-button hidden">Usuń logo</button>
140
- </div>
141
- </div>
142
  <div id="business-display" class="business-display"></div>
143
  <div id="logo-preview" class="logo-preview hidden">
144
  <span class="logo-preview-label">Logo sprzedawcy</span>
@@ -199,13 +233,20 @@
199
  </div>
200
  </fieldset>
201
 
202
- <fieldset>
203
- <legend>Dane nabywcy</legend>
204
- <div class="field-grid">
205
- <label>
206
- Nazwa / Imię i nazwisko
207
- <input type="text" name="clientName">
208
- </label>
 
 
 
 
 
 
 
209
  <label>
210
  NIP
211
  <input type="text" name="clientTaxId">
 
10
  <link rel="stylesheet" href="styles.css">
11
  </head>
12
  <body>
13
+ <main class="container">
14
+ <div class="brand-banner">
15
+ <img src="small_logotyp do strony.jpg" alt="Logotyp FakturON!" class="brand-logo">
16
+ </div>
17
+
18
+ <section id="hero-panel" class="panel hero-panel">
19
+ <div class="hero-columns">
20
+ <div class="hero-content">
21
+ <p class="eyebrow">Bezpłatny panel faktur</p>
22
+ <h1 class="app-title">Generator faktur</h1>
23
+ <p class="app-description">
24
+ Proste narzędzie do wystawiania i analizowania faktur: logujesz się, uzupełniasz dane kontrahenta,
25
+ zapisujesz dokument i od razu widzisz efekty w dashboardzie. Zero opłat, zero limitów.
26
+ </p>
27
+ <p class="hero-lead">
28
+ Cały proces zamyka się w jednym widoku – formularze, podgląd i historia korzystają z tej samej siatki,
29
+ dzięki czemu wyglądają identycznie na desktopie i na telefonie.
30
+ </p>
31
+ <ul class="header-highlights">
32
+ <li>
33
+ <strong>Zawsze bezpłatnie</strong>
34
+ <span>Generator działa bez limitów dokumentów i bez pakietów abonamentowych.</span>
35
+ </li>
36
+ <li>
37
+ <strong>Szybkie wystawianie</strong>
38
+ <span>W jednym formularzu zbierasz dane sprzedawcy, nabywcy i pozycje, a podgląd generuje się automatycznie.</span>
39
+ </li>
40
+ <li>
41
+ <strong>Spójny wygląd</strong>
42
+ <span>Każdy etap korzysta z tej samej siatki, więc łatwo pracować na desktopie i na telefonie.</span>
43
+ </li>
44
+ <li>
45
+ <strong>Dashboard i historia</strong>
46
+ <span>Po zalogowaniu od razu widać statystyki i listę faktur gotowych do pobrania.</span>
47
+ </li>
48
+ </ul>
49
+ </div>
50
+ <section id="auth-section" class="auth-panel">
51
+ <div class="auth-panel-header">
52
+ <p class="eyebrow">Logowanie</p>
53
+ <h2 class="auth-headline">Wejdź do generatora</h2>
54
+ <p class="auth-copy">Zaloguj się i prowadź całą obsługę faktur w jednym darmowym panelu.</p>
55
+ </div>
56
+ <div class="auth-login">
57
+ <div class="auth-card login-card">
58
+ <h3>Zaloguj się</h3>
59
+ <form id="login-form" class="form">
60
+ <label>
61
+ Email
62
+ <input type="email" name="email" autocomplete="email" required>
63
+ </label>
64
+ <label>
65
+ Hasło
66
+ <input type="password" name="password" autocomplete="current-password" required>
67
+ </label>
68
+ <button type="submit">Zaloguj</button>
69
+ <hr class="form-divider">
70
+ </form>
71
+ <p id="login-feedback" class="feedback" aria-live="polite"></p>
72
+ <div class="auth-actions">
73
+ <span>Nie masz konta?</span>
74
+ <button id="show-register-button" type="button" class="ghost-button">Stwórz konto</button>
75
+ </div>
76
+ <p id="legacy-login-hint" class="hint hidden"></p>
77
+ </div>
78
+ </div>
79
+ </section>
80
+ </div>
81
+ </section>
82
 
83
  <section id="register-section" class="panel hidden">
84
  <div class="auth-card register-card">
 
139
  <button id="cancel-register" type="button" class="link-button">Anuluj</button>
140
  </div>
141
  </form>
142
+ <p id="register-feedback" class="feedback"></p>
143
+ <p class="hint">Wskazowka: dane firmy zaktualizujesz pozniej w sekcji "Dane sprzedawcy".</p>
144
+ </div>
145
+ </section>
146
 
147
  <section id="app-section" class="panel hidden">
148
+ <header class="app-header">
149
+ <div>
150
+ <h2>Panel faktur</h2>
151
+ </div>
152
+ <div id="login-badge" class="login-badge hidden" aria-live="polite">
153
+ <span class="badge-label">Zalogowany jako</span>
154
+ <span id="current-login-label" class="badge-value"></span>
155
+ </div>
156
+ <nav class="app-nav">
157
+ <button type="button" class="app-nav-button active" data-view="invoice-builder">Nowa faktura</button>
158
+ <button type="button" class="app-nav-button" data-view="dashboard">Dashboard</button>
159
+ </nav>
160
+ <button id="logout-button" type="button" class="link-button">Wyloguj</button>
161
  </header>
162
 
163
  <section id="invoice-builder-section" class="app-view">
164
  <section class="business-section">
165
  <div class="business-section-header">
166
  <h3>Dane sprzedawcy</h3>
167
+ <div class="business-actions">
168
+ <button id="toggle-business-form" type="button" class="pill-button">Edycja danych</button>
169
+ <label for="logo-input" class="pill-button secondary">
170
+ <input id="logo-input" type="file" accept="image/png,image/jpeg" hidden>
171
+ Wgraj logo
172
+ </label>
173
+ <button id="remove-logo-button" type="button" class="pill-button danger hidden">Usuń logo</button>
174
+ </div>
175
+ </div>
176
  <div id="business-display" class="business-display"></div>
177
  <div id="logo-preview" class="logo-preview hidden">
178
  <span class="logo-preview-label">Logo sprzedawcy</span>
 
233
  </div>
234
  </fieldset>
235
 
236
+ <fieldset>
237
+ <legend>Dane nabywcy</legend>
238
+ <div class="client-lookup">
239
+ <label for="client-search">
240
+ Szybkie wyszukiwanie nabywcy
241
+ <input type="text" id="client-search" placeholder="Wpisz NIP lub nazwę klienta">
242
+ </label>
243
+ <div id="client-suggestions" class="client-suggestions hidden" role="listbox"></div>
244
+ </div>
245
+ <div class="field-grid">
246
+ <label>
247
+ Nazwa / Imię i nazwisko
248
+ <input type="text" name="clientName">
249
+ </label>
250
  <label>
251
  NIP
252
  <input type="text" name="clientTaxId">
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()
main.js CHANGED
@@ -63,8 +63,9 @@ const EXEMPTION_REASONS = [
63
 
64
  const EXEMPTION_REASON_LOOKUP = new Map(EXEMPTION_REASONS.map((reason) => [reason.value, reason]));
65
 
66
- const authSection = document.getElementById("auth-section");
67
- const appSection = document.getElementById("app-section");
 
68
 
69
  const registerForm = document.getElementById("register-form");
70
  const loginForm = document.getElementById("login-form");
@@ -73,14 +74,17 @@ const loginSubmitButtonDefaultText = loginSubmitButton && loginSubmitButton.text
73
  const invoiceForm = document.getElementById("invoice-form");
74
  const businessForm = document.getElementById("business-form");
75
 
76
- const registerFeedback = document.getElementById("register-feedback");
77
- const loginFeedback = document.getElementById("login-feedback");
78
- const businessFeedback = document.getElementById("business-feedback");
79
- const logoFeedback = document.getElementById("logo-feedback");
80
- const registerSection = document.getElementById("register-section");
81
- const showRegisterButton = document.getElementById("show-register-button");
82
- const backToLoginButton = document.getElementById("back-to-login");
83
- const cancelRegisterButton = document.getElementById("cancel-register");
 
 
 
84
 
85
  const businessDisplay = document.getElementById("business-display");
86
  const toggleBusinessFormButton = document.getElementById("toggle-business-form");
@@ -144,7 +148,8 @@ let invoicesChart = null;
144
  let maxLogoSize = 512 * 1024;
145
  let pdfFontPromise = null;
146
  let pdfFontBase64 = null;
147
- let customExemptionNote = "";
 
148
 
149
  function setVisibility(element, visible) {
150
  if (!element) {
@@ -159,17 +164,19 @@ function setVisibility(element, visible) {
159
  }
160
  }
161
 
162
- function setAppState(state) {
163
- if (state === "app") {
164
- setVisibility(authSection, false);
165
- setVisibility(registerSection, false);
166
- setVisibility(appSection, true);
167
- } else {
168
- setVisibility(authSection, true);
169
- setVisibility(registerSection, false);
170
- setVisibility(appSection, false);
171
- }
172
- }
 
 
173
 
174
  function openRegisterPanel() {
175
  if (!registerSection) {
@@ -461,13 +468,22 @@ function setActiveView(view) {
461
  }
462
  }
463
 
464
- function updateLoginLabel() {
465
- if (!currentLogin) {
466
- currentLoginLabel.textContent = "";
467
- return;
468
- }
469
- currentLoginLabel.textContent = `Zalogowany jako ${currentLogin}`;
470
- }
 
 
 
 
 
 
 
 
 
471
 
472
  function updateLogoPreview() {
473
  if (currentLogo && currentLogo.data && currentLogo.mime_type) {
@@ -786,14 +802,9 @@ function startInvoiceEdit(invoiceId) {
786
  invoiceForm.elements.saleDate.value = invoice.sale_date || "";
787
  invoiceForm.elements.paymentTerm.value = invoice.payment_term || 14;
788
 
789
- if (invoice.client) {
790
- invoiceForm.elements.clientName.value = invoice.client.name || "";
791
- invoiceForm.elements.clientTaxId.value = invoice.client.tax_id || "";
792
- invoiceForm.elements.clientAddress.value = invoice.client.address_line || "";
793
- invoiceForm.elements.clientPostalCode.value = invoice.client.postal_code || "";
794
- invoiceForm.elements.clientCity.value = invoice.client.city || "";
795
- invoiceForm.elements.clientPhone.value = invoice.client.phone || "";
796
- }
797
 
798
  itemsBody.innerHTML = "";
799
  if (Array.isArray(invoice.items) && invoice.items.length > 0) {
@@ -931,15 +942,108 @@ function fillBusinessForm(business) {
931
  businessForm.elements.bank_account.value = business.bank_account || "";
932
  }
933
 
934
- function setBusinessFormVisibility(visible, { preserveFeedback = false } = {}) {
935
- setVisibility(businessForm, visible);
936
- if (toggleBusinessFormButton) {
937
- toggleBusinessFormButton.textContent = visible ? "Ukryj formularz" : "Edycja danych";
938
- }
939
  if (!visible && !preserveFeedback) {
940
  clearFeedback(businessFeedback);
941
- }
942
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
943
 
944
  function vatSelectElement(initialValue = "23") {
945
  const select = document.createElement("select");
@@ -1374,24 +1478,33 @@ function renderInvoicePreview(invoice) {
1374
  `;
1375
  }
1376
 
1377
- function drawPartyBox(doc, title, lines, x, y, width) {
1378
- const lineHeight = 5;
1379
- const wrappedLines = lines.flatMap((line) => doc.splitTextToSize(line, width));
1380
- const boxHeight = wrappedLines.length * lineHeight + 14;
1381
-
1382
- doc.roundedRect(x - 4, y - 8, width + 8, boxHeight, 2, 2);
1383
- doc.setFontSize(11);
1384
- doc.text(title, x, y);
1385
- doc.setFontSize(10);
1386
-
1387
- let cursor = y + 5;
1388
- wrappedLines.forEach((line) => {
1389
- doc.text(line, x, cursor);
1390
- cursor += lineHeight;
1391
- });
1392
-
1393
- return y - 8 + boxHeight;
1394
- }
 
 
 
 
 
 
 
 
 
1395
 
1396
  function arrayBufferToBase64(buffer) {
1397
  const bytes = new Uint8Array(buffer);
@@ -1404,8 +1517,16 @@ function arrayBufferToBase64(buffer) {
1404
  return btoa(binary);
1405
  }
1406
 
1407
- const PDF_FONT_FILE = "Roboto-VariableFont_wdth,wght.ttf";
1408
- const PDF_FONT_NAME = "RobotoPolish";
 
 
 
 
 
 
 
 
1409
 
1410
  async function ensurePdfFont() {
1411
  if (pdfFontPromise) {
@@ -1463,38 +1584,26 @@ async function generatePdf(business, invoice, logo) {
1463
  doc.addFont(PDF_FONT_FILE, PDF_FONT_NAME, "normal");
1464
  }
1465
 
1466
- doc.setFont(PDF_FONT_NAME, "normal");
1467
- if (logo && logo.data && logo.mime_type) {
1468
- const format = logo.mime_type === "image/png" ? "PNG" : "JPEG";
1469
- const dataUrl = logo.data_url || `data:${logo.mime_type};base64,${logo.data}`;
1470
- try {
1471
- let logoWidth = 45;
1472
- let logoHeight = 18;
1473
- if (doc.getImageProperties) {
1474
- const props = doc.getImageProperties(dataUrl);
1475
- if (props?.width && props?.height) {
1476
- const ratio = props.height / props.width;
1477
- logoHeight = logoWidth * ratio;
1478
- if (logoHeight > 22) {
1479
- logoHeight = 22;
1480
- logoWidth = logoHeight / ratio;
1481
- }
1482
- }
1483
- }
1484
- const logoX = pageWidth - marginX - logoWidth;
1485
- const logoY = 14;
1486
- doc.addImage(dataUrl, format, logoX, logoY, logoWidth, logoHeight);
1487
- } catch (error) {
1488
- console.warn("Nie udało się dodać logo do PDF:", error);
1489
- }
1490
- }
1491
- doc.setFontSize(16);
1492
- doc.text(`Faktura ${invoice.invoice_id}`, marginX, cursorY);
1493
- doc.setFontSize(10);
1494
- doc.text(`Data wystawienia: ${invoice.issued_at}`, marginX, cursorY + 6);
1495
- doc.text(`Data sprzedaży: ${invoice.sale_date}`, marginX, cursorY + 12);
1496
-
1497
- cursorY += 22;
1498
  const columnWidth = 85;
1499
  const sellerX = marginX + columnWidth + 12;
1500
 
@@ -1517,9 +1626,38 @@ async function generatePdf(business, invoice, logo) {
1517
  `Konto: ${business.bank_account}`,
1518
  ];
1519
 
1520
- const buyerBottom = drawPartyBox(doc, "NABYWCA", clientLines, marginX, cursorY, columnWidth);
1521
- const sellerBottom = drawPartyBox(doc, "SPRZEDAWCA", sellerLines, sellerX, cursorY, columnWidth);
1522
- cursorY = Math.max(buyerBottom, sellerBottom) + 12;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1523
 
1524
  const tableColumns = [
1525
  { key: "name", label: "Nazwa", width: 44 },
@@ -1536,27 +1674,46 @@ async function generatePdf(business, invoice, logo) {
1536
  tableColumns.forEach((column) => {
1537
  column.headerLines = doc.splitTextToSize(column.label, column.width - 4);
1538
  });
1539
- const headerHeight = Math.max(...tableColumns.map((column) => column.headerLines.length * headerLineHeight + 3));
1540
-
1541
- const tableWidth = tableColumns.reduce((sum, column) => sum + column.width, 0);
1542
-
1543
- doc.setFillColor(241, 243, 247);
1544
- doc.rect(marginX, cursorY, tableWidth, headerHeight, "F");
1545
- doc.rect(marginX, cursorY, tableWidth, headerHeight);
1546
- let offsetX = marginX;
1547
- doc.setFontSize(10);
1548
- tableColumns.forEach((column) => {
1549
- doc.rect(offsetX, cursorY, column.width, headerHeight);
1550
- column.headerLines.forEach((line, index) => {
1551
- const textY = cursorY + 4 + index * headerLineHeight;
1552
- doc.text((line || "").trim(), offsetX + 2, textY);
1553
- });
1554
- offsetX += column.width;
1555
- });
1556
- cursorY += headerHeight;
 
 
 
 
 
 
1557
 
1558
- const invoiceItems = Array.isArray(invoice.items) ? invoice.items : [];
1559
- invoiceItems.forEach((item) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
1560
  const quantity = formatQuantity(item.quantity);
1561
  const unitLabel = UNIT_OPTIONS.some((option) => option.value === item.unit) ? item.unit : DEFAULT_UNIT;
1562
  const unitNet = formatCurrency(item.unit_price_net);
@@ -1564,80 +1721,113 @@ async function generatePdf(business, invoice, logo) {
1564
  const vatAmount = formatCurrency(item.vat_amount);
1565
  const grossTotal = formatCurrency(item.gross_total);
1566
 
1567
- const wrapText = (text, width) =>
1568
- doc
1569
- .splitTextToSize(text ?? "", width)
1570
- .map((line) => line.trim());
 
 
1571
 
1572
  const columnData = tableColumns.map((column) => {
1573
  switch (column.key) {
1574
  case "name":
1575
  return wrapText(item.name, column.width - 4);
1576
- case "quantity":
1577
- return [quantity];
1578
- case "unit":
1579
- return [unitLabel];
1580
- case "unitNet":
1581
- return [unitNet];
1582
- case "netTotal":
1583
- return [netTotal];
1584
- case "vatLabel":
1585
- return [item.vat_label];
1586
- case "vatAmount":
1587
- return [vatAmount];
1588
- case "grossTotal":
1589
- return [grossTotal];
1590
- default:
1591
- return [""];
1592
- }
1593
- });
1594
 
1595
  const rowHeight = Math.max(...columnData.map((data) => data.length)) * lineHeight + 2;
1596
- offsetX = marginX;
1597
- tableColumns.forEach((column, index) => {
1598
- doc.rect(offsetX, cursorY, column.width, rowHeight);
1599
- const lines = columnData[index];
1600
- lines.forEach((line, lineIndex) => {
1601
- const textY = cursorY + (lineIndex + 1) * lineHeight;
1602
- const content = (line || "").trim();
1603
- doc.text(content, offsetX + 2, textY);
1604
- });
1605
- offsetX += column.width;
1606
- });
1607
-
1608
- cursorY += rowHeight;
1609
- });
1610
-
1611
- cursorY += 10;
1612
- doc.setFontSize(11);
1613
- doc.text("Podsumowanie stawek:", marginX, cursorY);
1614
- cursorY += 6;
1615
-
1616
- const summaryEntries = Array.isArray(invoice.summary) ? invoice.summary : [];
1617
- summaryEntries.forEach((entry) => {
1618
- const summaryLine = `${entry.vat_label} – Netto: ${formatCurrency(entry.net_total)} / VAT: ${formatCurrency(entry.vat_total)} / Brutto: ${formatCurrency(entry.gross_total)}`;
1619
- const wrapped = doc.splitTextToSize(summaryLine, 170);
1620
- wrapped.forEach((line) => {
1621
- doc.text((line || "").trim(), marginX, cursorY);
1622
- cursorY += lineHeight;
1623
- });
1624
  });
1625
 
1626
- cursorY += 6;
1627
- doc.setFontSize(12);
1628
- doc.text(`Suma netto: ${formatCurrency(invoice.totals.net)}`, marginX, cursorY);
1629
- doc.text(`Suma VAT: ${formatCurrency(invoice.totals.vat)}`, marginX, cursorY + 6);
1630
- doc.text(`Suma brutto: ${formatCurrency(invoice.totals.gross)}`, marginX, cursorY + 12);
1631
- if (invoice.payment_term) {
1632
- doc.text(`Termin płatności: ${invoice.payment_term} dni`, marginX, cursorY + 18);
1633
- }
1634
- cursorY += 30;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1635
 
1636
- if (invoice.exemption_note) {
1637
- doc.setFontSize(10);
1638
- const noteLines = doc.splitTextToSize(`Podstawa prawna zwolnienia: ${invoice.exemption_note}`, 170);
1639
- doc.text(noteLines, marginX, cursorY);
1640
- }
 
1641
 
1642
  doc.save(`${invoice.invoice_id}.pdf`);
1643
  }
@@ -2082,11 +2272,11 @@ if (logoInput) {
2082
  });
2083
  }
2084
 
2085
- if (removeLogoButton) {
2086
- removeLogoButton.addEventListener("click", async () => {
2087
- clearFeedback(logoFeedback);
2088
- if (!currentLogo) {
2089
- showFeedback(logoFeedback, "Brak logo do usunięcia.");
2090
  return;
2091
  }
2092
  try {
@@ -2097,17 +2287,40 @@ if (removeLogoButton) {
2097
  } catch (error) {
2098
  showFeedback(logoFeedback, error.message || "Nie udało się usunąć logo.");
2099
  }
2100
- });
2101
- }
2102
-
2103
- if (cancelEditInvoiceButton) {
2104
- cancelEditInvoiceButton.addEventListener("click", () => {
2105
- exitInvoiceEdit();
2106
- resetInvoiceForm();
2107
- });
2108
- }
2109
-
2110
- initialize().catch((error) => {
2111
- console.error(error);
2112
- showFeedback(registerFeedback, "Nie udało się uruchomić aplikacji.");
2113
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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");
 
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");
 
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) {
 
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) {
 
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) {
 
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) {
 
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");
 
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);
 
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) {
 
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
 
 
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 },
 
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);
 
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
  }
 
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 {
 
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,2 @@
1
- Flask==3.0.2
2
- psycopg2-binary==2.9.9
 
1
+ Flask==3.0.2
2
+ psycopg2-binary==2.9.9
server.py CHANGED
@@ -19,6 +19,7 @@ from db import (
19
  fetch_one,
20
  fetch_business_logo,
21
  insert_invoice,
 
22
  update_business,
23
  update_business_logo,
24
  upsert_client,
@@ -331,13 +332,39 @@ def api_business() -> Any:
331
 
332
  if not account:
333
  return jsonify({"error": "Nie znaleziono konta."}), 404
334
- account["business"] = payload
335
- save_store(data)
336
- return jsonify({"message": "Dane sprzedawcy zostaly zaktualizowane."})
337
-
338
-
339
- @app.route("/api/logo", methods=["GET", "POST", "DELETE"])
340
- def api_logo() -> Any:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  try:
342
  login_key = require_auth()
343
  except PermissionError:
 
19
  fetch_one,
20
  fetch_business_logo,
21
  insert_invoice,
22
+ search_clients,
23
  update_business,
24
  update_business_logo,
25
  upsert_client,
 
332
 
333
  if not account:
334
  return jsonify({"error": "Nie znaleziono konta."}), 404
335
+ account["business"] = payload
336
+ save_store(data)
337
+ return jsonify({"message": "Dane sprzedawcy zostaly zaktualizowane."})
338
+
339
+
340
+ @app.route("/api/clients", methods=["GET"])
341
+ def api_clients() -> Any:
342
+ try:
343
+ login_key = require_auth()
344
+ except PermissionError:
345
+ return jsonify({"error": "Brak autoryzacji."}), 401
346
+
347
+ if not DATABASE_AVAILABLE:
348
+ return jsonify({"clients": []})
349
+
350
+ try:
351
+ account_row = get_account_row(login_key)
352
+ except KeyError:
353
+ return jsonify({"error": "Nie znaleziono konta."}), 404
354
+
355
+ search_term = (request.args.get("q") or "").strip()
356
+ limit_param = request.args.get("limit", "10")
357
+ try:
358
+ limit_value = int(limit_param)
359
+ except ValueError:
360
+ limit_value = 10
361
+ limit_value = max(1, min(25, limit_value))
362
+ clients = search_clients(account_row["id"], search_term, limit_value)
363
+ return jsonify({"clients": clients})
364
+
365
+
366
+ @app.route("/api/logo", methods=["GET", "POST", "DELETE"])
367
+ def api_logo() -> Any:
368
  try:
369
  login_key = require_auth()
370
  except PermissionError:
styles.css CHANGED
@@ -1,155 +1,254 @@
1
- :root {
2
- --bg: #f5f5f5;
3
- --panel-bg: #ffffff;
4
- --text: #202124;
5
- --muted: #5f6368;
6
- --accent: #1a73e8;
7
- --danger: #c5221f;
8
- --border: #dadce0;
9
- --radius: 10px;
10
- --shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
11
- font-family: "Roboto", "Segoe UI", Tahoma, sans-serif;
12
- }
13
-
14
- * {
15
- box-sizing: border-box;
16
- }
17
-
18
- body {
19
- margin: 0;
20
- background: linear-gradient(180deg, #f7f9fc 0%, #eef1f6 100%);
21
- color: var(--text);
22
- }
23
-
24
- .container {
25
- max-width: 980px;
26
- margin: 0 auto;
27
- padding: 40px 20px 64px;
28
- }
29
-
30
- .header-section {
31
- display: flex;
32
- flex-direction: column;
33
- align-items: center;
34
- gap: 16px;
35
- margin-bottom: 32px;
36
- text-align: center;
37
- }
38
-
39
- .logo-container {
40
- display: flex;
41
- flex-direction: column;
42
- align-items: center;
43
- gap: 12px;
44
- }
45
-
46
- .logo {
47
- max-width: 180px;
48
- height: auto;
49
- border-radius: 8px;
50
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
51
- }
52
-
53
- .app-title {
54
- text-align: center;
55
- font-size: 24px;
56
- margin: 0;
57
- font-weight: 600;
58
- }
59
-
60
- .app-description {
61
- text-align: center;
62
- font-size: 16px;
63
- color: var(--muted);
64
- line-height: 1.5;
65
- margin: 0;
66
- max-width: 600px;
67
- }
68
-
69
- .panel {
70
- background: var(--panel-bg);
71
- border-radius: var(--radius);
72
- box-shadow: var(--shadow);
73
- padding: 28px 32px;
74
- margin-bottom: 32px;
75
- border: 1px solid rgba(32, 33, 36, 0.08);
76
- }
77
-
78
- .auth-card {
79
- border: 1px solid rgba(32, 33, 36, 0.12);
80
- border-radius: var(--radius);
81
- padding: 24px 28px;
82
- background: #f9fbff;
83
- display: grid;
84
- gap: 18px;
85
- max-width: 520px;
86
- margin: 0 auto;
87
- width: 100%;
88
- }
89
-
90
- .auth-login {
91
- display: flex;
92
- justify-content: center;
93
- }
94
-
95
- .login-card {
96
- width: 100%;
97
- }
98
-
99
- #register-section {
100
- display: flex;
101
- justify-content: center;
102
- }
103
-
104
- #register-section .register-card {
105
- max-width: 640px;
106
- width: 100%;
107
- }
108
-
109
- .register-header {
110
- display: flex;
111
- justify-content: space-between;
112
- align-items: center;
113
- gap: 16px;
114
- }
115
-
116
- .auth-card h3 {
117
- margin: 0;
118
- }
119
-
120
- .auth-card .form {
121
- gap: 18px;
122
- width: 100%;
123
- }
124
-
125
-
126
- .auth-actions {
127
- margin-top: 4px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
 
129
- display: flex;
130
- flex-direction: column;
131
- align-items: center;
132
- justify-content: center;
133
- gap: 10px;
134
- font-size: 14px;
135
- color: var(--muted);
136
- text-align: center;
137
- }
138
 
139
- .ghost-button {
140
- padding: 10px 20px;
141
- border: 1px solid var(--accent);
142
- border-radius: 8px;
143
- background: transparent;
144
- color: var(--accent);
145
- font-weight: 600;
146
- cursor: pointer;
147
- transition: background 0.2s ease, color 0.2s ease;
148
- }
149
 
150
- .ghost-button:hover {
151
- background: rgba(26, 115, 232, 0.12);
152
- }
 
 
 
 
 
 
 
 
 
 
 
153
 
154
  .form-divider {
155
  border: none;
@@ -157,233 +256,420 @@ body {
157
  margin: 16px 0 0;
158
  }
159
 
160
- .register-fields {
161
- display: grid;
162
- gap: 24px;
163
- }
164
-
165
- .register-credentials {
166
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
167
- }
168
-
169
- .register-company {
170
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
171
- }
172
-
173
- .hidden {
174
- display: none;
175
- }
176
-
177
- .form {
178
- display: grid;
179
- gap: 20px;
180
- }
181
-
182
- .field-grid {
183
- display: grid;
184
- gap: 18px 24px;
185
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
186
- }
187
-
188
- label {
189
- display: grid;
190
- gap: 8px;
191
- font-weight: 600;
192
- font-size: 15px;
193
- }
194
-
195
- input,
196
- textarea,
197
- select {
198
- padding: 12px 14px;
199
- border-radius: 8px;
200
- border: 1px solid var(--border);
201
- font-size: 15px;
202
- background: #fbfbff;
203
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
 
205
- input:focus,
206
- textarea:focus,
207
- select:focus {
208
- outline: 2px solid rgba(26, 115, 232, 0.35);
209
- outline-offset: 1px;
210
- border-color: var(--accent);
211
- background: #ffffff;
212
- }
 
 
 
 
 
213
 
214
- textarea {
215
- resize: vertical;
216
- min-height: 96px;
217
- }
 
 
 
 
 
 
 
 
 
218
 
219
- #exemption-note-wrapper {
220
- display: grid;
221
- gap: 12px;
222
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
 
224
- #exemption-note-wrapper textarea[readonly] {
225
- background: #f4f6fb;
226
  color: var(--muted);
 
 
227
  }
228
 
229
- button {
230
- padding: 12px 20px;
231
- border-radius: 8px;
232
- border: none;
233
- font-size: 15px;
234
- font-weight: 600;
235
- background: var(--accent);
236
- color: white;
237
- cursor: pointer;
238
- transition: background 0.2s ease;
239
- }
240
-
241
- button:hover {
242
- background: #0f5ec4;
243
- }
244
-
245
- .button {
246
- display: inline-flex;
247
- align-items: center;
248
- justify-content: center;
249
- gap: 8px;
250
- padding: 12px 20px;
251
- border-radius: 8px;
252
- border: none;
253
- font-size: 15px;
254
- font-weight: 600;
255
- background: var(--accent);
256
- color: #ffffff;
257
- cursor: pointer;
258
- transition: background 0.2s ease;
259
- }
260
-
261
- .button.secondary {
262
- background: rgba(26, 115, 232, 0.12);
263
- color: var(--accent);
264
- border: 1px solid rgba(26, 115, 232, 0.25);
265
- }
266
-
267
- .button.secondary:hover {
268
- background: rgba(26, 115, 232, 0.2);
269
- }
270
-
271
- .button input[type="file"] {
272
- display: none;
273
- }
274
 
275
- button:disabled {
276
- opacity: 0.6;
277
- cursor: not-allowed;
278
  }
279
 
280
- .link-button {
281
- background: none;
282
- color: var(--accent);
283
- padding: 0;
284
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
 
286
- .link-button:hover {
287
- color: #0f5ec4;
288
- background: none;
289
- }
 
 
 
 
 
 
 
 
 
290
 
291
- .hint {
292
- color: var(--muted);
293
- font-size: 13px;
294
- margin: 0;
295
- }
 
 
 
296
 
297
- .feedback {
298
- color: var(--muted);
299
- min-height: 20px;
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  font-size: 14px;
 
 
 
 
 
301
  }
302
 
303
- .feedback:empty {
304
- display: none;
 
305
  }
306
 
307
- .feedback.error {
 
 
 
 
 
308
  color: var(--danger);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  }
310
-
311
- .feedback.success {
312
- color: #188038;
313
- }
314
-
315
- .app-header {
316
- display: flex;
317
- justify-content: space-between;
318
- align-items: center;
319
- margin-bottom: 24px;
320
- flex-wrap: wrap;
321
- gap: 16px;
322
- }
323
-
324
- .app-subtitle {
325
- margin: 4px 0 0;
326
- color: var(--muted);
327
- font-size: 14px;
328
- }
329
-
330
- .app-nav {
331
- display: flex;
332
- align-items: center;
333
- gap: 8px;
334
- flex-wrap: wrap;
335
- }
336
-
337
- .app-nav-button {
338
- background: rgba(26, 115, 232, 0.12);
339
- color: var(--accent);
340
- border: 1px solid rgba(26, 115, 232, 0.25);
341
- }
342
-
343
- .app-nav-button:hover {
344
- background: rgba(26, 115, 232, 0.2);
345
- }
346
-
347
- .app-nav-button.active {
348
- background: var(--accent);
349
- color: #ffffff;
350
- }
351
-
352
- .app-nav-button.active:hover {
353
- background: #0f5ec4;
354
- }
355
-
356
- .business-section {
357
- border: 1px solid rgba(32, 33, 36, 0.08);
358
- border-radius: var(--radius);
359
- padding: 20px 24px;
360
- background: #fbfcff;
361
- margin-bottom: 24px;
362
- }
363
-
364
- .app-view {
365
- display: grid;
366
- gap: 24px;
367
- }
368
-
369
- .business-section-header {
370
- display: flex;
371
- justify-content: space-between;
372
- align-items: center;
373
- margin-bottom: 12px;
374
- }
375
-
376
- .business-actions {
377
- display: flex;
378
- gap: 12px;
379
- flex-wrap: wrap;
380
- align-items: center;
381
- }
382
-
383
- .business-display {
384
- font-size: 15px;
385
- line-height: 1.4;
386
- }
387
 
388
  .business-display-grid {
389
  display: grid;
@@ -401,24 +687,25 @@ button:disabled {
401
  font-weight: 600;
402
  }
403
 
404
- .logo-preview {
405
- margin-top: 16px;
406
- border: 1px solid var(--border);
407
- border-radius: 8px;
408
- padding: 12px;
409
- display: grid;
410
- gap: 8px;
411
- max-width: 260px;
412
- background: #ffffff;
413
- }
414
-
415
- .logo-preview-label {
416
- font-size: 12px;
417
- text-transform: uppercase;
418
- letter-spacing: 0.05em;
419
- color: var(--muted);
420
- font-weight: 600;
421
- }
 
422
 
423
  .logo-preview img {
424
  max-width: 100%;
@@ -426,97 +713,124 @@ button:disabled {
426
  object-fit: contain;
427
  }
428
 
429
- .form-actions {
430
- display: flex;
431
- align-items: center;
432
- gap: 16px;
433
- }
434
-
435
- .items-section {
436
- border: 1px solid rgba(32, 33, 36, 0.08);
437
- border-radius: var(--radius);
438
- padding: 20px 24px;
439
- background: #ffffff;
440
- display: grid;
441
- gap: 18px;
442
- }
443
-
444
- .items-header {
445
- display: flex;
446
- justify-content: space-between;
447
- align-items: center;
448
- }
449
-
450
- .items-table-wrapper {
451
- overflow-x: auto;
452
- }
453
-
454
- .items-table {
455
- width: 100%;
456
- border-collapse: collapse;
457
- font-size: 14px;
458
- }
459
-
460
- .items-table th,
461
- .items-table td {
462
- border: 1px solid var(--border);
463
- padding: 10px 12px;
464
- text-align: left;
465
- }
466
-
467
- .items-table th {
468
- background: #f1f3f7;
469
- font-weight: 600;
470
- text-transform: uppercase;
471
- letter-spacing: 0.02em;
472
- }
473
-
474
- .items-table input,
475
- .items-table select {
476
- width: 100%;
477
- padding: 8px 10px;
478
- border-radius: 6px;
479
- border: 1px solid var(--border);
480
- background: #ffffff;
481
- }
482
-
483
- .items-table .remove-item {
484
- color: var(--danger);
485
- background: none;
486
- padding: 0;
487
- }
488
-
489
- .items-table .remove-item:hover {
490
- text-decoration: underline;
491
- }
492
 
493
- .totals {
494
- display: flex;
495
- flex-wrap: wrap;
496
- gap: 16px;
497
- font-weight: 600;
498
- margin-top: 8px;
499
- padding: 12px 16px;
500
- border-radius: 8px;
501
- background: #f7f9ff;
502
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
503
 
504
- .rate-summary {
505
- display: grid;
506
- gap: 10px;
507
- margin-top: 8px;
508
- }
 
 
 
 
 
 
 
 
 
 
 
 
509
 
510
- .rate-summary-item {
511
- display: flex;
512
- flex-wrap: wrap;
513
- gap: 12px;
514
- font-weight: 600;
515
- padding: 8px 12px;
516
- border: 1px dashed rgba(26, 115, 232, 0.25);
517
- border-radius: 6px;
518
- background: #ffffff;
519
- }
 
 
 
 
 
 
520
 
521
  .invoice-preview {
522
  display: grid;
@@ -596,110 +910,132 @@ button:disabled {
596
  font-size: 14px;
597
  }
598
 
599
- .dashboard-header {
600
- display: flex;
601
- flex-wrap: wrap;
602
- align-items: center;
603
- justify-content: space-between;
604
- gap: 16px;
605
- }
606
-
607
- .filters {
608
- display: flex;
609
- flex-wrap: wrap;
610
- gap: 16px;
611
- align-items: flex-end;
612
- }
613
-
614
- .dashboard-summary {
615
- display: grid;
616
- gap: 16px;
617
- margin: 24px 0;
618
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
619
- }
620
-
621
- .summary-card {
622
- border: 1px solid rgba(32, 33, 36, 0.08);
623
- border-radius: var(--radius);
624
- background: #fbfcff;
625
- padding: 16px;
626
- display: grid;
627
- gap: 6px;
628
- }
629
-
630
- .summary-label {
631
- font-size: 13px;
632
- font-weight: 600;
633
- color: var(--muted);
634
- text-transform: uppercase;
635
- letter-spacing: 0.05em;
636
- }
637
-
638
- .summary-count {
639
- font-size: 20px;
640
- font-weight: 700;
641
- }
642
-
643
- .summary-amount {
644
- font-size: 16px;
645
- font-weight: 600;
646
- color: var(--accent);
647
- }
648
-
649
- .dashboard-chart {
650
- border: 1px solid rgba(32, 33, 36, 0.08);
651
- border-radius: var(--radius);
652
- background: #ffffff;
653
- padding: 16px;
654
- }
655
-
656
- .dashboard-table .items-table th,
657
- .dashboard-table .items-table td {
658
- white-space: nowrap;
659
- }
660
-
661
- .dashboard-table .items-table td:last-child {
662
- width: 160px;
663
- }
664
-
665
- .table-actions {
666
- display: flex;
667
- flex-wrap: wrap;
668
- gap: 8px;
669
- }
670
-
671
- #invoices-empty {
672
- margin-top: 12px;
673
- text-align: center;
674
- }
 
 
 
 
 
675
 
676
- @media (max-width: 640px) {
677
- .panel {
678
- padding: 20px;
679
- }
680
-
681
- .header-section {
682
- flex-direction: column;
683
- text-align: center;
684
- gap: 16px;
685
- }
686
-
687
- .logo {
688
- max-width: 150px;
689
- }
690
-
691
- .app-title {
692
- font-size: 20px;
693
- }
694
-
695
- .app-header {
696
- flex-direction: column;
697
- gap: 12px;
698
- align-items: flex-start;
699
- }
700
-
701
- .business-section,
702
- .items-section {
703
- padding: 16px;
704
- }
705
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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;
 
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;
 
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%;
 
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;
 
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
+ }