Spaces:
Sleeping
Sleeping
Data base test
#5
by
Antoni09 - opened
- .gitattributes +0 -1
- 0f5c994848b44107af0395713ad69da0-free.png +0 -0
- 1.png +0 -3
- Dockerfile +31 -12
- README.md +67 -36
- db.py +0 -265
- entrypoint.sh +27 -27
- gitattributes +0 -36
- index.html +0 -390
- invoice_app.py +190 -190
- logotyp do strony.png +0 -0
- main.js +0 -2326
- requirements.txt +6 -2
- server.py +336 -952
- small_logotyp do strony.jpg +0 -0
- static/css/styles.css +365 -0
- static/js/main.js +984 -0
- styles.css +0 -1041
- templates/index.html +209 -0
- web_invoice_store.json +0 -0
.gitattributes
CHANGED
|
@@ -34,4 +34,3 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
Roboto-VariableFont_wdth,wght.ttf filter=lfs diff=lfs merge=lfs -text
|
| 37 |
-
1.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
Roboto-VariableFont_wdth,wght.ttf filter=lfs diff=lfs merge=lfs -text
|
|
|
0f5c994848b44107af0395713ad69da0-free.png
DELETED
|
Binary file (6.1 kB)
|
|
|
1.png
DELETED
Git LFS Details
|
Dockerfile
CHANGED
|
@@ -1,12 +1,31 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
ENV
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# System deps (jeśli używasz psycopg2-binary, wystarczy ca-certificates)
|
| 5 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 6 |
+
ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
| 7 |
+
|
| 8 |
+
# Dobre praktyki
|
| 9 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 10 |
+
ENV PYTHONUNBUFFERED=1
|
| 11 |
+
|
| 12 |
+
# Nie-root user (HF i tak odpala jako uid 1000)
|
| 13 |
+
RUN useradd -m appuser
|
| 14 |
+
WORKDIR /app
|
| 15 |
+
|
| 16 |
+
# Zależności
|
| 17 |
+
COPY requirements.txt .
|
| 18 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 19 |
+
|
| 20 |
+
# Kod
|
| 21 |
+
COPY . .
|
| 22 |
+
|
| 23 |
+
# Entrypoint (migracje/init + start)
|
| 24 |
+
COPY entrypoint.sh /entrypoint.sh
|
| 25 |
+
RUN chmod +x /entrypoint.sh
|
| 26 |
+
|
| 27 |
+
USER appuser
|
| 28 |
+
EXPOSE 7860
|
| 29 |
+
|
| 30 |
+
# UWAGA: zmienne ze Spaces (DATABASE_URL, itp.) będą dostępne dopiero w runtime
|
| 31 |
+
ENTRYPOINT ["/entrypoint.sh"]
|
README.md
CHANGED
|
@@ -9,56 +9,87 @@ pinned: false
|
|
| 9 |
|
| 10 |
# Generator faktur
|
| 11 |
|
| 12 |
-
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
- **Frontend + backend** – logowanie, edycja danych firmy, inteligentny formularz nabywcy, historia faktur i eleganckie PDF-y z logo.
|
| 17 |
|
| 18 |
-
|
| 19 |
|
| 20 |
-
|
| 21 |
|
| 22 |
-
##
|
| 23 |
-
|
| 24 |
-
-
|
| 25 |
-
|
|
|
|
| 26 |
|
| 27 |
-
### Uruchomienie
|
| 28 |
```powershell
|
| 29 |
-
python
|
| 30 |
-
# aplikacja działa na http://localhost:5000
|
| 31 |
```
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|
| 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 |
-
|
| 44 |
|
| 45 |
-
##
|
| 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 |
-
|
|
|
|
|
|
|
| 52 |
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
-
### Uruchomienie
|
| 56 |
```powershell
|
| 57 |
-
python
|
| 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 |
-
###
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
# Generator faktur
|
| 11 |
|
| 12 |
+
Repozytorium zawiera dwa sposoby wystawiania faktur:
|
| 13 |
|
| 14 |
+
1. prosta aplikacje CLI,
|
| 15 |
+
2. rozbudowany frontend/ backend, ktory generuje faktury w PDF.
|
|
|
|
| 16 |
|
| 17 |
+
## 1. Aplikacja CLI
|
| 18 |
|
| 19 |
+
Niewielkie narzedzie w Pythonie, ktore pomaga zebrac podstawowe dane i wystawic fakture z pojedyncza pozycja (usluga lub towar). Dane firmy zapisywane sa lokalnie w `invoice_data.json`, a kazda wygenerowana faktura trafia do katalogu `invoices/`.
|
| 20 |
|
| 21 |
+
## Wymagania
|
| 22 |
+
|
| 23 |
+
- Python 3.8 lub nowszy
|
| 24 |
+
|
| 25 |
+
## Pierwsze uruchomienie
|
| 26 |
|
|
|
|
| 27 |
```powershell
|
| 28 |
+
python invoice_app.py
|
|
|
|
| 29 |
```
|
| 30 |
|
| 31 |
+
1. Podaj dane firmy (nazwa, adres, NIP, numer konta).
|
| 32 |
+
2. Ustaw haslo, ktore bedzie wymagane przy kolejnych uruchomieniach.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
+
Po zakonczeniu konfiguracji uruchom aplikacje ponownie, aby sie zalogowac i wystawic pierwsza fakture.
|
| 35 |
|
| 36 |
+
## Wystawianie faktury
|
|
|
|
|
|
|
| 37 |
|
| 38 |
+
```powershell
|
| 39 |
+
python invoice_app.py
|
| 40 |
+
```
|
| 41 |
|
| 42 |
+
1. Zaloguj sie haslem ustawionym wczesniej.
|
| 43 |
+
2. Podaj opis uslugi/towaru, ilosc oraz cene jednostkowa.
|
| 44 |
+
3. Opcjonalnie wpisz dane klienta.
|
| 45 |
|
| 46 |
+
Aplikacja automatycznie policzy kwote netto i zapisze fakture w katalogu `invoices/` jako plik tekstowy (np. `invoices/FV-20240101-120000.txt`). Dane faktury sa rowniez dopisywane do pliku `invoice_data.json`, dzieki czemu latwo przechowywac historie.
|
| 47 |
+
|
| 48 |
+
## 2. Aplikacja webowa (frontend + backend)
|
| 49 |
+
|
| 50 |
+
Interfejs przegladarkowy z serwerem REST (Flask), ktory przechowuje dane firmy oraz historie faktur w pliku `web_invoice_store.json`.
|
| 51 |
+
|
| 52 |
+
### Wymagania
|
| 53 |
+
|
| 54 |
+
- Python 3.8+
|
| 55 |
+
- Zainstalowany pakiet `Flask`
|
| 56 |
|
|
|
|
| 57 |
```powershell
|
| 58 |
+
python -m pip install -r requirements.txt
|
| 59 |
```
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
+
### Uruchomienie
|
| 62 |
+
|
| 63 |
+
1. Start serwera API oraz statycznego frontendu:
|
| 64 |
+
|
| 65 |
+
```powershell
|
| 66 |
+
python server.py
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
2. W przegladarce odwiedz `http://localhost:5000/`.
|
| 70 |
+
|
| 71 |
+
### Funkcje webowego generatora
|
| 72 |
+
|
| 73 |
+
- Pierwsze uruchomienie:
|
| 74 |
+
- konfiguracja danych sprzedawcy (nazwa, adres, kod pocztowy, miejscowosc, NIP, numer konta, haslo),
|
| 75 |
+
- dane przechowywane lokalnie na serwerze w `web_invoice_store.json`.
|
| 76 |
+
- Logowanie chronione haslem (token przechowywany w `sessionStorage` przegladarki).
|
| 77 |
+
- Panel po zalogowaniu:
|
| 78 |
+
- wyswietlenie danych sprzedawcy oraz mozliwosc edycji bezpośrednio z poziomu UI,
|
| 79 |
+
- formularz danych nabywcy z rozszerzonym adresem (ulica, kod pocztowy, miejscowosc, NIP),
|
| 80 |
+
- pole daty sprzedazy/wykonania uslugi (niezalezne od daty wystawienia),
|
| 81 |
+
- pozycje faktury z dynamiczna tabela: wprowadzamy cene brutto, wybieramy stawke VAT (23/8/5/0/ZW/NP), aplikacja automatycznie liczy cene netto, wartosc netto, VAT i brutto,
|
| 82 |
+
- obsluga pozycji zwolnionych (ZW) wraz z polem na podstawe prawna zwolnienia (wyswietlana na fakturze),
|
| 83 |
+
- podsumowanie stawek (np. 23% – X netto / VAT – Y, ZW – Z netto / VAT – 0) zamiast pojedynczej sumy netto,
|
| 84 |
+
- generowanie podgladu faktury oraz eksport do PDF w formacie A4 (NABYWCA po lewej, SPRZEDAWCA po prawej, tabela zgodna z nazewnictwem ustawy: „Cena jedn. netto”, „Wartosc netto (pozycja)”, „Stawka VAT”, „Kwota VAT (pozycja)”, „Wartosc brutto”).
|
| 85 |
+
- Historia faktur zapisywana jest po stronie serwera (ostatnie 200 dokumentow).
|
| 86 |
+
|
| 87 |
+
> **Uwaga:** do wygenerowania pliku PDF wykorzystywana jest biblioteka [jsPDF](https://cdnjs.com/libraries/jspdf) ladowana z CDN. Przegladarka musi miec dostep do internetu, aby pobrac skrypt.
|
| 88 |
+
|
| 89 |
+
### Reset danych webowych
|
| 90 |
+
|
| 91 |
+
Usun plik `web_invoice_store.json`, aby uruchomic konfiguracje od nowa (spowoduje to utrate historii faktur).
|
| 92 |
+
|
| 93 |
+
## Reset danych CLI
|
| 94 |
+
|
| 95 |
+
Jezeli chcesz rozpoczac konfiguracje od nowa, usun pliki `invoice_data.json` i katalog `invoices/` (uwaga: spowoduje to utrate historii wystawionych faktur).
|
db.py
DELETED
|
@@ -1,265 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
from contextlib import contextmanager
|
| 3 |
-
from typing import Any, Dict, List, Optional, Sequence
|
| 4 |
-
|
| 5 |
-
import psycopg2
|
| 6 |
-
from psycopg2.extras import RealDictCursor
|
| 7 |
-
|
| 8 |
-
DATABASE_URL = os.environ.get("NEON_DATABASE_URL")
|
| 9 |
-
|
| 10 |
-
if not DATABASE_URL:
|
| 11 |
-
raise RuntimeError(
|
| 12 |
-
"Brak zmiennej NEON_DATABASE_URL. Ustaw sekret w Hugging Face lub "
|
| 13 |
-
"ustaw zmienną środowiskową lokalnie."
|
| 14 |
-
)
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
@contextmanager
|
| 19 |
-
def db_conn():
|
| 20 |
-
conn = psycopg2.connect(DATABASE_URL, cursor_factory=RealDictCursor)
|
| 21 |
-
try:
|
| 22 |
-
yield conn
|
| 23 |
-
conn.commit()
|
| 24 |
-
except Exception:
|
| 25 |
-
conn.rollback()
|
| 26 |
-
raise
|
| 27 |
-
finally:
|
| 28 |
-
conn.close()
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def fetch_one(query: str, params: Sequence[Any]) -> Optional[Dict[str, Any]]:
|
| 32 |
-
with db_conn() as conn, conn.cursor() as cur:
|
| 33 |
-
cur.execute(query, params)
|
| 34 |
-
return cur.fetchone()
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
def fetch_all(query: str, params: Sequence[Any] = ()) -> List[Dict[str, Any]]:
|
| 38 |
-
with db_conn() as conn, conn.cursor() as cur:
|
| 39 |
-
cur.execute(query, params)
|
| 40 |
-
return cur.fetchall()
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def execute(query: str, params: Sequence[Any]) -> None:
|
| 44 |
-
with db_conn() as conn, conn.cursor() as cur:
|
| 45 |
-
cur.execute(query, params)
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
def create_account(login: str, email: str, password_hash: str) -> int:
|
| 49 |
-
with db_conn() as conn, conn.cursor() as cur:
|
| 50 |
-
cur.execute(
|
| 51 |
-
"""
|
| 52 |
-
INSERT INTO accounts (login, password_hash)
|
| 53 |
-
VALUES (%s, %s)
|
| 54 |
-
RETURNING id
|
| 55 |
-
""",
|
| 56 |
-
(login, password_hash),
|
| 57 |
-
)
|
| 58 |
-
account_id = cur.fetchone()["id"]
|
| 59 |
-
cur.execute(
|
| 60 |
-
"""
|
| 61 |
-
INSERT INTO business_profiles (account_id, company_name, owner_name,
|
| 62 |
-
address_line, postal_code, city, tax_id, bank_account)
|
| 63 |
-
VALUES (%s, '', '', '', '', '', '', '')
|
| 64 |
-
""",
|
| 65 |
-
(account_id,),
|
| 66 |
-
)
|
| 67 |
-
return account_id
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
def update_business(account_id: int, data: Dict[str, str]) -> None:
|
| 71 |
-
execute(
|
| 72 |
-
"""
|
| 73 |
-
UPDATE business_profiles
|
| 74 |
-
SET company_name = %s,
|
| 75 |
-
owner_name = %s,
|
| 76 |
-
address_line = %s,
|
| 77 |
-
postal_code = %s,
|
| 78 |
-
city = %s,
|
| 79 |
-
tax_id = %s,
|
| 80 |
-
bank_account = %s
|
| 81 |
-
WHERE account_id = %s
|
| 82 |
-
""",
|
| 83 |
-
(
|
| 84 |
-
data["company_name"],
|
| 85 |
-
data["owner_name"],
|
| 86 |
-
data["address_line"],
|
| 87 |
-
data["postal_code"],
|
| 88 |
-
data["city"],
|
| 89 |
-
data["tax_id"],
|
| 90 |
-
data["bank_account"],
|
| 91 |
-
account_id,
|
| 92 |
-
),
|
| 93 |
-
)
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
def fetch_business_logo(account_id: int) -> Optional[Dict[str, Optional[str]]]:
|
| 98 |
-
row = fetch_one(
|
| 99 |
-
"""
|
| 100 |
-
SELECT logo_mime_type, logo_data_base64
|
| 101 |
-
FROM business_profiles
|
| 102 |
-
WHERE account_id = %s
|
| 103 |
-
""",
|
| 104 |
-
(account_id,),
|
| 105 |
-
)
|
| 106 |
-
if not row:
|
| 107 |
-
return None
|
| 108 |
-
mime_type = row.get("logo_mime_type")
|
| 109 |
-
data_base64 = row.get("logo_data_base64")
|
| 110 |
-
if not mime_type or not data_base64:
|
| 111 |
-
return None
|
| 112 |
-
return {"mime_type": mime_type, "data": data_base64}
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
def update_business_logo(account_id: int, mime: Optional[str], data_base64: Optional[str]) -> None:
|
| 116 |
-
execute(
|
| 117 |
-
"""
|
| 118 |
-
UPDATE business_profiles
|
| 119 |
-
SET logo_mime_type = %s,
|
| 120 |
-
logo_data_base64 = %s
|
| 121 |
-
WHERE account_id = %s
|
| 122 |
-
""",
|
| 123 |
-
(mime, data_base64, account_id),
|
| 124 |
-
)
|
| 125 |
-
|
| 126 |
-
def upsert_client(account_id: int, payload: Dict[str, str]) -> int:
|
| 127 |
-
row = fetch_one(
|
| 128 |
-
"""
|
| 129 |
-
SELECT id FROM clients
|
| 130 |
-
WHERE account_id = %s AND tax_id = %s
|
| 131 |
-
""",
|
| 132 |
-
(account_id, payload["tax_id"]),
|
| 133 |
-
)
|
| 134 |
-
if row:
|
| 135 |
-
client_id = row["id"]
|
| 136 |
-
execute(
|
| 137 |
-
"""
|
| 138 |
-
UPDATE clients
|
| 139 |
-
SET name = %s,
|
| 140 |
-
address_line = %s,
|
| 141 |
-
postal_code = %s,
|
| 142 |
-
city = %s,
|
| 143 |
-
phone = %s
|
| 144 |
-
WHERE id = %s
|
| 145 |
-
""",
|
| 146 |
-
(
|
| 147 |
-
payload["name"],
|
| 148 |
-
payload["address_line"],
|
| 149 |
-
payload["postal_code"],
|
| 150 |
-
payload["city"],
|
| 151 |
-
payload.get("phone"),
|
| 152 |
-
client_id,
|
| 153 |
-
),
|
| 154 |
-
)
|
| 155 |
-
return client_id
|
| 156 |
-
|
| 157 |
-
with db_conn() as conn, conn.cursor() as cur:
|
| 158 |
-
cur.execute(
|
| 159 |
-
"""
|
| 160 |
-
INSERT INTO clients (account_id, name, address_line, postal_code, city, tax_id, phone)
|
| 161 |
-
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
| 162 |
-
RETURNING id
|
| 163 |
-
""",
|
| 164 |
-
(
|
| 165 |
-
account_id,
|
| 166 |
-
payload["name"],
|
| 167 |
-
payload["address_line"],
|
| 168 |
-
payload["postal_code"],
|
| 169 |
-
payload["city"],
|
| 170 |
-
payload["tax_id"],
|
| 171 |
-
payload.get("phone"),
|
| 172 |
-
),
|
| 173 |
-
)
|
| 174 |
-
return cur.fetchone()["id"]
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
def search_clients(account_id: int, term: str, limit: int = 10) -> List[Dict[str, Any]]:
|
| 178 |
-
query = (term or "").strip().lower()
|
| 179 |
-
like = f"%{query}%"
|
| 180 |
-
return fetch_all(
|
| 181 |
-
"""
|
| 182 |
-
SELECT name, tax_id, address_line, postal_code, city, phone
|
| 183 |
-
FROM clients
|
| 184 |
-
WHERE account_id = %s
|
| 185 |
-
AND (
|
| 186 |
-
%s = '' OR
|
| 187 |
-
LOWER(COALESCE(name, '')) LIKE %s OR
|
| 188 |
-
LOWER(COALESCE(tax_id, '')) LIKE %s
|
| 189 |
-
)
|
| 190 |
-
ORDER BY LOWER(COALESCE(name, tax_id, '')) ASC
|
| 191 |
-
LIMIT %s
|
| 192 |
-
""",
|
| 193 |
-
(account_id, query, like, like, limit),
|
| 194 |
-
)
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
def insert_invoice(account_id: int, client_id: int, invoice: Dict[str, Any]) -> int:
|
| 198 |
-
with db_conn() as conn, conn.cursor() as cur:
|
| 199 |
-
cur.execute(
|
| 200 |
-
"""
|
| 201 |
-
INSERT INTO invoices (account_id, client_id, invoice_number, issued_at,
|
| 202 |
-
sale_date, payment_term_days, exemption_note,
|
| 203 |
-
total_net, total_vat, total_gross)
|
| 204 |
-
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
| 205 |
-
RETURNING id
|
| 206 |
-
""",
|
| 207 |
-
(
|
| 208 |
-
account_id,
|
| 209 |
-
client_id,
|
| 210 |
-
invoice["invoice_id"],
|
| 211 |
-
invoice["issued_at"],
|
| 212 |
-
invoice["sale_date"],
|
| 213 |
-
invoice.get("payment_term", 14),
|
| 214 |
-
invoice.get("exemption_note"),
|
| 215 |
-
invoice["totals"]["net"],
|
| 216 |
-
invoice["totals"]["vat"],
|
| 217 |
-
invoice["totals"]["gross"],
|
| 218 |
-
),
|
| 219 |
-
)
|
| 220 |
-
invoice_id = cur.fetchone()["id"]
|
| 221 |
-
|
| 222 |
-
cur.executemany(
|
| 223 |
-
"""
|
| 224 |
-
INSERT INTO invoice_items (invoice_id, line_no, name, quantity, unit,
|
| 225 |
-
vat_code, vat_label, unit_price_net,
|
| 226 |
-
unit_price_gross, net_total, vat_amount, gross_total)
|
| 227 |
-
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
| 228 |
-
""",
|
| 229 |
-
[
|
| 230 |
-
(
|
| 231 |
-
invoice_id,
|
| 232 |
-
idx + 1,
|
| 233 |
-
item["name"],
|
| 234 |
-
item["quantity"],
|
| 235 |
-
item.get("unit"),
|
| 236 |
-
item.get("vat_code"),
|
| 237 |
-
item.get("vat_label"),
|
| 238 |
-
item["unit_price_net"],
|
| 239 |
-
item["unit_price_gross"],
|
| 240 |
-
item["net_total"],
|
| 241 |
-
item["vat_amount"],
|
| 242 |
-
item["gross_total"],
|
| 243 |
-
)
|
| 244 |
-
for idx, item in enumerate(invoice["items"])
|
| 245 |
-
],
|
| 246 |
-
)
|
| 247 |
-
|
| 248 |
-
cur.executemany(
|
| 249 |
-
"""
|
| 250 |
-
INSERT INTO invoice_vat_summary (invoice_id, vat_label, net_total, vat_total, gross_total)
|
| 251 |
-
VALUES (%s, %s, %s, %s, %s)
|
| 252 |
-
""",
|
| 253 |
-
[
|
| 254 |
-
(
|
| 255 |
-
invoice_id,
|
| 256 |
-
row["vat_label"],
|
| 257 |
-
row["net_total"],
|
| 258 |
-
row["vat_total"],
|
| 259 |
-
row["gross_total"],
|
| 260 |
-
)
|
| 261 |
-
for row in invoice["summary"]
|
| 262 |
-
],
|
| 263 |
-
)
|
| 264 |
-
|
| 265 |
-
return invoice_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
entrypoint.sh
CHANGED
|
@@ -1,27 +1,27 @@
|
|
| 1 |
-
#!/usr/bin/env bash
|
| 2 |
-
set -euo pipefail
|
| 3 |
-
|
| 4 |
-
# /data jest montowane przez HF; jeśli nie mamy uprawnień, pomiń
|
| 5 |
-
if [ ! -d /data ]; then
|
| 6 |
-
echo "Info: /data not available (will be mounted by Spaces)."
|
| 7 |
-
fi
|
| 8 |
-
|
| 9 |
-
: "${DATABASE_URL:?ERROR: DATABASE_URL is not set}"
|
| 10 |
-
|
| 11 |
-
python - <<'PY'
|
| 12 |
-
import os
|
| 13 |
-
from sqlalchemy import create_engine, text
|
| 14 |
-
engine = create_engine(os.environ["DATABASE_URL"], pool_pre_ping=True)
|
| 15 |
-
with engine.begin() as conn:
|
| 16 |
-
conn.execute(text("""
|
| 17 |
-
CREATE TABLE IF NOT EXISTS notes (
|
| 18 |
-
id SERIAL PRIMARY KEY,
|
| 19 |
-
body TEXT NOT NULL,
|
| 20 |
-
created_at TIMESTAMPTZ DEFAULT now()
|
| 21 |
-
)
|
| 22 |
-
"""))
|
| 23 |
-
PY
|
| 24 |
-
|
| 25 |
-
# Start aplikacji – dopasuj do swojej
|
| 26 |
-
exec python server.py
|
| 27 |
-
# (albo: exec python server.py)
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# /data jest montowane przez HF; jeśli nie mamy uprawnień, pomiń
|
| 5 |
+
if [ ! -d /data ]; then
|
| 6 |
+
echo "Info: /data not available (will be mounted by Spaces)."
|
| 7 |
+
fi
|
| 8 |
+
|
| 9 |
+
: "${DATABASE_URL:?ERROR: DATABASE_URL is not set}"
|
| 10 |
+
|
| 11 |
+
python - <<'PY'
|
| 12 |
+
import os
|
| 13 |
+
from sqlalchemy import create_engine, text
|
| 14 |
+
engine = create_engine(os.environ["DATABASE_URL"], pool_pre_ping=True)
|
| 15 |
+
with engine.begin() as conn:
|
| 16 |
+
conn.execute(text("""
|
| 17 |
+
CREATE TABLE IF NOT EXISTS notes (
|
| 18 |
+
id SERIAL PRIMARY KEY,
|
| 19 |
+
body TEXT NOT NULL,
|
| 20 |
+
created_at TIMESTAMPTZ DEFAULT now()
|
| 21 |
+
)
|
| 22 |
+
"""))
|
| 23 |
+
PY
|
| 24 |
+
|
| 25 |
+
# Start aplikacji – dopasuj do swojej
|
| 26 |
+
exec python server.py
|
| 27 |
+
# (albo: exec python server.py)
|
gitattributes
DELETED
|
@@ -1,36 +0,0 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
-
Roboto-VariableFont_wdth,wght.ttf filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
index.html
DELETED
|
@@ -1,390 +0,0 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
<html lang="pl"><head>
|
| 3 |
-
|
| 4 |
-
<meta charset="UTF-8">
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
-
<title>Generator faktur</title>
|
| 7 |
-
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
-
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
| 10 |
-
<link rel="stylesheet" href="styles.css">
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
</head><body>
|
| 14 |
-
<main class="container">
|
| 15 |
-
<div class="brand-banner">
|
| 16 |
-
<img src="small_logotyp do strony.jpg" alt="Logotyp FakturON!" class="brand-logo">
|
| 17 |
-
</div>
|
| 18 |
-
|
| 19 |
-
<section id="hero-panel" class="panel hero-panel">
|
| 20 |
-
<div class="hero-columns">
|
| 21 |
-
<div class="hero-content">
|
| 22 |
-
<p class="eyebrow">Bezpłatny panel faktur</p>
|
| 23 |
-
<h1 class="app-title">Generator faktur</h1>
|
| 24 |
-
<p class="app-description">
|
| 25 |
-
Proste narzędzie do wystawiania i analizowania faktur: logujesz się, uzupełniasz dane kontrahenta,
|
| 26 |
-
zapisujesz dokument i od razu widzisz efekty w dashboardzie. Zero opłat, zero limitów.
|
| 27 |
-
</p>
|
| 28 |
-
<p class="hero-lead">
|
| 29 |
-
Cały proces zamyka się w jednym widoku – formularze, podgląd i historia korzystają z tej samej siatki,
|
| 30 |
-
dzięki czemu wyglądają identycznie na desktopie i na telefonie.
|
| 31 |
-
</p>
|
| 32 |
-
<ul class="header-highlights">
|
| 33 |
-
<li>
|
| 34 |
-
<strong>Zawsze bezpłatnie</strong>
|
| 35 |
-
<span>Generator działa bez limitów dokumentów i bez pakietów abonamentowych.</span>
|
| 36 |
-
</li>
|
| 37 |
-
<li>
|
| 38 |
-
<strong>Szybkie wystawianie</strong>
|
| 39 |
-
<span>W jednym formularzu zbierasz dane sprzedawcy, nabywcy i pozycje, a podgląd generuje się automatycznie.</span>
|
| 40 |
-
</li>
|
| 41 |
-
<li>
|
| 42 |
-
<strong>Spójny wygląd</strong>
|
| 43 |
-
<span>Każdy etap korzysta z tej samej siatki, więc łatwo pracować na desktopie i na telefonie.</span>
|
| 44 |
-
</li>
|
| 45 |
-
<li>
|
| 46 |
-
<strong>Dashboard i historia</strong>
|
| 47 |
-
<span>Po zalogowaniu od razu widać statystyki i listę faktur gotowych do pobrania.</span>
|
| 48 |
-
</li>
|
| 49 |
-
</ul>
|
| 50 |
-
</div>
|
| 51 |
-
<section id="auth-section" class="auth-panel">
|
| 52 |
-
<div class="auth-panel-header">
|
| 53 |
-
<p class="eyebrow">Logowanie</p>
|
| 54 |
-
<h2 class="auth-headline">Wejdź do generatora</h2>
|
| 55 |
-
<p class="auth-copy">Zaloguj się i prowadź całą obsługę faktur w jednym darmowym panelu.</p>
|
| 56 |
-
</div>
|
| 57 |
-
<div class="auth-login">
|
| 58 |
-
<div class="auth-card login-card">
|
| 59 |
-
<h3>Zaloguj się</h3>
|
| 60 |
-
<form id="login-form" class="form">
|
| 61 |
-
<label>
|
| 62 |
-
Email
|
| 63 |
-
<input type="email" name="email" autocomplete="email" required>
|
| 64 |
-
</label>
|
| 65 |
-
<label>
|
| 66 |
-
Hasło
|
| 67 |
-
<input type="password" name="password" autocomplete="current-password" required>
|
| 68 |
-
</label>
|
| 69 |
-
<button type="submit">Zaloguj</button>
|
| 70 |
-
<hr class="form-divider">
|
| 71 |
-
</form>
|
| 72 |
-
<p id="login-feedback" class="feedback" aria-live="polite"></p>
|
| 73 |
-
<div class="auth-actions">
|
| 74 |
-
<span>Nie masz konta?</span>
|
| 75 |
-
<button id="show-register-button" type="button" class="ghost-button">Stwórz konto</button>
|
| 76 |
-
</div>
|
| 77 |
-
<p id="legacy-login-hint" class="hint hidden"></p>
|
| 78 |
-
</div>
|
| 79 |
-
</div>
|
| 80 |
-
</section>
|
| 81 |
-
</div>
|
| 82 |
-
</section>
|
| 83 |
-
|
| 84 |
-
<section id="register-section" class="panel hidden">
|
| 85 |
-
<div class="auth-card register-card">
|
| 86 |
-
<div class="register-header">
|
| 87 |
-
<h3>Załóż konto</h3>
|
| 88 |
-
<button id="back-to-login" type="button" class="link-button">Wróć do logowania</button>
|
| 89 |
-
</div>
|
| 90 |
-
<form id="register-form" class="form">
|
| 91 |
-
<div class="register-fields">
|
| 92 |
-
<div class="field-grid register-credentials">
|
| 93 |
-
<label>
|
| 94 |
-
Email
|
| 95 |
-
<input type="email" name="email" autocomplete="email" required>
|
| 96 |
-
</label>
|
| 97 |
-
<label>
|
| 98 |
-
Hasło
|
| 99 |
-
<input type="password" name="password" autocomplete="new-password" required>
|
| 100 |
-
</label>
|
| 101 |
-
<label>
|
| 102 |
-
Powtórz hasło
|
| 103 |
-
<input type="password" name="confirm_password" autocomplete="new-password" required>
|
| 104 |
-
</label>
|
| 105 |
-
</div>
|
| 106 |
-
|
| 107 |
-
<div class="field-grid register-company">
|
| 108 |
-
<label>
|
| 109 |
-
Nazwa firmy
|
| 110 |
-
<input type="text" name="company_name" required>
|
| 111 |
-
</label>
|
| 112 |
-
<label>
|
| 113 |
-
Imię i nazwisko właściciela
|
| 114 |
-
<input type="text" name="owner_name" required>
|
| 115 |
-
</label>
|
| 116 |
-
<label>
|
| 117 |
-
Ulica i numer
|
| 118 |
-
<input type="text" name="address_line" required>
|
| 119 |
-
</label>
|
| 120 |
-
<label>
|
| 121 |
-
Kod pocztowy
|
| 122 |
-
<input type="text" name="postal_code" required>
|
| 123 |
-
</label>
|
| 124 |
-
<label>
|
| 125 |
-
Miejscowość
|
| 126 |
-
<input type="text" name="city" required>
|
| 127 |
-
</label>
|
| 128 |
-
<label>
|
| 129 |
-
NIP
|
| 130 |
-
<input type="text" name="tax_id" required>
|
| 131 |
-
</label>
|
| 132 |
-
<label>
|
| 133 |
-
Numer konta bankowego
|
| 134 |
-
<input type="text" name="bank_account" required>
|
| 135 |
-
</label>
|
| 136 |
-
</div>
|
| 137 |
-
</div>
|
| 138 |
-
<div class="form-actions">
|
| 139 |
-
<button type="submit">Utwórz konto</button>
|
| 140 |
-
<button id="cancel-register" type="button" class="link-button">Anuluj</button>
|
| 141 |
-
</div>
|
| 142 |
-
</form>
|
| 143 |
-
<p id="register-feedback" class="feedback"></p>
|
| 144 |
-
<p class="hint">Wskazowka: dane firmy zaktualizujesz pozniej w sekcji "Dane sprzedawcy".</p>
|
| 145 |
-
</div>
|
| 146 |
-
</section>
|
| 147 |
-
|
| 148 |
-
<section id="app-section" class="panel hidden">
|
| 149 |
-
<header class="app-header">
|
| 150 |
-
<div>
|
| 151 |
-
<h2>Panel faktur</h2>
|
| 152 |
-
</div>
|
| 153 |
-
<div id="login-badge" class="login-badge hidden" aria-live="polite">
|
| 154 |
-
<span class="badge-label">Zalogowany jako</span>
|
| 155 |
-
<span id="current-login-label" class="badge-value"></span>
|
| 156 |
-
</div>
|
| 157 |
-
<nav class="app-nav">
|
| 158 |
-
<button type="button" class="app-nav-button active" data-view="invoice-builder">Nowa faktura</button>
|
| 159 |
-
<button type="button" class="app-nav-button" data-view="dashboard">Dashboard</button>
|
| 160 |
-
</nav>
|
| 161 |
-
<button id="logout-button" type="button" class="link-button">Wyloguj</button>
|
| 162 |
-
</header>
|
| 163 |
-
|
| 164 |
-
<section id="invoice-builder-section" class="app-view">
|
| 165 |
-
<section class="business-section">
|
| 166 |
-
<div class="business-section-header">
|
| 167 |
-
<h3>Dane sprzedawcy</h3>
|
| 168 |
-
<div class="business-actions">
|
| 169 |
-
<button id="toggle-business-form" type="button" class="pill-button">Edycja danych</button>
|
| 170 |
-
<label for="logo-input" class="pill-button secondary">
|
| 171 |
-
<input id="logo-input" type="file" accept="image/png,image/jpeg" hidden>
|
| 172 |
-
Wgraj logo
|
| 173 |
-
</label>
|
| 174 |
-
<button id="remove-logo-button" type="button" class="pill-button danger hidden">Usuń logo</button>
|
| 175 |
-
</div>
|
| 176 |
-
</div>
|
| 177 |
-
<div id="business-display" class="business-display"></div>
|
| 178 |
-
<div id="logo-preview" class="logo-preview hidden">
|
| 179 |
-
<span class="logo-preview-label">Logo sprzedawcy</span>
|
| 180 |
-
<img id="logo-preview-image" alt="Logo firmy">
|
| 181 |
-
</div>
|
| 182 |
-
<p id="logo-feedback" class="feedback"></p>
|
| 183 |
-
<form id="business-form" class="form hidden">
|
| 184 |
-
<div class="field-grid">
|
| 185 |
-
<label>
|
| 186 |
-
Nazwa firmy
|
| 187 |
-
<input type="text" name="company_name" required>
|
| 188 |
-
</label>
|
| 189 |
-
<label>
|
| 190 |
-
Imię i nazwisko właściciela
|
| 191 |
-
<input type="text" name="owner_name" required>
|
| 192 |
-
</label>
|
| 193 |
-
<label>
|
| 194 |
-
Ulica i numer
|
| 195 |
-
<input type="text" name="address_line" required>
|
| 196 |
-
</label>
|
| 197 |
-
<label>
|
| 198 |
-
Kod pocztowy
|
| 199 |
-
<input type="text" name="postal_code" required>
|
| 200 |
-
</label>
|
| 201 |
-
<label>
|
| 202 |
-
Miejscowość
|
| 203 |
-
<input type="text" name="city" required>
|
| 204 |
-
</label>
|
| 205 |
-
<label>
|
| 206 |
-
NIP
|
| 207 |
-
<input type="text" name="tax_id" required>
|
| 208 |
-
</label>
|
| 209 |
-
<label>
|
| 210 |
-
Numer konta bankowego
|
| 211 |
-
<input type="text" name="bank_account" required>
|
| 212 |
-
</label>
|
| 213 |
-
</div>
|
| 214 |
-
<div class="form-actions">
|
| 215 |
-
<button type="submit">Zapisz</button>
|
| 216 |
-
<button id="cancel-business-update" type="button" class="link-button">Anuluj</button>
|
| 217 |
-
</div>
|
| 218 |
-
<p id="business-feedback" class="feedback"></p>
|
| 219 |
-
</form>
|
| 220 |
-
</section>
|
| 221 |
-
|
| 222 |
-
<form id="invoice-form" class="form">
|
| 223 |
-
<fieldset>
|
| 224 |
-
<legend>Informacje o fakturze</legend>
|
| 225 |
-
<div class="field-grid">
|
| 226 |
-
<label>
|
| 227 |
-
Data sprzedaży / wykonania usługi
|
| 228 |
-
<input type="date" name="saleDate">
|
| 229 |
-
</label>
|
| 230 |
-
<label>
|
| 231 |
-
Termin płatności (dni)
|
| 232 |
-
<input type="number" name="paymentTerm" min="1" step="1" value="14">
|
| 233 |
-
</label>
|
| 234 |
-
</div>
|
| 235 |
-
</fieldset>
|
| 236 |
-
|
| 237 |
-
<fieldset>
|
| 238 |
-
<legend>Dane nabywcy</legend>
|
| 239 |
-
<div class="client-lookup">
|
| 240 |
-
<label for="client-search">
|
| 241 |
-
Szybkie wyszukiwanie nabywcy
|
| 242 |
-
<input type="text" id="client-search" placeholder="Wpisz NIP lub nazwę klienta">
|
| 243 |
-
</label>
|
| 244 |
-
<div id="client-suggestions" class="client-suggestions hidden" role="listbox"></div>
|
| 245 |
-
</div>
|
| 246 |
-
<div class="field-grid">
|
| 247 |
-
<label>
|
| 248 |
-
Nazwa / Imię i nazwisko
|
| 249 |
-
<input type="text" name="clientName">
|
| 250 |
-
</label>
|
| 251 |
-
<label>
|
| 252 |
-
NIP
|
| 253 |
-
<input type="text" name="clientTaxId">
|
| 254 |
-
</label>
|
| 255 |
-
<label>
|
| 256 |
-
Ulica i numer
|
| 257 |
-
<input type="text" name="clientAddress">
|
| 258 |
-
</label>
|
| 259 |
-
<label>
|
| 260 |
-
Kod pocztowy
|
| 261 |
-
<input type="text" name="clientPostalCode">
|
| 262 |
-
</label>
|
| 263 |
-
<label>
|
| 264 |
-
Miejscowość
|
| 265 |
-
<input type="text" name="clientCity">
|
| 266 |
-
</label>
|
| 267 |
-
<label>
|
| 268 |
-
Numer telefonu
|
| 269 |
-
<input type="tel" name="clientPhone">
|
| 270 |
-
</label>
|
| 271 |
-
</div>
|
| 272 |
-
</fieldset>
|
| 273 |
-
|
| 274 |
-
<section class="items-section">
|
| 275 |
-
<header class="items-header">
|
| 276 |
-
<h3>Pozycje faktury</h3>
|
| 277 |
-
<button type="button" id="add-item-button">Dodaj pozycję</button>
|
| 278 |
-
</header>
|
| 279 |
-
<div class="items-table-wrapper">
|
| 280 |
-
<table class="items-table">
|
| 281 |
-
<thead>
|
| 282 |
-
<tr>
|
| 283 |
-
<th>Nazwa towaru/usługi</th>
|
| 284 |
-
<th>Ilość</th>
|
| 285 |
-
<th>Jednostka</th>
|
| 286 |
-
<th>Cena jedn. brutto (PLN)</th>
|
| 287 |
-
<th>Stawka VAT</th>
|
| 288 |
-
<th>Wartość brutto (PLN)</th>
|
| 289 |
-
<th></th>
|
| 290 |
-
</tr>
|
| 291 |
-
</thead>
|
| 292 |
-
<tbody id="items-body"></tbody>
|
| 293 |
-
</table>
|
| 294 |
-
</div>
|
| 295 |
-
</section>
|
| 296 |
-
|
| 297 |
-
<div id="totals-container" class="totals">
|
| 298 |
-
<span id="total-net">Suma netto: 0.00 PLN</span>
|
| 299 |
-
<span id="total-vat">Kwota VAT: 0.00 PLN</span>
|
| 300 |
-
<span id="total-gross">Suma brutto: 0.00 PLN</span>
|
| 301 |
-
</div>
|
| 302 |
-
|
| 303 |
-
<section id="rate-summary" class="rate-summary"></section>
|
| 304 |
-
|
| 305 |
-
<div id="exemption-note-wrapper" class="hidden">
|
| 306 |
-
<label for="exemption-reason">Powód zastosowania stawki ZW/0%</label>
|
| 307 |
-
<select id="exemption-reason" class="form-select">
|
| 308 |
-
<option value="">Wybierz podstawę zwolnienia...</option>
|
| 309 |
-
</select>
|
| 310 |
-
<label for="exemption-note" id="exemption-note-label">Podstawa prawna zwolnienia</label>
|
| 311 |
-
<textarea id="exemption-note" rows="3" placeholder="np. Art. 43 ust. 1 pkt 19 ustawy o VAT"></textarea>
|
| 312 |
-
</div>
|
| 313 |
-
|
| 314 |
-
<div class="form-actions">
|
| 315 |
-
<button type="submit" id="save-invoice-button">Generuj fakturę</button>
|
| 316 |
-
<button id="cancel-edit-invoice" type="button" class="link-button hidden">Anuluj edycję</button>
|
| 317 |
-
</div>
|
| 318 |
-
</form>
|
| 319 |
-
|
| 320 |
-
<section id="invoice-result" class="panel hidden">
|
| 321 |
-
<h3>Podgląd faktury</h3>
|
| 322 |
-
<div id="invoice-output" class="invoice-preview"></div>
|
| 323 |
-
<button id="download-button" type="button">Pobierz jako plik PDF</button>
|
| 324 |
-
</section>
|
| 325 |
-
</section>
|
| 326 |
-
|
| 327 |
-
<section id="dashboard-section" class="app-view hidden">
|
| 328 |
-
<header class="dashboard-header">
|
| 329 |
-
<div class="filters">
|
| 330 |
-
<label>
|
| 331 |
-
Od
|
| 332 |
-
<input type="date" id="filter-start-date">
|
| 333 |
-
</label>
|
| 334 |
-
<label>
|
| 335 |
-
Do
|
| 336 |
-
<input type="date" id="filter-end-date">
|
| 337 |
-
</label>
|
| 338 |
-
<button type="button" id="clear-filters" class="button secondary">Wyczyść</button>
|
| 339 |
-
</div>
|
| 340 |
-
<p id="dashboard-feedback" class="feedback"></p>
|
| 341 |
-
</header>
|
| 342 |
-
|
| 343 |
-
<section class="dashboard-summary">
|
| 344 |
-
<div class="summary-card">
|
| 345 |
-
<span class="summary-label">Ostatnie 30 dni</span>
|
| 346 |
-
<span id="summary-month-count" class="summary-count">0 faktur</span>
|
| 347 |
-
<span id="summary-month-amount" class="summary-amount">0.00 PLN</span>
|
| 348 |
-
</div>
|
| 349 |
-
<div class="summary-card">
|
| 350 |
-
<span class="summary-label">Bieżący kwartał</span>
|
| 351 |
-
<span id="summary-quarter-count" class="summary-count">0 faktur</span>
|
| 352 |
-
<span id="summary-quarter-amount" class="summary-amount">0.00 PLN</span>
|
| 353 |
-
</div>
|
| 354 |
-
<div class="summary-card">
|
| 355 |
-
<span class="summary-label">Bieżący rok</span>
|
| 356 |
-
<span id="summary-year-count" class="summary-count">0 faktur</span>
|
| 357 |
-
<span id="summary-year-amount" class="summary-amount">0.00 PLN</span>
|
| 358 |
-
</div>
|
| 359 |
-
</section>
|
| 360 |
-
|
| 361 |
-
<section class="dashboard-chart">
|
| 362 |
-
<canvas id="invoices-chart" aria-label="Podsumowanie faktur"></canvas>
|
| 363 |
-
</section>
|
| 364 |
-
|
| 365 |
-
<section class="dashboard-table">
|
| 366 |
-
<div class="items-table-wrapper">
|
| 367 |
-
<table class="items-table">
|
| 368 |
-
<thead>
|
| 369 |
-
<tr>
|
| 370 |
-
<th>Numer</th>
|
| 371 |
-
<th>Data wystawienia</th>
|
| 372 |
-
<th>Nabywca</th>
|
| 373 |
-
<th>Suma brutto</th>
|
| 374 |
-
<th>Akcje</th>
|
| 375 |
-
</tr>
|
| 376 |
-
</thead>
|
| 377 |
-
<tbody id="invoices-table-body"></tbody>
|
| 378 |
-
</table>
|
| 379 |
-
<p id="invoices-empty" class="hint hidden">Brak faktur do wyświetlenia.</p>
|
| 380 |
-
</div>
|
| 381 |
-
</section>
|
| 382 |
-
</section>
|
| 383 |
-
</section>
|
| 384 |
-
</main>
|
| 385 |
-
|
| 386 |
-
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js" defer></script>
|
| 387 |
-
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.6/dist/chart.umd.min.js" defer></script>
|
| 388 |
-
<script src="main.js" defer></script>
|
| 389 |
-
</body>
|
| 390 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
invoice_app.py
CHANGED
|
@@ -1,190 +1,190 @@
|
|
| 1 |
-
import getpass
|
| 2 |
-
import hashlib
|
| 3 |
-
import json
|
| 4 |
-
import os
|
| 5 |
-
from datetime import datetime
|
| 6 |
-
|
| 7 |
-
DATA_FILE = "invoice_data.json"
|
| 8 |
-
INVOICE_FOLDER = "invoices"
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
def hash_password(password: str) -> str:
|
| 12 |
-
return hashlib.sha256(password.encode("utf-8")).hexdigest()
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
def prompt_non_empty(prompt: str) -> str:
|
| 16 |
-
while True:
|
| 17 |
-
value = input(prompt).strip()
|
| 18 |
-
if value:
|
| 19 |
-
return value
|
| 20 |
-
print("Pole nie moze byc puste.")
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def prompt_positive_float(prompt: str) -> float:
|
| 24 |
-
while True:
|
| 25 |
-
raw_value = input(prompt).replace(",", ".").strip()
|
| 26 |
-
try:
|
| 27 |
-
value = float(raw_value)
|
| 28 |
-
except ValueError:
|
| 29 |
-
print("Wprowadz liczbe.")
|
| 30 |
-
continue
|
| 31 |
-
if value <= 0:
|
| 32 |
-
print("Wartosc musi byc wieksza od zera.")
|
| 33 |
-
continue
|
| 34 |
-
return value
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
def load_data():
|
| 38 |
-
if not os.path.exists(DATA_FILE):
|
| 39 |
-
return None
|
| 40 |
-
with open(DATA_FILE, "r", encoding="utf-8") as handle:
|
| 41 |
-
return json.load(handle)
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
def save_data(data) -> None:
|
| 45 |
-
with open(DATA_FILE, "w", encoding="utf-8") as handle:
|
| 46 |
-
json.dump(data, handle, indent=2, ensure_ascii=False)
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
def run_setup():
|
| 50 |
-
print("=== Konfiguracja konta przedsiebiorcy ===")
|
| 51 |
-
business = {
|
| 52 |
-
"company_name": prompt_non_empty("Nazwa firmy: "),
|
| 53 |
-
"owner_name": prompt_non_empty("Imie i nazwisko wlasciciela: "),
|
| 54 |
-
"address": prompt_non_empty("Adres: "),
|
| 55 |
-
"tax_id": prompt_non_empty("NIP: "),
|
| 56 |
-
"bank_account": prompt_non_empty("Numer konta bankowego: "),
|
| 57 |
-
}
|
| 58 |
-
|
| 59 |
-
print("\nUstaw haslo do logowania.")
|
| 60 |
-
while True:
|
| 61 |
-
password = getpass.getpass("Haslo: ")
|
| 62 |
-
confirm = getpass.getpass("Powtorz haslo: ")
|
| 63 |
-
if not password:
|
| 64 |
-
print("Haslo nie moze byc puste.")
|
| 65 |
-
continue
|
| 66 |
-
if password != confirm:
|
| 67 |
-
print("Hasla nie sa identyczne. Sprobuj ponownie.")
|
| 68 |
-
continue
|
| 69 |
-
break
|
| 70 |
-
|
| 71 |
-
data = {
|
| 72 |
-
"business": business,
|
| 73 |
-
"password_hash": hash_password(password),
|
| 74 |
-
"invoices": [],
|
| 75 |
-
}
|
| 76 |
-
save_data(data)
|
| 77 |
-
print("\nDane zapisane. Uruchom aplikacje ponownie, aby zalogowac sie i wystawiac faktury.")
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
def authenticate(data) -> bool:
|
| 81 |
-
for attempt in range(3):
|
| 82 |
-
password = getpass.getpass("Haslo: ")
|
| 83 |
-
if hash_password(password) == data["password_hash"]:
|
| 84 |
-
return True
|
| 85 |
-
print("Nieprawidlowe haslo.")
|
| 86 |
-
return False
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
def prompt_client_details():
|
| 90 |
-
answer = input("Dodac dane klienta? (T/N): ").strip().lower()
|
| 91 |
-
if answer not in ("t", "tak"):
|
| 92 |
-
return {}
|
| 93 |
-
print("\n=== Dane klienta ===")
|
| 94 |
-
client = {
|
| 95 |
-
"name": prompt_non_empty("Nazwa / Imie i nazwisko: "),
|
| 96 |
-
"address": prompt_non_empty("Adres: "),
|
| 97 |
-
"tax_id": input("NIP (opcjonalnie): ").strip(),
|
| 98 |
-
}
|
| 99 |
-
return client
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def format_invoice_text(invoice_id: str, business: dict, invoice: dict) -> str:
|
| 103 |
-
lines = [
|
| 104 |
-
f"Faktura: {invoice_id}",
|
| 105 |
-
f"Data wystawienia: {invoice['issued_at']}",
|
| 106 |
-
"",
|
| 107 |
-
"=== Sprzedawca ===",
|
| 108 |
-
f"Nazwa: {business['company_name']}",
|
| 109 |
-
f"Wlasciciel: {business['owner_name']}",
|
| 110 |
-
f"Adres: {business['address']}",
|
| 111 |
-
f"NIP: {business['tax_id']}",
|
| 112 |
-
f"Konto bankowe: {business['bank_account']}",
|
| 113 |
-
"",
|
| 114 |
-
"=== Nabywca ===",
|
| 115 |
-
]
|
| 116 |
-
|
| 117 |
-
client = invoice.get("client", {})
|
| 118 |
-
if client:
|
| 119 |
-
lines.extend(
|
| 120 |
-
[
|
| 121 |
-
f"Nazwa: {client.get('name', '')}",
|
| 122 |
-
f"Adres: {client.get('address', '')}",
|
| 123 |
-
f"NIP: {client.get('tax_id', '') or '---'}",
|
| 124 |
-
]
|
| 125 |
-
)
|
| 126 |
-
else:
|
| 127 |
-
lines.append("Brak danych klienta (pole opcjonalne).")
|
| 128 |
-
|
| 129 |
-
lines.extend(
|
| 130 |
-
[
|
| 131 |
-
"",
|
| 132 |
-
"=== Pozycja ===",
|
| 133 |
-
f"Opis: {invoice['item_description']}",
|
| 134 |
-
f"Ilosc: {invoice['quantity']}",
|
| 135 |
-
f"Cena jednostkowa: {invoice['unit_price']:.2f} PLN",
|
| 136 |
-
f"Wartosc netto: {invoice['net_total']:.2f} PLN",
|
| 137 |
-
]
|
| 138 |
-
)
|
| 139 |
-
return "\n".join(lines)
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
def create_invoice(data):
|
| 143 |
-
print("\n=== Wystaw fakture ===")
|
| 144 |
-
description = prompt_non_empty("Opis uslugi / towaru: ")
|
| 145 |
-
quantity = prompt_positive_float("Ilosc: ")
|
| 146 |
-
unit_price = prompt_positive_float("Cena jednostkowa (PLN): ")
|
| 147 |
-
client = prompt_client_details()
|
| 148 |
-
|
| 149 |
-
issued_at = datetime.now().strftime("%Y-%m-%d %H:%M")
|
| 150 |
-
invoice_id = datetime.now().strftime("FV-%Y%m%d-%H%M%S")
|
| 151 |
-
net_total = quantity * unit_price
|
| 152 |
-
|
| 153 |
-
invoice = {
|
| 154 |
-
"invoice_id": invoice_id,
|
| 155 |
-
"issued_at": issued_at,
|
| 156 |
-
"item_description": description,
|
| 157 |
-
"quantity": quantity,
|
| 158 |
-
"unit_price": unit_price,
|
| 159 |
-
"net_total": net_total,
|
| 160 |
-
"client": client,
|
| 161 |
-
}
|
| 162 |
-
|
| 163 |
-
os.makedirs(INVOICE_FOLDER, exist_ok=True)
|
| 164 |
-
invoice_path = os.path.join(INVOICE_FOLDER, f"{invoice_id}.txt")
|
| 165 |
-
with open(invoice_path, "w", encoding="utf-8") as handle:
|
| 166 |
-
handle.write(format_invoice_text(invoice_id, data["business"], invoice))
|
| 167 |
-
|
| 168 |
-
data["invoices"].append(invoice)
|
| 169 |
-
save_data(data)
|
| 170 |
-
|
| 171 |
-
print(f"\nFaktura zapisana do {invoice_path}")
|
| 172 |
-
print(f"Suma do zaplaty: {net_total:.2f} PLN")
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
def main():
|
| 176 |
-
data = load_data()
|
| 177 |
-
if data is None:
|
| 178 |
-
run_setup()
|
| 179 |
-
return
|
| 180 |
-
|
| 181 |
-
print("=== Logowanie ===")
|
| 182 |
-
if not authenticate(data):
|
| 183 |
-
print("Zbyt wiele nieudanych prob logowania. Zakonczono.")
|
| 184 |
-
return
|
| 185 |
-
|
| 186 |
-
create_invoice(data)
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
if __name__ == "__main__":
|
| 190 |
-
main()
|
|
|
|
| 1 |
+
import getpass
|
| 2 |
+
import hashlib
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
DATA_FILE = "invoice_data.json"
|
| 8 |
+
INVOICE_FOLDER = "invoices"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def hash_password(password: str) -> str:
|
| 12 |
+
return hashlib.sha256(password.encode("utf-8")).hexdigest()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def prompt_non_empty(prompt: str) -> str:
|
| 16 |
+
while True:
|
| 17 |
+
value = input(prompt).strip()
|
| 18 |
+
if value:
|
| 19 |
+
return value
|
| 20 |
+
print("Pole nie moze byc puste.")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def prompt_positive_float(prompt: str) -> float:
|
| 24 |
+
while True:
|
| 25 |
+
raw_value = input(prompt).replace(",", ".").strip()
|
| 26 |
+
try:
|
| 27 |
+
value = float(raw_value)
|
| 28 |
+
except ValueError:
|
| 29 |
+
print("Wprowadz liczbe.")
|
| 30 |
+
continue
|
| 31 |
+
if value <= 0:
|
| 32 |
+
print("Wartosc musi byc wieksza od zera.")
|
| 33 |
+
continue
|
| 34 |
+
return value
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def load_data():
|
| 38 |
+
if not os.path.exists(DATA_FILE):
|
| 39 |
+
return None
|
| 40 |
+
with open(DATA_FILE, "r", encoding="utf-8") as handle:
|
| 41 |
+
return json.load(handle)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def save_data(data) -> None:
|
| 45 |
+
with open(DATA_FILE, "w", encoding="utf-8") as handle:
|
| 46 |
+
json.dump(data, handle, indent=2, ensure_ascii=False)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def run_setup():
|
| 50 |
+
print("=== Konfiguracja konta przedsiebiorcy ===")
|
| 51 |
+
business = {
|
| 52 |
+
"company_name": prompt_non_empty("Nazwa firmy: "),
|
| 53 |
+
"owner_name": prompt_non_empty("Imie i nazwisko wlasciciela: "),
|
| 54 |
+
"address": prompt_non_empty("Adres: "),
|
| 55 |
+
"tax_id": prompt_non_empty("NIP: "),
|
| 56 |
+
"bank_account": prompt_non_empty("Numer konta bankowego: "),
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
print("\nUstaw haslo do logowania.")
|
| 60 |
+
while True:
|
| 61 |
+
password = getpass.getpass("Haslo: ")
|
| 62 |
+
confirm = getpass.getpass("Powtorz haslo: ")
|
| 63 |
+
if not password:
|
| 64 |
+
print("Haslo nie moze byc puste.")
|
| 65 |
+
continue
|
| 66 |
+
if password != confirm:
|
| 67 |
+
print("Hasla nie sa identyczne. Sprobuj ponownie.")
|
| 68 |
+
continue
|
| 69 |
+
break
|
| 70 |
+
|
| 71 |
+
data = {
|
| 72 |
+
"business": business,
|
| 73 |
+
"password_hash": hash_password(password),
|
| 74 |
+
"invoices": [],
|
| 75 |
+
}
|
| 76 |
+
save_data(data)
|
| 77 |
+
print("\nDane zapisane. Uruchom aplikacje ponownie, aby zalogowac sie i wystawiac faktury.")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def authenticate(data) -> bool:
|
| 81 |
+
for attempt in range(3):
|
| 82 |
+
password = getpass.getpass("Haslo: ")
|
| 83 |
+
if hash_password(password) == data["password_hash"]:
|
| 84 |
+
return True
|
| 85 |
+
print("Nieprawidlowe haslo.")
|
| 86 |
+
return False
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def prompt_client_details():
|
| 90 |
+
answer = input("Dodac dane klienta? (T/N): ").strip().lower()
|
| 91 |
+
if answer not in ("t", "tak"):
|
| 92 |
+
return {}
|
| 93 |
+
print("\n=== Dane klienta ===")
|
| 94 |
+
client = {
|
| 95 |
+
"name": prompt_non_empty("Nazwa / Imie i nazwisko: "),
|
| 96 |
+
"address": prompt_non_empty("Adres: "),
|
| 97 |
+
"tax_id": input("NIP (opcjonalnie): ").strip(),
|
| 98 |
+
}
|
| 99 |
+
return client
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def format_invoice_text(invoice_id: str, business: dict, invoice: dict) -> str:
|
| 103 |
+
lines = [
|
| 104 |
+
f"Faktura: {invoice_id}",
|
| 105 |
+
f"Data wystawienia: {invoice['issued_at']}",
|
| 106 |
+
"",
|
| 107 |
+
"=== Sprzedawca ===",
|
| 108 |
+
f"Nazwa: {business['company_name']}",
|
| 109 |
+
f"Wlasciciel: {business['owner_name']}",
|
| 110 |
+
f"Adres: {business['address']}",
|
| 111 |
+
f"NIP: {business['tax_id']}",
|
| 112 |
+
f"Konto bankowe: {business['bank_account']}",
|
| 113 |
+
"",
|
| 114 |
+
"=== Nabywca ===",
|
| 115 |
+
]
|
| 116 |
+
|
| 117 |
+
client = invoice.get("client", {})
|
| 118 |
+
if client:
|
| 119 |
+
lines.extend(
|
| 120 |
+
[
|
| 121 |
+
f"Nazwa: {client.get('name', '')}",
|
| 122 |
+
f"Adres: {client.get('address', '')}",
|
| 123 |
+
f"NIP: {client.get('tax_id', '') or '---'}",
|
| 124 |
+
]
|
| 125 |
+
)
|
| 126 |
+
else:
|
| 127 |
+
lines.append("Brak danych klienta (pole opcjonalne).")
|
| 128 |
+
|
| 129 |
+
lines.extend(
|
| 130 |
+
[
|
| 131 |
+
"",
|
| 132 |
+
"=== Pozycja ===",
|
| 133 |
+
f"Opis: {invoice['item_description']}",
|
| 134 |
+
f"Ilosc: {invoice['quantity']}",
|
| 135 |
+
f"Cena jednostkowa: {invoice['unit_price']:.2f} PLN",
|
| 136 |
+
f"Wartosc netto: {invoice['net_total']:.2f} PLN",
|
| 137 |
+
]
|
| 138 |
+
)
|
| 139 |
+
return "\n".join(lines)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def create_invoice(data):
|
| 143 |
+
print("\n=== Wystaw fakture ===")
|
| 144 |
+
description = prompt_non_empty("Opis uslugi / towaru: ")
|
| 145 |
+
quantity = prompt_positive_float("Ilosc: ")
|
| 146 |
+
unit_price = prompt_positive_float("Cena jednostkowa (PLN): ")
|
| 147 |
+
client = prompt_client_details()
|
| 148 |
+
|
| 149 |
+
issued_at = datetime.now().strftime("%Y-%m-%d %H:%M")
|
| 150 |
+
invoice_id = datetime.now().strftime("FV-%Y%m%d-%H%M%S")
|
| 151 |
+
net_total = quantity * unit_price
|
| 152 |
+
|
| 153 |
+
invoice = {
|
| 154 |
+
"invoice_id": invoice_id,
|
| 155 |
+
"issued_at": issued_at,
|
| 156 |
+
"item_description": description,
|
| 157 |
+
"quantity": quantity,
|
| 158 |
+
"unit_price": unit_price,
|
| 159 |
+
"net_total": net_total,
|
| 160 |
+
"client": client,
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
os.makedirs(INVOICE_FOLDER, exist_ok=True)
|
| 164 |
+
invoice_path = os.path.join(INVOICE_FOLDER, f"{invoice_id}.txt")
|
| 165 |
+
with open(invoice_path, "w", encoding="utf-8") as handle:
|
| 166 |
+
handle.write(format_invoice_text(invoice_id, data["business"], invoice))
|
| 167 |
+
|
| 168 |
+
data["invoices"].append(invoice)
|
| 169 |
+
save_data(data)
|
| 170 |
+
|
| 171 |
+
print(f"\nFaktura zapisana do {invoice_path}")
|
| 172 |
+
print(f"Suma do zaplaty: {net_total:.2f} PLN")
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def main():
|
| 176 |
+
data = load_data()
|
| 177 |
+
if data is None:
|
| 178 |
+
run_setup()
|
| 179 |
+
return
|
| 180 |
+
|
| 181 |
+
print("=== Logowanie ===")
|
| 182 |
+
if not authenticate(data):
|
| 183 |
+
print("Zbyt wiele nieudanych prob logowania. Zakonczono.")
|
| 184 |
+
return
|
| 185 |
+
|
| 186 |
+
create_invoice(data)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
if __name__ == "__main__":
|
| 190 |
+
main()
|
logotyp do strony.png
DELETED
|
Binary file (62.2 kB)
|
|
|
main.js
DELETED
|
@@ -1,2326 +0,0 @@
|
|
| 1 |
-
const APP_PATHNAME =
|
| 2 |
-
typeof window !== "undefined" && window.location && typeof window.location.pathname === "string"
|
| 3 |
-
? window.location.pathname.replace(/\/$/, "")
|
| 4 |
-
: "";
|
| 5 |
-
|
| 6 |
-
const VAT_OPTIONS = [
|
| 7 |
-
{ value: "23", label: "23%" },
|
| 8 |
-
{ value: "8", label: "8%" },
|
| 9 |
-
{ value: "5", label: "5%" },
|
| 10 |
-
{ value: "0", label: "0% (ZW)" },
|
| 11 |
-
{ value: "ZW", label: "ZW - zwolnione" },
|
| 12 |
-
{ value: "NP", label: "NP - poza zakresem" },
|
| 13 |
-
];
|
| 14 |
-
|
| 15 |
-
const VAT_RATE_VALUES = {
|
| 16 |
-
"23": 0.23,
|
| 17 |
-
"8": 0.08,
|
| 18 |
-
"5": 0.05,
|
| 19 |
-
"0": 0,
|
| 20 |
-
ZW: 0,
|
| 21 |
-
NP: 0,
|
| 22 |
-
};
|
| 23 |
-
|
| 24 |
-
const UNIT_OPTIONS = [
|
| 25 |
-
{ value: "szt.", label: "szt." },
|
| 26 |
-
{ value: "godz.", label: "godz." },
|
| 27 |
-
];
|
| 28 |
-
|
| 29 |
-
const DEFAULT_UNIT = UNIT_OPTIONS[0].value;
|
| 30 |
-
|
| 31 |
-
const EXEMPTION_REASONS = [
|
| 32 |
-
{
|
| 33 |
-
value: "art_43_1_19",
|
| 34 |
-
label: "Art. 43 ust. 1 pkt 19 ustawy o VAT - usługi medyczne",
|
| 35 |
-
note: "Art. 43 ust. 1 pkt 19 ustawy o VAT - usługi w zakresie opieki medycznej.",
|
| 36 |
-
},
|
| 37 |
-
{
|
| 38 |
-
value: "art_43_1_18",
|
| 39 |
-
label: "Art. 43 ust. 1 pkt 18 ustawy o VAT - usługi edukacyjne",
|
| 40 |
-
note: "Art. 43 ust. 1 pkt 18 ustawy o VAT - usługi edukacyjne w formach przewidzianych w przepisach.",
|
| 41 |
-
},
|
| 42 |
-
{
|
| 43 |
-
value: "art_43_1_37",
|
| 44 |
-
label: "Art. 43 ust. 1 pkt 37 ustawy o VAT - usługi finansowe",
|
| 45 |
-
note: "Art. 43 ust. 1 pkt 37 ustawy o VAT - usługi finansowe i pośrednictwa finansowego.",
|
| 46 |
-
},
|
| 47 |
-
{
|
| 48 |
-
value: "art_113",
|
| 49 |
-
label: "Art. 113 ust. 1 i 9 ustawy o VAT - zwolnienie podmiotowe",
|
| 50 |
-
note: "Art. 113 ust. 1 i 9 ustawy o VAT - zwolnienie podmiotowe do 200 000 PLN obrotu.",
|
| 51 |
-
},
|
| 52 |
-
{
|
| 53 |
-
value: "par_3_ust_1_pkt_1",
|
| 54 |
-
label: "Par. 3 ust. 1 pkt 1 rozporządzenia MF z 20.12.2013 r.",
|
| 55 |
-
note: "Par. 3 ust. 1 pkt 1 rozporządzenia MF z 20.12.2013 r. - dostawa towarów używanych.",
|
| 56 |
-
},
|
| 57 |
-
{
|
| 58 |
-
value: "custom",
|
| 59 |
-
label: "Inne (wpisz własny opis)",
|
| 60 |
-
note: "",
|
| 61 |
-
},
|
| 62 |
-
];
|
| 63 |
-
|
| 64 |
-
const EXEMPTION_REASON_LOOKUP = new Map(EXEMPTION_REASONS.map((reason) => [reason.value, reason]));
|
| 65 |
-
|
| 66 |
-
const heroPanel = document.getElementById("hero-panel");
|
| 67 |
-
const authSection = document.getElementById("auth-section");
|
| 68 |
-
const appSection = document.getElementById("app-section");
|
| 69 |
-
|
| 70 |
-
const registerForm = document.getElementById("register-form");
|
| 71 |
-
const loginForm = document.getElementById("login-form");
|
| 72 |
-
const loginSubmitButton = loginForm ? loginForm.querySelector('button[type="submit"]') : null;
|
| 73 |
-
const loginSubmitButtonDefaultText = loginSubmitButton && loginSubmitButton.textContent ? loginSubmitButton.textContent.trim() || "Zaloguj" : "Zaloguj";
|
| 74 |
-
const invoiceForm = document.getElementById("invoice-form");
|
| 75 |
-
const businessForm = document.getElementById("business-form");
|
| 76 |
-
|
| 77 |
-
const registerFeedback = document.getElementById("register-feedback");
|
| 78 |
-
const loginFeedback = document.getElementById("login-feedback");
|
| 79 |
-
const businessFeedback = document.getElementById("business-feedback");
|
| 80 |
-
const logoFeedback = document.getElementById("logo-feedback");
|
| 81 |
-
const registerSection = document.getElementById("register-section");
|
| 82 |
-
const showRegisterButton = document.getElementById("show-register-button");
|
| 83 |
-
const backToLoginButton = document.getElementById("back-to-login");
|
| 84 |
-
const cancelRegisterButton = document.getElementById("cancel-register");
|
| 85 |
-
const clientSearchInput = document.getElementById("client-search");
|
| 86 |
-
const clientSuggestionsContainer = document.getElementById("client-suggestions");
|
| 87 |
-
const loginBadge = document.getElementById("login-badge");
|
| 88 |
-
|
| 89 |
-
const businessDisplay = document.getElementById("business-display");
|
| 90 |
-
const toggleBusinessFormButton = document.getElementById("toggle-business-form");
|
| 91 |
-
const cancelBusinessUpdateButton = document.getElementById("cancel-business-update");
|
| 92 |
-
const currentLoginLabel = document.getElementById("current-login-label");
|
| 93 |
-
|
| 94 |
-
const itemsBody = document.getElementById("items-body");
|
| 95 |
-
const addItemButton = document.getElementById("add-item-button");
|
| 96 |
-
|
| 97 |
-
const totalNetLabel = document.getElementById("total-net");
|
| 98 |
-
const totalVatLabel = document.getElementById("total-vat");
|
| 99 |
-
const totalGrossLabel = document.getElementById("total-gross");
|
| 100 |
-
const rateSummaryContainer = document.getElementById("rate-summary");
|
| 101 |
-
|
| 102 |
-
const exemptionNoteWrapper = document.getElementById("exemption-note-wrapper");
|
| 103 |
-
const exemptionReasonSelect = document.getElementById("exemption-reason");
|
| 104 |
-
const exemptionNoteInput = document.getElementById("exemption-note");
|
| 105 |
-
|
| 106 |
-
const invoiceResult = document.getElementById("invoice-result");
|
| 107 |
-
const invoiceOutput = document.getElementById("invoice-output");
|
| 108 |
-
const downloadButton = document.getElementById("download-button");
|
| 109 |
-
const logoutButton = document.getElementById("logout-button");
|
| 110 |
-
const cancelEditInvoiceButton = document.getElementById("cancel-edit-invoice");
|
| 111 |
-
const saveInvoiceButton = document.getElementById("save-invoice-button");
|
| 112 |
-
|
| 113 |
-
const invoiceBuilderSection = document.getElementById("invoice-builder-section");
|
| 114 |
-
const dashboardSection = document.getElementById("dashboard-section");
|
| 115 |
-
const appNavButtons = Array.from(document.querySelectorAll(".app-nav-button"));
|
| 116 |
-
|
| 117 |
-
const invoicesTableBody = document.getElementById("invoices-table-body");
|
| 118 |
-
const invoicesEmpty = document.getElementById("invoices-empty");
|
| 119 |
-
const dashboardFeedback = document.getElementById("dashboard-feedback");
|
| 120 |
-
|
| 121 |
-
const filterStartDate = document.getElementById("filter-start-date");
|
| 122 |
-
const filterEndDate = document.getElementById("filter-end-date");
|
| 123 |
-
const clearFiltersButton = document.getElementById("clear-filters");
|
| 124 |
-
|
| 125 |
-
const summaryMonthCount = document.getElementById("summary-month-count");
|
| 126 |
-
const summaryMonthAmount = document.getElementById("summary-month-amount");
|
| 127 |
-
const summaryQuarterCount = document.getElementById("summary-quarter-count");
|
| 128 |
-
const summaryQuarterAmount = document.getElementById("summary-quarter-amount");
|
| 129 |
-
const summaryYearCount = document.getElementById("summary-year-count");
|
| 130 |
-
const summaryYearAmount = document.getElementById("summary-year-amount");
|
| 131 |
-
|
| 132 |
-
const logoInput = document.getElementById("logo-input");
|
| 133 |
-
const logoPreview = document.getElementById("logo-preview");
|
| 134 |
-
const logoPreviewImage = document.getElementById("logo-preview-image");
|
| 135 |
-
const removeLogoButton = document.getElementById("remove-logo-button");
|
| 136 |
-
const legacyLoginHint = document.getElementById("legacy-login-hint");
|
| 137 |
-
const invoicesChartCanvas = document.getElementById("invoices-chart");
|
| 138 |
-
|
| 139 |
-
let authToken = sessionStorage.getItem("invoiceAuthToken") || null;
|
| 140 |
-
let currentLogin = sessionStorage.getItem("invoiceLogin") || "";
|
| 141 |
-
let currentBusiness = null;
|
| 142 |
-
let currentLogo = null;
|
| 143 |
-
let lastInvoice = null;
|
| 144 |
-
let invoicesCache = [];
|
| 145 |
-
let editingInvoiceId = null;
|
| 146 |
-
let activeView = "invoice-builder";
|
| 147 |
-
let invoicesChart = null;
|
| 148 |
-
let maxLogoSize = 512 * 1024;
|
| 149 |
-
let pdfFontPromise = null;
|
| 150 |
-
let pdfFontBase64 = null;
|
| 151 |
-
let customExemptionNote = "";
|
| 152 |
-
let clientLookupTimeout = null;
|
| 153 |
-
|
| 154 |
-
function setVisibility(element, visible) {
|
| 155 |
-
if (!element) {
|
| 156 |
-
return;
|
| 157 |
-
}
|
| 158 |
-
if (visible) {
|
| 159 |
-
element.classList.remove("hidden");
|
| 160 |
-
element.style.removeProperty("display");
|
| 161 |
-
} else {
|
| 162 |
-
element.classList.add("hidden");
|
| 163 |
-
element.style.display = "none";
|
| 164 |
-
}
|
| 165 |
-
}
|
| 166 |
-
|
| 167 |
-
function setAppState(state) {
|
| 168 |
-
if (state === "app") {
|
| 169 |
-
setVisibility(authSection, false);
|
| 170 |
-
setVisibility(registerSection, false);
|
| 171 |
-
setVisibility(appSection, true);
|
| 172 |
-
setVisibility(heroPanel, false);
|
| 173 |
-
} else {
|
| 174 |
-
setVisibility(authSection, true);
|
| 175 |
-
setVisibility(registerSection, false);
|
| 176 |
-
setVisibility(appSection, false);
|
| 177 |
-
setVisibility(heroPanel, true);
|
| 178 |
-
}
|
| 179 |
-
}
|
| 180 |
-
|
| 181 |
-
function openRegisterPanel() {
|
| 182 |
-
if (!registerSection) {
|
| 183 |
-
return;
|
| 184 |
-
}
|
| 185 |
-
setVisibility(authSection, false);
|
| 186 |
-
setVisibility(registerSection, true);
|
| 187 |
-
setVisibility(appSection, false);
|
| 188 |
-
clearFeedback(registerFeedback);
|
| 189 |
-
clearFeedback(loginFeedback);
|
| 190 |
-
if (registerForm) {
|
| 191 |
-
const emailInput = registerForm.elements.email;
|
| 192 |
-
if (emailInput) {
|
| 193 |
-
emailInput.focus();
|
| 194 |
-
}
|
| 195 |
-
}
|
| 196 |
-
const scrollTarget = registerSection.querySelector(".register-card") || registerSection;
|
| 197 |
-
const scrollIntoView = () => scrollTarget.scrollIntoView({ behavior: "smooth", block: "start" });
|
| 198 |
-
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
|
| 199 |
-
window.requestAnimationFrame(scrollIntoView);
|
| 200 |
-
} else if (typeof requestAnimationFrame === "function") {
|
| 201 |
-
requestAnimationFrame(scrollIntoView);
|
| 202 |
-
} else {
|
| 203 |
-
scrollIntoView();
|
| 204 |
-
}
|
| 205 |
-
}
|
| 206 |
-
|
| 207 |
-
function closeRegisterPanel({ resetForm = true, focusTrigger = false } = {}) {
|
| 208 |
-
if (!registerSection) {
|
| 209 |
-
return;
|
| 210 |
-
}
|
| 211 |
-
setVisibility(registerSection, false);
|
| 212 |
-
setVisibility(authSection, true);
|
| 213 |
-
setVisibility(appSection, false);
|
| 214 |
-
clearFeedback(registerFeedback);
|
| 215 |
-
clearFeedback(loginFeedback);
|
| 216 |
-
if (resetForm && registerForm) {
|
| 217 |
-
registerForm.reset();
|
| 218 |
-
}
|
| 219 |
-
if (focusTrigger) {
|
| 220 |
-
if (showRegisterButton) {
|
| 221 |
-
showRegisterButton.focus();
|
| 222 |
-
}
|
| 223 |
-
const scrollTarget = authSection ? authSection.querySelector(".login-card") || authSection : null;
|
| 224 |
-
if (scrollTarget) {
|
| 225 |
-
const scrollToLogin = () => scrollTarget.scrollIntoView({ behavior: "smooth", block: "start" });
|
| 226 |
-
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
|
| 227 |
-
window.requestAnimationFrame(scrollToLogin);
|
| 228 |
-
} else if (typeof requestAnimationFrame === "function") {
|
| 229 |
-
requestAnimationFrame(scrollToLogin);
|
| 230 |
-
} else {
|
| 231 |
-
scrollToLogin();
|
| 232 |
-
}
|
| 233 |
-
}
|
| 234 |
-
}
|
| 235 |
-
}
|
| 236 |
-
|
| 237 |
-
function clearFeedback(element) {
|
| 238 |
-
if (!element) {
|
| 239 |
-
return;
|
| 240 |
-
}
|
| 241 |
-
element.textContent = "";
|
| 242 |
-
element.classList.remove("error", "success");
|
| 243 |
-
}
|
| 244 |
-
|
| 245 |
-
function showFeedback(element, message, type = "error") {
|
| 246 |
-
if (!element) {
|
| 247 |
-
return;
|
| 248 |
-
}
|
| 249 |
-
element.textContent = message;
|
| 250 |
-
element.classList.remove("error", "success");
|
| 251 |
-
if (type) {
|
| 252 |
-
element.classList.add(type);
|
| 253 |
-
}
|
| 254 |
-
}
|
| 255 |
-
|
| 256 |
-
function parseNumber(value) {
|
| 257 |
-
if (typeof value === "number") {
|
| 258 |
-
return Number.isFinite(value) ? value : 0;
|
| 259 |
-
}
|
| 260 |
-
if (!value) {
|
| 261 |
-
return 0;
|
| 262 |
-
}
|
| 263 |
-
const normalized = value.toString().replace(",", ".");
|
| 264 |
-
const parsed = Number.parseFloat(normalized);
|
| 265 |
-
return Number.isFinite(parsed) ? parsed : 0;
|
| 266 |
-
}
|
| 267 |
-
|
| 268 |
-
function parseIntegerString(value) {
|
| 269 |
-
if (value === null || value === undefined) {
|
| 270 |
-
return Number.NaN;
|
| 271 |
-
}
|
| 272 |
-
const normalized = value.toString().trim();
|
| 273 |
-
if (!normalized) {
|
| 274 |
-
return 0;
|
| 275 |
-
}
|
| 276 |
-
const parsed = Number.parseFloat(normalized.replace(",", "."));
|
| 277 |
-
if (!Number.isFinite(parsed) || Math.floor(parsed) !== parsed) {
|
| 278 |
-
return Number.NaN;
|
| 279 |
-
}
|
| 280 |
-
return parsed;
|
| 281 |
-
}
|
| 282 |
-
|
| 283 |
-
function formatQuantity(value) {
|
| 284 |
-
const parsed = parseIntegerString(value);
|
| 285 |
-
if (Number.isNaN(parsed)) {
|
| 286 |
-
return "0";
|
| 287 |
-
}
|
| 288 |
-
return parsed.toString();
|
| 289 |
-
}
|
| 290 |
-
|
| 291 |
-
function formatCurrency(value) {
|
| 292 |
-
const number = parseNumber(value);
|
| 293 |
-
return `${number.toFixed(2)} PLN`;
|
| 294 |
-
}
|
| 295 |
-
|
| 296 |
-
function vatLabelFromCode(code) {
|
| 297 |
-
if (code === "ZW" || code === "0") {
|
| 298 |
-
return "ZW";
|
| 299 |
-
}
|
| 300 |
-
if (code === "NP") {
|
| 301 |
-
return "NP";
|
| 302 |
-
}
|
| 303 |
-
return `${code}%`;
|
| 304 |
-
}
|
| 305 |
-
|
| 306 |
-
function requiresExemption(code) {
|
| 307 |
-
return code === "ZW" || code === "0";
|
| 308 |
-
}
|
| 309 |
-
|
| 310 |
-
function populateExemptionReasons() {
|
| 311 |
-
if (!exemptionReasonSelect || exemptionReasonSelect.dataset.initialized === "true") {
|
| 312 |
-
return;
|
| 313 |
-
}
|
| 314 |
-
const existingValues = new Set(Array.from(exemptionReasonSelect.options).map((option) => option.value));
|
| 315 |
-
EXEMPTION_REASONS.forEach((reason) => {
|
| 316 |
-
if (existingValues.has(reason.value)) {
|
| 317 |
-
return;
|
| 318 |
-
}
|
| 319 |
-
const option = document.createElement("option");
|
| 320 |
-
option.value = reason.value;
|
| 321 |
-
option.textContent = reason.label;
|
| 322 |
-
exemptionReasonSelect.appendChild(option);
|
| 323 |
-
});
|
| 324 |
-
exemptionReasonSelect.dataset.initialized = "true";
|
| 325 |
-
}
|
| 326 |
-
|
| 327 |
-
function applyExemptionReasonSelection({ preserveCustom = false } = {}) {
|
| 328 |
-
if (!exemptionReasonSelect || !exemptionNoteInput) {
|
| 329 |
-
return;
|
| 330 |
-
}
|
| 331 |
-
const selectedValue = exemptionReasonSelect.value;
|
| 332 |
-
const selectedReason = EXEMPTION_REASON_LOOKUP.get(selectedValue);
|
| 333 |
-
|
| 334 |
-
// Ukryj pole "Podstawa prawna zwolnienia" jeśli nie wybrano opcji "Inne..."
|
| 335 |
-
const exemptionNoteLabel = document.getElementById("exemption-note-label");
|
| 336 |
-
if (exemptionNoteLabel) {
|
| 337 |
-
if (selectedValue === "custom") {
|
| 338 |
-
exemptionNoteLabel.style.display = "block";
|
| 339 |
-
exemptionNoteInput.style.display = "block";
|
| 340 |
-
} else {
|
| 341 |
-
exemptionNoteLabel.style.display = "none";
|
| 342 |
-
exemptionNoteInput.style.display = "none";
|
| 343 |
-
}
|
| 344 |
-
}
|
| 345 |
-
|
| 346 |
-
if (!selectedReason) {
|
| 347 |
-
if (!preserveCustom) {
|
| 348 |
-
exemptionNoteInput.readOnly = false;
|
| 349 |
-
exemptionNoteInput.value = "";
|
| 350 |
-
}
|
| 351 |
-
return;
|
| 352 |
-
}
|
| 353 |
-
if (selectedValue === "custom") {
|
| 354 |
-
exemptionNoteInput.readOnly = false;
|
| 355 |
-
if (!preserveCustom) {
|
| 356 |
-
exemptionNoteInput.value = customExemptionNote;
|
| 357 |
-
}
|
| 358 |
-
return;
|
| 359 |
-
}
|
| 360 |
-
exemptionNoteInput.readOnly = true;
|
| 361 |
-
exemptionNoteInput.value = selectedReason.note;
|
| 362 |
-
}
|
| 363 |
-
|
| 364 |
-
function findExemptionReasonByNote(note) {
|
| 365 |
-
if (!note) {
|
| 366 |
-
return null;
|
| 367 |
-
}
|
| 368 |
-
const normalized = note.trim().toLowerCase();
|
| 369 |
-
return (
|
| 370 |
-
EXEMPTION_REASONS.find(
|
| 371 |
-
(reason) =>
|
| 372 |
-
reason.value !== "custom" && reason.note && reason.note.trim().toLowerCase() === normalized
|
| 373 |
-
) || null
|
| 374 |
-
);
|
| 375 |
-
}
|
| 376 |
-
|
| 377 |
-
function syncExemptionControlsWithNote(note) {
|
| 378 |
-
if (!exemptionNoteInput) {
|
| 379 |
-
return;
|
| 380 |
-
}
|
| 381 |
-
const trimmed = (note || "").trim();
|
| 382 |
-
exemptionNoteInput.readOnly = false;
|
| 383 |
-
if (!exemptionReasonSelect) {
|
| 384 |
-
exemptionNoteInput.value = trimmed;
|
| 385 |
-
return;
|
| 386 |
-
}
|
| 387 |
-
if (!trimmed) {
|
| 388 |
-
customExemptionNote = "";
|
| 389 |
-
exemptionReasonSelect.value = "";
|
| 390 |
-
exemptionNoteInput.value = "";
|
| 391 |
-
return;
|
| 392 |
-
}
|
| 393 |
-
const matchedReason = findExemptionReasonByNote(trimmed);
|
| 394 |
-
if (matchedReason && matchedReason.value !== "custom") {
|
| 395 |
-
exemptionReasonSelect.value = matchedReason.value;
|
| 396 |
-
applyExemptionReasonSelection({ preserveCustom: true });
|
| 397 |
-
} else {
|
| 398 |
-
customExemptionNote = trimmed;
|
| 399 |
-
exemptionReasonSelect.value = "custom";
|
| 400 |
-
exemptionNoteInput.readOnly = false;
|
| 401 |
-
exemptionNoteInput.value = trimmed;
|
| 402 |
-
}
|
| 403 |
-
}
|
| 404 |
-
|
| 405 |
-
function updateExemptionVisibility(exemptionNeeded) {
|
| 406 |
-
if (!exemptionNoteWrapper || !exemptionNoteInput) {
|
| 407 |
-
return;
|
| 408 |
-
}
|
| 409 |
-
if (exemptionNeeded) {
|
| 410 |
-
populateExemptionReasons();
|
| 411 |
-
setVisibility(exemptionNoteWrapper, true);
|
| 412 |
-
applyExemptionReasonSelection({ preserveCustom: true });
|
| 413 |
-
return;
|
| 414 |
-
}
|
| 415 |
-
setVisibility(exemptionNoteWrapper, false);
|
| 416 |
-
if (exemptionReasonSelect) {
|
| 417 |
-
exemptionReasonSelect.value = "";
|
| 418 |
-
}
|
| 419 |
-
customExemptionNote = "";
|
| 420 |
-
exemptionNoteInput.readOnly = false;
|
| 421 |
-
exemptionNoteInput.value = "";
|
| 422 |
-
}
|
| 423 |
-
|
| 424 |
-
function formatInvoicesCount(count) {
|
| 425 |
-
const value = Number.parseInt(count, 10) || 0;
|
| 426 |
-
const absolute = Math.abs(value);
|
| 427 |
-
const mod10 = absolute % 10;
|
| 428 |
-
const mod100 = absolute % 100;
|
| 429 |
-
let suffix = "faktur";
|
| 430 |
-
if (mod10 === 1 && mod100 !== 11) {
|
| 431 |
-
suffix = "faktura";
|
| 432 |
-
} else if ([2, 3, 4].includes(mod10) && ![12, 13, 14].includes(mod100)) {
|
| 433 |
-
suffix = "faktury";
|
| 434 |
-
}
|
| 435 |
-
return `${value} ${suffix}`;
|
| 436 |
-
}
|
| 437 |
-
|
| 438 |
-
function parseInvoiceIssuedAt(invoice) {
|
| 439 |
-
if (!invoice || !invoice.issued_at) {
|
| 440 |
-
return null;
|
| 441 |
-
}
|
| 442 |
-
const normalized = invoice.issued_at.replace(" ", "T");
|
| 443 |
-
const parsed = new Date(normalized);
|
| 444 |
-
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
| 445 |
-
}
|
| 446 |
-
|
| 447 |
-
function parseDateInput(value) {
|
| 448 |
-
if (!value) {
|
| 449 |
-
return null;
|
| 450 |
-
}
|
| 451 |
-
const parts = value.split("-").map((part) => Number.parseInt(part, 10));
|
| 452 |
-
if (parts.length !== 3 || parts.some(Number.isNaN)) {
|
| 453 |
-
return null;
|
| 454 |
-
}
|
| 455 |
-
return new Date(parts[0], parts[1] - 1, parts[2]);
|
| 456 |
-
}
|
| 457 |
-
|
| 458 |
-
function setActiveView(view) {
|
| 459 |
-
activeView = view === "dashboard" ? "dashboard" : "invoice-builder";
|
| 460 |
-
setVisibility(invoiceBuilderSection, activeView === "invoice-builder");
|
| 461 |
-
setVisibility(dashboardSection, activeView === "dashboard");
|
| 462 |
-
const showDashboard = activeView === "dashboard";
|
| 463 |
-
appNavButtons.forEach((button) => {
|
| 464 |
-
button.classList.toggle("active", button.dataset.view === activeView);
|
| 465 |
-
});
|
| 466 |
-
if (showDashboard) {
|
| 467 |
-
applyInvoiceFilters();
|
| 468 |
-
}
|
| 469 |
-
}
|
| 470 |
-
|
| 471 |
-
function updateLoginLabel() {
|
| 472 |
-
if (!currentLoginLabel) {
|
| 473 |
-
return;
|
| 474 |
-
}
|
| 475 |
-
if (!currentLogin) {
|
| 476 |
-
currentLoginLabel.textContent = "";
|
| 477 |
-
if (loginBadge) {
|
| 478 |
-
loginBadge.classList.add("hidden");
|
| 479 |
-
}
|
| 480 |
-
return;
|
| 481 |
-
}
|
| 482 |
-
currentLoginLabel.textContent = currentLogin;
|
| 483 |
-
if (loginBadge) {
|
| 484 |
-
loginBadge.classList.remove("hidden");
|
| 485 |
-
}
|
| 486 |
-
}
|
| 487 |
-
|
| 488 |
-
function updateLogoPreview() {
|
| 489 |
-
if (currentLogo && currentLogo.data && currentLogo.mime_type) {
|
| 490 |
-
const dataUrl = currentLogo.data_url || `data:${currentLogo.mime_type};base64,${currentLogo.data}`;
|
| 491 |
-
logoPreviewImage.src = dataUrl;
|
| 492 |
-
logoPreview.classList.remove("hidden");
|
| 493 |
-
removeLogoButton.classList.remove("hidden");
|
| 494 |
-
} else {
|
| 495 |
-
logoPreviewImage.removeAttribute("src");
|
| 496 |
-
logoPreview.classList.add("hidden");
|
| 497 |
-
removeLogoButton.classList.add("hidden");
|
| 498 |
-
}
|
| 499 |
-
}
|
| 500 |
-
|
| 501 |
-
function renderInvoicesTable(invoices) {
|
| 502 |
-
invoicesTableBody.innerHTML = "";
|
| 503 |
-
if (!Array.isArray(invoices) || invoices.length === 0) {
|
| 504 |
-
invoicesEmpty.classList.remove("hidden");
|
| 505 |
-
return;
|
| 506 |
-
}
|
| 507 |
-
|
| 508 |
-
invoicesEmpty.classList.add("hidden");
|
| 509 |
-
invoices.forEach((invoice) => {
|
| 510 |
-
const row = document.createElement("tr");
|
| 511 |
-
|
| 512 |
-
const numberCell = document.createElement("td");
|
| 513 |
-
numberCell.textContent = invoice.invoice_id || "---";
|
| 514 |
-
row.appendChild(numberCell);
|
| 515 |
-
|
| 516 |
-
const issuedCell = document.createElement("td");
|
| 517 |
-
issuedCell.textContent = invoice.issued_at || "-";
|
| 518 |
-
row.appendChild(issuedCell);
|
| 519 |
-
|
| 520 |
-
const clientCell = document.createElement("td");
|
| 521 |
-
const clientName = invoice.client?.name || "";
|
| 522 |
-
const clientCity = invoice.client?.city || "";
|
| 523 |
-
clientCell.textContent = clientName ? `${clientName}${clientCity ? ` (${clientCity})` : ""}` : "-";
|
| 524 |
-
row.appendChild(clientCell);
|
| 525 |
-
|
| 526 |
-
const grossCell = document.createElement("td");
|
| 527 |
-
grossCell.textContent = formatCurrency(invoice.totals?.gross ?? 0);
|
| 528 |
-
row.appendChild(grossCell);
|
| 529 |
-
|
| 530 |
-
const actionsCell = document.createElement("td");
|
| 531 |
-
const actionsWrapper = document.createElement("div");
|
| 532 |
-
actionsWrapper.className = "table-actions";
|
| 533 |
-
|
| 534 |
-
const editButton = document.createElement("button");
|
| 535 |
-
editButton.type = "button";
|
| 536 |
-
editButton.textContent = "Edytuj";
|
| 537 |
-
editButton.addEventListener("click", () => {
|
| 538 |
-
startInvoiceEdit(invoice.invoice_id);
|
| 539 |
-
});
|
| 540 |
-
|
| 541 |
-
const pdfButton = document.createElement("button");
|
| 542 |
-
pdfButton.type = "button";
|
| 543 |
-
pdfButton.className = "button secondary";
|
| 544 |
-
pdfButton.dataset.download = invoice.invoice_id;
|
| 545 |
-
pdfButton.textContent = "PDF";
|
| 546 |
-
|
| 547 |
-
const deleteButton = document.createElement("button");
|
| 548 |
-
deleteButton.type = "button";
|
| 549 |
-
deleteButton.className = "button secondary";
|
| 550 |
-
deleteButton.textContent = "Usuń";
|
| 551 |
-
deleteButton.addEventListener("click", async () => {
|
| 552 |
-
clearFeedback(dashboardFeedback);
|
| 553 |
-
const shouldDelete = window.confirm(`Usuńac fakturę ${invoice.invoice_id}?`);
|
| 554 |
-
if (!shouldDelete) {
|
| 555 |
-
return;
|
| 556 |
-
}
|
| 557 |
-
await deleteInvoice(invoice.invoice_id);
|
| 558 |
-
});
|
| 559 |
-
|
| 560 |
-
actionsWrapper.appendChild(editButton);
|
| 561 |
-
actionsWrapper.appendChild(pdfButton);
|
| 562 |
-
actionsWrapper.appendChild(deleteButton);
|
| 563 |
-
actionsCell.appendChild(actionsWrapper);
|
| 564 |
-
row.appendChild(actionsCell);
|
| 565 |
-
|
| 566 |
-
invoicesTableBody.appendChild(row);
|
| 567 |
-
});
|
| 568 |
-
}
|
| 569 |
-
|
| 570 |
-
function applyInvoiceFilters() {
|
| 571 |
-
if (!Array.isArray(invoicesCache)) {
|
| 572 |
-
renderInvoicesTable([]);
|
| 573 |
-
return;
|
| 574 |
-
}
|
| 575 |
-
|
| 576 |
-
let filtered = invoicesCache.slice();
|
| 577 |
-
const startDate = parseDateInput(filterStartDate?.value);
|
| 578 |
-
const endDate = parseDateInput(filterEndDate?.value);
|
| 579 |
-
|
| 580 |
-
if (startDate) {
|
| 581 |
-
const startTime = startDate.getTime();
|
| 582 |
-
filtered = filtered.filter((invoice) => {
|
| 583 |
-
const issued = parseInvoiceIssuedAt(invoice);
|
| 584 |
-
return !issued || issued.getTime() >= startTime;
|
| 585 |
-
});
|
| 586 |
-
}
|
| 587 |
-
|
| 588 |
-
if (endDate) {
|
| 589 |
-
const endBoundary = new Date(endDate);
|
| 590 |
-
endBoundary.setHours(23, 59, 59, 999);
|
| 591 |
-
const endTime = endBoundary.getTime();
|
| 592 |
-
filtered = filtered.filter((invoice) => {
|
| 593 |
-
const issued = parseInvoiceIssuedAt(invoice);
|
| 594 |
-
return !issued || issued.getTime() <= endTime;
|
| 595 |
-
});
|
| 596 |
-
}
|
| 597 |
-
|
| 598 |
-
filtered.sort((a, b) => (b.issued_at || "").localeCompare(a.issued_at || ""));
|
| 599 |
-
renderInvoicesTable(filtered);
|
| 600 |
-
}
|
| 601 |
-
|
| 602 |
-
if (invoicesTableBody) {
|
| 603 |
-
invoicesTableBody.addEventListener("click", async (event) => {
|
| 604 |
-
const target = event.target;
|
| 605 |
-
if (!(target instanceof HTMLElement)) {
|
| 606 |
-
return;
|
| 607 |
-
}
|
| 608 |
-
const pdfTrigger = target.closest("[data-download]");
|
| 609 |
-
if (pdfTrigger) {
|
| 610 |
-
const invoiceId = pdfTrigger.getAttribute("data-download");
|
| 611 |
-
if (!invoiceId) {
|
| 612 |
-
return;
|
| 613 |
-
}
|
| 614 |
-
const invoiceData = invoicesCache.find((invoice) => invoice.invoice_id === invoiceId);
|
| 615 |
-
if (!invoiceData || !currentBusiness) {
|
| 616 |
-
showFeedback(dashboardFeedback, "Nie udało się przygotować PDF. Odśwież dane i spróbuj ponownie.");
|
| 617 |
-
return;
|
| 618 |
-
}
|
| 619 |
-
try {
|
| 620 |
-
await generatePdf(currentBusiness, invoiceData, currentLogo);
|
| 621 |
-
} catch (error) {
|
| 622 |
-
console.error(error);
|
| 623 |
-
showFeedback(dashboardFeedback, "Nie udało się wygenerować PDF-a.");
|
| 624 |
-
}
|
| 625 |
-
}
|
| 626 |
-
});
|
| 627 |
-
}
|
| 628 |
-
|
| 629 |
-
async function refreshInvoices() {
|
| 630 |
-
if (!authToken) {
|
| 631 |
-
invoicesCache = [];
|
| 632 |
-
renderInvoicesTable([]);
|
| 633 |
-
return;
|
| 634 |
-
}
|
| 635 |
-
clearFeedback(dashboardFeedback);
|
| 636 |
-
try {
|
| 637 |
-
const data = await apiRequest("/api/invoices", {}, true);
|
| 638 |
-
invoicesCache = Array.isArray(data.invoices) ? data.invoices.slice() : [];
|
| 639 |
-
invoicesCache.sort((a, b) => (b.issued_at || "").localeCompare(a.issued_at || ""));
|
| 640 |
-
applyInvoiceFilters();
|
| 641 |
-
} catch (error) {
|
| 642 |
-
console.error(error);
|
| 643 |
-
showFeedback(dashboardFeedback, error.message || "Nie udało się pobrać faktur.");
|
| 644 |
-
}
|
| 645 |
-
}
|
| 646 |
-
|
| 647 |
-
function updateSummaryCards(summary) {
|
| 648 |
-
const monthSummary = summary?.last_month || { count: 0, gross_total: 0 };
|
| 649 |
-
const quarterSummary = summary?.quarter || { count: 0, gross_total: 0 };
|
| 650 |
-
const yearSummary = summary?.year || { count: 0, gross_total: 0 };
|
| 651 |
-
|
| 652 |
-
summaryMonthCount.textContent = formatInvoicesCount(monthSummary.count);
|
| 653 |
-
summaryQuarterCount.textContent = formatInvoicesCount(quarterSummary.count);
|
| 654 |
-
summaryYearCount.textContent = formatInvoicesCount(yearSummary.count);
|
| 655 |
-
|
| 656 |
-
summaryMonthAmount.textContent = formatCurrency(monthSummary.gross_total ?? 0);
|
| 657 |
-
summaryQuarterAmount.textContent = formatCurrency(quarterSummary.gross_total ?? 0);
|
| 658 |
-
summaryYearAmount.textContent = formatCurrency(yearSummary.gross_total ?? 0);
|
| 659 |
-
}
|
| 660 |
-
|
| 661 |
-
function updateSummaryChart(summary) {
|
| 662 |
-
if (!invoicesChartCanvas || typeof window.Chart === "undefined") {
|
| 663 |
-
return;
|
| 664 |
-
}
|
| 665 |
-
|
| 666 |
-
const labels = ["Ostatnie 30 dni", "Bieżący kwartał", "Bieżący rok"];
|
| 667 |
-
const counts = [
|
| 668 |
-
Number.parseInt(summary?.last_month?.count ?? 0, 10) || 0,
|
| 669 |
-
Number.parseInt(summary?.quarter?.count ?? 0, 10) || 0,
|
| 670 |
-
Number.parseInt(summary?.year?.count ?? 0, 10) || 0,
|
| 671 |
-
];
|
| 672 |
-
const amounts = [
|
| 673 |
-
parseNumber(summary?.last_month?.gross_total ?? 0),
|
| 674 |
-
parseNumber(summary?.quarter?.gross_total ?? 0),
|
| 675 |
-
parseNumber(summary?.year?.gross_total ?? 0),
|
| 676 |
-
];
|
| 677 |
-
|
| 678 |
-
const chartData = {
|
| 679 |
-
labels,
|
| 680 |
-
datasets: [
|
| 681 |
-
{
|
| 682 |
-
label: "Liczba faktur",
|
| 683 |
-
data: counts,
|
| 684 |
-
backgroundColor: "rgba(26, 115, 232, 0.65)",
|
| 685 |
-
yAxisID: "count",
|
| 686 |
-
borderRadius: 6,
|
| 687 |
-
},
|
| 688 |
-
{
|
| 689 |
-
label: "Suma brutto (PLN)",
|
| 690 |
-
data: amounts,
|
| 691 |
-
type: "line",
|
| 692 |
-
fill: false,
|
| 693 |
-
borderColor: "rgba(26, 115, 232, 0.65)",
|
| 694 |
-
backgroundColor: "rgba(26, 115, 232, 0.35)",
|
| 695 |
-
tension: 0.3,
|
| 696 |
-
yAxisID: "amount",
|
| 697 |
-
},
|
| 698 |
-
],
|
| 699 |
-
};
|
| 700 |
-
|
| 701 |
-
const options = {
|
| 702 |
-
responsive: true,
|
| 703 |
-
maintainAspectRatio: false,
|
| 704 |
-
scales: {
|
| 705 |
-
count: {
|
| 706 |
-
beginAtZero: true,
|
| 707 |
-
position: "left",
|
| 708 |
-
ticks: {
|
| 709 |
-
precision: 0,
|
| 710 |
-
stepSize: 1,
|
| 711 |
-
},
|
| 712 |
-
},
|
| 713 |
-
amount: {
|
| 714 |
-
beginAtZero: true,
|
| 715 |
-
position: "right",
|
| 716 |
-
grid: {
|
| 717 |
-
drawOnChartArea: false,
|
| 718 |
-
},
|
| 719 |
-
ticks: {
|
| 720 |
-
callback: (value) => `${new Intl.NumberFormat("pl-PL", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value)} PLN`,
|
| 721 |
-
},
|
| 722 |
-
},
|
| 723 |
-
},
|
| 724 |
-
plugins: {
|
| 725 |
-
legend: {
|
| 726 |
-
position: "bottom",
|
| 727 |
-
},
|
| 728 |
-
tooltip: {
|
| 729 |
-
callbacks: {
|
| 730 |
-
label(context) {
|
| 731 |
-
if (context.dataset.yAxisID === "amount") {
|
| 732 |
-
return `${context.dataset.label}: ${new Intl.NumberFormat("pl-PL", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(context.parsed.y)} PLN`;
|
| 733 |
-
}
|
| 734 |
-
return `${context.dataset.label}: ${context.parsed.y}`;
|
| 735 |
-
},
|
| 736 |
-
},
|
| 737 |
-
},
|
| 738 |
-
},
|
| 739 |
-
};
|
| 740 |
-
|
| 741 |
-
if (!invoicesChart) {
|
| 742 |
-
invoicesChart = new window.Chart(invoicesChartCanvas, {
|
| 743 |
-
type: "bar",
|
| 744 |
-
data: chartData,
|
| 745 |
-
options,
|
| 746 |
-
});
|
| 747 |
-
} else {
|
| 748 |
-
invoicesChart.data = chartData;
|
| 749 |
-
invoicesChart.options = options;
|
| 750 |
-
invoicesChart.update();
|
| 751 |
-
}
|
| 752 |
-
}
|
| 753 |
-
|
| 754 |
-
async function refreshSummary() {
|
| 755 |
-
if (!authToken) {
|
| 756 |
-
updateSummaryCards({});
|
| 757 |
-
updateSummaryChart({});
|
| 758 |
-
return;
|
| 759 |
-
}
|
| 760 |
-
clearFeedback(dashboardFeedback);
|
| 761 |
-
try {
|
| 762 |
-
const data = await apiRequest("/api/invoices/summary", {}, true);
|
| 763 |
-
updateSummaryCards(data.summary);
|
| 764 |
-
updateSummaryChart(data.summary);
|
| 765 |
-
} catch (error) {
|
| 766 |
-
console.error(error);
|
| 767 |
-
showFeedback(dashboardFeedback, error.message || "Nie udało się pobrać podsumowania.");
|
| 768 |
-
}
|
| 769 |
-
}
|
| 770 |
-
|
| 771 |
-
async function deleteInvoice(invoiceId) {
|
| 772 |
-
if (!invoiceId) {
|
| 773 |
-
return;
|
| 774 |
-
}
|
| 775 |
-
try {
|
| 776 |
-
await apiRequest(`/api/invoices/${encodeURIComponent(invoiceId)}`, { method: "DELETE" }, true);
|
| 777 |
-
invoicesCache = invoicesCache.filter((invoice) => invoice.invoice_id !== invoiceId);
|
| 778 |
-
applyInvoiceFilters();
|
| 779 |
-
await refreshSummary();
|
| 780 |
-
} catch (error) {
|
| 781 |
-
console.error(error);
|
| 782 |
-
showFeedback(dashboardFeedback, error.message || "Nie udało się usunąć faktury.");
|
| 783 |
-
}
|
| 784 |
-
}
|
| 785 |
-
|
| 786 |
-
function startInvoiceEdit(invoiceId) {
|
| 787 |
-
if (!invoiceId) {
|
| 788 |
-
return;
|
| 789 |
-
}
|
| 790 |
-
const invoice = invoicesCache.find((item) => item.invoice_id === invoiceId);
|
| 791 |
-
if (!invoice) {
|
| 792 |
-
showFeedback(dashboardFeedback, "Nie znaleziono wybranej faktury.");
|
| 793 |
-
return;
|
| 794 |
-
}
|
| 795 |
-
|
| 796 |
-
editingInvoiceId = invoiceId;
|
| 797 |
-
saveInvoiceButton.textContent = "Zapisz zmiany";
|
| 798 |
-
cancelEditInvoiceButton.classList.remove("hidden");
|
| 799 |
-
setActiveView("invoice-builder");
|
| 800 |
-
|
| 801 |
-
resetInvoiceForm();
|
| 802 |
-
invoiceForm.elements.saleDate.value = invoice.sale_date || "";
|
| 803 |
-
invoiceForm.elements.paymentTerm.value = invoice.payment_term || 14;
|
| 804 |
-
|
| 805 |
-
if (invoice.client) {
|
| 806 |
-
setClientFormValues(invoice.client);
|
| 807 |
-
}
|
| 808 |
-
|
| 809 |
-
itemsBody.innerHTML = "";
|
| 810 |
-
if (Array.isArray(invoice.items) && invoice.items.length > 0) {
|
| 811 |
-
invoice.items.forEach((item) => {
|
| 812 |
-
createItemRow({
|
| 813 |
-
name: item.name,
|
| 814 |
-
quantity: item.quantity,
|
| 815 |
-
unit_price_gross: item.unit_price_gross ?? item.gross_total,
|
| 816 |
-
vat_code: item.vat_code,
|
| 817 |
-
unit: item.unit,
|
| 818 |
-
});
|
| 819 |
-
});
|
| 820 |
-
} else {
|
| 821 |
-
createItemRow();
|
| 822 |
-
}
|
| 823 |
-
|
| 824 |
-
const note = invoice.exemption_note || "";
|
| 825 |
-
syncExemptionControlsWithNote(note);
|
| 826 |
-
const requiresNote = Array.isArray(invoice.items)
|
| 827 |
-
? invoice.items.some((item) => requiresExemption(item.vat_code))
|
| 828 |
-
: false;
|
| 829 |
-
updateExemptionVisibility(requiresNote);
|
| 830 |
-
|
| 831 |
-
lastInvoice = invoice;
|
| 832 |
-
}
|
| 833 |
-
|
| 834 |
-
function exitInvoiceEdit() {
|
| 835 |
-
editingInvoiceId = null;
|
| 836 |
-
saveInvoiceButton.textContent = "Generuj fakturę";
|
| 837 |
-
cancelEditInvoiceButton.classList.add("hidden");
|
| 838 |
-
}
|
| 839 |
-
|
| 840 |
-
function buildApiUrl(path = "") {
|
| 841 |
-
if (!path) {
|
| 842 |
-
return APP_PATHNAME || "/";
|
| 843 |
-
}
|
| 844 |
-
if (/^https?:\/\//i.test(path)) {
|
| 845 |
-
return path;
|
| 846 |
-
}
|
| 847 |
-
return path.startsWith("/")
|
| 848 |
-
? `${APP_PATHNAME}${path}` || "/"
|
| 849 |
-
: `${APP_PATHNAME}/${path}`.replace(/\/{2,}/g, "/");
|
| 850 |
-
}
|
| 851 |
-
|
| 852 |
-
async function apiRequest(path, { method = "GET", body, headers = {} } = {}, requireAuth = false) {
|
| 853 |
-
const options = {
|
| 854 |
-
method,
|
| 855 |
-
headers: {
|
| 856 |
-
"Content-Type": "application/json",
|
| 857 |
-
...headers,
|
| 858 |
-
},
|
| 859 |
-
};
|
| 860 |
-
|
| 861 |
-
if (body !== undefined) {
|
| 862 |
-
options.body = JSON.stringify(body);
|
| 863 |
-
}
|
| 864 |
-
|
| 865 |
-
if (requireAuth) {
|
| 866 |
-
if (!authToken) {
|
| 867 |
-
throw new Error("Brak tokenu autoryzacyjnego.");
|
| 868 |
-
}
|
| 869 |
-
options.headers.Authorization = `Bearer ${authToken}`;
|
| 870 |
-
}
|
| 871 |
-
|
| 872 |
-
const url = buildApiUrl(path);
|
| 873 |
-
const response = await fetch(url, options);
|
| 874 |
-
const isJson = response.headers.get("content-type")?.includes("application/json");
|
| 875 |
-
const data = isJson ? await response.json() : {};
|
| 876 |
-
|
| 877 |
-
if (response.status === 401) {
|
| 878 |
-
authToken = null;
|
| 879 |
-
currentLogin = "";
|
| 880 |
-
sessionStorage.removeItem("invoiceAuthToken");
|
| 881 |
-
sessionStorage.removeItem("invoiceLogin");
|
| 882 |
-
setAppState("auth");
|
| 883 |
-
throw new Error(data.error || "Sesja wygasła. Zaloguj się ponownie.");
|
| 884 |
-
}
|
| 885 |
-
|
| 886 |
-
if (!response.ok) {
|
| 887 |
-
throw new Error(data.error || "Wystapil błąd podczas komunikacji z serwerem.");
|
| 888 |
-
}
|
| 889 |
-
|
| 890 |
-
return data;
|
| 891 |
-
}
|
| 892 |
-
|
| 893 |
-
function renderBusinessDisplay(business) {
|
| 894 |
-
if (!business) {
|
| 895 |
-
businessDisplay.textContent = "Brak zapisanych danych firmy.";
|
| 896 |
-
return;
|
| 897 |
-
}
|
| 898 |
-
|
| 899 |
-
const fallback = (value) => {
|
| 900 |
-
if (!value) {
|
| 901 |
-
return "---";
|
| 902 |
-
}
|
| 903 |
-
const trimmed = value.toString().trim();
|
| 904 |
-
return trimmed || "---";
|
| 905 |
-
};
|
| 906 |
-
|
| 907 |
-
const companyName = fallback(business.company_name);
|
| 908 |
-
const ownerName = fallback(business.owner_name);
|
| 909 |
-
const addressLine = fallback(business.address_line);
|
| 910 |
-
const location = fallback([business.postal_code, business.city].filter(Boolean).join(" "));
|
| 911 |
-
const taxLine = `NIP: ${fallback(business.tax_id)}`;
|
| 912 |
-
const bankLine = `Konto: ${fallback(business.bank_account)}`;
|
| 913 |
-
|
| 914 |
-
businessDisplay.innerHTML = `
|
| 915 |
-
<div class="business-display-grid">
|
| 916 |
-
<div class="business-display-item business-display-item--name">
|
| 917 |
-
<strong>${companyName}</strong>
|
| 918 |
-
<span>${ownerName}</span>
|
| 919 |
-
</div>
|
| 920 |
-
<div class="business-display-item">
|
| 921 |
-
<span>${addressLine}</span>
|
| 922 |
-
<span>${location}</span>
|
| 923 |
-
</div>
|
| 924 |
-
<div class="business-display-item">
|
| 925 |
-
<span>${taxLine}</span>
|
| 926 |
-
<span>${bankLine}</span>
|
| 927 |
-
</div>
|
| 928 |
-
</div>
|
| 929 |
-
`;
|
| 930 |
-
}
|
| 931 |
-
|
| 932 |
-
function fillBusinessForm(business) {
|
| 933 |
-
if (!business) {
|
| 934 |
-
return;
|
| 935 |
-
}
|
| 936 |
-
businessForm.elements.company_name.value = business.company_name || "";
|
| 937 |
-
businessForm.elements.owner_name.value = business.owner_name || "";
|
| 938 |
-
businessForm.elements.address_line.value = business.address_line || "";
|
| 939 |
-
businessForm.elements.postal_code.value = business.postal_code || "";
|
| 940 |
-
businessForm.elements.city.value = business.city || "";
|
| 941 |
-
businessForm.elements.tax_id.value = business.tax_id || "";
|
| 942 |
-
businessForm.elements.bank_account.value = business.bank_account || "";
|
| 943 |
-
}
|
| 944 |
-
|
| 945 |
-
function setBusinessFormVisibility(visible, { preserveFeedback = false } = {}) {
|
| 946 |
-
setVisibility(businessForm, visible);
|
| 947 |
-
if (toggleBusinessFormButton) {
|
| 948 |
-
toggleBusinessFormButton.textContent = visible ? "Ukryj formularz" : "Edycja danych";
|
| 949 |
-
}
|
| 950 |
-
if (!visible && !preserveFeedback) {
|
| 951 |
-
clearFeedback(businessFeedback);
|
| 952 |
-
}
|
| 953 |
-
}
|
| 954 |
-
|
| 955 |
-
function setClientFormValues(client = {}) {
|
| 956 |
-
if (!invoiceForm) {
|
| 957 |
-
return;
|
| 958 |
-
}
|
| 959 |
-
invoiceForm.elements.clientName.value = client.name || "";
|
| 960 |
-
invoiceForm.elements.clientTaxId.value = client.tax_id || "";
|
| 961 |
-
invoiceForm.elements.clientAddress.value = client.address_line || "";
|
| 962 |
-
invoiceForm.elements.clientPostalCode.value = client.postal_code || "";
|
| 963 |
-
invoiceForm.elements.clientCity.value = client.city || "";
|
| 964 |
-
invoiceForm.elements.clientPhone.value = client.phone || "";
|
| 965 |
-
}
|
| 966 |
-
|
| 967 |
-
function hideClientSuggestions() {
|
| 968 |
-
if (!clientSuggestionsContainer) {
|
| 969 |
-
return;
|
| 970 |
-
}
|
| 971 |
-
clientSuggestionsContainer.classList.add("hidden");
|
| 972 |
-
clientSuggestionsContainer.innerHTML = "";
|
| 973 |
-
}
|
| 974 |
-
|
| 975 |
-
function selectClientFromLookup(client) {
|
| 976 |
-
setClientFormValues(client);
|
| 977 |
-
if (clientSearchInput) {
|
| 978 |
-
const summary = [client.name, client.tax_id].filter(Boolean).join(" • ");
|
| 979 |
-
clientSearchInput.value = summary || client.name || client.tax_id || "";
|
| 980 |
-
}
|
| 981 |
-
hideClientSuggestions();
|
| 982 |
-
}
|
| 983 |
-
|
| 984 |
-
function renderClientSuggestions(clients) {
|
| 985 |
-
if (!clientSuggestionsContainer) {
|
| 986 |
-
return;
|
| 987 |
-
}
|
| 988 |
-
clientSuggestionsContainer.innerHTML = "";
|
| 989 |
-
if (!Array.isArray(clients) || clients.length === 0) {
|
| 990 |
-
const empty = document.createElement("p");
|
| 991 |
-
empty.className = "client-suggestions-empty";
|
| 992 |
-
empty.textContent = "Brak dopasowanych klientów.";
|
| 993 |
-
clientSuggestionsContainer.appendChild(empty);
|
| 994 |
-
clientSuggestionsContainer.classList.remove("hidden");
|
| 995 |
-
return;
|
| 996 |
-
}
|
| 997 |
-
const fragment = document.createDocumentFragment();
|
| 998 |
-
clients.forEach((client) => {
|
| 999 |
-
const button = document.createElement("button");
|
| 1000 |
-
button.type = "button";
|
| 1001 |
-
button.className = "client-suggestion";
|
| 1002 |
-
button.setAttribute("role", "option");
|
| 1003 |
-
button.innerHTML = `
|
| 1004 |
-
<strong>${client.name || "Bez nazwy"}</strong>
|
| 1005 |
-
<span>${[client.tax_id, client.city].filter(Boolean).join(" • ")}</span>
|
| 1006 |
-
`;
|
| 1007 |
-
button.addEventListener("click", () => {
|
| 1008 |
-
selectClientFromLookup(client);
|
| 1009 |
-
});
|
| 1010 |
-
fragment.appendChild(button);
|
| 1011 |
-
});
|
| 1012 |
-
clientSuggestionsContainer.appendChild(fragment);
|
| 1013 |
-
clientSuggestionsContainer.classList.remove("hidden");
|
| 1014 |
-
}
|
| 1015 |
-
|
| 1016 |
-
async function requestClientSuggestions(term) {
|
| 1017 |
-
const query = (term || "").trim();
|
| 1018 |
-
if (!clientSuggestionsContainer || !clientSearchInput) {
|
| 1019 |
-
return;
|
| 1020 |
-
}
|
| 1021 |
-
if (!authToken || query.length < 2) {
|
| 1022 |
-
hideClientSuggestions();
|
| 1023 |
-
return;
|
| 1024 |
-
}
|
| 1025 |
-
try {
|
| 1026 |
-
const data = await apiRequest(`/api/clients?q=${encodeURIComponent(query)}`, {}, true);
|
| 1027 |
-
renderClientSuggestions(data.clients || []);
|
| 1028 |
-
} catch (error) {
|
| 1029 |
-
console.error(error);
|
| 1030 |
-
hideClientSuggestions();
|
| 1031 |
-
}
|
| 1032 |
-
}
|
| 1033 |
-
|
| 1034 |
-
function handleClientSearchInput(event) {
|
| 1035 |
-
const term = event.target.value || "";
|
| 1036 |
-
if (clientLookupTimeout) {
|
| 1037 |
-
window.clearTimeout(clientLookupTimeout);
|
| 1038 |
-
}
|
| 1039 |
-
if (!term.trim()) {
|
| 1040 |
-
hideClientSuggestions();
|
| 1041 |
-
return;
|
| 1042 |
-
}
|
| 1043 |
-
clientLookupTimeout = window.setTimeout(() => {
|
| 1044 |
-
requestClientSuggestions(term);
|
| 1045 |
-
}, 250);
|
| 1046 |
-
}
|
| 1047 |
-
|
| 1048 |
-
function vatSelectElement(initialValue = "23") {
|
| 1049 |
-
const select = document.createElement("select");
|
| 1050 |
-
select.className = "item-vat";
|
| 1051 |
-
VAT_OPTIONS.forEach((option) => {
|
| 1052 |
-
const element = document.createElement("option");
|
| 1053 |
-
element.value = option.value;
|
| 1054 |
-
element.textContent = option.label;
|
| 1055 |
-
select.appendChild(element);
|
| 1056 |
-
});
|
| 1057 |
-
select.value = VAT_OPTIONS.some((option) => option.value === initialValue) ? initialValue : "23";
|
| 1058 |
-
return select;
|
| 1059 |
-
}
|
| 1060 |
-
|
| 1061 |
-
function unitSelectElement(initialValue = DEFAULT_UNIT) {
|
| 1062 |
-
const select = document.createElement("select");
|
| 1063 |
-
select.className = "item-unit";
|
| 1064 |
-
UNIT_OPTIONS.forEach((option) => {
|
| 1065 |
-
const element = document.createElement("option");
|
| 1066 |
-
element.value = option.value;
|
| 1067 |
-
element.textContent = option.label;
|
| 1068 |
-
select.appendChild(element);
|
| 1069 |
-
});
|
| 1070 |
-
select.value = UNIT_OPTIONS.some((option) => option.value === initialValue) ? initialValue : DEFAULT_UNIT;
|
| 1071 |
-
return select;
|
| 1072 |
-
}
|
| 1073 |
-
|
| 1074 |
-
function createItemRow(initialValues = {}) {
|
| 1075 |
-
const row = document.createElement("tr");
|
| 1076 |
-
|
| 1077 |
-
const nameCell = document.createElement("td");
|
| 1078 |
-
const nameInput = document.createElement("input");
|
| 1079 |
-
nameInput.type = "text";
|
| 1080 |
-
nameInput.className = "item-name";
|
| 1081 |
-
nameInput.placeholder = "Nazwa towaru lub usługi";
|
| 1082 |
-
if (initialValues.name) {
|
| 1083 |
-
nameInput.value = initialValues.name;
|
| 1084 |
-
}
|
| 1085 |
-
nameCell.appendChild(nameInput);
|
| 1086 |
-
|
| 1087 |
-
const quantityCell = document.createElement("td");
|
| 1088 |
-
const quantityInput = document.createElement("input");
|
| 1089 |
-
quantityInput.type = "number";
|
| 1090 |
-
quantityInput.className = "item-quantity";
|
| 1091 |
-
quantityInput.min = "1";
|
| 1092 |
-
quantityInput.step = "1";
|
| 1093 |
-
quantityInput.inputMode = "numeric";
|
| 1094 |
-
const parsedQuantity = parseIntegerString(initialValues.quantity);
|
| 1095 |
-
const safeQuantity = Number.isNaN(parsedQuantity) || parsedQuantity <= 0 ? 1 : parsedQuantity;
|
| 1096 |
-
quantityInput.value = String(safeQuantity);
|
| 1097 |
-
quantityCell.appendChild(quantityInput);
|
| 1098 |
-
|
| 1099 |
-
const unitCell = document.createElement("td");
|
| 1100 |
-
const unitSelect = unitSelectElement(initialValues.unit);
|
| 1101 |
-
unitCell.appendChild(unitSelect);
|
| 1102 |
-
|
| 1103 |
-
const unitGrossCell = document.createElement("td");
|
| 1104 |
-
const unitGrossInput = document.createElement("input");
|
| 1105 |
-
unitGrossInput.type = "number";
|
| 1106 |
-
unitGrossInput.className = "item-gross";
|
| 1107 |
-
unitGrossInput.min = "0.01";
|
| 1108 |
-
unitGrossInput.step = "0.01";
|
| 1109 |
-
unitGrossInput.placeholder = "Brutto";
|
| 1110 |
-
if (initialValues.unit_price_gross) {
|
| 1111 |
-
unitGrossInput.value = initialValues.unit_price_gross;
|
| 1112 |
-
}
|
| 1113 |
-
unitGrossCell.appendChild(unitGrossInput);
|
| 1114 |
-
|
| 1115 |
-
const vatCell = document.createElement("td");
|
| 1116 |
-
const vatSelect = vatSelectElement(initialValues.vat_code);
|
| 1117 |
-
vatCell.appendChild(vatSelect);
|
| 1118 |
-
|
| 1119 |
-
const totalCell = document.createElement("td");
|
| 1120 |
-
totalCell.className = "item-total";
|
| 1121 |
-
totalCell.textContent = "0.00 PLN";
|
| 1122 |
-
|
| 1123 |
-
const actionsCell = document.createElement("td");
|
| 1124 |
-
const removeButton = document.createElement("button");
|
| 1125 |
-
removeButton.type = "button";
|
| 1126 |
-
removeButton.className = "remove-item";
|
| 1127 |
-
removeButton.textContent = "Usuń";
|
| 1128 |
-
actionsCell.appendChild(removeButton);
|
| 1129 |
-
|
| 1130 |
-
row.appendChild(nameCell);
|
| 1131 |
-
row.appendChild(quantityCell);
|
| 1132 |
-
row.appendChild(unitCell);
|
| 1133 |
-
row.appendChild(unitGrossCell);
|
| 1134 |
-
row.appendChild(vatCell);
|
| 1135 |
-
row.appendChild(totalCell);
|
| 1136 |
-
row.appendChild(actionsCell);
|
| 1137 |
-
|
| 1138 |
-
const handleChange = () => updateTotals();
|
| 1139 |
-
nameInput.addEventListener("input", handleChange);
|
| 1140 |
-
quantityInput.addEventListener("input", () => {
|
| 1141 |
-
const sanitized = quantityInput.value.replace(/[^0-9]/g, "");
|
| 1142 |
-
quantityInput.value = sanitized;
|
| 1143 |
-
handleChange();
|
| 1144 |
-
});
|
| 1145 |
-
quantityInput.addEventListener("blur", () => {
|
| 1146 |
-
const parsed = parseIntegerString(quantityInput.value);
|
| 1147 |
-
quantityInput.value = Number.isNaN(parsed) || parsed <= 0 ? "1" : String(parsed);
|
| 1148 |
-
handleChange();
|
| 1149 |
-
});
|
| 1150 |
-
unitGrossInput.addEventListener("input", handleChange);
|
| 1151 |
-
vatSelect.addEventListener("change", handleChange);
|
| 1152 |
-
unitSelect.addEventListener("change", handleChange);
|
| 1153 |
-
|
| 1154 |
-
removeButton.addEventListener("click", () => {
|
| 1155 |
-
if (itemsBody.children.length === 1) {
|
| 1156 |
-
nameInput.value = "";
|
| 1157 |
-
quantityInput.value = "1";
|
| 1158 |
-
unitGrossInput.value = "";
|
| 1159 |
-
vatSelect.value = "23";
|
| 1160 |
-
unitSelect.value = DEFAULT_UNIT;
|
| 1161 |
-
updateTotals();
|
| 1162 |
-
return;
|
| 1163 |
-
}
|
| 1164 |
-
row.remove();
|
| 1165 |
-
updateTotals();
|
| 1166 |
-
});
|
| 1167 |
-
|
| 1168 |
-
itemsBody.appendChild(row);
|
| 1169 |
-
updateTotals();
|
| 1170 |
-
}
|
| 1171 |
-
|
| 1172 |
-
function calculateRowTotals(row) {
|
| 1173 |
-
const name = row.querySelector(".item-name")?.value.trim() ?? "";
|
| 1174 |
-
const quantityRaw = row.querySelector(".item-quantity")?.value;
|
| 1175 |
-
const quantityParsed = parseIntegerString(quantityRaw);
|
| 1176 |
-
const quantityValid = Number.isInteger(quantityParsed) && quantityParsed > 0;
|
| 1177 |
-
const quantity = quantityValid ? quantityParsed : 0;
|
| 1178 |
-
const unitGross = parseNumber(row.querySelector(".item-gross")?.value);
|
| 1179 |
-
const vatCode = row.querySelector(".item-vat")?.value ?? "23";
|
| 1180 |
-
const rate = VAT_RATE_VALUES[vatCode] ?? 0;
|
| 1181 |
-
const unit = row.querySelector(".item-unit")?.value || DEFAULT_UNIT;
|
| 1182 |
-
const unitLabel = UNIT_OPTIONS.some((option) => option.value === unit) ? unit : DEFAULT_UNIT;
|
| 1183 |
-
|
| 1184 |
-
const hasValues = name || quantity > 0 || unitGross > 0;
|
| 1185 |
-
if (!hasValues) {
|
| 1186 |
-
return {
|
| 1187 |
-
valid: false,
|
| 1188 |
-
vatCode,
|
| 1189 |
-
vatLabel: vatLabelFromCode(vatCode),
|
| 1190 |
-
requiresExemption: requiresExemption(vatCode),
|
| 1191 |
-
quantity,
|
| 1192 |
-
unitGross,
|
| 1193 |
-
unitNet: 0,
|
| 1194 |
-
netTotal: 0,
|
| 1195 |
-
vatAmount: 0,
|
| 1196 |
-
grossTotal: 0,
|
| 1197 |
-
unit: unitLabel,
|
| 1198 |
-
};
|
| 1199 |
-
}
|
| 1200 |
-
|
| 1201 |
-
if (!quantityValid || unitGross <= 0) {
|
| 1202 |
-
return {
|
| 1203 |
-
valid: false,
|
| 1204 |
-
vatCode,
|
| 1205 |
-
vatLabel: vatLabelFromCode(vatCode),
|
| 1206 |
-
requiresExemption: requiresExemption(vatCode),
|
| 1207 |
-
quantity,
|
| 1208 |
-
unitGross,
|
| 1209 |
-
unitNet: 0,
|
| 1210 |
-
netTotal: 0,
|
| 1211 |
-
vatAmount: 0,
|
| 1212 |
-
grossTotal: quantity * unitGross,
|
| 1213 |
-
unit: unitLabel,
|
| 1214 |
-
};
|
| 1215 |
-
}
|
| 1216 |
-
|
| 1217 |
-
const grossTotal = quantity * unitGross;
|
| 1218 |
-
const netTotal = rate > 0 ? grossTotal / (1 + rate) : grossTotal;
|
| 1219 |
-
const vatAmount = grossTotal - netTotal;
|
| 1220 |
-
const unitNet = netTotal / quantity;
|
| 1221 |
-
|
| 1222 |
-
return {
|
| 1223 |
-
valid: true,
|
| 1224 |
-
vatCode,
|
| 1225 |
-
vatLabel: vatLabelFromCode(vatCode),
|
| 1226 |
-
requiresExemption: requiresExemption(vatCode),
|
| 1227 |
-
quantity,
|
| 1228 |
-
unitGross,
|
| 1229 |
-
unitNet,
|
| 1230 |
-
netTotal,
|
| 1231 |
-
vatAmount,
|
| 1232 |
-
grossTotal,
|
| 1233 |
-
unit: unitLabel,
|
| 1234 |
-
};
|
| 1235 |
-
}
|
| 1236 |
-
|
| 1237 |
-
function updateTotals() {
|
| 1238 |
-
let totalNet = 0;
|
| 1239 |
-
let totalVat = 0;
|
| 1240 |
-
let totalGross = 0;
|
| 1241 |
-
const summary = new Map();
|
| 1242 |
-
let exemptionNeeded = false;
|
| 1243 |
-
|
| 1244 |
-
const rows = Array.from(itemsBody.querySelectorAll("tr"));
|
| 1245 |
-
rows.forEach((row) => {
|
| 1246 |
-
const totals = calculateRowTotals(row);
|
| 1247 |
-
if (totals.requiresExemption) {
|
| 1248 |
-
exemptionNeeded = true;
|
| 1249 |
-
}
|
| 1250 |
-
const totalCell = row.querySelector(".item-total");
|
| 1251 |
-
totalCell.textContent = formatCurrency(totals.grossTotal);
|
| 1252 |
-
|
| 1253 |
-
if (!totals.valid) {
|
| 1254 |
-
return;
|
| 1255 |
-
}
|
| 1256 |
-
|
| 1257 |
-
totalNet += totals.netTotal;
|
| 1258 |
-
totalVat += totals.vatAmount;
|
| 1259 |
-
totalGross += totals.grossTotal;
|
| 1260 |
-
|
| 1261 |
-
const existing = summary.get(totals.vatLabel) || { net: 0, vat: 0, gross: 0 };
|
| 1262 |
-
existing.net += totals.netTotal;
|
| 1263 |
-
existing.vat += totals.vatAmount;
|
| 1264 |
-
existing.gross += totals.grossTotal;
|
| 1265 |
-
summary.set(totals.vatLabel, existing);
|
| 1266 |
-
});
|
| 1267 |
-
|
| 1268 |
-
totalNetLabel.textContent = `Suma netto: ${totalNet.toFixed(2)} PLN`;
|
| 1269 |
-
totalVatLabel.textContent = `Kwota VAT: ${totalVat.toFixed(2)} PLN`;
|
| 1270 |
-
totalGrossLabel.textContent = `Suma brutto: ${totalGross.toFixed(2)} PLN`;
|
| 1271 |
-
renderRateSummary(summary);
|
| 1272 |
-
|
| 1273 |
-
updateExemptionVisibility(exemptionNeeded);
|
| 1274 |
-
}
|
| 1275 |
-
|
| 1276 |
-
function renderRateSummary(summary) {
|
| 1277 |
-
if (!summary || summary.size === 0) {
|
| 1278 |
-
rateSummaryContainer.innerHTML = "";
|
| 1279 |
-
return;
|
| 1280 |
-
}
|
| 1281 |
-
|
| 1282 |
-
const entries = Array.from(summary.entries()).sort(([a], [b]) => a.localeCompare(b));
|
| 1283 |
-
const markup = entries
|
| 1284 |
-
.map(
|
| 1285 |
-
([label, totals]) =>
|
| 1286 |
-
`<div class="rate-summary-item">
|
| 1287 |
-
<span>${label}</span>
|
| 1288 |
-
<span>Netto: ${totals.net.toFixed(2)} PLN</span>
|
| 1289 |
-
<span>VAT: ${totals.vat.toFixed(2)} PLN</span>
|
| 1290 |
-
<span>Brutto: ${totals.gross.toFixed(2)} PLN</span>
|
| 1291 |
-
</div>`
|
| 1292 |
-
)
|
| 1293 |
-
.join("");
|
| 1294 |
-
rateSummaryContainer.innerHTML = `<h4>Podsumowanie stawek</h4>${markup}`;
|
| 1295 |
-
}
|
| 1296 |
-
|
| 1297 |
-
function collectInvoicePayload() {
|
| 1298 |
-
const items = [];
|
| 1299 |
-
const rows = Array.from(itemsBody.querySelectorAll("tr"));
|
| 1300 |
-
|
| 1301 |
-
rows.forEach((row) => {
|
| 1302 |
-
const name = row.querySelector(".item-name")?.value.trim() ?? "";
|
| 1303 |
-
const quantityRaw = row.querySelector(".item-quantity")?.value;
|
| 1304 |
-
const quantityParsed = parseIntegerString(quantityRaw);
|
| 1305 |
-
const quantityValid = Number.isInteger(quantityParsed) && quantityParsed > 0;
|
| 1306 |
-
const quantity = quantityValid ? quantityParsed : 0;
|
| 1307 |
-
const unitGross = parseNumber(row.querySelector(".item-gross")?.value);
|
| 1308 |
-
const vatCode = row.querySelector(".item-vat")?.value ?? "23";
|
| 1309 |
-
const unitValue = row.querySelector(".item-unit")?.value || DEFAULT_UNIT;
|
| 1310 |
-
const unit = UNIT_OPTIONS.some((option) => option.value === unitValue) ? unitValue : DEFAULT_UNIT;
|
| 1311 |
-
|
| 1312 |
-
const hasValues = name || quantity > 0 || unitGross > 0;
|
| 1313 |
-
if (!hasValues) {
|
| 1314 |
-
return;
|
| 1315 |
-
}
|
| 1316 |
-
|
| 1317 |
-
if (!name) {
|
| 1318 |
-
throw new Error("Każda pozycja musi mieć nazwę.");
|
| 1319 |
-
}
|
| 1320 |
-
if (!quantityValid) {
|
| 1321 |
-
throw new Error("Ilość musi byc dodatnia liczba calkowita.");
|
| 1322 |
-
}
|
| 1323 |
-
if (unitGross <= 0) {
|
| 1324 |
-
throw new Error("Cena brutto musi być większa od zera.");
|
| 1325 |
-
}
|
| 1326 |
-
|
| 1327 |
-
items.push({
|
| 1328 |
-
name,
|
| 1329 |
-
quantity,
|
| 1330 |
-
unit,
|
| 1331 |
-
unit_price_gross: unitGross.toFixed(2),
|
| 1332 |
-
vat_code: vatCode,
|
| 1333 |
-
});
|
| 1334 |
-
});
|
| 1335 |
-
|
| 1336 |
-
if (items.length === 0) {
|
| 1337 |
-
throw new Error("Dodaj przynajmniej jedną pozycję.");
|
| 1338 |
-
}
|
| 1339 |
-
|
| 1340 |
-
const saleDate = invoiceForm.elements.saleDate.value || null;
|
| 1341 |
-
const paymentTerm = parseInt(invoiceForm.elements.paymentTerm.value) || 14;
|
| 1342 |
-
const requiresExemptionNote = items.some((item) => item.vat_code === "ZW" || item.vat_code === "0");
|
| 1343 |
-
let exemptionNote = "";
|
| 1344 |
-
if (requiresExemptionNote) {
|
| 1345 |
-
const noteFromTextarea = exemptionNoteInput.value.trim();
|
| 1346 |
-
if (exemptionReasonSelect) {
|
| 1347 |
-
const selectedReason = EXEMPTION_REASON_LOOKUP.get(exemptionReasonSelect.value);
|
| 1348 |
-
if (selectedReason && selectedReason.value !== "custom") {
|
| 1349 |
-
exemptionNote = selectedReason.note;
|
| 1350 |
-
} else {
|
| 1351 |
-
exemptionNote = noteFromTextarea;
|
| 1352 |
-
}
|
| 1353 |
-
} else {
|
| 1354 |
-
exemptionNote = noteFromTextarea;
|
| 1355 |
-
}
|
| 1356 |
-
if (!exemptionNote) {
|
| 1357 |
-
throw new Error("Wybierz lub wpisz podstawę zwolnienia dla pozycji ze stawka ZW/0%.");
|
| 1358 |
-
}
|
| 1359 |
-
}
|
| 1360 |
-
const client = {
|
| 1361 |
-
name: (invoiceForm.elements.clientName.value || "").trim(),
|
| 1362 |
-
tax_id: (invoiceForm.elements.clientTaxId.value || "").trim(),
|
| 1363 |
-
address_line: (invoiceForm.elements.clientAddress.value || "").trim(),
|
| 1364 |
-
postal_code: (invoiceForm.elements.clientPostalCode.value || "").trim(),
|
| 1365 |
-
city: (invoiceForm.elements.clientCity.value || "").trim(),
|
| 1366 |
-
phone: (invoiceForm.elements.clientPhone.value || "").trim(),
|
| 1367 |
-
};
|
| 1368 |
-
|
| 1369 |
-
return {
|
| 1370 |
-
sale_date: saleDate,
|
| 1371 |
-
payment_term: paymentTerm,
|
| 1372 |
-
client,
|
| 1373 |
-
items,
|
| 1374 |
-
exemption_note: exemptionNote,
|
| 1375 |
-
};
|
| 1376 |
-
}
|
| 1377 |
-
|
| 1378 |
-
function renderInvoicePreview(invoice) {
|
| 1379 |
-
if (!invoice || !currentBusiness) {
|
| 1380 |
-
invoiceOutput.innerHTML = "<p>Brak danych faktury.</p>";
|
| 1381 |
-
return;
|
| 1382 |
-
}
|
| 1383 |
-
|
| 1384 |
-
const client = invoice.client || {};
|
| 1385 |
-
const hasClientData = client.name || client.address_line || client.postal_code || client.city || client.tax_id;
|
| 1386 |
-
|
| 1387 |
-
const itemsRows = (invoice.items || [])
|
| 1388 |
-
.map((item) => {
|
| 1389 |
-
const quantityDisplay = formatQuantity(item.quantity);
|
| 1390 |
-
const unitDisplay = UNIT_OPTIONS.some((option) => option.value === item.unit) ? item.unit : DEFAULT_UNIT;
|
| 1391 |
-
return `
|
| 1392 |
-
<tr>
|
| 1393 |
-
<td>${item.name}</td>
|
| 1394 |
-
<td>${quantityDisplay}</td>
|
| 1395 |
-
<td>${unitDisplay}</td>
|
| 1396 |
-
<td>${formatCurrency(item.unit_price_net)}</td>
|
| 1397 |
-
<td>${formatCurrency(item.net_total)}</td>
|
| 1398 |
-
<td>${item.vat_label}</td>
|
| 1399 |
-
<td>${formatCurrency(item.vat_amount)}</td>
|
| 1400 |
-
<td>${formatCurrency(item.gross_total)}</td>
|
| 1401 |
-
</tr>`;
|
| 1402 |
-
})
|
| 1403 |
-
.join("");
|
| 1404 |
-
|
| 1405 |
-
const summaryRows = (invoice.summary || [])
|
| 1406 |
-
.map(
|
| 1407 |
-
(entry) =>
|
| 1408 |
-
`<div class="rate-summary-item">
|
| 1409 |
-
<span>${entry.vat_label}</span>
|
| 1410 |
-
<span>Netto: ${formatCurrency(entry.net_total)}</span>
|
| 1411 |
-
<span>VAT: ${formatCurrency(entry.vat_total)}</span>
|
| 1412 |
-
<span>Brutto: ${formatCurrency(entry.gross_total)}</span>
|
| 1413 |
-
</div>`
|
| 1414 |
-
)
|
| 1415 |
-
.join("");
|
| 1416 |
-
|
| 1417 |
-
invoiceOutput.innerHTML = `
|
| 1418 |
-
<div class="invoice-preview-meta">
|
| 1419 |
-
<span><strong>Numer:</strong> ${invoice.invoice_id}</span>
|
| 1420 |
-
<span><strong>Data wystawienia:</strong> ${invoice.issued_at}</span>
|
| 1421 |
-
<span><strong>Data sprzedaży:</strong> ${invoice.sale_date}</span>
|
| 1422 |
-
${invoice.payment_term ? `<span><strong>Termin płatności:</strong> ${invoice.payment_term} dni</span>` : ''}
|
| 1423 |
-
</div>
|
| 1424 |
-
<div class="invoice-preview-header">
|
| 1425 |
-
<div class="invoice-preview-card">
|
| 1426 |
-
<h4>Nabywca</h4>
|
| 1427 |
-
${
|
| 1428 |
-
hasClientData
|
| 1429 |
-
? `
|
| 1430 |
-
<p>${client.name || "---"}</p>
|
| 1431 |
-
<p>${client.address_line || "---"}</p>
|
| 1432 |
-
<p>${client.postal_code || ""} ${client.city || ""}</p>
|
| 1433 |
-
<p>${client.tax_id ? `NIP: ${client.tax_id}` : ""}</p>
|
| 1434 |
-
${client.phone ? `<p>Tel: ${client.phone}</p>` : ''}
|
| 1435 |
-
`
|
| 1436 |
-
: "<p>Brak danych nabywcy.</p>"
|
| 1437 |
-
}
|
| 1438 |
-
</div>
|
| 1439 |
-
<div class="invoice-preview-card">
|
| 1440 |
-
<h4>Sprzedawca</h4>
|
| 1441 |
-
<p>${currentBusiness.company_name}</p>
|
| 1442 |
-
<p>${currentBusiness.owner_name}</p>
|
| 1443 |
-
<p>${currentBusiness.address_line}</p>
|
| 1444 |
-
<p>${currentBusiness.postal_code} ${currentBusiness.city}</p>
|
| 1445 |
-
<p>NIP: ${currentBusiness.tax_id}</p>
|
| 1446 |
-
<p>Konto: ${currentBusiness.bank_account}</p>
|
| 1447 |
-
</div>
|
| 1448 |
-
</div>
|
| 1449 |
-
<table>
|
| 1450 |
-
<thead>
|
| 1451 |
-
<tr>
|
| 1452 |
-
<th>Nazwa</th>
|
| 1453 |
-
<th>Ilość</th>
|
| 1454 |
-
<th>Jednostka</th>
|
| 1455 |
-
<th>Cena jedn. netto</th>
|
| 1456 |
-
<th>Wartość netto (pozycja)</th>
|
| 1457 |
-
<th>Stawka VAT</th>
|
| 1458 |
-
<th>Kwota VAT (pozycja)</th>
|
| 1459 |
-
<th>Wartość brutto</th>
|
| 1460 |
-
</tr>
|
| 1461 |
-
</thead>
|
| 1462 |
-
<tbody>${itemsRows}</tbody>
|
| 1463 |
-
</table>
|
| 1464 |
-
<div class="rate-summary">
|
| 1465 |
-
<h4>Podsumowanie stawek</h4>
|
| 1466 |
-
${summaryRows}
|
| 1467 |
-
</div>
|
| 1468 |
-
<div class="invoice-preview-summary">
|
| 1469 |
-
<span>Netto: ${formatCurrency(invoice.totals.net)}</span>
|
| 1470 |
-
<span>VAT: ${formatCurrency(invoice.totals.vat)}</span>
|
| 1471 |
-
<span>Brutto: ${formatCurrency(invoice.totals.gross)}</span>
|
| 1472 |
-
</div>
|
| 1473 |
-
${
|
| 1474 |
-
invoice.exemption_note
|
| 1475 |
-
? `<div class="invoice-preview-note"><strong>Podstawa prawna zwolnienia:</strong> ${invoice.exemption_note}</div>`
|
| 1476 |
-
: ""
|
| 1477 |
-
}
|
| 1478 |
-
`;
|
| 1479 |
-
}
|
| 1480 |
-
|
| 1481 |
-
function drawPartyBox(doc, title, lines, x, y, width, options = {}) {
|
| 1482 |
-
const lineHeight = 5;
|
| 1483 |
-
const wrappedLines = lines.flatMap((line) => doc.splitTextToSize(line, width));
|
| 1484 |
-
const boxHeight = wrappedLines.length * lineHeight + 18;
|
| 1485 |
-
const bgColor = options.bgColor || PDF_COLORS.surface;
|
| 1486 |
-
const plain = options.plain || false;
|
| 1487 |
-
|
| 1488 |
-
if (!plain) {
|
| 1489 |
-
doc.setDrawColor(...PDF_COLORS.border);
|
| 1490 |
-
doc.setFillColor(...bgColor);
|
| 1491 |
-
doc.roundedRect(x - 4, y - 10, width + 8, boxHeight, 2.5, 2.5, "FD");
|
| 1492 |
-
}
|
| 1493 |
-
|
| 1494 |
-
doc.setFontSize(11);
|
| 1495 |
-
doc.setTextColor(...PDF_COLORS.muted);
|
| 1496 |
-
doc.text(title.toUpperCase(), x, y - 2);
|
| 1497 |
-
doc.setFontSize(10);
|
| 1498 |
-
doc.setTextColor(...PDF_COLORS.text);
|
| 1499 |
-
|
| 1500 |
-
let cursor = y + 4;
|
| 1501 |
-
wrappedLines.forEach((line) => {
|
| 1502 |
-
doc.text(line, x, cursor);
|
| 1503 |
-
cursor += lineHeight;
|
| 1504 |
-
});
|
| 1505 |
-
|
| 1506 |
-
return y - 10 + boxHeight;
|
| 1507 |
-
}
|
| 1508 |
-
|
| 1509 |
-
function arrayBufferToBase64(buffer) {
|
| 1510 |
-
const bytes = new Uint8Array(buffer);
|
| 1511 |
-
const chunkSize = 0x8000;
|
| 1512 |
-
let binary = "";
|
| 1513 |
-
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
| 1514 |
-
const chunk = bytes.subarray(offset, Math.min(offset + chunkSize, bytes.length));
|
| 1515 |
-
binary += String.fromCharCode.apply(null, chunk);
|
| 1516 |
-
}
|
| 1517 |
-
return btoa(binary);
|
| 1518 |
-
}
|
| 1519 |
-
|
| 1520 |
-
const PDF_FONT_FILE = "Roboto-VariableFont_wdth,wght.ttf";
|
| 1521 |
-
const PDF_FONT_NAME = "RobotoPolish";
|
| 1522 |
-
const PDF_COLORS = {
|
| 1523 |
-
accent: [37, 99, 235],
|
| 1524 |
-
accentMuted: [226, 236, 255],
|
| 1525 |
-
text: [16, 24, 40],
|
| 1526 |
-
muted: [102, 112, 133],
|
| 1527 |
-
border: [215, 222, 236],
|
| 1528 |
-
surface: [249, 251, 255],
|
| 1529 |
-
};
|
| 1530 |
-
|
| 1531 |
-
async function ensurePdfFont() {
|
| 1532 |
-
if (pdfFontPromise) {
|
| 1533 |
-
return pdfFontPromise;
|
| 1534 |
-
}
|
| 1535 |
-
|
| 1536 |
-
if (!window.jspdf || !window.jspdf.jsPDF) {
|
| 1537 |
-
throw new Error("Biblioteka jsPDF nie została załadowana.");
|
| 1538 |
-
}
|
| 1539 |
-
|
| 1540 |
-
const { jsPDF } = window.jspdf;
|
| 1541 |
-
const loadBase64 = async () => {
|
| 1542 |
-
if (typeof window !== "undefined" && window.PDF_FONT_BASE64) {
|
| 1543 |
-
return window.PDF_FONT_BASE64;
|
| 1544 |
-
}
|
| 1545 |
-
const response = await fetch(`/${encodeURIComponent(PDF_FONT_FILE)}`);
|
| 1546 |
-
if (!response.ok) {
|
| 1547 |
-
throw new Error(`Nie udało się pobrać czcionki Roboto (status ${response.status}).`);
|
| 1548 |
-
}
|
| 1549 |
-
const buffer = await response.arrayBuffer();
|
| 1550 |
-
return arrayBufferToBase64(buffer);
|
| 1551 |
-
};
|
| 1552 |
-
|
| 1553 |
-
pdfFontPromise = loadBase64().then((data) => {
|
| 1554 |
-
pdfFontBase64 = data;
|
| 1555 |
-
return data;
|
| 1556 |
-
});
|
| 1557 |
-
|
| 1558 |
-
return pdfFontPromise;
|
| 1559 |
-
}
|
| 1560 |
-
|
| 1561 |
-
async function generatePdf(business, invoice, logo) {
|
| 1562 |
-
if (!window.jspdf || !window.jspdf.jsPDF) {
|
| 1563 |
-
alert("Biblioteka jsPDF nie została załadowana. Sprawdź połączenie z internetem.");
|
| 1564 |
-
return;
|
| 1565 |
-
}
|
| 1566 |
-
|
| 1567 |
-
let fontBase64;
|
| 1568 |
-
try {
|
| 1569 |
-
fontBase64 = await ensurePdfFont();
|
| 1570 |
-
} catch (error) {
|
| 1571 |
-
alert(error.message || "Nie udało się przygotować czcionki do PDF.");
|
| 1572 |
-
return;
|
| 1573 |
-
}
|
| 1574 |
-
|
| 1575 |
-
const { jsPDF } = window.jspdf;
|
| 1576 |
-
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
|
| 1577 |
-
const marginX = 18;
|
| 1578 |
-
let cursorY = 20;
|
| 1579 |
-
const pageWidth = doc.internal.pageSize.getWidth();
|
| 1580 |
-
|
| 1581 |
-
if (!doc.getFontList()[PDF_FONT_NAME]) {
|
| 1582 |
-
const embeddedFont = pdfFontBase64 || fontBase64;
|
| 1583 |
-
doc.addFileToVFS(PDF_FONT_FILE, embeddedFont);
|
| 1584 |
-
doc.addFont(PDF_FONT_FILE, PDF_FONT_NAME, "normal");
|
| 1585 |
-
}
|
| 1586 |
-
|
| 1587 |
-
doc.setFont(PDF_FONT_NAME, "normal");
|
| 1588 |
-
doc.setTextColor(...PDF_COLORS.text);
|
| 1589 |
-
doc.setFontSize(18);
|
| 1590 |
-
doc.text("Faktura", marginX, cursorY + 2);
|
| 1591 |
-
doc.setFontSize(13);
|
| 1592 |
-
doc.text(invoice.invoice_id, marginX, cursorY + 10);
|
| 1593 |
-
doc.setFontSize(10);
|
| 1594 |
-
doc.setTextColor(...PDF_COLORS.muted);
|
| 1595 |
-
const metaLines = [
|
| 1596 |
-
`Data wystawienia: ${invoice.issued_at}`,
|
| 1597 |
-
`Data sprzedaży: ${invoice.sale_date}`,
|
| 1598 |
-
];
|
| 1599 |
-
if (invoice.payment_term) {
|
| 1600 |
-
metaLines.push(`Termin płatności: ${invoice.payment_term} dni`);
|
| 1601 |
-
}
|
| 1602 |
-
metaLines.forEach((line, index) => {
|
| 1603 |
-
doc.text(line, marginX, cursorY + 18 + index * 5);
|
| 1604 |
-
});
|
| 1605 |
-
|
| 1606 |
-
cursorY += 18 + metaLines.length * 5 + 6;
|
| 1607 |
-
const columnWidth = 85;
|
| 1608 |
-
const sellerX = marginX + columnWidth + 12;
|
| 1609 |
-
|
| 1610 |
-
const clientLines = invoice.client && (invoice.client.name || invoice.client.address_line || invoice.client.city || invoice.client.tax_id || invoice.client.phone)
|
| 1611 |
-
? [
|
| 1612 |
-
invoice.client.name || "---",
|
| 1613 |
-
invoice.client.address_line || "",
|
| 1614 |
-
`${(invoice.client.postal_code || "").trim()} ${(invoice.client.city || "").trim()}`.trim(),
|
| 1615 |
-
invoice.client.tax_id ? `NIP: ${invoice.client.tax_id}` : "",
|
| 1616 |
-
invoice.client.phone ? `Tel: ${invoice.client.phone}` : "",
|
| 1617 |
-
].filter((line) => line && line.trim())
|
| 1618 |
-
: ["Brak danych nabywcy"];
|
| 1619 |
-
|
| 1620 |
-
const sellerLines = [
|
| 1621 |
-
business.company_name,
|
| 1622 |
-
business.owner_name,
|
| 1623 |
-
business.address_line,
|
| 1624 |
-
`${business.postal_code} ${business.city}`.trim(),
|
| 1625 |
-
`NIP: ${business.tax_id}`,
|
| 1626 |
-
`Konto: ${business.bank_account}`,
|
| 1627 |
-
];
|
| 1628 |
-
|
| 1629 |
-
let logoBottom = cursorY;
|
| 1630 |
-
if (logo && logo.data && logo.mime_type) {
|
| 1631 |
-
const format = logo.mime_type === "image/png" ? "PNG" : "JPEG";
|
| 1632 |
-
const dataUrl = logo.data_url || `data:${logo.mime_type};base64,${logo.data}`;
|
| 1633 |
-
try {
|
| 1634 |
-
let logoWidth = 40;
|
| 1635 |
-
let logoHeight = 16;
|
| 1636 |
-
if (doc.getImageProperties) {
|
| 1637 |
-
const props = doc.getImageProperties(dataUrl);
|
| 1638 |
-
if (props?.width && props?.height) {
|
| 1639 |
-
const ratio = props.height / props.width;
|
| 1640 |
-
logoHeight = logoWidth * ratio;
|
| 1641 |
-
if (logoHeight > 20) {
|
| 1642 |
-
logoHeight = 20;
|
| 1643 |
-
logoWidth = logoHeight / ratio;
|
| 1644 |
-
}
|
| 1645 |
-
}
|
| 1646 |
-
}
|
| 1647 |
-
const logoX = sellerX;
|
| 1648 |
-
const logoY = Math.max(cursorY - logoHeight - 12, 18);
|
| 1649 |
-
doc.addImage(dataUrl, format, logoX, logoY, logoWidth, logoHeight);
|
| 1650 |
-
logoBottom = logoY + logoHeight;
|
| 1651 |
-
} catch (error) {
|
| 1652 |
-
console.warn("Nie udało się dodać logo nad sprzedawcą:", error);
|
| 1653 |
-
}
|
| 1654 |
-
}
|
| 1655 |
-
|
| 1656 |
-
const buyerBottom = drawPartyBox(doc, "NABYWCA", clientLines, marginX, cursorY, columnWidth, { plain: true });
|
| 1657 |
-
const sellerBottom = drawPartyBox(doc, "SPRZEDAWCA", sellerLines, sellerX, cursorY, columnWidth, {
|
| 1658 |
-
plain: true,
|
| 1659 |
-
});
|
| 1660 |
-
cursorY = Math.max(buyerBottom, sellerBottom, logoBottom) + 12;
|
| 1661 |
-
|
| 1662 |
-
const tableColumns = [
|
| 1663 |
-
{ key: "name", label: "Nazwa", width: 44 },
|
| 1664 |
-
{ key: "quantity", label: "Ilość", width: 14 },
|
| 1665 |
-
{ key: "unit", label: "Jednostka", width: 14 },
|
| 1666 |
-
{ key: "unitNet", label: "Cena jedn. netto", width: 23 },
|
| 1667 |
-
{ key: "netTotal", label: "Wartość netto", width: 23 },
|
| 1668 |
-
{ key: "vatLabel", label: "Stawka VAT", width: 14 },
|
| 1669 |
-
{ key: "vatAmount", label: "Kwota VAT", width: 21 },
|
| 1670 |
-
{ key: "grossTotal", label: "Wartość brutto", width: 21 },
|
| 1671 |
-
];
|
| 1672 |
-
const lineHeight = 5;
|
| 1673 |
-
const headerLineHeight = 4.2;
|
| 1674 |
-
tableColumns.forEach((column) => {
|
| 1675 |
-
column.headerLines = doc.splitTextToSize(column.label, column.width - 4);
|
| 1676 |
-
});
|
| 1677 |
-
const headerHeight = Math.max(...tableColumns.map((column) => column.headerLines.length * headerLineHeight + 3));
|
| 1678 |
-
|
| 1679 |
-
const tableWidth = tableColumns.reduce((sum, column) => sum + column.width, 0);
|
| 1680 |
-
|
| 1681 |
-
doc.setFillColor(...PDF_COLORS.accentMuted);
|
| 1682 |
-
doc.setDrawColor(...PDF_COLORS.border);
|
| 1683 |
-
doc.rect(marginX, cursorY, tableWidth, headerHeight, "F");
|
| 1684 |
-
doc.rect(marginX, cursorY, tableWidth, headerHeight);
|
| 1685 |
-
let offsetX = marginX;
|
| 1686 |
-
doc.setFontSize(10);
|
| 1687 |
-
doc.setTextColor(...PDF_COLORS.text);
|
| 1688 |
-
const rightAlignedColumns = new Set(["quantity", "unit", "unitNet", "netTotal", "vatAmount", "grossTotal"]);
|
| 1689 |
-
tableColumns.forEach((column) => {
|
| 1690 |
-
doc.rect(offsetX, cursorY, column.width, headerHeight);
|
| 1691 |
-
column.headerLines.forEach((line, index) => {
|
| 1692 |
-
const textY = cursorY + 4 + index * headerLineHeight;
|
| 1693 |
-
const textX = rightAlignedColumns.has(column.key) ? offsetX + column.width - 2 : offsetX + 2;
|
| 1694 |
-
doc.text((line || "").trim(), textX, textY, {
|
| 1695 |
-
align: rightAlignedColumns.has(column.key) ? "right" : "left",
|
| 1696 |
-
});
|
| 1697 |
-
});
|
| 1698 |
-
offsetX += column.width;
|
| 1699 |
-
});
|
| 1700 |
-
cursorY += headerHeight;
|
| 1701 |
-
|
| 1702 |
-
const withPercent = (value) => {
|
| 1703 |
-
if (!value) {
|
| 1704 |
-
return "-";
|
| 1705 |
-
}
|
| 1706 |
-
if (/%$/.test(value)) {
|
| 1707 |
-
return value;
|
| 1708 |
-
}
|
| 1709 |
-
if (/^\d+(\.\d+)?$/.test(value)) {
|
| 1710 |
-
return `${value}%`;
|
| 1711 |
-
}
|
| 1712 |
-
return value;
|
| 1713 |
-
};
|
| 1714 |
-
|
| 1715 |
-
const invoiceItems = Array.isArray(invoice.items) ? invoice.items : [];
|
| 1716 |
-
invoiceItems.forEach((item, rowIndex) => {
|
| 1717 |
-
const quantity = formatQuantity(item.quantity);
|
| 1718 |
-
const unitLabel = UNIT_OPTIONS.some((option) => option.value === item.unit) ? item.unit : DEFAULT_UNIT;
|
| 1719 |
-
const unitNet = formatCurrency(item.unit_price_net);
|
| 1720 |
-
const netTotal = formatCurrency(item.net_total);
|
| 1721 |
-
const vatAmount = formatCurrency(item.vat_amount);
|
| 1722 |
-
const grossTotal = formatCurrency(item.gross_total);
|
| 1723 |
-
|
| 1724 |
-
const wrapText = (text, width) => {
|
| 1725 |
-
const available = Math.max(width - 4, 6);
|
| 1726 |
-
return doc
|
| 1727 |
-
.splitTextToSize(text ?? "", available)
|
| 1728 |
-
.map((line) => line.trim());
|
| 1729 |
-
};
|
| 1730 |
-
|
| 1731 |
-
const columnData = tableColumns.map((column) => {
|
| 1732 |
-
switch (column.key) {
|
| 1733 |
-
case "name":
|
| 1734 |
-
return wrapText(item.name, column.width - 4);
|
| 1735 |
-
case "quantity":
|
| 1736 |
-
return wrapText(quantity, column.width);
|
| 1737 |
-
case "unit":
|
| 1738 |
-
return wrapText(unitLabel, column.width);
|
| 1739 |
-
case "unitNet":
|
| 1740 |
-
return wrapText(unitNet, column.width);
|
| 1741 |
-
case "netTotal":
|
| 1742 |
-
return wrapText(netTotal, column.width);
|
| 1743 |
-
case "vatLabel":
|
| 1744 |
-
return wrapText(withPercent(item.vat_label), column.width);
|
| 1745 |
-
case "vatAmount":
|
| 1746 |
-
return wrapText(vatAmount, column.width);
|
| 1747 |
-
case "grossTotal":
|
| 1748 |
-
return wrapText(grossTotal, column.width);
|
| 1749 |
-
default:
|
| 1750 |
-
return wrapText("", column.width);
|
| 1751 |
-
}
|
| 1752 |
-
});
|
| 1753 |
-
|
| 1754 |
-
const rowHeight = Math.max(...columnData.map((data) => data.length)) * lineHeight + 2;
|
| 1755 |
-
offsetX = marginX;
|
| 1756 |
-
if (rowIndex % 2 === 1) {
|
| 1757 |
-
doc.setFillColor(248, 250, 253);
|
| 1758 |
-
doc.rect(marginX, cursorY, tableWidth, rowHeight, "F");
|
| 1759 |
-
}
|
| 1760 |
-
tableColumns.forEach((column, index) => {
|
| 1761 |
-
doc.rect(offsetX, cursorY, column.width, rowHeight);
|
| 1762 |
-
const lines = columnData[index];
|
| 1763 |
-
lines.forEach((line, lineIndex) => {
|
| 1764 |
-
const textY = cursorY + (lineIndex + 1) * lineHeight;
|
| 1765 |
-
const content = (line || "").trim();
|
| 1766 |
-
const alignRight = rightAlignedColumns.has(column.key);
|
| 1767 |
-
const textX = alignRight ? offsetX + column.width - 2 : offsetX + 2;
|
| 1768 |
-
doc.text(content, textX, textY, { align: alignRight ? "right" : "left" });
|
| 1769 |
-
});
|
| 1770 |
-
offsetX += column.width;
|
| 1771 |
-
});
|
| 1772 |
-
|
| 1773 |
-
cursorY += rowHeight;
|
| 1774 |
-
});
|
| 1775 |
-
|
| 1776 |
-
cursorY += 10;
|
| 1777 |
-
doc.setFontSize(11);
|
| 1778 |
-
doc.text("Podsumowanie stawek", marginX, cursorY);
|
| 1779 |
-
cursorY += 6;
|
| 1780 |
-
|
| 1781 |
-
const summaryEntries = Array.isArray(invoice.summary) ? invoice.summary : [];
|
| 1782 |
-
doc.setFontSize(10);
|
| 1783 |
-
doc.setTextColor(...PDF_COLORS.text);
|
| 1784 |
-
summaryEntries.forEach((entry) => {
|
| 1785 |
-
const summaryLine = `${withPercent(entry.vat_label)} | Netto: ${formatCurrency(entry.net_total)} VAT: ${formatCurrency(entry.vat_total)} Brutto: ${formatCurrency(entry.gross_total)}`;
|
| 1786 |
-
const wrapped = doc.splitTextToSize(summaryLine, 170);
|
| 1787 |
-
wrapped.forEach((line) => {
|
| 1788 |
-
doc.text((line || "").trim(), marginX, cursorY);
|
| 1789 |
-
cursorY += lineHeight;
|
| 1790 |
-
});
|
| 1791 |
-
cursorY += 2;
|
| 1792 |
-
});
|
| 1793 |
-
cursorY += 6;
|
| 1794 |
-
|
| 1795 |
-
const totals = [
|
| 1796 |
-
{ label: "Netto", value: formatCurrency(invoice.totals.net), variant: "muted" },
|
| 1797 |
-
{ label: "VAT", value: formatCurrency(invoice.totals.vat), variant: "muted" },
|
| 1798 |
-
{ label: "Brutto", value: formatCurrency(invoice.totals.gross), variant: "accent" },
|
| 1799 |
-
];
|
| 1800 |
-
const chipWidth = 54;
|
| 1801 |
-
const chipHeight = 20;
|
| 1802 |
-
doc.setFontSize(10);
|
| 1803 |
-
totals.forEach((chip, index) => {
|
| 1804 |
-
const x = marginX + index * (chipWidth + 12);
|
| 1805 |
-
if (chip.variant === "accent") {
|
| 1806 |
-
doc.setFillColor(...PDF_COLORS.accent);
|
| 1807 |
-
doc.setTextColor(255, 255, 255);
|
| 1808 |
-
} else {
|
| 1809 |
-
doc.setFillColor(...PDF_COLORS.surface);
|
| 1810 |
-
doc.setTextColor(...PDF_COLORS.muted);
|
| 1811 |
-
}
|
| 1812 |
-
doc.roundedRect(x, cursorY, chipWidth, chipHeight, 4, 4, "F");
|
| 1813 |
-
doc.text(chip.label.toUpperCase(), x + 3, cursorY + 6);
|
| 1814 |
-
doc.setFontSize(chip.variant === "accent" ? 12 : 11);
|
| 1815 |
-
if (chip.variant === "accent") {
|
| 1816 |
-
doc.setTextColor(255, 255, 255);
|
| 1817 |
-
} else {
|
| 1818 |
-
doc.setTextColor(...PDF_COLORS.text);
|
| 1819 |
-
}
|
| 1820 |
-
doc.text(chip.value, x + 3, cursorY + 14);
|
| 1821 |
-
doc.setFontSize(10);
|
| 1822 |
-
});
|
| 1823 |
-
cursorY += chipHeight + 12;
|
| 1824 |
-
|
| 1825 |
-
if (invoice.exemption_note) {
|
| 1826 |
-
doc.setFontSize(10);
|
| 1827 |
-
doc.setTextColor(...PDF_COLORS.text);
|
| 1828 |
-
const noteLines = doc.splitTextToSize(`Podstawa prawna zwolnienia: ${invoice.exemption_note}`, 170);
|
| 1829 |
-
doc.text(noteLines, marginX, cursorY);
|
| 1830 |
-
}
|
| 1831 |
-
|
| 1832 |
-
doc.save(`${invoice.invoice_id}.pdf`);
|
| 1833 |
-
}
|
| 1834 |
-
|
| 1835 |
-
async function loadBusinessData() {
|
| 1836 |
-
const data = await apiRequest("/api/business", {}, true);
|
| 1837 |
-
currentBusiness = data.business;
|
| 1838 |
-
renderBusinessDisplay(currentBusiness);
|
| 1839 |
-
fillBusinessForm(currentBusiness);
|
| 1840 |
-
setBusinessFormVisibility(false);
|
| 1841 |
-
}
|
| 1842 |
-
|
| 1843 |
-
async function loadLogo() {
|
| 1844 |
-
try {
|
| 1845 |
-
const data = await apiRequest("/api/logo", {}, true);
|
| 1846 |
-
currentLogo = data.logo || null;
|
| 1847 |
-
} catch (error) {
|
| 1848 |
-
console.error("Nie udało się pobrać logo:", error);
|
| 1849 |
-
currentLogo = null;
|
| 1850 |
-
}
|
| 1851 |
-
updateLogoPreview();
|
| 1852 |
-
}
|
| 1853 |
-
|
| 1854 |
-
function resetInvoiceForm() {
|
| 1855 |
-
invoiceForm.reset();
|
| 1856 |
-
customExemptionNote = "";
|
| 1857 |
-
updateExemptionVisibility(false);
|
| 1858 |
-
itemsBody.innerHTML = "";
|
| 1859 |
-
createItemRow();
|
| 1860 |
-
const now = new Date();
|
| 1861 |
-
const year = now.getFullYear();
|
| 1862 |
-
const month = String(now.getMonth() + 1).padStart(2, "0");
|
| 1863 |
-
const day = String(now.getDate()).padStart(2, "0");
|
| 1864 |
-
if (invoiceForm.elements.saleDate) {
|
| 1865 |
-
invoiceForm.elements.saleDate.value = `${year}-${month}-${day}`;
|
| 1866 |
-
}
|
| 1867 |
-
updateTotals();
|
| 1868 |
-
}
|
| 1869 |
-
|
| 1870 |
-
async function bootstrapApp() {
|
| 1871 |
-
try {
|
| 1872 |
-
await loadBusinessData();
|
| 1873 |
-
await loadLogo();
|
| 1874 |
-
exitInvoiceEdit();
|
| 1875 |
-
resetInvoiceForm();
|
| 1876 |
-
invoiceResult.classList.add("hidden");
|
| 1877 |
-
lastInvoice = null;
|
| 1878 |
-
await refreshInvoices();
|
| 1879 |
-
await refreshSummary();
|
| 1880 |
-
updateLoginLabel();
|
| 1881 |
-
setAppState("app");
|
| 1882 |
-
activeView = "invoice-builder";
|
| 1883 |
-
setActiveView(activeView);
|
| 1884 |
-
} catch (error) {
|
| 1885 |
-
console.error(error);
|
| 1886 |
-
authToken = null;
|
| 1887 |
-
currentLogin = "";
|
| 1888 |
-
sessionStorage.removeItem("invoiceAuthToken");
|
| 1889 |
-
sessionStorage.removeItem("invoiceLogin");
|
| 1890 |
-
showFeedback(loginFeedback, error.message || "Nie udało się pobrać danych konta.");
|
| 1891 |
-
setAppState("auth");
|
| 1892 |
-
}
|
| 1893 |
-
}
|
| 1894 |
-
|
| 1895 |
-
async function initialize() {
|
| 1896 |
-
exitInvoiceEdit();
|
| 1897 |
-
resetInvoiceForm();
|
| 1898 |
-
updateLogoPreview();
|
| 1899 |
-
updateSummaryCards({});
|
| 1900 |
-
updateSummaryChart({});
|
| 1901 |
-
setActiveView("invoice-builder");
|
| 1902 |
-
setAppState("auth");
|
| 1903 |
-
closeRegisterPanel({ resetForm: true, focusTrigger: false });
|
| 1904 |
-
clearFeedback(registerFeedback);
|
| 1905 |
-
clearFeedback(loginFeedback);
|
| 1906 |
-
if (legacyLoginHint) {
|
| 1907 |
-
legacyLoginHint.classList.add("hidden");
|
| 1908 |
-
legacyLoginHint.textContent = "";
|
| 1909 |
-
}
|
| 1910 |
-
if (authToken) {
|
| 1911 |
-
await bootstrapApp().catch((error) => {
|
| 1912 |
-
console.error(error);
|
| 1913 |
-
showFeedback(registerFeedback, "Nie uda?o si? nawi?za? po??czenia z serwerem.");
|
| 1914 |
-
});
|
| 1915 |
-
}
|
| 1916 |
-
}
|
| 1917 |
-
|
| 1918 |
-
|
| 1919 |
-
if (registerForm && registerFeedback && loginFeedback) {
|
| 1920 |
-
registerForm.addEventListener("submit", async (event) => {
|
| 1921 |
-
event.preventDefault();
|
| 1922 |
-
clearFeedback(registerFeedback);
|
| 1923 |
-
clearFeedback(loginFeedback);
|
| 1924 |
-
|
| 1925 |
-
const formData = new FormData(registerForm);
|
| 1926 |
-
const emailValue = formData.get("email")?.toString().trim() ?? "";
|
| 1927 |
-
const password = formData.get("password")?.toString() ?? "";
|
| 1928 |
-
const confirmPassword = formData.get("confirm_password")?.toString() ?? "";
|
| 1929 |
-
|
| 1930 |
-
if (!emailValue) {
|
| 1931 |
-
showFeedback(registerFeedback, "Podaj adres email.");
|
| 1932 |
-
return;
|
| 1933 |
-
}
|
| 1934 |
-
if (password !== confirmPassword) {
|
| 1935 |
-
showFeedback(registerFeedback, "Hasła musza byc identyczne.");
|
| 1936 |
-
return;
|
| 1937 |
-
}
|
| 1938 |
-
|
| 1939 |
-
if (password.trim().length < 4) {
|
| 1940 |
-
showFeedback(registerFeedback, "Hasło musi miec co najmniej 4 znaki.");
|
| 1941 |
-
return;
|
| 1942 |
-
}
|
| 1943 |
-
|
| 1944 |
-
const payload = {
|
| 1945 |
-
email: emailValue,
|
| 1946 |
-
password,
|
| 1947 |
-
confirm_password: confirmPassword,
|
| 1948 |
-
company_name: formData.get("company_name")?.toString().trim(),
|
| 1949 |
-
owner_name: formData.get("owner_name")?.toString().trim(),
|
| 1950 |
-
address_line: formData.get("address_line")?.toString().trim(),
|
| 1951 |
-
postal_code: formData.get("postal_code")?.toString().trim(),
|
| 1952 |
-
city: formData.get("city")?.toString().trim(),
|
| 1953 |
-
tax_id: formData.get("tax_id")?.toString().trim(),
|
| 1954 |
-
bank_account: formData.get("bank_account")?.toString().trim(),
|
| 1955 |
-
};
|
| 1956 |
-
|
| 1957 |
-
try {
|
| 1958 |
-
await apiRequest("/api/register", { method: "POST", body: payload });
|
| 1959 |
-
showFeedback(registerFeedback, "Konto utworzone. Możesz sie zalogowac.", "success");
|
| 1960 |
-
if (loginForm && loginForm.elements.email) {
|
| 1961 |
-
loginForm.elements.email.value = emailValue;
|
| 1962 |
-
}
|
| 1963 |
-
registerForm.reset();
|
| 1964 |
-
setTimeout(() => {
|
| 1965 |
-
closeRegisterPanel({ resetForm: true, focusTrigger: false });
|
| 1966 |
-
clearFeedback(registerFeedback);
|
| 1967 |
-
clearFeedback(loginFeedback);
|
| 1968 |
-
showFeedback(loginFeedback, "Konto utworzone. Zaloguj się haslem.", "success");
|
| 1969 |
-
if (loginForm) {
|
| 1970 |
-
const passwordInput = loginForm.elements.password;
|
| 1971 |
-
if (passwordInput) {
|
| 1972 |
-
passwordInput.focus();
|
| 1973 |
-
}
|
| 1974 |
-
}
|
| 1975 |
-
}, 1600);
|
| 1976 |
-
} catch (error) {
|
| 1977 |
-
showFeedback(registerFeedback, error.message || "Nie udało się utworzyć konta.");
|
| 1978 |
-
}
|
| 1979 |
-
});
|
| 1980 |
-
}
|
| 1981 |
-
|
| 1982 |
-
if (loginForm && loginFeedback) {
|
| 1983 |
-
const setLoginSubmittingState = (isSubmitting) => {
|
| 1984 |
-
if (!loginSubmitButton) {
|
| 1985 |
-
return;
|
| 1986 |
-
}
|
| 1987 |
-
if (isSubmitting) {
|
| 1988 |
-
loginSubmitButton.disabled = true;
|
| 1989 |
-
loginSubmitButton.setAttribute("data-loading", "true");
|
| 1990 |
-
loginSubmitButton.textContent = "Logowanie...";
|
| 1991 |
-
} else {
|
| 1992 |
-
loginSubmitButton.disabled = false;
|
| 1993 |
-
loginSubmitButton.textContent = loginSubmitButtonDefaultText;
|
| 1994 |
-
loginSubmitButton.removeAttribute("data-loading");
|
| 1995 |
-
}
|
| 1996 |
-
};
|
| 1997 |
-
|
| 1998 |
-
loginForm.addEventListener("submit", async (event) => {
|
| 1999 |
-
event.preventDefault();
|
| 2000 |
-
clearFeedback(loginFeedback);
|
| 2001 |
-
|
| 2002 |
-
const emailElement = loginForm.elements.email;
|
| 2003 |
-
const emailValue = emailElement ? emailElement.value.trim() : "";
|
| 2004 |
-
const password = loginForm.elements.password.value;
|
| 2005 |
-
|
| 2006 |
-
if (!emailValue) {
|
| 2007 |
-
showFeedback(loginFeedback, "Podaj adres email.");
|
| 2008 |
-
return;
|
| 2009 |
-
}
|
| 2010 |
-
|
| 2011 |
-
if (!password) {
|
| 2012 |
-
showFeedback(loginFeedback, "Podaj hasło.");
|
| 2013 |
-
return;
|
| 2014 |
-
}
|
| 2015 |
-
|
| 2016 |
-
setLoginSubmittingState(true);
|
| 2017 |
-
|
| 2018 |
-
try {
|
| 2019 |
-
const response = await apiRequest("/api/login", { method: "POST", body: { email: emailValue, password } });
|
| 2020 |
-
authToken = response.token;
|
| 2021 |
-
currentLogin = response.email || response.login || emailValue;
|
| 2022 |
-
sessionStorage.setItem("invoiceAuthToken", authToken);
|
| 2023 |
-
sessionStorage.setItem("invoiceLogin", currentLogin);
|
| 2024 |
-
loginForm.reset();
|
| 2025 |
-
await bootstrapApp();
|
| 2026 |
-
} catch (error) {
|
| 2027 |
-
const errorMessage = error instanceof Error ? error.message : String(error || "");
|
| 2028 |
-
let feedbackMessage = errorMessage || "Logowanie nie powiodło się.";
|
| 2029 |
-
if (/nieprawidlowy (login|email) lub hasło/i.test(errorMessage)) {
|
| 2030 |
-
feedbackMessage = "Podany email lub hasło są nieprawidłowe. Utwórz konto, jeśli jeszcze go nie masz.";
|
| 2031 |
-
} else if (/brak autoryzacji/i.test(errorMessage) || /brak tokenu autoryzacyjnego/i.test(errorMessage)) {
|
| 2032 |
-
feedbackMessage = "Sesja wygasła. Zaloguj się ponownie.";
|
| 2033 |
-
} else if (/failed to fetch|networkerror/i.test(errorMessage) || error instanceof TypeError) {
|
| 2034 |
-
feedbackMessage = "Nie udało się nawiązać połączenia z serwerem. Sprawdź, czy aplikacja serwerowa jest uruchomiona.";
|
| 2035 |
-
}
|
| 2036 |
-
showFeedback(loginFeedback, feedbackMessage);
|
| 2037 |
-
} finally {
|
| 2038 |
-
setLoginSubmittingState(false);
|
| 2039 |
-
}
|
| 2040 |
-
});
|
| 2041 |
-
}
|
| 2042 |
-
|
| 2043 |
-
if (toggleBusinessFormButton && businessForm && businessFeedback) {
|
| 2044 |
-
toggleBusinessFormButton.addEventListener("click", () => {
|
| 2045 |
-
const isVisible = !businessForm.classList.contains("hidden");
|
| 2046 |
-
if (!isVisible) {
|
| 2047 |
-
setBusinessFormVisibility(true, { preserveFeedback: true });
|
| 2048 |
-
} else {
|
| 2049 |
-
setBusinessFormVisibility(false);
|
| 2050 |
-
}
|
| 2051 |
-
});
|
| 2052 |
-
}
|
| 2053 |
-
|
| 2054 |
-
if (cancelBusinessUpdateButton && businessForm && businessFeedback && toggleBusinessFormButton) {
|
| 2055 |
-
cancelBusinessUpdateButton.addEventListener("click", () => {
|
| 2056 |
-
setBusinessFormVisibility(false);
|
| 2057 |
-
});
|
| 2058 |
-
}
|
| 2059 |
-
|
| 2060 |
-
if (businessForm && businessFeedback) {
|
| 2061 |
-
businessForm.addEventListener("submit", async (event) => {
|
| 2062 |
-
event.preventDefault();
|
| 2063 |
-
clearFeedback(businessFeedback);
|
| 2064 |
-
|
| 2065 |
-
const formData = new FormData(businessForm);
|
| 2066 |
-
const payload = {
|
| 2067 |
-
company_name: formData.get("company_name")?.toString().trim(),
|
| 2068 |
-
owner_name: formData.get("owner_name")?.toString().trim(),
|
| 2069 |
-
address_line: formData.get("address_line")?.toString().trim(),
|
| 2070 |
-
postal_code: formData.get("postal_code")?.toString().trim(),
|
| 2071 |
-
city: formData.get("city")?.toString().trim(),
|
| 2072 |
-
tax_id: formData.get("tax_id")?.toString().trim(),
|
| 2073 |
-
bank_account: formData.get("bank_account")?.toString().trim(),
|
| 2074 |
-
};
|
| 2075 |
-
|
| 2076 |
-
try {
|
| 2077 |
-
await apiRequest("/api/business", { method: "POST", body: payload }, true);
|
| 2078 |
-
await loadBusinessData();
|
| 2079 |
-
setBusinessFormVisibility(false, { preserveFeedback: true });
|
| 2080 |
-
showFeedback(businessFeedback, "Dane sprzedawcy zaktualizowane.", "success");
|
| 2081 |
-
setTimeout(() => clearFeedback(businessFeedback), 2000);
|
| 2082 |
-
} catch (error) {
|
| 2083 |
-
showFeedback(businessFeedback, error.message || "Nie udało się zaktualizować danych.");
|
| 2084 |
-
}
|
| 2085 |
-
});
|
| 2086 |
-
}
|
| 2087 |
-
|
| 2088 |
-
if (exemptionReasonSelect) {
|
| 2089 |
-
populateExemptionReasons();
|
| 2090 |
-
let previousReasonValue = exemptionReasonSelect.value;
|
| 2091 |
-
applyExemptionReasonSelection({ preserveCustom: true });
|
| 2092 |
-
exemptionReasonSelect.addEventListener("change", () => {
|
| 2093 |
-
if (previousReasonValue === "custom" && exemptionNoteInput) {
|
| 2094 |
-
customExemptionNote = exemptionNoteInput.value.trim();
|
| 2095 |
-
}
|
| 2096 |
-
previousReasonValue = exemptionReasonSelect.value;
|
| 2097 |
-
applyExemptionReasonSelection();
|
| 2098 |
-
if (exemptionReasonSelect.value === "custom" && exemptionNoteInput) {
|
| 2099 |
-
exemptionNoteInput.focus();
|
| 2100 |
-
}
|
| 2101 |
-
});
|
| 2102 |
-
}
|
| 2103 |
-
|
| 2104 |
-
if (exemptionNoteInput) {
|
| 2105 |
-
exemptionNoteInput.addEventListener("input", () => {
|
| 2106 |
-
if (exemptionReasonSelect && exemptionReasonSelect.value === "custom") {
|
| 2107 |
-
customExemptionNote = exemptionNoteInput.value;
|
| 2108 |
-
}
|
| 2109 |
-
});
|
| 2110 |
-
}
|
| 2111 |
-
|
| 2112 |
-
if (invoiceForm) {
|
| 2113 |
-
invoiceForm.addEventListener("submit", async (event) => {
|
| 2114 |
-
event.preventDefault();
|
| 2115 |
-
try {
|
| 2116 |
-
const payload = collectInvoicePayload();
|
| 2117 |
-
let response;
|
| 2118 |
-
if (editingInvoiceId) {
|
| 2119 |
-
response = await apiRequest(`/api/invoices/${encodeURIComponent(editingInvoiceId)}`, { method: "PUT", body: payload }, true);
|
| 2120 |
-
exitInvoiceEdit();
|
| 2121 |
-
} else {
|
| 2122 |
-
response = await apiRequest("/api/invoices", { method: "POST", body: payload }, true);
|
| 2123 |
-
}
|
| 2124 |
-
lastInvoice = response.invoice;
|
| 2125 |
-
renderInvoicePreview(lastInvoice);
|
| 2126 |
-
if (invoiceResult) {
|
| 2127 |
-
invoiceResult.classList.remove("hidden");
|
| 2128 |
-
}
|
| 2129 |
-
await refreshInvoices();
|
| 2130 |
-
await refreshSummary();
|
| 2131 |
-
resetInvoiceForm();
|
| 2132 |
-
} catch (error) {
|
| 2133 |
-
alert(error.message || "Nie udało się zapisać faktury.");
|
| 2134 |
-
}
|
| 2135 |
-
});
|
| 2136 |
-
}
|
| 2137 |
-
|
| 2138 |
-
if (addItemButton) {
|
| 2139 |
-
addItemButton.addEventListener("click", () => {
|
| 2140 |
-
createItemRow();
|
| 2141 |
-
});
|
| 2142 |
-
}
|
| 2143 |
-
|
| 2144 |
-
if (downloadButton) {
|
| 2145 |
-
downloadButton.addEventListener("click", async () => {
|
| 2146 |
-
if (!lastInvoice || !currentBusiness) {
|
| 2147 |
-
alert("Brak faktury do pobrania. Wygeneruj ją najpierw.");
|
| 2148 |
-
return;
|
| 2149 |
-
}
|
| 2150 |
-
await generatePdf(currentBusiness, lastInvoice, currentLogo);
|
| 2151 |
-
});
|
| 2152 |
-
}
|
| 2153 |
-
|
| 2154 |
-
if (logoutButton) {
|
| 2155 |
-
logoutButton.addEventListener("click", () => {
|
| 2156 |
-
authToken = null;
|
| 2157 |
-
currentLogin = "";
|
| 2158 |
-
sessionStorage.removeItem("invoiceAuthToken");
|
| 2159 |
-
sessionStorage.removeItem("invoiceLogin");
|
| 2160 |
-
lastInvoice = null;
|
| 2161 |
-
currentBusiness = null;
|
| 2162 |
-
currentLogo = null;
|
| 2163 |
-
invoicesCache = [];
|
| 2164 |
-
exitInvoiceEdit();
|
| 2165 |
-
resetInvoiceForm();
|
| 2166 |
-
setBusinessFormVisibility(false);
|
| 2167 |
-
if (invoiceResult) {
|
| 2168 |
-
invoiceResult.classList.add("hidden");
|
| 2169 |
-
}
|
| 2170 |
-
updateLogoPreview();
|
| 2171 |
-
updateLoginLabel();
|
| 2172 |
-
renderInvoicesTable([]);
|
| 2173 |
-
updateSummaryCards({});
|
| 2174 |
-
updateSummaryChart({});
|
| 2175 |
-
closeRegisterPanel({ resetForm: true, focusTrigger: true });
|
| 2176 |
-
clearFeedback(registerFeedback);
|
| 2177 |
-
clearFeedback(loginFeedback);
|
| 2178 |
-
clearFeedback(businessFeedback);
|
| 2179 |
-
clearFeedback(logoFeedback);
|
| 2180 |
-
clearFeedback(dashboardFeedback);
|
| 2181 |
-
setAppState("auth");
|
| 2182 |
-
});
|
| 2183 |
-
}
|
| 2184 |
-
|
| 2185 |
-
appNavButtons.forEach((button) => {
|
| 2186 |
-
button.addEventListener("click", () => {
|
| 2187 |
-
setActiveView(button.dataset.view);
|
| 2188 |
-
});
|
| 2189 |
-
});
|
| 2190 |
-
|
| 2191 |
-
if (filterStartDate) {
|
| 2192 |
-
filterStartDate.addEventListener("change", applyInvoiceFilters);
|
| 2193 |
-
}
|
| 2194 |
-
if (filterEndDate) {
|
| 2195 |
-
filterEndDate.addEventListener("change", applyInvoiceFilters);
|
| 2196 |
-
}
|
| 2197 |
-
if (clearFiltersButton) {
|
| 2198 |
-
clearFiltersButton.addEventListener("click", () => {
|
| 2199 |
-
if (filterStartDate) {
|
| 2200 |
-
filterStartDate.value = "";
|
| 2201 |
-
}
|
| 2202 |
-
if (filterEndDate) {
|
| 2203 |
-
filterEndDate.value = "";
|
| 2204 |
-
}
|
| 2205 |
-
applyInvoiceFilters();
|
| 2206 |
-
});
|
| 2207 |
-
}
|
| 2208 |
-
|
| 2209 |
-
if (showRegisterButton) {
|
| 2210 |
-
showRegisterButton.addEventListener("click", () => {
|
| 2211 |
-
openRegisterPanel();
|
| 2212 |
-
});
|
| 2213 |
-
}
|
| 2214 |
-
|
| 2215 |
-
if (backToLoginButton) {
|
| 2216 |
-
backToLoginButton.addEventListener("click", () => {
|
| 2217 |
-
closeRegisterPanel({ resetForm: false, focusTrigger: true });
|
| 2218 |
-
});
|
| 2219 |
-
}
|
| 2220 |
-
|
| 2221 |
-
if (cancelRegisterButton) {
|
| 2222 |
-
cancelRegisterButton.addEventListener("click", () => {
|
| 2223 |
-
closeRegisterPanel({ resetForm: true, focusTrigger: true });
|
| 2224 |
-
});
|
| 2225 |
-
}
|
| 2226 |
-
|
| 2227 |
-
if (logoInput) {
|
| 2228 |
-
logoInput.addEventListener("change", (event) => {
|
| 2229 |
-
const file = event.target.files?.[0];
|
| 2230 |
-
if (!file) {
|
| 2231 |
-
return;
|
| 2232 |
-
}
|
| 2233 |
-
clearFeedback(logoFeedback);
|
| 2234 |
-
if (file.size > maxLogoSize) {
|
| 2235 |
-
showFeedback(logoFeedback, `Logo jest zbyt duze. Maksymalny rozmiar to ${(maxLogoSize / 1024).toFixed(0)} KB.`);
|
| 2236 |
-
logoInput.value = "";
|
| 2237 |
-
return;
|
| 2238 |
-
}
|
| 2239 |
-
const reader = new FileReader();
|
| 2240 |
-
reader.onload = async () => {
|
| 2241 |
-
try {
|
| 2242 |
-
const base64 = reader.result?.toString();
|
| 2243 |
-
if (!base64) {
|
| 2244 |
-
throw new Error("Nie udało się odczytać pliku.");
|
| 2245 |
-
}
|
| 2246 |
-
const response = await apiRequest(
|
| 2247 |
-
"/api/logo",
|
| 2248 |
-
{
|
| 2249 |
-
method: "POST",
|
| 2250 |
-
body: {
|
| 2251 |
-
filename: file.name,
|
| 2252 |
-
mime_type: file.type,
|
| 2253 |
-
content: base64,
|
| 2254 |
-
},
|
| 2255 |
-
},
|
| 2256 |
-
true
|
| 2257 |
-
);
|
| 2258 |
-
currentLogo = response.logo;
|
| 2259 |
-
updateLogoPreview();
|
| 2260 |
-
showFeedback(logoFeedback, "Logo zapisane.", "success");
|
| 2261 |
-
} catch (error) {
|
| 2262 |
-
showFeedback(logoFeedback, error.message || "Nie udało się zapisać logo.");
|
| 2263 |
-
} finally {
|
| 2264 |
-
logoInput.value = "";
|
| 2265 |
-
}
|
| 2266 |
-
};
|
| 2267 |
-
reader.onerror = () => {
|
| 2268 |
-
showFeedback(logoFeedback, "Nie udało się wczytać pliku logo.");
|
| 2269 |
-
logoInput.value = "";
|
| 2270 |
-
};
|
| 2271 |
-
reader.readAsDataURL(file);
|
| 2272 |
-
});
|
| 2273 |
-
}
|
| 2274 |
-
|
| 2275 |
-
if (removeLogoButton) {
|
| 2276 |
-
removeLogoButton.addEventListener("click", async () => {
|
| 2277 |
-
clearFeedback(logoFeedback);
|
| 2278 |
-
if (!currentLogo) {
|
| 2279 |
-
showFeedback(logoFeedback, "Brak logo do usunięcia.");
|
| 2280 |
-
return;
|
| 2281 |
-
}
|
| 2282 |
-
try {
|
| 2283 |
-
await apiRequest("/api/logo", { method: "DELETE" }, true);
|
| 2284 |
-
currentLogo = null;
|
| 2285 |
-
updateLogoPreview();
|
| 2286 |
-
showFeedback(logoFeedback, "Logo usunięte.", "success");
|
| 2287 |
-
} catch (error) {
|
| 2288 |
-
showFeedback(logoFeedback, error.message || "Nie udało się usunąć logo.");
|
| 2289 |
-
}
|
| 2290 |
-
});
|
| 2291 |
-
}
|
| 2292 |
-
|
| 2293 |
-
if (cancelEditInvoiceButton) {
|
| 2294 |
-
cancelEditInvoiceButton.addEventListener("click", () => {
|
| 2295 |
-
exitInvoiceEdit();
|
| 2296 |
-
resetInvoiceForm();
|
| 2297 |
-
});
|
| 2298 |
-
}
|
| 2299 |
-
|
| 2300 |
-
if (clientSearchInput) {
|
| 2301 |
-
clientSearchInput.addEventListener("input", handleClientSearchInput);
|
| 2302 |
-
clientSearchInput.addEventListener("focus", () => {
|
| 2303 |
-
if ((clientSearchInput.value || "").trim().length >= 2) {
|
| 2304 |
-
requestClientSuggestions(clientSearchInput.value);
|
| 2305 |
-
}
|
| 2306 |
-
});
|
| 2307 |
-
}
|
| 2308 |
-
|
| 2309 |
-
document.addEventListener("click", (event) => {
|
| 2310 |
-
if (!clientSuggestionsContainer || !clientSearchInput) {
|
| 2311 |
-
return;
|
| 2312 |
-
}
|
| 2313 |
-
if (
|
| 2314 |
-
clientSuggestionsContainer.contains(event.target) ||
|
| 2315 |
-
clientSearchInput === event.target ||
|
| 2316 |
-
clientSearchInput.contains(event.target)
|
| 2317 |
-
) {
|
| 2318 |
-
return;
|
| 2319 |
-
}
|
| 2320 |
-
hideClientSuggestions();
|
| 2321 |
-
});
|
| 2322 |
-
|
| 2323 |
-
initialize().catch((error) => {
|
| 2324 |
-
console.error(error);
|
| 2325 |
-
showFeedback(registerFeedback, "Nie udało się uruchomić aplikacji.");
|
| 2326 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
requirements.txt
CHANGED
|
@@ -1,2 +1,6 @@
|
|
| 1 |
-
Flask=
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Flask>=2.3,<3.0
|
| 2 |
+
|
| 3 |
+
SQLAlchemy
|
| 4 |
+
psycopg2-binary
|
| 5 |
+
uvicorn
|
| 6 |
+
fastapi
|
server.py
CHANGED
|
@@ -1,73 +1,75 @@
|
|
| 1 |
-
import
|
| 2 |
-
import
|
| 3 |
-
import
|
| 4 |
-
import
|
| 5 |
-
import
|
| 6 |
-
|
| 7 |
-
import
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
from
|
| 14 |
-
|
| 15 |
-
from
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
)
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
"
|
| 40 |
-
"
|
| 41 |
-
"
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
| 71 |
def _decimal(value: Any) -> Decimal:
|
| 72 |
try:
|
| 73 |
return Decimal(str(value))
|
|
@@ -75,135 +77,201 @@ def _decimal(value: Any) -> Decimal:
|
|
| 75 |
raise ValueError(f"Niepoprawna wartosc liczby: {value}") from error
|
| 76 |
|
| 77 |
|
| 78 |
-
def
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
try:
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
| 87 |
|
| 88 |
|
| 89 |
-
def
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
payload = request.get_json(force=True)
|
| 203 |
-
|
| 204 |
-
password = payload.get("password")
|
| 205 |
-
confirm = payload.get("confirm_password")
|
| 206 |
-
business_fields = [
|
| 207 |
"company_name",
|
| 208 |
"owner_name",
|
| 209 |
"address_line",
|
|
@@ -211,796 +279,112 @@ def api_register() -> Any:
|
|
| 211 |
"city",
|
| 212 |
"tax_id",
|
| 213 |
"bank_account",
|
|
|
|
| 214 |
]
|
| 215 |
-
business_data: Dict[str, str] = {}
|
| 216 |
-
|
| 217 |
-
for field in business_fields:
|
| 218 |
-
value = (payload.get(field) or "").strip()
|
| 219 |
-
if not value:
|
| 220 |
-
return jsonify({"error": f"Pole {field} jest wymagane."}), 400
|
| 221 |
-
business_data[field] = value
|
| 222 |
-
|
| 223 |
-
if password != confirm:
|
| 224 |
-
return jsonify({"error": "Hasla musza byc identyczne."}), 400
|
| 225 |
-
if len(password or "") < PASSWORD_MIN_LENGTH:
|
| 226 |
-
return jsonify({"error": "Haslo jest za krotkie."}), 400
|
| 227 |
-
|
| 228 |
-
login_key, display_email = normalize_email(email)
|
| 229 |
-
password_hash = hash_password(password)
|
| 230 |
-
|
| 231 |
-
if DATABASE_AVAILABLE:
|
| 232 |
-
if fetch_one("SELECT 1 FROM accounts WHERE login = %s", (login_key,)):
|
| 233 |
-
return jsonify({"error": "Konto o podanym emailu juz istnieje."}), 400
|
| 234 |
-
account_id = create_account(login_key, display_email, password_hash)
|
| 235 |
-
update_business(account_id, business_data)
|
| 236 |
-
return jsonify({"message": "Konto zostalo utworzone."})
|
| 237 |
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
"
|
| 247 |
-
"
|
| 248 |
-
"
|
| 249 |
-
"
|
| 250 |
-
"
|
|
|
|
|
|
|
| 251 |
}
|
|
|
|
|
|
|
|
|
|
| 252 |
save_store(data)
|
| 253 |
-
return jsonify({"message": "
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
@app.route("/api/login", methods=["POST"])
|
| 257 |
-
def api_login() -> Any:
|
| 258 |
-
payload = request.get_json(force=True)
|
| 259 |
-
identifier = payload.get("identifier") or payload.get("email")
|
| 260 |
-
password = payload.get("password")
|
| 261 |
-
if not identifier or not password:
|
| 262 |
-
return jsonify({"error": "Podaj email/login i haslo."}), 400
|
| 263 |
-
|
| 264 |
-
login_key, _ = normalize_email(identifier)
|
| 265 |
-
|
| 266 |
-
if DATABASE_AVAILABLE:
|
| 267 |
-
row = fetch_one(
|
| 268 |
-
"SELECT id, password_hash FROM accounts WHERE login = %s",
|
| 269 |
-
(login_key,),
|
| 270 |
-
)
|
| 271 |
-
if not row or row["password_hash"] != hash_password(password):
|
| 272 |
-
return jsonify({"error": "Niepoprawne dane logowania."}), 401
|
| 273 |
-
token = uuid.uuid4().hex
|
| 274 |
-
SESSION_TOKENS[token] = {
|
| 275 |
-
"login_key": login_key,
|
| 276 |
-
"account_id": row["id"],
|
| 277 |
-
"expires_at": datetime.utcnow() + TOKEN_TTL,
|
| 278 |
-
}
|
| 279 |
-
return jsonify({"token": token, "login": login_key})
|
| 280 |
-
|
| 281 |
-
data = load_store()
|
| 282 |
-
accounts = data.get("accounts") or {}
|
| 283 |
-
login_key, account = find_account_identifier(accounts, login_key)
|
| 284 |
-
if not account or account.get("password_hash") != hash_password(password):
|
| 285 |
-
return jsonify({"error": "Niepoprawne dane logowania."}), 401
|
| 286 |
-
token = uuid.uuid4().hex
|
| 287 |
-
SESSION_TOKENS[token] = {
|
| 288 |
-
"login_key": login_key,
|
| 289 |
-
"expires_at": datetime.utcnow() + TOKEN_TTL,
|
| 290 |
-
}
|
| 291 |
-
return jsonify({"token": token, "login": account.get("login", login_key)})
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
@app.route("/api/logout", methods=["POST"])
|
| 295 |
-
def api_logout() -> Any:
|
| 296 |
-
token = request.headers.get("Authorization", "").replace("Bearer ", "")
|
| 297 |
-
SESSION_TOKENS.pop(token, None)
|
| 298 |
-
return jsonify({"message": "Wylogowano."})
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
@app.route("/api/business", methods=["GET", "POST"])
|
| 302 |
-
def api_business() -> Any:
|
| 303 |
-
try:
|
| 304 |
-
login_key = require_auth()
|
| 305 |
-
except PermissionError:
|
| 306 |
-
return jsonify({"error": "Brak autoryzacji."}), 401
|
| 307 |
-
|
| 308 |
-
data = load_store()
|
| 309 |
-
account = data.get("accounts", {}).get(login_key)
|
| 310 |
-
account_row = None
|
| 311 |
-
if DATABASE_AVAILABLE:
|
| 312 |
-
try:
|
| 313 |
-
account_row = get_account_row(login_key)
|
| 314 |
-
except KeyError:
|
| 315 |
-
return jsonify({"error": "Nie znaleziono konta."}), 404
|
| 316 |
-
|
| 317 |
-
if request.method == "GET":
|
| 318 |
-
if DATABASE_AVAILABLE:
|
| 319 |
-
profile = fetch_one(
|
| 320 |
-
"""
|
| 321 |
-
SELECT company_name, owner_name, address_line, postal_code,
|
| 322 |
-
city, tax_id, bank_account
|
| 323 |
-
FROM business_profiles
|
| 324 |
-
WHERE account_id = %s
|
| 325 |
-
""",
|
| 326 |
-
(account_row["id"],),
|
| 327 |
-
)
|
| 328 |
-
return jsonify({"business": profile})
|
| 329 |
-
if not account:
|
| 330 |
-
return jsonify({"business": None})
|
| 331 |
-
return jsonify({"business": account.get("business")})
|
| 332 |
-
|
| 333 |
-
payload = request.get_json(force=True)
|
| 334 |
-
required_fields = [
|
| 335 |
-
"company_name",
|
| 336 |
-
"owner_name",
|
| 337 |
-
"address_line",
|
| 338 |
-
"postal_code",
|
| 339 |
-
"city",
|
| 340 |
-
"tax_id",
|
| 341 |
-
"bank_account",
|
| 342 |
-
]
|
| 343 |
-
for field in required_fields:
|
| 344 |
-
if not (payload.get(field) or "").strip():
|
| 345 |
-
return jsonify({"error": f"Pole {field} jest wymagane."}), 400
|
| 346 |
-
|
| 347 |
-
if DATABASE_AVAILABLE:
|
| 348 |
-
update_business(account_row["id"], payload)
|
| 349 |
-
return jsonify({"message": "Dane sprzedawcy zostaly zaktualizowane."})
|
| 350 |
-
|
| 351 |
-
if not account:
|
| 352 |
-
return jsonify({"error": "Nie znaleziono konta."}), 404
|
| 353 |
-
account["business"] = payload
|
| 354 |
-
save_store(data)
|
| 355 |
-
return jsonify({"message": "Dane sprzedawcy zostaly zaktualizowane."})
|
| 356 |
|
| 357 |
|
| 358 |
-
@app.route("/api/
|
| 359 |
-
def
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
return jsonify({"error": "Brak autoryzacji."}), 401
|
| 364 |
|
| 365 |
-
if not
|
| 366 |
-
return jsonify({"
|
| 367 |
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
except KeyError:
|
| 371 |
-
return jsonify({"error": "Nie znaleziono konta."}), 404
|
| 372 |
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
limit_value = max(1, min(25, limit_value))
|
| 380 |
-
clients = search_clients(account_row["id"], search_term, limit_value)
|
| 381 |
-
return jsonify({"clients": clients})
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
@app.route("/api/logo", methods=["GET", "POST", "DELETE"])
|
| 385 |
-
def api_logo() -> Any:
|
| 386 |
-
try:
|
| 387 |
-
login_key = require_auth()
|
| 388 |
-
except PermissionError:
|
| 389 |
-
return jsonify({"error": "Brak autoryzacji."}), 401
|
| 390 |
-
|
| 391 |
-
account_row = None
|
| 392 |
-
account = None
|
| 393 |
-
data = None
|
| 394 |
-
|
| 395 |
-
if DATABASE_AVAILABLE:
|
| 396 |
-
try:
|
| 397 |
-
account_row = get_account_row(login_key)
|
| 398 |
-
except KeyError:
|
| 399 |
-
return jsonify({"error": "Nie znaleziono konta."}), 404
|
| 400 |
-
else:
|
| 401 |
-
data = load_store()
|
| 402 |
-
try:
|
| 403 |
-
account = get_account(data, login_key)
|
| 404 |
-
except KeyError:
|
| 405 |
-
return jsonify({"error": "Nie znaleziono konta."}), 404
|
| 406 |
-
|
| 407 |
-
if request.method == "GET":
|
| 408 |
-
if DATABASE_AVAILABLE:
|
| 409 |
-
logo_row = fetch_business_logo(account_row["id"])
|
| 410 |
-
if not logo_row:
|
| 411 |
-
return jsonify({"logo": None})
|
| 412 |
-
mime_type = logo_row["mime_type"]
|
| 413 |
-
encoded = logo_row["data"]
|
| 414 |
-
data_url = f"data:{mime_type};base64,{encoded}" if mime_type and encoded else None
|
| 415 |
-
return jsonify(
|
| 416 |
-
{
|
| 417 |
-
"logo": {
|
| 418 |
-
"filename": None,
|
| 419 |
-
"mime_type": mime_type,
|
| 420 |
-
"data": encoded,
|
| 421 |
-
"data_url": data_url,
|
| 422 |
-
"uploaded_at": None,
|
| 423 |
-
}
|
| 424 |
-
}
|
| 425 |
-
)
|
| 426 |
-
logo = account.get("logo") if account else None
|
| 427 |
-
if not logo:
|
| 428 |
-
return jsonify({"logo": None})
|
| 429 |
-
encoded = logo.get("data")
|
| 430 |
-
mime_type = logo.get("mime_type")
|
| 431 |
-
data_url = None
|
| 432 |
-
if encoded and mime_type:
|
| 433 |
-
data_url = f"data:{mime_type};base64,{encoded}"
|
| 434 |
-
return jsonify(
|
| 435 |
-
{
|
| 436 |
-
"logo": {
|
| 437 |
-
"filename": logo.get("filename"),
|
| 438 |
-
"mime_type": mime_type,
|
| 439 |
-
"data": encoded,
|
| 440 |
-
"data_url": data_url,
|
| 441 |
-
"uploaded_at": logo.get("uploaded_at"),
|
| 442 |
-
}
|
| 443 |
-
}
|
| 444 |
-
)
|
| 445 |
-
|
| 446 |
-
if request.method == "DELETE":
|
| 447 |
-
if DATABASE_AVAILABLE:
|
| 448 |
-
update_business_logo(account_row["id"], None, None)
|
| 449 |
-
return jsonify({"message": "Logo zostalo usuniete."})
|
| 450 |
-
if not account or data is None:
|
| 451 |
-
return jsonify({"error": "Nie znaleziono konta."}), 404
|
| 452 |
-
account["logo"] = None
|
| 453 |
-
save_store(data)
|
| 454 |
-
return jsonify({"message": "Logo zostalo usuniete."})
|
| 455 |
-
|
| 456 |
-
payload = request.get_json(force=True)
|
| 457 |
-
raw_content = (payload.get("content") or payload.get("data") or "").strip()
|
| 458 |
-
if not raw_content:
|
| 459 |
-
return jsonify({"error": "Brak danych logo."}), 400
|
| 460 |
-
|
| 461 |
-
provided_mime = (payload.get("mime_type") or "").strip()
|
| 462 |
-
filename = sanitize_filename(payload.get("filename"))
|
| 463 |
-
|
| 464 |
-
if raw_content.startswith("data:"):
|
| 465 |
-
try:
|
| 466 |
-
header, encoded_content = raw_content.split(",", 1)
|
| 467 |
-
except ValueError:
|
| 468 |
-
return jsonify({"error": "Niepoprawny format danych logo."}), 400
|
| 469 |
-
header = header.strip()
|
| 470 |
-
if ";base64" not in header:
|
| 471 |
-
return jsonify({"error": "Niepoprawny format danych logo (oczekiwano base64)."}), 400
|
| 472 |
-
mime_type = header.split(";")[0].replace("data:", "", 1) or provided_mime
|
| 473 |
-
base64_content = encoded_content.strip()
|
| 474 |
-
else:
|
| 475 |
-
mime_type = provided_mime
|
| 476 |
-
base64_content = raw_content
|
| 477 |
-
|
| 478 |
-
mime_type = (mime_type or "").lower()
|
| 479 |
-
if mime_type not in ALLOWED_LOGO_MIME_TYPES:
|
| 480 |
-
return jsonify({"error": "Dozwolone formaty logo to PNG lub JPG."}), 400
|
| 481 |
-
|
| 482 |
-
try:
|
| 483 |
-
logo_bytes = base64.b64decode(base64_content, validate=True)
|
| 484 |
-
except (ValueError, binascii.Error):
|
| 485 |
-
return jsonify({"error": "Nie udalo sie odczytac danych logo (base64)."}), 400
|
| 486 |
-
|
| 487 |
-
if len(logo_bytes) > MAX_LOGO_SIZE:
|
| 488 |
-
return jsonify({"error": f"Logo jest zbyt duze (maksymalnie {MAX_LOGO_SIZE // 1024} KB)."}), 400
|
| 489 |
-
|
| 490 |
-
encoded_logo = base64.b64encode(logo_bytes).decode("ascii")
|
| 491 |
-
stored_logo = {
|
| 492 |
-
"filename": filename,
|
| 493 |
-
"mime_type": mime_type,
|
| 494 |
-
"data": encoded_logo,
|
| 495 |
-
"uploaded_at": datetime.utcnow().isoformat(timespec="seconds"),
|
| 496 |
-
}
|
| 497 |
-
|
| 498 |
-
if DATABASE_AVAILABLE:
|
| 499 |
-
update_business_logo(account_row["id"], stored_logo["mime_type"], stored_logo["data"])
|
| 500 |
-
return jsonify({"logo": stored_logo})
|
| 501 |
-
|
| 502 |
-
account["logo"] = stored_logo
|
| 503 |
-
save_store(data)
|
| 504 |
-
return jsonify({"logo": stored_logo})
|
| 505 |
-
|
| 506 |
-
def normalize_phone(phone: Optional[str]) -> Optional[str]:
|
| 507 |
-
if not phone:
|
| 508 |
-
return None
|
| 509 |
-
digits = re.sub(r"[^\d+]", "", phone)
|
| 510 |
-
return digits or None
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
def validate_client(payload: Dict[str, Any]) -> Dict[str, str]:
|
| 514 |
-
client_payload = payload.get("client") or {}
|
| 515 |
-
client = {
|
| 516 |
-
"name": (client_payload.get("name") or payload.get("clientName") or "").strip(),
|
| 517 |
-
"tax_id": (client_payload.get("tax_id") or payload.get("clientTaxId") or "").strip(),
|
| 518 |
-
"address_line": (client_payload.get("address_line") or payload.get("clientAddress") or "").strip(),
|
| 519 |
-
"postal_code": (client_payload.get("postal_code") or payload.get("clientPostalCode") or "").strip(),
|
| 520 |
-
"city": (client_payload.get("city") or payload.get("clientCity") or "").strip(),
|
| 521 |
-
"phone": normalize_phone(client_payload.get("phone") or payload.get("clientPhone")),
|
| 522 |
-
}
|
| 523 |
-
return client
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
def build_invoice(payload: Dict[str, Any], business: Dict[str, Any], client: Dict[str, str]) -> Dict[str, Any]:
|
| 527 |
-
now = datetime.now()
|
| 528 |
-
invoice_id = f"FV-{now.strftime('%Y%m%d-%H%M%S')}"
|
| 529 |
-
issued_at = now.strftime("%Y-%m-%d %H:%M")
|
| 530 |
-
sale_date = payload.get("sale_date") or payload.get("saleDate") or date.today().isoformat()
|
| 531 |
-
payment_term = int(payload.get("payment_term") or payload.get("paymentTerm") or 14)
|
| 532 |
-
items = payload.get("items") or []
|
| 533 |
-
|
| 534 |
-
normalized_items: List[Dict[str, Any]] = []
|
| 535 |
-
for item in items:
|
| 536 |
-
name = (item.get("name") or "").strip()
|
| 537 |
-
if not name:
|
| 538 |
-
raise ValueError("Nazwa pozycji nie moze byc pusta.")
|
| 539 |
-
quantity = _quantize(_decimal(item.get("quantity") or "0"))
|
| 540 |
-
if quantity <= Decimal("0"):
|
| 541 |
-
raise ValueError("Ilosc musi byc dodatnia.")
|
| 542 |
-
unit = item.get("unit") or DEFAULT_UNIT
|
| 543 |
-
vat_code = str(item.get("vat_code") or item.get("vat") or item.get("vatCode") or "23")
|
| 544 |
-
if vat_code not in VAT_RATES:
|
| 545 |
-
raise ValueError("Niepoprawna stawka VAT.")
|
| 546 |
-
unit_price_raw = item.get("unit_price_gross")
|
| 547 |
-
if unit_price_raw in (None, ""):
|
| 548 |
-
unit_price_raw = item.get("unitPrice") or item.get("unit_price") or item.get("price")
|
| 549 |
-
unit_price_gross = _quantize(_decimal(unit_price_raw or "0"))
|
| 550 |
-
if unit_price_gross <= Decimal("0"):
|
| 551 |
-
raise ValueError("Cena musi byc dodatnia.")
|
| 552 |
-
vat_rate = VAT_RATES[vat_code]
|
| 553 |
-
if vat_rate is None:
|
| 554 |
-
unit_price_net = unit_price_gross
|
| 555 |
-
vat_amount = Decimal("0.00")
|
| 556 |
-
else:
|
| 557 |
-
unit_price_net = _quantize(unit_price_gross / (Decimal("1.0") + vat_rate))
|
| 558 |
-
vat_amount = _quantize(unit_price_gross - unit_price_net)
|
| 559 |
-
net_total = _quantize(unit_price_net * quantity)
|
| 560 |
-
vat_total = _quantize(vat_amount * quantity)
|
| 561 |
-
gross_total = _quantize(unit_price_gross * quantity)
|
| 562 |
-
normalized_items.append(
|
| 563 |
-
{
|
| 564 |
-
"name": name,
|
| 565 |
-
"quantity": str(quantity),
|
| 566 |
-
"unit": unit,
|
| 567 |
-
"vat_code": vat_code,
|
| 568 |
-
"vat_label": item.get("vatLabel") or vat_code,
|
| 569 |
-
"unit_price_net": str(unit_price_net),
|
| 570 |
-
"unit_price_gross": str(unit_price_gross),
|
| 571 |
-
"net_total": str(net_total),
|
| 572 |
-
"vat_amount": str(vat_amount),
|
| 573 |
-
"gross_total": str(gross_total),
|
| 574 |
-
}
|
| 575 |
-
)
|
| 576 |
-
|
| 577 |
-
totals = {"net": Decimal("0"), "vat": Decimal("0"), "gross": Decimal("0")}
|
| 578 |
-
summary: Dict[str, Dict[str, Decimal]] = {}
|
| 579 |
-
for item in normalized_items:
|
| 580 |
-
totals["net"] += Decimal(item["net_total"])
|
| 581 |
-
totals["vat"] += Decimal(item["vat_amount"])
|
| 582 |
-
totals["gross"] += Decimal(item["gross_total"])
|
| 583 |
-
label = item["vat_label"]
|
| 584 |
-
summary.setdefault(label, {"net_total": Decimal("0"), "vat_total": Decimal("0"), "gross_total": Decimal("0")})
|
| 585 |
-
summary[label]["net_total"] += Decimal(item["net_total"])
|
| 586 |
-
summary[label]["vat_total"] += Decimal(item["vat_amount"])
|
| 587 |
-
summary[label]["gross_total"] += Decimal(item["gross_total"])
|
| 588 |
-
|
| 589 |
-
totals = {key: str(_quantize(value)) for key, value in totals.items()}
|
| 590 |
-
summary_list = [
|
| 591 |
-
{
|
| 592 |
-
"vat_label": label,
|
| 593 |
-
"net_total": str(_quantize(values["net_total"])),
|
| 594 |
-
"vat_total": str(_quantize(values["vat_total"])),
|
| 595 |
-
"gross_total": str(_quantize(values["gross_total"])),
|
| 596 |
-
}
|
| 597 |
-
for label, values in summary.items()
|
| 598 |
-
]
|
| 599 |
-
|
| 600 |
-
exemption_note = (payload.get("exemption_note") or payload.get("exemptionNote") or "").strip()
|
| 601 |
-
|
| 602 |
-
return {
|
| 603 |
-
"invoice_id": invoice_id,
|
| 604 |
-
"issued_at": issued_at,
|
| 605 |
-
"sale_date": sale_date,
|
| 606 |
-
"payment_term": payment_term,
|
| 607 |
-
"items": normalized_items,
|
| 608 |
-
"summary": summary_list,
|
| 609 |
-
"totals": totals,
|
| 610 |
-
"client": client,
|
| 611 |
-
"business": business,
|
| 612 |
-
"exemption_note": exemption_note,
|
| 613 |
-
}
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
@app.route("/api/invoices", methods=["GET", "POST"])
|
| 617 |
-
def api_invoices() -> Any:
|
| 618 |
try:
|
| 619 |
-
|
| 620 |
except PermissionError:
|
| 621 |
return jsonify({"error": "Brak autoryzacji."}), 401
|
| 622 |
|
|
|
|
| 623 |
if request.method == "GET":
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
account_row = get_account_row(login_key)
|
| 627 |
-
except KeyError:
|
| 628 |
-
return jsonify({"error": "Nie znaleziono konta."}), 404
|
| 629 |
-
invoice_rows = fetch_all(
|
| 630 |
-
"""
|
| 631 |
-
SELECT i.id,
|
| 632 |
-
i.invoice_number,
|
| 633 |
-
i.issued_at,
|
| 634 |
-
i.sale_date,
|
| 635 |
-
i.payment_term_days,
|
| 636 |
-
i.exemption_note,
|
| 637 |
-
i.total_net,
|
| 638 |
-
i.total_vat,
|
| 639 |
-
i.total_gross,
|
| 640 |
-
c.name AS client_name,
|
| 641 |
-
c.address_line AS client_address,
|
| 642 |
-
c.postal_code AS client_postal_code,
|
| 643 |
-
c.city AS client_city,
|
| 644 |
-
c.tax_id AS client_tax_id,
|
| 645 |
-
c.phone AS client_phone
|
| 646 |
-
FROM invoices AS i
|
| 647 |
-
LEFT JOIN clients AS c ON c.id = i.client_id
|
| 648 |
-
WHERE i.account_id = %s
|
| 649 |
-
ORDER BY i.issued_at DESC
|
| 650 |
-
LIMIT %s
|
| 651 |
-
""",
|
| 652 |
-
(account_row["id"], INVOICE_HISTORY_LIMIT),
|
| 653 |
-
)
|
| 654 |
-
if not invoice_rows:
|
| 655 |
-
return jsonify({"invoices": []})
|
| 656 |
-
|
| 657 |
-
invoice_ids = [row["id"] for row in invoice_rows]
|
| 658 |
-
items_map: Dict[int, List[Dict[str, Any]]] = {row_id: [] for row_id in invoice_ids}
|
| 659 |
-
summary_map: Dict[int, List[Dict[str, str]]] = {row_id: [] for row_id in invoice_ids}
|
| 660 |
-
|
| 661 |
-
if invoice_ids:
|
| 662 |
-
item_rows = fetch_all(
|
| 663 |
-
"""
|
| 664 |
-
SELECT invoice_id, line_no, name, quantity, unit,
|
| 665 |
-
vat_code, vat_label, unit_price_net,
|
| 666 |
-
unit_price_gross, net_total, vat_amount, gross_total
|
| 667 |
-
FROM invoice_items
|
| 668 |
-
WHERE invoice_id = ANY(%s)
|
| 669 |
-
ORDER BY line_no
|
| 670 |
-
""",
|
| 671 |
-
(invoice_ids,),
|
| 672 |
-
)
|
| 673 |
-
for item in item_rows:
|
| 674 |
-
items_map.setdefault(item["invoice_id"], []).append(
|
| 675 |
-
{
|
| 676 |
-
"name": item["name"],
|
| 677 |
-
"quantity": _format_decimal_str(item.get("quantity"), "0.00"),
|
| 678 |
-
"unit": item.get("unit") or DEFAULT_UNIT,
|
| 679 |
-
"vat_code": item.get("vat_code"),
|
| 680 |
-
"vat_label": item.get("vat_label") or item.get("vat_code"),
|
| 681 |
-
"unit_price_net": _format_decimal_str(item.get("unit_price_net")),
|
| 682 |
-
"unit_price_gross": _format_decimal_str(item.get("unit_price_gross")),
|
| 683 |
-
"net_total": _format_decimal_str(item.get("net_total")),
|
| 684 |
-
"vat_amount": _format_decimal_str(item.get("vat_amount")),
|
| 685 |
-
"gross_total": _format_decimal_str(item.get("gross_total")),
|
| 686 |
-
}
|
| 687 |
-
)
|
| 688 |
-
|
| 689 |
-
summary_rows = fetch_all(
|
| 690 |
-
"""
|
| 691 |
-
SELECT invoice_id, vat_label, net_total, vat_total, gross_total
|
| 692 |
-
FROM invoice_vat_summary
|
| 693 |
-
WHERE invoice_id = ANY(%s)
|
| 694 |
-
ORDER BY vat_label
|
| 695 |
-
""",
|
| 696 |
-
(invoice_ids,),
|
| 697 |
-
)
|
| 698 |
-
for entry in summary_rows:
|
| 699 |
-
summary_map.setdefault(entry["invoice_id"], []).append(
|
| 700 |
-
{
|
| 701 |
-
"vat_label": entry.get("vat_label"),
|
| 702 |
-
"net_total": _format_decimal_str(entry.get("net_total")),
|
| 703 |
-
"vat_total": _format_decimal_str(entry.get("vat_total")),
|
| 704 |
-
"gross_total": _format_decimal_str(entry.get("gross_total")),
|
| 705 |
-
}
|
| 706 |
-
)
|
| 707 |
-
|
| 708 |
-
business_profile = get_business_profile(account_row["id"])
|
| 709 |
-
invoices: List[Dict[str, Any]] = []
|
| 710 |
-
for row in invoice_rows:
|
| 711 |
-
issued_at_value = row.get("issued_at")
|
| 712 |
-
sale_date_value = row.get("sale_date")
|
| 713 |
-
if isinstance(issued_at_value, datetime):
|
| 714 |
-
issued_at = issued_at_value.strftime("%Y-%m-%d %H:%M")
|
| 715 |
-
else:
|
| 716 |
-
issued_at = issued_at_value
|
| 717 |
-
if hasattr(sale_date_value, "isoformat"):
|
| 718 |
-
sale_date = sale_date_value.isoformat()
|
| 719 |
-
else:
|
| 720 |
-
sale_date = sale_date_value
|
| 721 |
-
client = None
|
| 722 |
-
if row.get("client_name"):
|
| 723 |
-
client = {
|
| 724 |
-
"name": row.get("client_name"),
|
| 725 |
-
"address_line": row.get("client_address"),
|
| 726 |
-
"postal_code": row.get("client_postal_code"),
|
| 727 |
-
"city": row.get("client_city"),
|
| 728 |
-
"tax_id": row.get("client_tax_id"),
|
| 729 |
-
"phone": row.get("client_phone"),
|
| 730 |
-
}
|
| 731 |
-
invoices.append(
|
| 732 |
-
{
|
| 733 |
-
"invoice_id": row.get("invoice_number"),
|
| 734 |
-
"issued_at": issued_at,
|
| 735 |
-
"sale_date": sale_date,
|
| 736 |
-
"payment_term": row.get("payment_term_days"),
|
| 737 |
-
"exemption_note": row.get("exemption_note"),
|
| 738 |
-
"items": items_map.get(row["id"], []),
|
| 739 |
-
"summary": summary_map.get(row["id"], []),
|
| 740 |
-
"totals": {
|
| 741 |
-
"net": _format_decimal_str(row.get("total_net")),
|
| 742 |
-
"vat": _format_decimal_str(row.get("total_vat")),
|
| 743 |
-
"gross": _format_decimal_str(row.get("total_gross")),
|
| 744 |
-
},
|
| 745 |
-
"client": client,
|
| 746 |
-
"business": business_profile,
|
| 747 |
-
}
|
| 748 |
-
)
|
| 749 |
-
return jsonify({"invoices": invoices})
|
| 750 |
-
|
| 751 |
-
data = load_store()
|
| 752 |
-
try:
|
| 753 |
-
account = get_account(data, login_key)
|
| 754 |
-
except KeyError:
|
| 755 |
-
return jsonify({"error": "Nie znaleziono konta."}), 404
|
| 756 |
-
invoices = account.get("invoices", [])[:INVOICE_HISTORY_LIMIT]
|
| 757 |
-
return jsonify({"invoices": invoices})
|
| 758 |
|
| 759 |
payload = request.get_json(force=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 760 |
|
| 761 |
-
if
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
except KeyError:
|
| 765 |
-
return jsonify({"error": "Nie znaleziono konta."}), 404
|
| 766 |
-
business = get_business_profile(account_row["id"])
|
| 767 |
-
if not business:
|
| 768 |
-
return jsonify({"error": "Ustaw dane sprzedawcy przed dodaniem faktury."}), 400
|
| 769 |
-
|
| 770 |
-
client = validate_client(payload)
|
| 771 |
-
try:
|
| 772 |
-
invoice = build_invoice(payload, business, client)
|
| 773 |
-
except ValueError as error:
|
| 774 |
-
return jsonify({"error": str(error)}), 400
|
| 775 |
-
|
| 776 |
-
client_id = upsert_client(
|
| 777 |
-
account_row["id"],
|
| 778 |
-
{
|
| 779 |
-
"name": client["name"],
|
| 780 |
-
"address_line": client["address_line"],
|
| 781 |
-
"postal_code": client["postal_code"],
|
| 782 |
-
"city": client["city"],
|
| 783 |
-
"tax_id": client["tax_id"],
|
| 784 |
-
"phone": client.get("phone"),
|
| 785 |
-
},
|
| 786 |
-
)
|
| 787 |
-
insert_invoice(account_row["id"], client_id, invoice)
|
| 788 |
-
return jsonify({"message": "Faktura zostala zapisana.", "invoice": invoice})
|
| 789 |
-
|
| 790 |
-
data = load_store()
|
| 791 |
-
try:
|
| 792 |
-
account = get_account(data, login_key)
|
| 793 |
-
except KeyError:
|
| 794 |
-
return jsonify({"error": "Nie znaleziono konta."}), 404
|
| 795 |
-
|
| 796 |
-
business = account.get("business")
|
| 797 |
-
if not business:
|
| 798 |
-
return jsonify({"error": "Ustaw dane sprzedawcy przed dodaniem faktury."}), 400
|
| 799 |
-
|
| 800 |
-
client = validate_client(payload)
|
| 801 |
-
try:
|
| 802 |
-
invoice = build_invoice(payload, business, client)
|
| 803 |
-
except ValueError as error:
|
| 804 |
-
return jsonify({"error": str(error)}), 400
|
| 805 |
|
| 806 |
-
|
| 807 |
-
invoices.insert(0, invoice)
|
| 808 |
-
account["invoices"] = invoices[:INVOICE_HISTORY_LIMIT]
|
| 809 |
save_store(data)
|
| 810 |
-
return jsonify({"
|
| 811 |
|
| 812 |
|
| 813 |
-
@app.route("/api/invoices
|
| 814 |
-
def
|
| 815 |
try:
|
| 816 |
-
|
| 817 |
except PermissionError:
|
| 818 |
return jsonify({"error": "Brak autoryzacji."}), 401
|
| 819 |
|
| 820 |
-
if DATABASE_AVAILABLE:
|
| 821 |
-
try:
|
| 822 |
-
account_row = get_account_row(login_key)
|
| 823 |
-
except KeyError:
|
| 824 |
-
return jsonify({"error": "Nie znaleziono konta."}), 404
|
| 825 |
-
|
| 826 |
-
invoice_row = fetch_one(
|
| 827 |
-
"""
|
| 828 |
-
SELECT id, issued_at
|
| 829 |
-
FROM invoices
|
| 830 |
-
WHERE account_id = %s AND invoice_number = %s
|
| 831 |
-
""",
|
| 832 |
-
(account_row["id"], invoice_id),
|
| 833 |
-
)
|
| 834 |
-
if not invoice_row:
|
| 835 |
-
return jsonify({"error": "Nie znaleziono faktury."}), 404
|
| 836 |
-
|
| 837 |
-
if request.method == "DELETE":
|
| 838 |
-
execute("DELETE FROM invoice_items WHERE invoice_id = %s", (invoice_row["id"],))
|
| 839 |
-
execute("DELETE FROM invoice_vat_summary WHERE invoice_id = %s", (invoice_row["id"],))
|
| 840 |
-
execute("DELETE FROM invoices WHERE id = %s", (invoice_row["id"],))
|
| 841 |
-
return jsonify({"message": "Faktura zostala usunieta."})
|
| 842 |
-
|
| 843 |
-
payload = request.get_json(force=True)
|
| 844 |
-
business = get_business_profile(account_row["id"])
|
| 845 |
-
if not business:
|
| 846 |
-
return jsonify({"error": "Ustaw dane sprzedawcy przed dodaniem faktury."}), 400
|
| 847 |
-
|
| 848 |
-
client = validate_client(payload)
|
| 849 |
-
try:
|
| 850 |
-
invoice = build_invoice(payload, business, client)
|
| 851 |
-
except ValueError as error:
|
| 852 |
-
return jsonify({"error": str(error)}), 400
|
| 853 |
-
|
| 854 |
-
invoice["invoice_id"] = invoice_id
|
| 855 |
-
existing_issued_at = invoice_row.get("issued_at")
|
| 856 |
-
if isinstance(existing_issued_at, datetime):
|
| 857 |
-
invoice["issued_at"] = existing_issued_at.strftime("%Y-%m-%d %H:%M")
|
| 858 |
-
elif existing_issued_at:
|
| 859 |
-
invoice["issued_at"] = str(existing_issued_at)
|
| 860 |
-
|
| 861 |
-
client_id = upsert_client(
|
| 862 |
-
account_row["id"],
|
| 863 |
-
{
|
| 864 |
-
"name": client["name"],
|
| 865 |
-
"address_line": client["address_line"],
|
| 866 |
-
"postal_code": client["postal_code"],
|
| 867 |
-
"city": client["city"],
|
| 868 |
-
"tax_id": client["tax_id"],
|
| 869 |
-
"phone": client.get("phone"),
|
| 870 |
-
},
|
| 871 |
-
)
|
| 872 |
-
|
| 873 |
-
execute("DELETE FROM invoice_items WHERE invoice_id = %s", (invoice_row["id"],))
|
| 874 |
-
execute("DELETE FROM invoice_vat_summary WHERE invoice_id = %s", (invoice_row["id"],))
|
| 875 |
-
execute("DELETE FROM invoices WHERE id = %s", (invoice_row["id"],))
|
| 876 |
-
insert_invoice(account_row["id"], client_id, invoice)
|
| 877 |
-
return jsonify({"message": "Faktura zostala zaktualizowana.", "invoice": invoice})
|
| 878 |
-
|
| 879 |
data = load_store()
|
| 880 |
-
|
| 881 |
-
account = get_account(data, login_key)
|
| 882 |
-
except KeyError:
|
| 883 |
-
return jsonify({"error": "Nie znaleziono konta."}), 404
|
| 884 |
-
|
| 885 |
-
invoices = account.get("invoices", [])
|
| 886 |
-
invoice_index = next(
|
| 887 |
-
(idx for idx, entry in enumerate(invoices) if entry.get("invoice_id") == invoice_id),
|
| 888 |
-
None,
|
| 889 |
-
)
|
| 890 |
-
if invoice_index is None:
|
| 891 |
-
return jsonify({"error": "Nie znaleziono faktury."}), 404
|
| 892 |
-
|
| 893 |
-
if request.method == "DELETE":
|
| 894 |
-
invoices.pop(invoice_index)
|
| 895 |
-
save_store(data)
|
| 896 |
-
return jsonify({"message": "Faktura zostala usunieta."})
|
| 897 |
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
if not business:
|
| 901 |
-
return jsonify({"error": "Ustaw dane sprzedawcy przed dodaniem faktury."}), 400
|
| 902 |
|
| 903 |
-
|
| 904 |
try:
|
| 905 |
-
invoice =
|
| 906 |
except ValueError as error:
|
| 907 |
return jsonify({"error": str(error)}), 400
|
| 908 |
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
if
|
| 912 |
-
|
| 913 |
-
invoices[invoice_index] = invoice
|
| 914 |
-
save_store(data)
|
| 915 |
-
return jsonify({"message": "Faktura zostala zaktualizowana.", "invoice": invoice})
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
@app.route("/api/invoices/summary", methods=["GET"])
|
| 919 |
-
def api_invoice_summary() -> Any:
|
| 920 |
-
try:
|
| 921 |
-
login_key = require_auth()
|
| 922 |
-
except PermissionError:
|
| 923 |
-
return jsonify({"error": "Brak autoryzacji."}), 401
|
| 924 |
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
quarter_first_month = ((now.month - 1) // 3) * 3 + 1
|
| 928 |
-
quarter_start = now.replace(month=quarter_first_month, day=1, hour=0, minute=0, second=0, microsecond=0)
|
| 929 |
-
year_start = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
| 930 |
-
|
| 931 |
-
def normalize_issued_at(value: Any) -> Optional[datetime]:
|
| 932 |
-
if isinstance(value, datetime):
|
| 933 |
-
tzinfo = value.tzinfo
|
| 934 |
-
if tzinfo is not None and tzinfo.utcoffset(value) is not None:
|
| 935 |
-
return value.astimezone(timezone.utc).replace(tzinfo=None)
|
| 936 |
-
return value
|
| 937 |
-
if isinstance(value, str):
|
| 938 |
-
candidate = value.strip()
|
| 939 |
-
if not candidate:
|
| 940 |
-
return None
|
| 941 |
-
for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
|
| 942 |
-
try:
|
| 943 |
-
return datetime.strptime(candidate, fmt)
|
| 944 |
-
except ValueError:
|
| 945 |
-
continue
|
| 946 |
-
return None
|
| 947 |
|
| 948 |
-
def aggregate_from_rows(rows: List[Dict[str, Any]], start: datetime) -> Dict[str, Any]:
|
| 949 |
-
count = 0
|
| 950 |
-
gross_total = Decimal("0.00")
|
| 951 |
-
for row in rows:
|
| 952 |
-
issued_dt = normalize_issued_at(row.get("issued_at"))
|
| 953 |
-
if issued_dt is None or issued_dt < start:
|
| 954 |
-
continue
|
| 955 |
-
try:
|
| 956 |
-
gross_total += _decimal(row.get("total_gross") or "0")
|
| 957 |
-
except ValueError:
|
| 958 |
-
continue
|
| 959 |
-
count += 1
|
| 960 |
-
return {"count": count, "gross_total": str(_quantize(gross_total))}
|
| 961 |
-
|
| 962 |
-
if DATABASE_AVAILABLE:
|
| 963 |
-
try:
|
| 964 |
-
account_row = get_account_row(login_key)
|
| 965 |
-
except KeyError:
|
| 966 |
-
return jsonify({"error": "Nie znaleziono konta."}), 404
|
| 967 |
-
rows = fetch_all(
|
| 968 |
-
"""
|
| 969 |
-
SELECT issued_at, total_gross
|
| 970 |
-
FROM invoices
|
| 971 |
-
WHERE account_id = %s
|
| 972 |
-
""",
|
| 973 |
-
(account_row["id"],),
|
| 974 |
-
)
|
| 975 |
-
summary = {
|
| 976 |
-
"last_month": aggregate_from_rows(rows, last_month_start),
|
| 977 |
-
"quarter": aggregate_from_rows(rows, quarter_start),
|
| 978 |
-
"year": aggregate_from_rows(rows, year_start),
|
| 979 |
-
}
|
| 980 |
-
return jsonify({"summary": summary})
|
| 981 |
|
| 982 |
-
|
| 983 |
-
|
| 984 |
-
|
| 985 |
-
except KeyError:
|
| 986 |
-
return jsonify({"error": "Nie znaleziono konta."}), 404
|
| 987 |
-
invoices = account.get("invoices", [])
|
| 988 |
-
rows = [
|
| 989 |
-
{
|
| 990 |
-
"issued_at": invoice.get("issued_at"),
|
| 991 |
-
"total_gross": (invoice.get("totals") or {}).get("gross", "0"),
|
| 992 |
-
}
|
| 993 |
-
for invoice in invoices
|
| 994 |
-
]
|
| 995 |
|
| 996 |
-
|
| 997 |
-
|
| 998 |
-
"quarter": aggregate_from_rows(rows, quarter_start),
|
| 999 |
-
"year": aggregate_from_rows(rows, year_start),
|
| 1000 |
-
}
|
| 1001 |
-
return jsonify({"summary": summary})
|
| 1002 |
-
|
| 1003 |
-
|
| 1004 |
-
if __name__ == "__main__":
|
| 1005 |
-
port = int(os.environ.get("PORT", "5000"))
|
| 1006 |
-
app.run(host="0.0.0.0", port=port, debug=True)
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
from flask import Flask, render_template, request, redirect, url_for
|
| 5 |
+
from sqlalchemy import create_engine, text
|
| 6 |
+
|
| 7 |
+
from flask import render_template
|
| 8 |
+
|
| 9 |
+
app = Flask(__name__)
|
| 10 |
+
engine = create_engine(os.environ["DATABASE_URL"], pool_pre_ping=True)
|
| 11 |
+
|
| 12 |
+
import uuid
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
from decimal import Decimal, ROUND_HALF_UP, getcontext
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 17 |
+
|
| 18 |
+
from flask import Flask, jsonify, request, send_from_directory
|
| 19 |
+
|
| 20 |
+
APP_ROOT = Path(__file__).parent.resolve()
|
| 21 |
+
DATA_DIR = Path(os.environ.get("DATA_DIR", APP_ROOT))
|
| 22 |
+
DATA_FILE = DATA_DIR / "web_invoice_store.json"
|
| 23 |
+
INVOICE_HISTORY_LIMIT = 200
|
| 24 |
+
|
| 25 |
+
VAT_RATES: Dict[str, Optional[Decimal]] = {
|
| 26 |
+
"23": Decimal("0.23"),
|
| 27 |
+
"8": Decimal("0.08"),
|
| 28 |
+
"5": Decimal("0.05"),
|
| 29 |
+
"0": Decimal("0.00"),
|
| 30 |
+
"ZW": None,
|
| 31 |
+
"NP": None,
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
SESSION_TOKENS: Dict[str, datetime] = {}
|
| 35 |
+
|
| 36 |
+
ALLOWED_STATIC = {
|
| 37 |
+
"index.html",
|
| 38 |
+
"styles.css",
|
| 39 |
+
"main.js",
|
| 40 |
+
"favicon.ico",
|
| 41 |
+
"Roboto-VariableFont_wdth,wght.ttf",
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
@app.route("/")
|
| 45 |
+
def index():
|
| 46 |
+
# Pobierz ostatnie notatki i pokaż w HTML
|
| 47 |
+
with engine.begin() as conn:
|
| 48 |
+
rows = conn.execute(text(
|
| 49 |
+
"SELECT id, body, created_at FROM notes ORDER BY id DESC LIMIT 20"
|
| 50 |
+
)).mappings().all()
|
| 51 |
+
return render_template("index.html", notes=rows)
|
| 52 |
+
|
| 53 |
+
if __name__ == "__main__": # Sprawdzamy, czy uruchamiamy główny skrypt
|
| 54 |
+
port = int(os.environ.get("PORT", "7860")) # Wcięcie pod warunkiem
|
| 55 |
+
app.run(host="0.0.0.0", port=port, debug=False) # Wcięcie pod warunkiem
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@app.post("/add")
|
| 59 |
+
def add():
|
| 60 |
+
body = request.form.get("body","").strip()
|
| 61 |
+
if body:
|
| 62 |
+
with engine.begin() as conn:
|
| 63 |
+
conn.execute(text("INSERT INTO notes (body) VALUES (:b)"), {"b": body})
|
| 64 |
+
return redirect(url_for("index"))
|
| 65 |
+
|
| 66 |
+
getcontext().prec = 10
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _quantize(value: Decimal) -> Decimal:
|
| 70 |
+
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
def _decimal(value: Any) -> Decimal:
|
| 74 |
try:
|
| 75 |
return Decimal(str(value))
|
|
|
|
| 77 |
raise ValueError(f"Niepoprawna wartosc liczby: {value}") from error
|
| 78 |
|
| 79 |
|
| 80 |
+
def hash_password(password: str) -> str:
|
| 81 |
+
return hashlib.sha256(password.encode("utf-8")).hexdigest()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def load_store() -> Dict[str, Any]:
|
| 85 |
+
if not DATA_FILE.exists():
|
| 86 |
+
return {"business": None, "password_hash": None, "invoices": []}
|
| 87 |
+
with DATA_FILE.open("r", encoding="utf-8") as handle:
|
| 88 |
+
return json.load(handle)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def save_store(data: Dict[str, Any]) -> None:
|
| 92 |
+
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
|
| 93 |
+
with DATA_FILE.open("w", encoding="utf-8") as handle:
|
| 94 |
+
json.dump(data, handle, ensure_ascii=False, indent=2)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def ensure_configured(data: Dict[str, Any]) -> None:
|
| 98 |
+
if not data.get("business") or not data.get("password_hash"):
|
| 99 |
+
raise ValueError("Aplikacja nie zostala skonfigurowana.")
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def parse_iso_date(value: Optional[str]) -> Optional[str]:
|
| 103 |
+
if not value:
|
| 104 |
+
return None
|
| 105 |
try:
|
| 106 |
+
parsed = datetime.fromisoformat(value)
|
| 107 |
+
return parsed.strftime("%Y-%m-%d")
|
| 108 |
+
except ValueError:
|
| 109 |
+
return None
|
| 110 |
|
| 111 |
|
| 112 |
+
def compute_invoice_items(items_payload: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], Dict[str, Dict[str, Decimal]]]:
|
| 113 |
+
if not items_payload:
|
| 114 |
+
raise ValueError("Dodaj przynajmniej jedna pozycje.")
|
| 115 |
+
|
| 116 |
+
computed_items: List[Dict[str, Any]] = []
|
| 117 |
+
summary: Dict[str, Dict[str, Decimal]] = {}
|
| 118 |
+
|
| 119 |
+
for raw in items_payload:
|
| 120 |
+
name = (raw.get("name") or "").strip()
|
| 121 |
+
if not name:
|
| 122 |
+
raise ValueError("Kazda pozycja musi miec nazwe.")
|
| 123 |
+
|
| 124 |
+
quantity = _decimal(raw.get("quantity", "0"))
|
| 125 |
+
if quantity <= 0:
|
| 126 |
+
raise ValueError("Ilosc musi byc wieksza od zera.")
|
| 127 |
+
|
| 128 |
+
vat_code = str(raw.get("vat_code", "")).upper()
|
| 129 |
+
if vat_code not in VAT_RATES:
|
| 130 |
+
raise ValueError(f"Nieznana stawka VAT: {vat_code}")
|
| 131 |
+
|
| 132 |
+
unit_price_gross = _decimal(raw.get("unit_price_gross", "0"))
|
| 133 |
+
if unit_price_gross <= 0:
|
| 134 |
+
raise ValueError("Cena brutto musi byc wieksza od zera.")
|
| 135 |
+
|
| 136 |
+
rate = VAT_RATES[vat_code]
|
| 137 |
+
if rate is None:
|
| 138 |
+
unit_price_net = unit_price_gross
|
| 139 |
+
vat_amount = Decimal("0.00")
|
| 140 |
+
else:
|
| 141 |
+
unit_price_net = unit_price_gross / (Decimal("1.00") + rate)
|
| 142 |
+
vat_amount = unit_price_gross - unit_price_net
|
| 143 |
+
|
| 144 |
+
unit_price_net = _quantize(unit_price_net)
|
| 145 |
+
unit_price_gross = _quantize(unit_price_gross)
|
| 146 |
+
|
| 147 |
+
net_total = _quantize(unit_price_net * quantity)
|
| 148 |
+
vat_amount_total = _quantize(vat_amount * quantity if rate is not None else Decimal("0.00"))
|
| 149 |
+
gross_total = _quantize(unit_price_gross * quantity)
|
| 150 |
+
|
| 151 |
+
vat_label = "ZW" if vat_code in {"ZW", "0"} else ("NP" if vat_code == "NP" else f"{vat_code}%")
|
| 152 |
+
|
| 153 |
+
computed_items.append(
|
| 154 |
+
{
|
| 155 |
+
"name": name,
|
| 156 |
+
"quantity": str(_quantize(quantity)),
|
| 157 |
+
"vat_code": vat_code,
|
| 158 |
+
"vat_label": vat_label,
|
| 159 |
+
"unit_price_net": str(unit_price_net),
|
| 160 |
+
"unit_price_gross": str(unit_price_gross),
|
| 161 |
+
"net_total": str(net_total),
|
| 162 |
+
"vat_amount": str(vat_amount_total),
|
| 163 |
+
"gross_total": str(gross_total),
|
| 164 |
+
}
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
summary_key = vat_label
|
| 168 |
+
bucket = summary.setdefault(summary_key, {"net": Decimal("0.00"), "vat": Decimal("0.00"), "gross": Decimal("0.00")})
|
| 169 |
+
bucket["net"] += net_total
|
| 170 |
+
bucket["vat"] += vat_amount_total
|
| 171 |
+
bucket["gross"] += gross_total
|
| 172 |
+
|
| 173 |
+
return computed_items, summary
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def computed_summary_to_serializable(summary: Dict[str, Dict[str, Decimal]]) -> List[Dict[str, str]]:
|
| 177 |
+
serialized = []
|
| 178 |
+
for vat_label, values in summary.items():
|
| 179 |
+
serialized.append(
|
| 180 |
+
{
|
| 181 |
+
"vat_label": vat_label,
|
| 182 |
+
"net_total": str(_quantize(values["net"])),
|
| 183 |
+
"vat_total": str(_quantize(values["vat"])),
|
| 184 |
+
"gross_total": str(_quantize(values["gross"])),
|
| 185 |
+
}
|
| 186 |
+
)
|
| 187 |
+
serialized.sort(key=lambda item: item["vat_label"])
|
| 188 |
+
return serialized
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def compute_invoice(payload: Dict[str, Any], business: Dict[str, Any]) -> Dict[str, Any]:
|
| 192 |
+
items_payload = payload.get("items", [])
|
| 193 |
+
computed_items, summary = compute_invoice_items(items_payload)
|
| 194 |
+
|
| 195 |
+
net_sum = sum(Decimal(item["net_total"]) for item in computed_items)
|
| 196 |
+
vat_sum = sum(Decimal(item["vat_amount"]) for item in computed_items)
|
| 197 |
+
gross_sum = sum(Decimal(item["gross_total"]) for item in computed_items)
|
| 198 |
+
|
| 199 |
+
issued_at = datetime.now()
|
| 200 |
+
invoice_id = issued_at.strftime("FV-%Y%m%d-%H%M%S")
|
| 201 |
+
|
| 202 |
+
sale_date = parse_iso_date(payload.get("sale_date")) or issued_at.strftime("%Y-%m-%d")
|
| 203 |
+
client_payload = payload.get("client") or {}
|
| 204 |
+
client = {
|
| 205 |
+
"name": (client_payload.get("name") or "").strip(),
|
| 206 |
+
"address_line": (client_payload.get("address_line") or "").strip(),
|
| 207 |
+
"postal_code": (client_payload.get("postal_code") or "").strip(),
|
| 208 |
+
"city": (client_payload.get("city") or "").strip(),
|
| 209 |
+
"tax_id": (client_payload.get("tax_id") or "").strip(),
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
invoice = {
|
| 213 |
+
"invoice_id": invoice_id,
|
| 214 |
+
"issued_at": issued_at.strftime("%Y-%m-%d %H:%M"),
|
| 215 |
+
"sale_date": sale_date,
|
| 216 |
+
"items": computed_items,
|
| 217 |
+
"summary": computed_summary_to_serializable(summary),
|
| 218 |
+
"totals": {
|
| 219 |
+
"net": str(_quantize(net_sum)),
|
| 220 |
+
"vat": str(_quantize(vat_sum)),
|
| 221 |
+
"gross": str(_quantize(gross_sum)),
|
| 222 |
+
},
|
| 223 |
+
"client": client,
|
| 224 |
+
"exemption_note": (payload.get("exemption_note") or "").strip(),
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
return invoice
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def create_token() -> str:
|
| 231 |
+
token = uuid.uuid4().hex
|
| 232 |
+
SESSION_TOKENS[token] = datetime.now()
|
| 233 |
+
return token
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def get_token() -> Optional[str]:
|
| 237 |
+
header = request.headers.get("Authorization", "")
|
| 238 |
+
if not header.startswith("Bearer "):
|
| 239 |
+
return None
|
| 240 |
+
return header.split(" ", 1)[1].strip()
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def require_auth() -> str:
|
| 244 |
+
token = get_token()
|
| 245 |
+
if not token or token not in SESSION_TOKENS:
|
| 246 |
+
raise PermissionError("Brak autoryzacji.")
|
| 247 |
+
return token
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
@app.route("/<path:path>")
|
| 251 |
+
def serve_static(path: str) -> Any:
|
| 252 |
+
if path not in ALLOWED_STATIC:
|
| 253 |
+
return jsonify({"error": "Nie znaleziono pliku."}), 404
|
| 254 |
+
target = Path(app.static_folder) / path
|
| 255 |
+
if target.is_file():
|
| 256 |
+
return send_from_directory(app.static_folder, path)
|
| 257 |
+
return jsonify({"error": "Nie znaleziono pliku."}), 404
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
@app.route("/api/status", methods=["GET"])
|
| 261 |
+
def api_status() -> Any:
|
| 262 |
+
data = load_store()
|
| 263 |
+
configured = bool(data.get("business") and data.get("password_hash"))
|
| 264 |
+
return jsonify({"configured": configured})
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
@app.route("/api/setup", methods=["POST"])
|
| 268 |
+
def api_setup() -> Any:
|
| 269 |
+
data = load_store()
|
| 270 |
+
if data.get("password_hash"):
|
| 271 |
+
return jsonify({"error": "Aplikacja zostala juz skonfigurowana."}), 400
|
| 272 |
+
|
| 273 |
payload = request.get_json(force=True)
|
| 274 |
+
required_fields = [
|
|
|
|
|
|
|
|
|
|
| 275 |
"company_name",
|
| 276 |
"owner_name",
|
| 277 |
"address_line",
|
|
|
|
| 279 |
"city",
|
| 280 |
"tax_id",
|
| 281 |
"bank_account",
|
| 282 |
+
"password",
|
| 283 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
|
| 285 |
+
missing = [field for field in required_fields if not (payload.get(field) or "").strip()]
|
| 286 |
+
if missing:
|
| 287 |
+
return jsonify({"error": f"Brakuje pol: {', '.join(missing)}"}), 400
|
| 288 |
+
|
| 289 |
+
if len(payload["password"]) < 4:
|
| 290 |
+
return jsonify({"error": "Haslo musi miec co najmniej 4 znaki."}), 400
|
| 291 |
+
|
| 292 |
+
data["business"] = {
|
| 293 |
+
"company_name": payload["company_name"].strip(),
|
| 294 |
+
"owner_name": payload["owner_name"].strip(),
|
| 295 |
+
"address_line": payload["address_line"].strip(),
|
| 296 |
+
"postal_code": payload["postal_code"].strip(),
|
| 297 |
+
"city": payload["city"].strip(),
|
| 298 |
+
"tax_id": payload["tax_id"].strip(),
|
| 299 |
+
"bank_account": payload["bank_account"].strip(),
|
| 300 |
}
|
| 301 |
+
data["password_hash"] = hash_password(payload["password"])
|
| 302 |
+
data.setdefault("invoices", [])
|
| 303 |
+
|
| 304 |
save_store(data)
|
| 305 |
+
return jsonify({"message": "Dane zapisane. Mozesz sie zalogowac."})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
|
| 307 |
|
| 308 |
+
@app.route("/api/login", methods=["POST"])
|
| 309 |
+
def api_login() -> Any:
|
| 310 |
+
payload = request.get_json(force=True)
|
| 311 |
+
password = (payload.get("password") or "").strip()
|
| 312 |
+
data = load_store()
|
|
|
|
| 313 |
|
| 314 |
+
if not data.get("password_hash"):
|
| 315 |
+
return jsonify({"error": "Aplikacja nie zostala skonfigurowana."}), 400
|
| 316 |
|
| 317 |
+
if hash_password(password) != data["password_hash"]:
|
| 318 |
+
return jsonify({"error": "Nieprawidlowe haslo."}), 401
|
|
|
|
|
|
|
| 319 |
|
| 320 |
+
token = create_token()
|
| 321 |
+
return jsonify({"token": token})
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
@app.route("/api/business", methods=["GET", "PUT"])
|
| 325 |
+
def api_business() -> Any:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
try:
|
| 327 |
+
require_auth()
|
| 328 |
except PermissionError:
|
| 329 |
return jsonify({"error": "Brak autoryzacji."}), 401
|
| 330 |
|
| 331 |
+
data = load_store()
|
| 332 |
if request.method == "GET":
|
| 333 |
+
ensure_configured(data)
|
| 334 |
+
return jsonify({"business": data["business"]})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
|
| 336 |
payload = request.get_json(force=True)
|
| 337 |
+
current = data.get("business") or {}
|
| 338 |
+
updated = {
|
| 339 |
+
"company_name": (payload.get("company_name") or current.get("company_name") or "").strip(),
|
| 340 |
+
"owner_name": (payload.get("owner_name") or current.get("owner_name") or "").strip(),
|
| 341 |
+
"address_line": (payload.get("address_line") or current.get("address_line") or "").strip(),
|
| 342 |
+
"postal_code": (payload.get("postal_code") or current.get("postal_code") or "").strip(),
|
| 343 |
+
"city": (payload.get("city") or current.get("city") or "").strip(),
|
| 344 |
+
"tax_id": (payload.get("tax_id") or current.get("tax_id") or "").strip(),
|
| 345 |
+
"bank_account": (payload.get("bank_account") or current.get("bank_account") or "").strip(),
|
| 346 |
+
}
|
| 347 |
|
| 348 |
+
missing = [field for field, value in updated.items() if not value]
|
| 349 |
+
if missing:
|
| 350 |
+
return jsonify({"error": f"Wypelnij wszystkie pola: {', '.join(missing)}"}), 400
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
|
| 352 |
+
data["business"] = updated
|
|
|
|
|
|
|
| 353 |
save_store(data)
|
| 354 |
+
return jsonify({"business": updated})
|
| 355 |
|
| 356 |
|
| 357 |
+
@app.route("/api/invoices", methods=["POST", "GET"])
|
| 358 |
+
def api_invoices() -> Any:
|
| 359 |
try:
|
| 360 |
+
require_auth()
|
| 361 |
except PermissionError:
|
| 362 |
return jsonify({"error": "Brak autoryzacji."}), 401
|
| 363 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
data = load_store()
|
| 365 |
+
ensure_configured(data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
|
| 367 |
+
if request.method == "GET":
|
| 368 |
+
return jsonify({"invoices": data.get("invoices", [])})
|
|
|
|
|
|
|
| 369 |
|
| 370 |
+
payload = request.get_json(force=True)
|
| 371 |
try:
|
| 372 |
+
invoice = compute_invoice(payload, data["business"])
|
| 373 |
except ValueError as error:
|
| 374 |
return jsonify({"error": str(error)}), 400
|
| 375 |
|
| 376 |
+
invoices = data.setdefault("invoices", [])
|
| 377 |
+
invoices.append(invoice)
|
| 378 |
+
if len(invoices) > INVOICE_HISTORY_LIMIT:
|
| 379 |
+
data["invoices"] = invoices[-INVOICE_HISTORY_LIMIT:]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
|
| 381 |
+
save_store(data)
|
| 382 |
+
return jsonify({"invoice": invoice})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
|
| 385 |
+
if __name__ == "__main__":
|
| 386 |
+
port = int(os.environ.get("PORT", "7860"))
|
| 387 |
+
app.run(host="0.0.0.0", port=port, debug=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
|
| 389 |
+
DATA_DIR = Path(os.environ.get("DATA_DIR", APP_ROOT))
|
| 390 |
+
DATA_FILE = DATA_DIR / "web_invoice_store.json"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
small_logotyp do strony.jpg
DELETED
|
Binary file (4.51 kB)
|
|
|
static/css/styles.css
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--bg: #f5f5f5;
|
| 3 |
+
--panel-bg: #ffffff;
|
| 4 |
+
--text: #202124;
|
| 5 |
+
--muted: #5f6368;
|
| 6 |
+
--accent: #1a73e8;
|
| 7 |
+
--danger: #c5221f;
|
| 8 |
+
--border: #dadce0;
|
| 9 |
+
--radius: 10px;
|
| 10 |
+
--shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
|
| 11 |
+
font-family: "Segoe UI", Tahoma, sans-serif;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
* {
|
| 15 |
+
box-sizing: border-box;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
body {
|
| 19 |
+
margin: 0;
|
| 20 |
+
background: linear-gradient(180deg, #f7f9fc 0%, #eef1f6 100%);
|
| 21 |
+
color: var(--text);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.container {
|
| 25 |
+
max-width: 980px;
|
| 26 |
+
margin: 0 auto;
|
| 27 |
+
padding: 40px 20px 64px;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
h1 {
|
| 31 |
+
text-align: center;
|
| 32 |
+
font-size: 32px;
|
| 33 |
+
margin-bottom: 32px;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.panel {
|
| 37 |
+
background: var(--panel-bg);
|
| 38 |
+
border-radius: var(--radius);
|
| 39 |
+
box-shadow: var(--shadow);
|
| 40 |
+
padding: 28px 32px;
|
| 41 |
+
margin-bottom: 32px;
|
| 42 |
+
border: 1px solid rgba(32, 33, 36, 0.08);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
.panel h2 {
|
| 46 |
+
margin-top: 0;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.hidden {
|
| 50 |
+
display: none;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.form {
|
| 54 |
+
display: grid;
|
| 55 |
+
gap: 20px;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.field-grid {
|
| 59 |
+
display: grid;
|
| 60 |
+
gap: 18px 24px;
|
| 61 |
+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
label {
|
| 65 |
+
display: grid;
|
| 66 |
+
gap: 8px;
|
| 67 |
+
font-weight: 600;
|
| 68 |
+
font-size: 15px;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
input,
|
| 72 |
+
textarea,
|
| 73 |
+
select {
|
| 74 |
+
padding: 12px 14px;
|
| 75 |
+
border-radius: 8px;
|
| 76 |
+
border: 1px solid var(--border);
|
| 77 |
+
font-size: 15px;
|
| 78 |
+
background: #fbfbff;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
input:focus,
|
| 82 |
+
textarea:focus,
|
| 83 |
+
select:focus {
|
| 84 |
+
outline: 2px solid rgba(26, 115, 232, 0.35);
|
| 85 |
+
outline-offset: 1px;
|
| 86 |
+
border-color: var(--accent);
|
| 87 |
+
background: #ffffff;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
textarea {
|
| 91 |
+
resize: vertical;
|
| 92 |
+
min-height: 96px;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
button {
|
| 96 |
+
padding: 12px 20px;
|
| 97 |
+
border-radius: 8px;
|
| 98 |
+
border: none;
|
| 99 |
+
font-size: 15px;
|
| 100 |
+
font-weight: 600;
|
| 101 |
+
background: var(--accent);
|
| 102 |
+
color: white;
|
| 103 |
+
cursor: pointer;
|
| 104 |
+
transition: background 0.2s ease;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
button:hover {
|
| 108 |
+
background: #0f5ec4;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
button:disabled {
|
| 112 |
+
opacity: 0.6;
|
| 113 |
+
cursor: not-allowed;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.link-button {
|
| 117 |
+
background: none;
|
| 118 |
+
color: var(--accent);
|
| 119 |
+
padding: 0;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.link-button:hover {
|
| 123 |
+
color: #0f5ec4;
|
| 124 |
+
background: none;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.hint {
|
| 128 |
+
color: var(--muted);
|
| 129 |
+
font-size: 13px;
|
| 130 |
+
margin: 0;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.feedback {
|
| 134 |
+
color: var(--muted);
|
| 135 |
+
min-height: 20px;
|
| 136 |
+
font-size: 14px;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.feedback.error {
|
| 140 |
+
color: var(--danger);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.feedback.success {
|
| 144 |
+
color: #188038;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.invoice-header {
|
| 148 |
+
display: flex;
|
| 149 |
+
justify-content: space-between;
|
| 150 |
+
align-items: center;
|
| 151 |
+
margin-bottom: 24px;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.business-section {
|
| 155 |
+
border: 1px solid rgba(32, 33, 36, 0.08);
|
| 156 |
+
border-radius: var(--radius);
|
| 157 |
+
padding: 20px 24px;
|
| 158 |
+
background: #fbfcff;
|
| 159 |
+
margin-bottom: 24px;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.business-section-header {
|
| 163 |
+
display: flex;
|
| 164 |
+
justify-content: space-between;
|
| 165 |
+
align-items: center;
|
| 166 |
+
margin-bottom: 12px;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
.business-display {
|
| 170 |
+
display: grid;
|
| 171 |
+
gap: 6px;
|
| 172 |
+
font-size: 15px;
|
| 173 |
+
line-height: 1.4;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
.business-display strong {
|
| 177 |
+
font-weight: 600;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
.form-actions {
|
| 181 |
+
display: flex;
|
| 182 |
+
align-items: center;
|
| 183 |
+
gap: 16px;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.items-section {
|
| 187 |
+
border: 1px solid rgba(32, 33, 36, 0.08);
|
| 188 |
+
border-radius: var(--radius);
|
| 189 |
+
padding: 20px 24px;
|
| 190 |
+
background: #ffffff;
|
| 191 |
+
display: grid;
|
| 192 |
+
gap: 18px;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.items-header {
|
| 196 |
+
display: flex;
|
| 197 |
+
justify-content: space-between;
|
| 198 |
+
align-items: center;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.items-table-wrapper {
|
| 202 |
+
overflow-x: auto;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.items-table {
|
| 206 |
+
width: 100%;
|
| 207 |
+
border-collapse: collapse;
|
| 208 |
+
font-size: 14px;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.items-table th,
|
| 212 |
+
.items-table td {
|
| 213 |
+
border: 1px solid var(--border);
|
| 214 |
+
padding: 10px 12px;
|
| 215 |
+
text-align: left;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.items-table th {
|
| 219 |
+
background: #f1f3f7;
|
| 220 |
+
font-weight: 600;
|
| 221 |
+
text-transform: uppercase;
|
| 222 |
+
letter-spacing: 0.02em;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
.items-table input,
|
| 226 |
+
.items-table select {
|
| 227 |
+
width: 100%;
|
| 228 |
+
padding: 8px 10px;
|
| 229 |
+
border-radius: 6px;
|
| 230 |
+
border: 1px solid var(--border);
|
| 231 |
+
background: #ffffff;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
.items-table .remove-item {
|
| 235 |
+
color: var(--danger);
|
| 236 |
+
background: none;
|
| 237 |
+
padding: 0;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
.items-table .remove-item:hover {
|
| 241 |
+
text-decoration: underline;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
.totals {
|
| 245 |
+
display: flex;
|
| 246 |
+
flex-wrap: wrap;
|
| 247 |
+
gap: 16px;
|
| 248 |
+
font-weight: 600;
|
| 249 |
+
margin-top: 8px;
|
| 250 |
+
padding: 12px 16px;
|
| 251 |
+
border-radius: 8px;
|
| 252 |
+
background: #f7f9ff;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.rate-summary {
|
| 256 |
+
display: grid;
|
| 257 |
+
gap: 10px;
|
| 258 |
+
margin-top: 8px;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
.rate-summary-item {
|
| 262 |
+
display: flex;
|
| 263 |
+
flex-wrap: wrap;
|
| 264 |
+
gap: 12px;
|
| 265 |
+
font-weight: 600;
|
| 266 |
+
padding: 8px 12px;
|
| 267 |
+
border: 1px dashed rgba(26, 115, 232, 0.25);
|
| 268 |
+
border-radius: 6px;
|
| 269 |
+
background: #ffffff;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
.invoice-preview {
|
| 273 |
+
display: grid;
|
| 274 |
+
gap: 24px;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
.invoice-preview-meta {
|
| 278 |
+
display: flex;
|
| 279 |
+
flex-wrap: wrap;
|
| 280 |
+
gap: 24px;
|
| 281 |
+
font-size: 14px;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
.invoice-preview-meta span {
|
| 285 |
+
display: inline-flex;
|
| 286 |
+
align-items: center;
|
| 287 |
+
gap: 6px;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
.invoice-preview-header {
|
| 291 |
+
display: flex;
|
| 292 |
+
flex-wrap: wrap;
|
| 293 |
+
gap: 24px;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.invoice-preview-card {
|
| 297 |
+
flex: 1 1 280px;
|
| 298 |
+
border: 1px solid var(--border);
|
| 299 |
+
border-radius: var(--radius);
|
| 300 |
+
padding: 16px 20px;
|
| 301 |
+
background: #f9fafc;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
.invoice-preview-card h4 {
|
| 305 |
+
margin: 0 0 12px;
|
| 306 |
+
font-size: 14px;
|
| 307 |
+
text-transform: uppercase;
|
| 308 |
+
letter-spacing: 0.05em;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
.invoice-preview-card p {
|
| 312 |
+
margin: 4px 0;
|
| 313 |
+
font-size: 14px;
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
.invoice-preview table {
|
| 317 |
+
width: 100%;
|
| 318 |
+
border-collapse: collapse;
|
| 319 |
+
font-size: 14px;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
.invoice-preview th,
|
| 323 |
+
.invoice-preview td {
|
| 324 |
+
border: 1px solid var(--border);
|
| 325 |
+
padding: 10px 12px;
|
| 326 |
+
text-align: left;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
.invoice-preview th {
|
| 330 |
+
background: #f1f3f7;
|
| 331 |
+
font-weight: 600;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
.invoice-preview-summary {
|
| 335 |
+
display: flex;
|
| 336 |
+
justify-content: flex-end;
|
| 337 |
+
flex-wrap: wrap;
|
| 338 |
+
gap: 16px;
|
| 339 |
+
font-weight: 600;
|
| 340 |
+
font-size: 15px;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
.invoice-preview-note {
|
| 344 |
+
padding: 12px 16px;
|
| 345 |
+
border-left: 3px solid var(--accent);
|
| 346 |
+
background: #f4f8ff;
|
| 347 |
+
font-size: 14px;
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
@media (max-width: 640px) {
|
| 351 |
+
.panel {
|
| 352 |
+
padding: 20px;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
.invoice-header {
|
| 356 |
+
flex-direction: column;
|
| 357 |
+
gap: 12px;
|
| 358 |
+
align-items: flex-start;
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
.business-section,
|
| 362 |
+
.items-section {
|
| 363 |
+
padding: 16px;
|
| 364 |
+
}
|
| 365 |
+
}
|
static/js/main.js
ADDED
|
@@ -0,0 +1,984 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const VAT_OPTIONS = [
|
| 2 |
+
{ value: "23", label: "23%" },
|
| 3 |
+
{ value: "8", label: "8%" },
|
| 4 |
+
{ value: "5", label: "5%" },
|
| 5 |
+
{ value: "0", label: "0% (ZW)" },
|
| 6 |
+
{ value: "ZW", label: "ZW - zwolnione" },
|
| 7 |
+
{ value: "NP", label: "NP - poza zakresem" },
|
| 8 |
+
];
|
| 9 |
+
|
| 10 |
+
const VAT_RATE_VALUES = {
|
| 11 |
+
"23": 0.23,
|
| 12 |
+
"8": 0.08,
|
| 13 |
+
"5": 0.05,
|
| 14 |
+
"0": 0,
|
| 15 |
+
ZW: 0,
|
| 16 |
+
NP: 0,
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
const setupSection = document.getElementById("setup-section");
|
| 20 |
+
const loginSection = document.getElementById("login-section");
|
| 21 |
+
const appSection = document.getElementById("app-section");
|
| 22 |
+
|
| 23 |
+
const setupForm = document.getElementById("setup-form");
|
| 24 |
+
const loginForm = document.getElementById("login-form");
|
| 25 |
+
const invoiceForm = document.getElementById("invoice-form");
|
| 26 |
+
const businessForm = document.getElementById("business-form");
|
| 27 |
+
|
| 28 |
+
const setupFeedback = document.getElementById("setup-feedback");
|
| 29 |
+
const loginFeedback = document.getElementById("login-feedback");
|
| 30 |
+
const businessFeedback = document.getElementById("business-feedback");
|
| 31 |
+
|
| 32 |
+
const businessDisplay = document.getElementById("business-display");
|
| 33 |
+
const toggleBusinessFormButton = document.getElementById("toggle-business-form");
|
| 34 |
+
const cancelBusinessUpdateButton = document.getElementById("cancel-business-update");
|
| 35 |
+
|
| 36 |
+
const itemsBody = document.getElementById("items-body");
|
| 37 |
+
const addItemButton = document.getElementById("add-item-button");
|
| 38 |
+
|
| 39 |
+
const totalNetLabel = document.getElementById("total-net");
|
| 40 |
+
const totalVatLabel = document.getElementById("total-vat");
|
| 41 |
+
const totalGrossLabel = document.getElementById("total-gross");
|
| 42 |
+
const rateSummaryContainer = document.getElementById("rate-summary");
|
| 43 |
+
|
| 44 |
+
const exemptionNoteWrapper = document.getElementById("exemption-note-wrapper");
|
| 45 |
+
const exemptionNoteInput = document.getElementById("exemption-note");
|
| 46 |
+
|
| 47 |
+
const invoiceResult = document.getElementById("invoice-result");
|
| 48 |
+
const invoiceOutput = document.getElementById("invoice-output");
|
| 49 |
+
const downloadButton = document.getElementById("download-button");
|
| 50 |
+
const logoutButton = document.getElementById("logout-button");
|
| 51 |
+
|
| 52 |
+
let authToken = sessionStorage.getItem("invoiceAuthToken") || null;
|
| 53 |
+
let currentBusiness = null;
|
| 54 |
+
let lastInvoice = null;
|
| 55 |
+
let pdfFontPromise = null;
|
| 56 |
+
let pdfFontBase64 = null;
|
| 57 |
+
|
| 58 |
+
function setState(state) {
|
| 59 |
+
setupSection.classList.add("hidden");
|
| 60 |
+
loginSection.classList.add("hidden");
|
| 61 |
+
appSection.classList.add("hidden");
|
| 62 |
+
|
| 63 |
+
if (state === "setup") {
|
| 64 |
+
setupSection.classList.remove("hidden");
|
| 65 |
+
} else if (state === "login") {
|
| 66 |
+
loginSection.classList.remove("hidden");
|
| 67 |
+
} else if (state === "app") {
|
| 68 |
+
appSection.classList.remove("hidden");
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function clearFeedback(element) {
|
| 73 |
+
element.textContent = "";
|
| 74 |
+
element.classList.remove("error", "success");
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function showFeedback(element, message, type = "error") {
|
| 78 |
+
element.textContent = message;
|
| 79 |
+
element.classList.remove("error", "success");
|
| 80 |
+
if (type) {
|
| 81 |
+
element.classList.add(type);
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function parseNumber(value) {
|
| 86 |
+
if (typeof value === "number") {
|
| 87 |
+
return Number.isFinite(value) ? value : 0;
|
| 88 |
+
}
|
| 89 |
+
if (!value) {
|
| 90 |
+
return 0;
|
| 91 |
+
}
|
| 92 |
+
const normalized = value.toString().replace(",", ".");
|
| 93 |
+
const parsed = Number.parseFloat(normalized);
|
| 94 |
+
return Number.isFinite(parsed) ? parsed : 0;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
function formatCurrency(value) {
|
| 98 |
+
const number = parseNumber(value);
|
| 99 |
+
return `${number.toFixed(2)} PLN`;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function vatLabelFromCode(code) {
|
| 103 |
+
if (code === "ZW" || code === "0") {
|
| 104 |
+
return "ZW";
|
| 105 |
+
}
|
| 106 |
+
if (code === "NP") {
|
| 107 |
+
return "NP";
|
| 108 |
+
}
|
| 109 |
+
return `${code}%`;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
function requiresExemption(code) {
|
| 113 |
+
return code === "ZW" || code === "0";
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
async function apiRequest(path, { method = "GET", body, headers = {} } = {}, requireAuth = false) {
|
| 117 |
+
const options = {
|
| 118 |
+
method,
|
| 119 |
+
headers: {
|
| 120 |
+
"Content-Type": "application/json",
|
| 121 |
+
...headers,
|
| 122 |
+
},
|
| 123 |
+
};
|
| 124 |
+
|
| 125 |
+
if (body !== undefined) {
|
| 126 |
+
options.body = JSON.stringify(body);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
if (requireAuth) {
|
| 130 |
+
if (!authToken) {
|
| 131 |
+
throw new Error("Brak tokenu autoryzacyjnego.");
|
| 132 |
+
}
|
| 133 |
+
options.headers.Authorization = `Bearer ${authToken}`;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
const response = await fetch(path, options);
|
| 137 |
+
const isJson = response.headers.get("content-type")?.includes("application/json");
|
| 138 |
+
const data = isJson ? await response.json() : {};
|
| 139 |
+
|
| 140 |
+
if (response.status === 401) {
|
| 141 |
+
authToken = null;
|
| 142 |
+
sessionStorage.removeItem("invoiceAuthToken");
|
| 143 |
+
setState("login");
|
| 144 |
+
throw new Error(data.error || "Sesja wygasla. Zaloguj sie ponownie.");
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
if (!response.ok) {
|
| 148 |
+
throw new Error(data.error || "Wystapil blad podczas komunikacji z serwerem.");
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
return data;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
function renderBusinessDisplay(business) {
|
| 155 |
+
if (!business) {
|
| 156 |
+
businessDisplay.textContent = "Brak zapisanych danych firmy.";
|
| 157 |
+
return;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
businessDisplay.innerHTML = `
|
| 161 |
+
<p><strong>${business.company_name}</strong></p>
|
| 162 |
+
<p>${business.owner_name}</p>
|
| 163 |
+
<p>${business.address_line}</p>
|
| 164 |
+
<p>${business.postal_code} ${business.city}</p>
|
| 165 |
+
<p>NIP: ${business.tax_id}</p>
|
| 166 |
+
<p>Konto: ${business.bank_account}</p>
|
| 167 |
+
`;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
function fillBusinessForm(business) {
|
| 171 |
+
if (!business) {
|
| 172 |
+
return;
|
| 173 |
+
}
|
| 174 |
+
businessForm.elements.company_name.value = business.company_name || "";
|
| 175 |
+
businessForm.elements.owner_name.value = business.owner_name || "";
|
| 176 |
+
businessForm.elements.address_line.value = business.address_line || "";
|
| 177 |
+
businessForm.elements.postal_code.value = business.postal_code || "";
|
| 178 |
+
businessForm.elements.city.value = business.city || "";
|
| 179 |
+
businessForm.elements.tax_id.value = business.tax_id || "";
|
| 180 |
+
businessForm.elements.bank_account.value = business.bank_account || "";
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
function vatSelectElement(initialValue = "23") {
|
| 184 |
+
const select = document.createElement("select");
|
| 185 |
+
select.className = "item-vat";
|
| 186 |
+
VAT_OPTIONS.forEach((option) => {
|
| 187 |
+
const element = document.createElement("option");
|
| 188 |
+
element.value = option.value;
|
| 189 |
+
element.textContent = option.label;
|
| 190 |
+
select.appendChild(element);
|
| 191 |
+
});
|
| 192 |
+
select.value = VAT_OPTIONS.some((option) => option.value === initialValue) ? initialValue : "23";
|
| 193 |
+
return select;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
function createItemRow(initialValues = {}) {
|
| 197 |
+
const row = document.createElement("tr");
|
| 198 |
+
|
| 199 |
+
const nameCell = document.createElement("td");
|
| 200 |
+
const nameInput = document.createElement("input");
|
| 201 |
+
nameInput.type = "text";
|
| 202 |
+
nameInput.className = "item-name";
|
| 203 |
+
nameInput.placeholder = "Nazwa towaru lub uslugi";
|
| 204 |
+
if (initialValues.name) {
|
| 205 |
+
nameInput.value = initialValues.name;
|
| 206 |
+
}
|
| 207 |
+
nameCell.appendChild(nameInput);
|
| 208 |
+
|
| 209 |
+
const quantityCell = document.createElement("td");
|
| 210 |
+
const quantityInput = document.createElement("input");
|
| 211 |
+
quantityInput.type = "number";
|
| 212 |
+
quantityInput.className = "item-quantity";
|
| 213 |
+
quantityInput.min = "0.01";
|
| 214 |
+
quantityInput.step = "0.01";
|
| 215 |
+
quantityInput.value = initialValues.quantity ?? "1";
|
| 216 |
+
quantityCell.appendChild(quantityInput);
|
| 217 |
+
|
| 218 |
+
const unitGrossCell = document.createElement("td");
|
| 219 |
+
const unitGrossInput = document.createElement("input");
|
| 220 |
+
unitGrossInput.type = "number";
|
| 221 |
+
unitGrossInput.className = "item-gross";
|
| 222 |
+
unitGrossInput.min = "0.01";
|
| 223 |
+
unitGrossInput.step = "0.01";
|
| 224 |
+
unitGrossInput.placeholder = "Brutto";
|
| 225 |
+
if (initialValues.unit_price_gross) {
|
| 226 |
+
unitGrossInput.value = initialValues.unit_price_gross;
|
| 227 |
+
}
|
| 228 |
+
unitGrossCell.appendChild(unitGrossInput);
|
| 229 |
+
|
| 230 |
+
const vatCell = document.createElement("td");
|
| 231 |
+
const vatSelect = vatSelectElement(initialValues.vat_code);
|
| 232 |
+
vatCell.appendChild(vatSelect);
|
| 233 |
+
|
| 234 |
+
const totalCell = document.createElement("td");
|
| 235 |
+
totalCell.className = "item-total";
|
| 236 |
+
totalCell.textContent = "0.00 PLN";
|
| 237 |
+
|
| 238 |
+
const actionsCell = document.createElement("td");
|
| 239 |
+
const removeButton = document.createElement("button");
|
| 240 |
+
removeButton.type = "button";
|
| 241 |
+
removeButton.className = "remove-item";
|
| 242 |
+
removeButton.textContent = "Usun";
|
| 243 |
+
actionsCell.appendChild(removeButton);
|
| 244 |
+
|
| 245 |
+
row.appendChild(nameCell);
|
| 246 |
+
row.appendChild(quantityCell);
|
| 247 |
+
row.appendChild(unitGrossCell);
|
| 248 |
+
row.appendChild(vatCell);
|
| 249 |
+
row.appendChild(totalCell);
|
| 250 |
+
row.appendChild(actionsCell);
|
| 251 |
+
|
| 252 |
+
const handleChange = () => updateTotals();
|
| 253 |
+
nameInput.addEventListener("input", handleChange);
|
| 254 |
+
quantityInput.addEventListener("input", handleChange);
|
| 255 |
+
unitGrossInput.addEventListener("input", handleChange);
|
| 256 |
+
vatSelect.addEventListener("change", handleChange);
|
| 257 |
+
|
| 258 |
+
removeButton.addEventListener("click", () => {
|
| 259 |
+
if (itemsBody.children.length === 1) {
|
| 260 |
+
nameInput.value = "";
|
| 261 |
+
quantityInput.value = "1";
|
| 262 |
+
unitGrossInput.value = "";
|
| 263 |
+
vatSelect.value = "23";
|
| 264 |
+
updateTotals();
|
| 265 |
+
return;
|
| 266 |
+
}
|
| 267 |
+
row.remove();
|
| 268 |
+
updateTotals();
|
| 269 |
+
});
|
| 270 |
+
|
| 271 |
+
itemsBody.appendChild(row);
|
| 272 |
+
updateTotals();
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
function calculateRowTotals(row) {
|
| 276 |
+
const name = row.querySelector(".item-name")?.value.trim() ?? "";
|
| 277 |
+
const quantity = parseNumber(row.querySelector(".item-quantity")?.value);
|
| 278 |
+
const unitGross = parseNumber(row.querySelector(".item-gross")?.value);
|
| 279 |
+
const vatCode = row.querySelector(".item-vat")?.value ?? "23";
|
| 280 |
+
const rate = VAT_RATE_VALUES[vatCode] ?? 0;
|
| 281 |
+
|
| 282 |
+
const hasValues = name || quantity > 0 || unitGross > 0;
|
| 283 |
+
if (!hasValues) {
|
| 284 |
+
return {
|
| 285 |
+
valid: false,
|
| 286 |
+
vatCode,
|
| 287 |
+
vatLabel: vatLabelFromCode(vatCode),
|
| 288 |
+
requiresExemption: requiresExemption(vatCode),
|
| 289 |
+
quantity,
|
| 290 |
+
unitGross,
|
| 291 |
+
unitNet: 0,
|
| 292 |
+
netTotal: 0,
|
| 293 |
+
vatAmount: 0,
|
| 294 |
+
grossTotal: 0,
|
| 295 |
+
};
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
if (quantity <= 0 || unitGross <= 0) {
|
| 299 |
+
return {
|
| 300 |
+
valid: false,
|
| 301 |
+
vatCode,
|
| 302 |
+
vatLabel: vatLabelFromCode(vatCode),
|
| 303 |
+
requiresExemption: requiresExemption(vatCode),
|
| 304 |
+
quantity,
|
| 305 |
+
unitGross,
|
| 306 |
+
unitNet: 0,
|
| 307 |
+
netTotal: 0,
|
| 308 |
+
vatAmount: 0,
|
| 309 |
+
grossTotal: quantity * unitGross,
|
| 310 |
+
};
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
const grossTotal = quantity * unitGross;
|
| 314 |
+
const netTotal = rate > 0 ? grossTotal / (1 + rate) : grossTotal;
|
| 315 |
+
const vatAmount = grossTotal - netTotal;
|
| 316 |
+
const unitNet = netTotal / quantity;
|
| 317 |
+
|
| 318 |
+
return {
|
| 319 |
+
valid: true,
|
| 320 |
+
vatCode,
|
| 321 |
+
vatLabel: vatLabelFromCode(vatCode),
|
| 322 |
+
requiresExemption: requiresExemption(vatCode),
|
| 323 |
+
quantity,
|
| 324 |
+
unitGross,
|
| 325 |
+
unitNet,
|
| 326 |
+
netTotal,
|
| 327 |
+
vatAmount,
|
| 328 |
+
grossTotal,
|
| 329 |
+
};
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
function updateTotals() {
|
| 333 |
+
let totalNet = 0;
|
| 334 |
+
let totalVat = 0;
|
| 335 |
+
let totalGross = 0;
|
| 336 |
+
const summary = new Map();
|
| 337 |
+
let exemptionNeeded = false;
|
| 338 |
+
|
| 339 |
+
const rows = Array.from(itemsBody.querySelectorAll("tr"));
|
| 340 |
+
rows.forEach((row) => {
|
| 341 |
+
const totals = calculateRowTotals(row);
|
| 342 |
+
if (totals.requiresExemption) {
|
| 343 |
+
exemptionNeeded = true;
|
| 344 |
+
}
|
| 345 |
+
const totalCell = row.querySelector(".item-total");
|
| 346 |
+
totalCell.textContent = formatCurrency(totals.grossTotal);
|
| 347 |
+
|
| 348 |
+
if (!totals.valid) {
|
| 349 |
+
return;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
totalNet += totals.netTotal;
|
| 353 |
+
totalVat += totals.vatAmount;
|
| 354 |
+
totalGross += totals.grossTotal;
|
| 355 |
+
|
| 356 |
+
const existing = summary.get(totals.vatLabel) || { net: 0, vat: 0, gross: 0 };
|
| 357 |
+
existing.net += totals.netTotal;
|
| 358 |
+
existing.vat += totals.vatAmount;
|
| 359 |
+
existing.gross += totals.grossTotal;
|
| 360 |
+
summary.set(totals.vatLabel, existing);
|
| 361 |
+
});
|
| 362 |
+
|
| 363 |
+
totalNetLabel.textContent = `Suma netto: ${totalNet.toFixed(2)} PLN`;
|
| 364 |
+
totalVatLabel.textContent = `Kwota VAT: ${totalVat.toFixed(2)} PLN`;
|
| 365 |
+
totalGrossLabel.textContent = `Suma brutto: ${totalGross.toFixed(2)} PLN`;
|
| 366 |
+
renderRateSummary(summary);
|
| 367 |
+
|
| 368 |
+
if (exemptionNeeded) {
|
| 369 |
+
exemptionNoteWrapper.classList.remove("hidden");
|
| 370 |
+
} else {
|
| 371 |
+
exemptionNoteWrapper.classList.add("hidden");
|
| 372 |
+
exemptionNoteInput.value = "";
|
| 373 |
+
}
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
function renderRateSummary(summary) {
|
| 377 |
+
if (!summary || summary.size === 0) {
|
| 378 |
+
rateSummaryContainer.innerHTML = "";
|
| 379 |
+
return;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
const entries = Array.from(summary.entries()).sort(([a], [b]) => a.localeCompare(b));
|
| 383 |
+
const markup = entries
|
| 384 |
+
.map(
|
| 385 |
+
([label, totals]) =>
|
| 386 |
+
`<div class="rate-summary-item">
|
| 387 |
+
<span>${label}</span>
|
| 388 |
+
<span>Netto: ${totals.net.toFixed(2)} PLN</span>
|
| 389 |
+
<span>VAT: ${totals.vat.toFixed(2)} PLN</span>
|
| 390 |
+
<span>Brutto: ${totals.gross.toFixed(2)} PLN</span>
|
| 391 |
+
</div>`
|
| 392 |
+
)
|
| 393 |
+
.join("");
|
| 394 |
+
rateSummaryContainer.innerHTML = `<h4>Podsumowanie stawek</h4>${markup}`;
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
function collectInvoicePayload() {
|
| 398 |
+
const items = [];
|
| 399 |
+
const rows = Array.from(itemsBody.querySelectorAll("tr"));
|
| 400 |
+
|
| 401 |
+
rows.forEach((row) => {
|
| 402 |
+
const name = row.querySelector(".item-name")?.value.trim() ?? "";
|
| 403 |
+
const quantity = parseNumber(row.querySelector(".item-quantity")?.value);
|
| 404 |
+
const unitGross = parseNumber(row.querySelector(".item-gross")?.value);
|
| 405 |
+
const vatCode = row.querySelector(".item-vat")?.value ?? "23";
|
| 406 |
+
|
| 407 |
+
const hasValues = name || quantity > 0 || unitGross > 0;
|
| 408 |
+
if (!hasValues) {
|
| 409 |
+
return;
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
if (!name) {
|
| 413 |
+
throw new Error("Kazda pozycja musi miec nazwe.");
|
| 414 |
+
}
|
| 415 |
+
if (quantity <= 0) {
|
| 416 |
+
throw new Error("Ilosc musi byc wieksza od zera.");
|
| 417 |
+
}
|
| 418 |
+
if (unitGross <= 0) {
|
| 419 |
+
throw new Error("Cena brutto musi byc wieksza od zera.");
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
items.push({
|
| 423 |
+
name,
|
| 424 |
+
quantity: quantity.toFixed(2),
|
| 425 |
+
unit_price_gross: unitGross.toFixed(2),
|
| 426 |
+
vat_code: vatCode,
|
| 427 |
+
});
|
| 428 |
+
});
|
| 429 |
+
|
| 430 |
+
if (items.length === 0) {
|
| 431 |
+
throw new Error("Dodaj przynajmniej jedna pozycje.");
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
const saleDate = invoiceForm.elements.saleDate.value || null;
|
| 435 |
+
const exemptionNote = exemptionNoteInput.value.trim();
|
| 436 |
+
const requiresExemptionNote = items.some((item) => item.vat_code === "ZW" || item.vat_code === "0");
|
| 437 |
+
if (requiresExemptionNote && !exemptionNote) {
|
| 438 |
+
throw new Error("Podaj podstawe prawna zwolnienia dla pozycji rozliczanych jako ZW.");
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
const client = {
|
| 442 |
+
name: (invoiceForm.elements.clientName.value || "").trim(),
|
| 443 |
+
tax_id: (invoiceForm.elements.clientTaxId.value || "").trim(),
|
| 444 |
+
address_line: (invoiceForm.elements.clientAddress.value || "").trim(),
|
| 445 |
+
postal_code: (invoiceForm.elements.clientPostalCode.value || "").trim(),
|
| 446 |
+
city: (invoiceForm.elements.clientCity.value || "").trim(),
|
| 447 |
+
};
|
| 448 |
+
|
| 449 |
+
return {
|
| 450 |
+
sale_date: saleDate,
|
| 451 |
+
client,
|
| 452 |
+
items,
|
| 453 |
+
exemption_note: exemptionNote,
|
| 454 |
+
};
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
function renderInvoicePreview(invoice) {
|
| 458 |
+
if (!invoice || !currentBusiness) {
|
| 459 |
+
invoiceOutput.innerHTML = "<p>Brak danych faktury.</p>";
|
| 460 |
+
return;
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
const client = invoice.client || {};
|
| 464 |
+
const hasClientData = client.name || client.address_line || client.postal_code || client.city || client.tax_id;
|
| 465 |
+
|
| 466 |
+
const itemsRows = invoice.items
|
| 467 |
+
.map(
|
| 468 |
+
(item) => `
|
| 469 |
+
<tr>
|
| 470 |
+
<td>${item.name}</td>
|
| 471 |
+
<td>${parseNumber(item.quantity).toFixed(2)}</td>
|
| 472 |
+
<td>${formatCurrency(item.unit_price_net)}</td>
|
| 473 |
+
<td>${formatCurrency(item.net_total)}</td>
|
| 474 |
+
<td>${item.vat_label}</td>
|
| 475 |
+
<td>${formatCurrency(item.vat_amount)}</td>
|
| 476 |
+
<td>${formatCurrency(item.gross_total)}</td>
|
| 477 |
+
</tr>`
|
| 478 |
+
)
|
| 479 |
+
.join("");
|
| 480 |
+
|
| 481 |
+
const summaryRows = (invoice.summary || [])
|
| 482 |
+
.map(
|
| 483 |
+
(entry) =>
|
| 484 |
+
`<div class="rate-summary-item">
|
| 485 |
+
<span>${entry.vat_label}</span>
|
| 486 |
+
<span>Netto: ${formatCurrency(entry.net_total)}</span>
|
| 487 |
+
<span>VAT: ${formatCurrency(entry.vat_total)}</span>
|
| 488 |
+
<span>Brutto: ${formatCurrency(entry.gross_total)}</span>
|
| 489 |
+
</div>`
|
| 490 |
+
)
|
| 491 |
+
.join("");
|
| 492 |
+
|
| 493 |
+
invoiceOutput.innerHTML = `
|
| 494 |
+
<div class="invoice-preview-meta">
|
| 495 |
+
<span><strong>Numer:</strong> ${invoice.invoice_id}</span>
|
| 496 |
+
<span><strong>Data wystawienia:</strong> ${invoice.issued_at}</span>
|
| 497 |
+
<span><strong>Data sprzedazy:</strong> ${invoice.sale_date}</span>
|
| 498 |
+
</div>
|
| 499 |
+
<div class="invoice-preview-header">
|
| 500 |
+
<div class="invoice-preview-card">
|
| 501 |
+
<h4>Nabywca</h4>
|
| 502 |
+
${
|
| 503 |
+
hasClientData
|
| 504 |
+
? `
|
| 505 |
+
<p>${client.name || "---"}</p>
|
| 506 |
+
<p>${client.address_line || "---"}</p>
|
| 507 |
+
<p>${client.postal_code || ""} ${client.city || ""}</p>
|
| 508 |
+
<p>${client.tax_id ? `NIP: ${client.tax_id}` : ""}</p>
|
| 509 |
+
`
|
| 510 |
+
: "<p>Brak danych nabywcy.</p>"
|
| 511 |
+
}
|
| 512 |
+
</div>
|
| 513 |
+
<div class="invoice-preview-card">
|
| 514 |
+
<h4>Sprzedawca</h4>
|
| 515 |
+
<p>${currentBusiness.company_name}</p>
|
| 516 |
+
<p>${currentBusiness.owner_name}</p>
|
| 517 |
+
<p>${currentBusiness.address_line}</p>
|
| 518 |
+
<p>${currentBusiness.postal_code} ${currentBusiness.city}</p>
|
| 519 |
+
<p>NIP: ${currentBusiness.tax_id}</p>
|
| 520 |
+
<p>Konto: ${currentBusiness.bank_account}</p>
|
| 521 |
+
</div>
|
| 522 |
+
</div>
|
| 523 |
+
<table>
|
| 524 |
+
<thead>
|
| 525 |
+
<tr>
|
| 526 |
+
<th>Nazwa</th>
|
| 527 |
+
<th>Ilość</th>
|
| 528 |
+
<th>Cena jedn. netto</th>
|
| 529 |
+
<th>Wartość netto (pozycja)</th>
|
| 530 |
+
<th>Stawka VAT</th>
|
| 531 |
+
<th>Kwota VAT (pozycja)</th>
|
| 532 |
+
<th>Wartość brutto</th>
|
| 533 |
+
</tr>
|
| 534 |
+
</thead>
|
| 535 |
+
<tbody>${itemsRows}</tbody>
|
| 536 |
+
</table>
|
| 537 |
+
<div class="rate-summary">
|
| 538 |
+
<h4>Podsumowanie stawek</h4>
|
| 539 |
+
${summaryRows}
|
| 540 |
+
</div>
|
| 541 |
+
<div class="invoice-preview-summary">
|
| 542 |
+
<span>Netto: ${formatCurrency(invoice.totals.net)}</span>
|
| 543 |
+
<span>VAT: ${formatCurrency(invoice.totals.vat)}</span>
|
| 544 |
+
<span>Brutto: ${formatCurrency(invoice.totals.gross)}</span>
|
| 545 |
+
</div>
|
| 546 |
+
${
|
| 547 |
+
invoice.exemption_note
|
| 548 |
+
? `<div class="invoice-preview-note"><strong>Podstawa prawna zwolnienia:</strong> ${invoice.exemption_note}</div>`
|
| 549 |
+
: ""
|
| 550 |
+
}
|
| 551 |
+
`;
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
function drawPartyBox(doc, title, lines, x, y, width) {
|
| 555 |
+
const lineHeight = 5;
|
| 556 |
+
const wrappedLines = lines.flatMap((line) => doc.splitTextToSize(line, width));
|
| 557 |
+
const boxHeight = wrappedLines.length * lineHeight + 14;
|
| 558 |
+
|
| 559 |
+
doc.roundedRect(x - 4, y - 8, width + 8, boxHeight, 2, 2);
|
| 560 |
+
doc.setFontSize(11);
|
| 561 |
+
doc.text(title, x, y);
|
| 562 |
+
doc.setFontSize(10);
|
| 563 |
+
|
| 564 |
+
let cursor = y + 5;
|
| 565 |
+
wrappedLines.forEach((line) => {
|
| 566 |
+
doc.text(line, x, cursor);
|
| 567 |
+
cursor += lineHeight;
|
| 568 |
+
});
|
| 569 |
+
|
| 570 |
+
return y - 8 + boxHeight;
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
function arrayBufferToBase64(buffer) {
|
| 574 |
+
const bytes = new Uint8Array(buffer);
|
| 575 |
+
const chunkSize = 0x8000;
|
| 576 |
+
let binary = "";
|
| 577 |
+
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
| 578 |
+
const chunk = bytes.subarray(offset, Math.min(offset + chunkSize, bytes.length));
|
| 579 |
+
binary += String.fromCharCode.apply(null, chunk);
|
| 580 |
+
}
|
| 581 |
+
return btoa(binary);
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
const PDF_FONT_FILE = "Roboto-VariableFont_wdth,wght.ttf";
|
| 585 |
+
const PDF_FONT_NAME = "RobotoPolish";
|
| 586 |
+
|
| 587 |
+
async function ensurePdfFont() {
|
| 588 |
+
if (pdfFontPromise) {
|
| 589 |
+
return pdfFontPromise;
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
if (!window.jspdf || !window.jspdf.jsPDF) {
|
| 593 |
+
throw new Error("Biblioteka jsPDF nie zostala zaladowana.");
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
const { jsPDF } = window.jspdf;
|
| 597 |
+
const loadBase64 = async () => {
|
| 598 |
+
if (typeof window !== "undefined" && window.PDF_FONT_BASE64) {
|
| 599 |
+
return window.PDF_FONT_BASE64;
|
| 600 |
+
}
|
| 601 |
+
const response = await fetch(`/${encodeURIComponent(PDF_FONT_FILE)}`);
|
| 602 |
+
if (!response.ok) {
|
| 603 |
+
throw new Error(`Nie udalo sie pobrac czcionki Roboto (status ${response.status}).`);
|
| 604 |
+
}
|
| 605 |
+
const buffer = await response.arrayBuffer();
|
| 606 |
+
return arrayBufferToBase64(buffer);
|
| 607 |
+
};
|
| 608 |
+
|
| 609 |
+
pdfFontPromise = loadBase64().then((data) => {
|
| 610 |
+
pdfFontBase64 = data;
|
| 611 |
+
return data;
|
| 612 |
+
});
|
| 613 |
+
|
| 614 |
+
return pdfFontPromise;
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
async function generatePdf(business, invoice) {
|
| 618 |
+
if (!window.jspdf || !window.jspdf.jsPDF) {
|
| 619 |
+
alert("Biblioteka jsPDF nie zostala zaladowana. Sprawdz polaczenie z internetem.");
|
| 620 |
+
return;
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
let fontBase64;
|
| 624 |
+
try {
|
| 625 |
+
fontBase64 = await ensurePdfFont();
|
| 626 |
+
} catch (error) {
|
| 627 |
+
alert(error.message || "Nie udalo sie przygotowac czcionki do PDF.");
|
| 628 |
+
return;
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
const { jsPDF } = window.jspdf;
|
| 632 |
+
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
|
| 633 |
+
const marginX = 18;
|
| 634 |
+
let cursorY = 20;
|
| 635 |
+
|
| 636 |
+
if (!doc.getFontList()[PDF_FONT_NAME]) {
|
| 637 |
+
const embeddedFont = pdfFontBase64 || fontBase64;
|
| 638 |
+
doc.addFileToVFS(PDF_FONT_FILE, embeddedFont);
|
| 639 |
+
doc.addFont(PDF_FONT_FILE, PDF_FONT_NAME, "normal");
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
doc.setFont(PDF_FONT_NAME, "normal");
|
| 643 |
+
doc.setFontSize(16);
|
| 644 |
+
doc.text(`Faktura ${invoice.invoice_id}`, marginX, cursorY);
|
| 645 |
+
doc.setFontSize(10);
|
| 646 |
+
doc.text(`Data wystawienia: ${invoice.issued_at}`, marginX, cursorY + 6);
|
| 647 |
+
doc.text(`Data sprzedaży: ${invoice.sale_date}`, marginX, cursorY + 12);
|
| 648 |
+
|
| 649 |
+
cursorY += 22;
|
| 650 |
+
const columnWidth = 85;
|
| 651 |
+
const sellerX = marginX + columnWidth + 12;
|
| 652 |
+
|
| 653 |
+
const clientLines = invoice.client && (invoice.client.name || invoice.client.address_line || invoice.client.city || invoice.client.tax_id)
|
| 654 |
+
? [
|
| 655 |
+
invoice.client.name || "---",
|
| 656 |
+
invoice.client.address_line || "",
|
| 657 |
+
`${(invoice.client.postal_code || "").trim()} ${(invoice.client.city || "").trim()}`.trim(),
|
| 658 |
+
invoice.client.tax_id ? `NIP: ${invoice.client.tax_id}` : "",
|
| 659 |
+
].filter((line) => line && line.trim())
|
| 660 |
+
: ["Brak danych nabywcy"];
|
| 661 |
+
|
| 662 |
+
const sellerLines = [
|
| 663 |
+
business.company_name,
|
| 664 |
+
business.owner_name,
|
| 665 |
+
business.address_line,
|
| 666 |
+
`${business.postal_code} ${business.city}`.trim(),
|
| 667 |
+
`NIP: ${business.tax_id}`,
|
| 668 |
+
`Konto: ${business.bank_account}`,
|
| 669 |
+
];
|
| 670 |
+
|
| 671 |
+
const buyerBottom = drawPartyBox(doc, "NABYWCA", clientLines, marginX, cursorY, columnWidth);
|
| 672 |
+
const sellerBottom = drawPartyBox(doc, "SPRZEDAWCA", sellerLines, sellerX, cursorY, columnWidth);
|
| 673 |
+
cursorY = Math.max(buyerBottom, sellerBottom) + 12;
|
| 674 |
+
|
| 675 |
+
const tableColumns = [
|
| 676 |
+
{ key: "name", label: "Nazwa", width: 52 },
|
| 677 |
+
{ key: "quantity", label: "Ilość", width: 16 },
|
| 678 |
+
{ key: "unitNet", label: "Cena jedn. netto", width: 24 },
|
| 679 |
+
{ key: "netTotal", label: "Wartość netto", width: 24 },
|
| 680 |
+
{ key: "vatLabel", label: "Stawka VAT", width: 15 },
|
| 681 |
+
{ key: "vatAmount", label: "Kwota VAT", width: 22 },
|
| 682 |
+
{ key: "grossTotal", label: "Wartość brutto", width: 21 },
|
| 683 |
+
];
|
| 684 |
+
const tableWidth = tableColumns.reduce((sum, col) => sum + col.width, 0);
|
| 685 |
+
const lineHeight = 5;
|
| 686 |
+
const headerLineHeight = 4.2;
|
| 687 |
+
tableColumns.forEach((column) => {
|
| 688 |
+
column.headerLines = doc.splitTextToSize(column.label, column.width - 4);
|
| 689 |
+
});
|
| 690 |
+
const headerHeight = Math.max(...tableColumns.map((column) => column.headerLines.length * headerLineHeight + 3));
|
| 691 |
+
|
| 692 |
+
doc.setFillColor(241, 243, 247);
|
| 693 |
+
doc.rect(marginX, cursorY, tableWidth, headerHeight, "F");
|
| 694 |
+
doc.rect(marginX, cursorY, tableWidth, headerHeight);
|
| 695 |
+
let offsetX = marginX;
|
| 696 |
+
doc.setFontSize(10);
|
| 697 |
+
tableColumns.forEach((column) => {
|
| 698 |
+
doc.rect(offsetX, cursorY, column.width, headerHeight);
|
| 699 |
+
column.headerLines.forEach((line, index) => {
|
| 700 |
+
const textY = cursorY + 4 + index * headerLineHeight;
|
| 701 |
+
doc.text((line || "").trim(), offsetX + 2, textY);
|
| 702 |
+
});
|
| 703 |
+
offsetX += column.width;
|
| 704 |
+
});
|
| 705 |
+
cursorY += headerHeight;
|
| 706 |
+
|
| 707 |
+
invoice.items.forEach((item) => {
|
| 708 |
+
const quantity = parseNumber(item.quantity).toFixed(2);
|
| 709 |
+
const unitNet = formatCurrency(item.unit_price_net);
|
| 710 |
+
const netTotal = formatCurrency(item.net_total);
|
| 711 |
+
const vatAmount = formatCurrency(item.vat_amount);
|
| 712 |
+
const grossTotal = formatCurrency(item.gross_total);
|
| 713 |
+
|
| 714 |
+
const wrapText = (text, width) =>
|
| 715 |
+
doc
|
| 716 |
+
.splitTextToSize(text ?? "", width)
|
| 717 |
+
.map((line) => line.trim());
|
| 718 |
+
|
| 719 |
+
const columnData = tableColumns.map((column) => {
|
| 720 |
+
switch (column.key) {
|
| 721 |
+
case "name":
|
| 722 |
+
return wrapText(item.name, column.width - 4);
|
| 723 |
+
case "quantity":
|
| 724 |
+
return [quantity];
|
| 725 |
+
case "unitNet":
|
| 726 |
+
return [unitNet];
|
| 727 |
+
case "netTotal":
|
| 728 |
+
return [netTotal];
|
| 729 |
+
case "vatLabel":
|
| 730 |
+
return [item.vat_label];
|
| 731 |
+
case "vatAmount":
|
| 732 |
+
return [vatAmount];
|
| 733 |
+
case "grossTotal":
|
| 734 |
+
return [grossTotal];
|
| 735 |
+
default:
|
| 736 |
+
return [""];
|
| 737 |
+
}
|
| 738 |
+
});
|
| 739 |
+
|
| 740 |
+
const rowHeight = Math.max(...columnData.map((data) => data.length)) * lineHeight + 2;
|
| 741 |
+
offsetX = marginX;
|
| 742 |
+
tableColumns.forEach((column, index) => {
|
| 743 |
+
doc.rect(offsetX, cursorY, column.width, rowHeight);
|
| 744 |
+
const lines = columnData[index];
|
| 745 |
+
lines.forEach((line, lineIndex) => {
|
| 746 |
+
const textY = cursorY + (lineIndex + 1) * lineHeight;
|
| 747 |
+
const content = (line || "").trim();
|
| 748 |
+
doc.text(content, offsetX + 2, textY);
|
| 749 |
+
});
|
| 750 |
+
offsetX += column.width;
|
| 751 |
+
});
|
| 752 |
+
|
| 753 |
+
cursorY += rowHeight;
|
| 754 |
+
});
|
| 755 |
+
|
| 756 |
+
cursorY += 10;
|
| 757 |
+
doc.setFontSize(11);
|
| 758 |
+
doc.text("Podsumowanie stawek:", marginX, cursorY);
|
| 759 |
+
cursorY += 6;
|
| 760 |
+
|
| 761 |
+
const summaryEntries = Array.isArray(invoice.summary) ? invoice.summary : [];
|
| 762 |
+
summaryEntries.forEach((entry) => {
|
| 763 |
+
const summaryLine = `${entry.vat_label} – Netto: ${formatCurrency(entry.net_total)} / VAT: ${formatCurrency(entry.vat_total)} / Brutto: ${formatCurrency(entry.gross_total)}`;
|
| 764 |
+
const wrapped = doc.splitTextToSize(summaryLine, 170);
|
| 765 |
+
wrapped.forEach((line) => {
|
| 766 |
+
doc.text((line || "").trim(), marginX, cursorY);
|
| 767 |
+
cursorY += lineHeight;
|
| 768 |
+
});
|
| 769 |
+
});
|
| 770 |
+
|
| 771 |
+
cursorY += 6;
|
| 772 |
+
doc.setFontSize(12);
|
| 773 |
+
doc.text(`Suma netto: ${formatCurrency(invoice.totals.net)}`, marginX, cursorY);
|
| 774 |
+
doc.text(`Suma VAT: ${formatCurrency(invoice.totals.vat)}`, marginX, cursorY + 6);
|
| 775 |
+
doc.text(`Suma brutto: ${formatCurrency(invoice.totals.gross)}`, marginX, cursorY + 12);
|
| 776 |
+
cursorY += 20;
|
| 777 |
+
|
| 778 |
+
if (invoice.exemption_note) {
|
| 779 |
+
doc.setFontSize(10);
|
| 780 |
+
const noteLines = doc.splitTextToSize(`Podstawa prawna zwolnienia: ${invoice.exemption_note}`, 170);
|
| 781 |
+
doc.text(noteLines, marginX, cursorY);
|
| 782 |
+
}
|
| 783 |
+
|
| 784 |
+
doc.save(`${invoice.invoice_id}.pdf`);
|
| 785 |
+
}
|
| 786 |
+
|
| 787 |
+
async function loadBusinessData() {
|
| 788 |
+
const data = await apiRequest("/api/business", {}, true);
|
| 789 |
+
currentBusiness = data.business;
|
| 790 |
+
renderBusinessDisplay(currentBusiness);
|
| 791 |
+
fillBusinessForm(currentBusiness);
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
function resetInvoiceForm() {
|
| 795 |
+
invoiceForm.reset();
|
| 796 |
+
exemptionNoteInput.value = "";
|
| 797 |
+
exemptionNoteWrapper.classList.add("hidden");
|
| 798 |
+
itemsBody.innerHTML = "";
|
| 799 |
+
createItemRow();
|
| 800 |
+
const today = new Date().toISOString().slice(0, 10);
|
| 801 |
+
invoiceForm.elements.saleDate.value = today;
|
| 802 |
+
}
|
| 803 |
+
|
| 804 |
+
async function bootstrapApp() {
|
| 805 |
+
try {
|
| 806 |
+
await loadBusinessData();
|
| 807 |
+
setState("app");
|
| 808 |
+
} catch (error) {
|
| 809 |
+
console.error(error);
|
| 810 |
+
authToken = null;
|
| 811 |
+
sessionStorage.removeItem("invoiceAuthToken");
|
| 812 |
+
showFeedback(loginFeedback, error.message || "Nie udalo sie pobrac danych firmy.");
|
| 813 |
+
setState("login");
|
| 814 |
+
}
|
| 815 |
+
}
|
| 816 |
+
|
| 817 |
+
async function initialize() {
|
| 818 |
+
resetInvoiceForm();
|
| 819 |
+
try {
|
| 820 |
+
const status = await apiRequest("/api/status");
|
| 821 |
+
if (!status.configured) {
|
| 822 |
+
setState("setup");
|
| 823 |
+
return;
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
if (authToken) {
|
| 827 |
+
await bootstrapApp();
|
| 828 |
+
} else {
|
| 829 |
+
setState("login");
|
| 830 |
+
}
|
| 831 |
+
} catch (error) {
|
| 832 |
+
console.error(error);
|
| 833 |
+
setState("setup");
|
| 834 |
+
showFeedback(setupFeedback, "Nie udalo sie nawiazac polaczenia z serwerem.");
|
| 835 |
+
}
|
| 836 |
+
}
|
| 837 |
+
|
| 838 |
+
setupForm.addEventListener("submit", async (event) => {
|
| 839 |
+
event.preventDefault();
|
| 840 |
+
clearFeedback(setupFeedback);
|
| 841 |
+
|
| 842 |
+
const formData = new FormData(setupForm);
|
| 843 |
+
const password = formData.get("password")?.toString() ?? "";
|
| 844 |
+
const confirmPassword = formData.get("confirm_password")?.toString() ?? "";
|
| 845 |
+
|
| 846 |
+
if (password !== confirmPassword) {
|
| 847 |
+
showFeedback(setupFeedback, "Hasla musza byc identyczne.");
|
| 848 |
+
return;
|
| 849 |
+
}
|
| 850 |
+
|
| 851 |
+
if (password.trim().length < 4) {
|
| 852 |
+
showFeedback(setupFeedback, "Haslo musi miec co najmniej 4 znaki.");
|
| 853 |
+
return;
|
| 854 |
+
}
|
| 855 |
+
|
| 856 |
+
const payload = {
|
| 857 |
+
company_name: formData.get("company_name")?.toString().trim(),
|
| 858 |
+
owner_name: formData.get("owner_name")?.toString().trim(),
|
| 859 |
+
address_line: formData.get("address_line")?.toString().trim(),
|
| 860 |
+
postal_code: formData.get("postal_code")?.toString().trim(),
|
| 861 |
+
city: formData.get("city")?.toString().trim(),
|
| 862 |
+
tax_id: formData.get("tax_id")?.toString().trim(),
|
| 863 |
+
bank_account: formData.get("bank_account")?.toString().trim(),
|
| 864 |
+
password,
|
| 865 |
+
};
|
| 866 |
+
|
| 867 |
+
try {
|
| 868 |
+
await apiRequest("/api/setup", { method: "POST", body: payload });
|
| 869 |
+
showFeedback(setupFeedback, "Dane zapisane. Mozesz sie zalogowac.", "success");
|
| 870 |
+
setTimeout(() => {
|
| 871 |
+
setState("login");
|
| 872 |
+
clearFeedback(setupFeedback);
|
| 873 |
+
setupForm.reset();
|
| 874 |
+
}, 1500);
|
| 875 |
+
} catch (error) {
|
| 876 |
+
showFeedback(setupFeedback, error.message || "Nie udalo sie zapisac danych.");
|
| 877 |
+
}
|
| 878 |
+
});
|
| 879 |
+
|
| 880 |
+
loginForm.addEventListener("submit", async (event) => {
|
| 881 |
+
event.preventDefault();
|
| 882 |
+
clearFeedback(loginFeedback);
|
| 883 |
+
|
| 884 |
+
const password = loginForm.elements.password.value;
|
| 885 |
+
if (!password) {
|
| 886 |
+
showFeedback(loginFeedback, "Podaj haslo.");
|
| 887 |
+
return;
|
| 888 |
+
}
|
| 889 |
+
|
| 890 |
+
try {
|
| 891 |
+
const response = await apiRequest("/api/login", { method: "POST", body: { password } });
|
| 892 |
+
authToken = response.token;
|
| 893 |
+
sessionStorage.setItem("invoiceAuthToken", authToken);
|
| 894 |
+
loginForm.reset();
|
| 895 |
+
await bootstrapApp();
|
| 896 |
+
} catch (error) {
|
| 897 |
+
showFeedback(loginFeedback, error.message || "Logowanie nie powiodlo sie.");
|
| 898 |
+
}
|
| 899 |
+
});
|
| 900 |
+
|
| 901 |
+
toggleBusinessFormButton.addEventListener("click", () => {
|
| 902 |
+
const isHidden = businessForm.classList.contains("hidden");
|
| 903 |
+
if (isHidden) {
|
| 904 |
+
fillBusinessForm(currentBusiness);
|
| 905 |
+
businessForm.classList.remove("hidden");
|
| 906 |
+
toggleBusinessFormButton.textContent = "Ukryj formularz";
|
| 907 |
+
} else {
|
| 908 |
+
businessForm.classList.add("hidden");
|
| 909 |
+
toggleBusinessFormButton.textContent = "Edytuj dane";
|
| 910 |
+
clearFeedback(businessFeedback);
|
| 911 |
+
}
|
| 912 |
+
});
|
| 913 |
+
|
| 914 |
+
cancelBusinessUpdateButton.addEventListener("click", () => {
|
| 915 |
+
businessForm.classList.add("hidden");
|
| 916 |
+
toggleBusinessFormButton.textContent = "Edytuj dane";
|
| 917 |
+
clearFeedback(businessFeedback);
|
| 918 |
+
});
|
| 919 |
+
|
| 920 |
+
businessForm.addEventListener("submit", async (event) => {
|
| 921 |
+
event.preventDefault();
|
| 922 |
+
clearFeedback(businessFeedback);
|
| 923 |
+
|
| 924 |
+
const formData = new FormData(businessForm);
|
| 925 |
+
const payload = {
|
| 926 |
+
company_name: formData.get("company_name")?.toString().trim(),
|
| 927 |
+
owner_name: formData.get("owner_name")?.toString().trim(),
|
| 928 |
+
address_line: formData.get("address_line")?.toString().trim(),
|
| 929 |
+
postal_code: formData.get("postal_code")?.toString().trim(),
|
| 930 |
+
city: formData.get("city")?.toString().trim(),
|
| 931 |
+
tax_id: formData.get("tax_id")?.toString().trim(),
|
| 932 |
+
bank_account: formData.get("bank_account")?.toString().trim(),
|
| 933 |
+
};
|
| 934 |
+
|
| 935 |
+
try {
|
| 936 |
+
const data = await apiRequest("/api/business", { method: "PUT", body: payload }, true);
|
| 937 |
+
currentBusiness = data.business;
|
| 938 |
+
renderBusinessDisplay(currentBusiness);
|
| 939 |
+
showFeedback(businessFeedback, "Dane sprzedawcy zaktualizowane.", "success");
|
| 940 |
+
setTimeout(() => clearFeedback(businessFeedback), 2000);
|
| 941 |
+
} catch (error) {
|
| 942 |
+
showFeedback(businessFeedback, error.message || "Nie udalo sie zaktualizowac danych.");
|
| 943 |
+
}
|
| 944 |
+
});
|
| 945 |
+
|
| 946 |
+
invoiceForm.addEventListener("submit", async (event) => {
|
| 947 |
+
event.preventDefault();
|
| 948 |
+
try {
|
| 949 |
+
const payload = collectInvoicePayload();
|
| 950 |
+
const response = await apiRequest("/api/invoices", { method: "POST", body: payload }, true);
|
| 951 |
+
lastInvoice = response.invoice;
|
| 952 |
+
renderInvoicePreview(lastInvoice);
|
| 953 |
+
invoiceResult.classList.remove("hidden");
|
| 954 |
+
resetInvoiceForm();
|
| 955 |
+
} catch (error) {
|
| 956 |
+
alert(error.message || "Nie udalo sie wygenerowac faktury.");
|
| 957 |
+
}
|
| 958 |
+
});
|
| 959 |
+
|
| 960 |
+
addItemButton.addEventListener("click", () => {
|
| 961 |
+
createItemRow();
|
| 962 |
+
});
|
| 963 |
+
|
| 964 |
+
downloadButton.addEventListener("click", async () => {
|
| 965 |
+
if (!lastInvoice || !currentBusiness) {
|
| 966 |
+
alert("Brak faktury do pobrania. Wygeneruj ja najpierw.");
|
| 967 |
+
return;
|
| 968 |
+
}
|
| 969 |
+
await generatePdf(currentBusiness, lastInvoice);
|
| 970 |
+
});
|
| 971 |
+
|
| 972 |
+
logoutButton.addEventListener("click", () => {
|
| 973 |
+
authToken = null;
|
| 974 |
+
sessionStorage.removeItem("invoiceAuthToken");
|
| 975 |
+
lastInvoice = null;
|
| 976 |
+
currentBusiness = null;
|
| 977 |
+
invoiceResult.classList.add("hidden");
|
| 978 |
+
setState("login");
|
| 979 |
+
});
|
| 980 |
+
|
| 981 |
+
initialize().catch((error) => {
|
| 982 |
+
console.error(error);
|
| 983 |
+
showFeedback(setupFeedback, "Nie udalo sie uruchomic aplikacji.");
|
| 984 |
+
});
|
styles.css
DELETED
|
@@ -1,1041 +0,0 @@
|
|
| 1 |
-
:root {
|
| 2 |
-
--bg: #e9eef5;
|
| 3 |
-
--panel-bg: #ffffff;
|
| 4 |
-
--surface: #f7f9ff;
|
| 5 |
-
--surface-alt: #eef3ff;
|
| 6 |
-
--text: #101828;
|
| 7 |
-
--muted: #667085;
|
| 8 |
-
--accent: #2563eb;
|
| 9 |
-
--accent-dark: #1d4ed8;
|
| 10 |
-
--accent-soft: rgba(37, 99, 235, 0.1);
|
| 11 |
-
--danger: #dc2626;
|
| 12 |
-
--border: #dfe4ee;
|
| 13 |
-
--radius: 16px;
|
| 14 |
-
--radius-sm: 10px;
|
| 15 |
-
--shadow: 0 25px 60px rgba(15, 23, 42, 0.08);
|
| 16 |
-
font-family: "Roboto", "Segoe UI", Tahoma, sans-serif;
|
| 17 |
-
}
|
| 18 |
-
|
| 19 |
-
*,
|
| 20 |
-
*::before,
|
| 21 |
-
*::after {
|
| 22 |
-
box-sizing: border-box;
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
body {
|
| 26 |
-
margin: 0;
|
| 27 |
-
font-family: "Roboto", "Segoe UI", Tahoma, sans-serif;
|
| 28 |
-
color: var(--text);
|
| 29 |
-
line-height: 1.6;
|
| 30 |
-
background: radial-gradient(circle at top, #f5f7ff 0%, #e7edf8 60%, #dee5f1 100%);
|
| 31 |
-
min-height: 100vh;
|
| 32 |
-
-webkit-font-smoothing: antialiased;
|
| 33 |
-
}
|
| 34 |
-
|
| 35 |
-
.container {
|
| 36 |
-
max-width: 1200px;
|
| 37 |
-
margin: 0 auto;
|
| 38 |
-
padding: 48px 32px 80px;
|
| 39 |
-
display: grid;
|
| 40 |
-
gap: 32px;
|
| 41 |
-
}
|
| 42 |
-
|
| 43 |
-
.brand-banner {
|
| 44 |
-
display: flex;
|
| 45 |
-
justify-content: center;
|
| 46 |
-
align-items: center;
|
| 47 |
-
padding: 8px 0 16px;
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
.brand-logo {
|
| 51 |
-
max-width: 220px;
|
| 52 |
-
width: 100%;
|
| 53 |
-
height: auto;
|
| 54 |
-
border-radius: 16px;
|
| 55 |
-
box-shadow: 0 12px 35px rgba(15, 23, 42, 0.18);
|
| 56 |
-
}
|
| 57 |
-
|
| 58 |
-
.hero-panel {
|
| 59 |
-
background: linear-gradient(135deg, #fdfbff 0%, #f5f7ff 50%, #eef3ff 100%);
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
.hero-columns {
|
| 63 |
-
display: grid;
|
| 64 |
-
grid-template-columns: minmax(0, 1.2fr) minmax(300px, 0.8fr);
|
| 65 |
-
gap: 40px;
|
| 66 |
-
align-items: stretch;
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
.hero-content {
|
| 70 |
-
display: grid;
|
| 71 |
-
gap: 8px;
|
| 72 |
-
}
|
| 73 |
-
|
| 74 |
-
.hero-lead {
|
| 75 |
-
margin: 0;
|
| 76 |
-
color: var(--text);
|
| 77 |
-
font-size: 16px;
|
| 78 |
-
}
|
| 79 |
-
|
| 80 |
-
.eyebrow {
|
| 81 |
-
font-size: 13px;
|
| 82 |
-
text-transform: uppercase;
|
| 83 |
-
letter-spacing: 0.12em;
|
| 84 |
-
color: var(--accent);
|
| 85 |
-
margin: 0;
|
| 86 |
-
}
|
| 87 |
-
|
| 88 |
-
.app-title {
|
| 89 |
-
font-size: 24px;
|
| 90 |
-
margin: 0;
|
| 91 |
-
font-weight: 700;
|
| 92 |
-
line-height: 1.15;
|
| 93 |
-
display: inline-block;
|
| 94 |
-
width: fit-content;
|
| 95 |
-
}
|
| 96 |
-
|
| 97 |
-
.app-title::after {
|
| 98 |
-
content: "";
|
| 99 |
-
display: block;
|
| 100 |
-
width: 100%;
|
| 101 |
-
height: 3px;
|
| 102 |
-
background: var(--accent);
|
| 103 |
-
margin-top: 8px;
|
| 104 |
-
border-radius: 999px;
|
| 105 |
-
}
|
| 106 |
-
|
| 107 |
-
.app-description {
|
| 108 |
-
font-size: 16px;
|
| 109 |
-
color: var(--muted);
|
| 110 |
-
margin: 0;
|
| 111 |
-
max-width: 520px;
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
.header-highlights {
|
| 115 |
-
list-style: none;
|
| 116 |
-
padding: 0;
|
| 117 |
-
margin: 12px 0 0;
|
| 118 |
-
display: grid;
|
| 119 |
-
gap: 16px;
|
| 120 |
-
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
| 121 |
-
}
|
| 122 |
-
|
| 123 |
-
.header-highlights li {
|
| 124 |
-
padding: 18px;
|
| 125 |
-
border-radius: var(--radius-sm);
|
| 126 |
-
border: 1px solid rgba(16, 24, 40, 0.08);
|
| 127 |
-
background: #ffffff;
|
| 128 |
-
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
| 129 |
-
display: grid;
|
| 130 |
-
gap: 6px;
|
| 131 |
-
}
|
| 132 |
-
|
| 133 |
-
.header-highlights strong {
|
| 134 |
-
font-size: 15px;
|
| 135 |
-
color: var(--accent);
|
| 136 |
-
}
|
| 137 |
-
|
| 138 |
-
.header-highlights span {
|
| 139 |
-
font-size: 14px;
|
| 140 |
-
color: var(--muted);
|
| 141 |
-
}
|
| 142 |
-
|
| 143 |
-
.panel {
|
| 144 |
-
background: var(--panel-bg);
|
| 145 |
-
border-radius: var(--radius);
|
| 146 |
-
box-shadow: var(--shadow);
|
| 147 |
-
padding: 36px;
|
| 148 |
-
border: 1px solid rgba(15, 23, 42, 0.08);
|
| 149 |
-
}
|
| 150 |
-
|
| 151 |
-
.auth-card {
|
| 152 |
-
border: 1px solid rgba(16, 24, 40, 0.08);
|
| 153 |
-
border-radius: var(--radius);
|
| 154 |
-
padding: 28px 32px;
|
| 155 |
-
background: #ffffff;
|
| 156 |
-
display: grid;
|
| 157 |
-
gap: 20px;
|
| 158 |
-
max-width: 520px;
|
| 159 |
-
margin: 0 auto;
|
| 160 |
-
width: 100%;
|
| 161 |
-
box-shadow: 0 20px 35px rgba(15, 23, 42, 0.08);
|
| 162 |
-
}
|
| 163 |
-
|
| 164 |
-
.auth-panel {
|
| 165 |
-
display: grid;
|
| 166 |
-
gap: 24px;
|
| 167 |
-
height: 100%;
|
| 168 |
-
background: rgba(255, 255, 255, 0.8);
|
| 169 |
-
border-radius: var(--radius);
|
| 170 |
-
padding: 24px 28px;
|
| 171 |
-
border: 2px solid rgba(37, 99, 235, 0.4);
|
| 172 |
-
box-shadow: 0 20px 35px rgba(15, 23, 42, 0.1);
|
| 173 |
-
}
|
| 174 |
-
|
| 175 |
-
.auth-panel-header {
|
| 176 |
-
display: grid;
|
| 177 |
-
gap: 8px;
|
| 178 |
-
}
|
| 179 |
-
|
| 180 |
-
.auth-headline {
|
| 181 |
-
margin: 0;
|
| 182 |
-
font-size: 22px;
|
| 183 |
-
}
|
| 184 |
-
|
| 185 |
-
.auth-copy {
|
| 186 |
-
margin: 0;
|
| 187 |
-
color: var(--muted);
|
| 188 |
-
font-size: 15px;
|
| 189 |
-
}
|
| 190 |
-
|
| 191 |
-
.auth-login {
|
| 192 |
-
display: flex;
|
| 193 |
-
justify-content: center;
|
| 194 |
-
}
|
| 195 |
-
|
| 196 |
-
.login-card {
|
| 197 |
-
width: 100%;
|
| 198 |
-
}
|
| 199 |
-
|
| 200 |
-
#register-section {
|
| 201 |
-
display: flex;
|
| 202 |
-
justify-content: center;
|
| 203 |
-
}
|
| 204 |
-
|
| 205 |
-
#register-section .register-card {
|
| 206 |
-
max-width: 640px;
|
| 207 |
-
width: 100%;
|
| 208 |
-
}
|
| 209 |
-
|
| 210 |
-
.register-header {
|
| 211 |
-
display: flex;
|
| 212 |
-
justify-content: space-between;
|
| 213 |
-
align-items: center;
|
| 214 |
-
gap: 16px;
|
| 215 |
-
}
|
| 216 |
-
|
| 217 |
-
.auth-card h3 {
|
| 218 |
-
margin: 0;
|
| 219 |
-
}
|
| 220 |
-
|
| 221 |
-
.auth-card .form {
|
| 222 |
-
gap: 18px;
|
| 223 |
-
width: 100%;
|
| 224 |
-
}
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
.auth-actions {
|
| 228 |
-
margin-top: 4px;
|
| 229 |
-
display: flex;
|
| 230 |
-
flex-wrap: wrap;
|
| 231 |
-
align-items: center;
|
| 232 |
-
justify-content: space-between;
|
| 233 |
-
gap: 12px;
|
| 234 |
-
font-size: 14px;
|
| 235 |
-
color: var(--muted);
|
| 236 |
-
}
|
| 237 |
-
|
| 238 |
-
.ghost-button {
|
| 239 |
-
padding: 12px 20px;
|
| 240 |
-
border: 1px solid rgba(37, 99, 235, 0.35);
|
| 241 |
-
border-radius: var(--radius-sm);
|
| 242 |
-
background: transparent;
|
| 243 |
-
color: var(--accent);
|
| 244 |
-
font-weight: 600;
|
| 245 |
-
cursor: pointer;
|
| 246 |
-
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
| 247 |
-
}
|
| 248 |
-
|
| 249 |
-
.ghost-button:hover {
|
| 250 |
-
background: rgba(37, 99, 235, 0.08);
|
| 251 |
-
}
|
| 252 |
-
|
| 253 |
-
.form-divider {
|
| 254 |
-
border: none;
|
| 255 |
-
border-top: 1px solid var(--border);
|
| 256 |
-
margin: 16px 0 0;
|
| 257 |
-
}
|
| 258 |
-
|
| 259 |
-
.register-fields {
|
| 260 |
-
display: grid;
|
| 261 |
-
gap: 28px;
|
| 262 |
-
}
|
| 263 |
-
|
| 264 |
-
.register-credentials {
|
| 265 |
-
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
| 266 |
-
}
|
| 267 |
-
|
| 268 |
-
.register-company {
|
| 269 |
-
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
| 270 |
-
}
|
| 271 |
-
|
| 272 |
-
.hidden {
|
| 273 |
-
display: none;
|
| 274 |
-
}
|
| 275 |
-
|
| 276 |
-
.form {
|
| 277 |
-
display: grid;
|
| 278 |
-
gap: 24px;
|
| 279 |
-
width: 100%;
|
| 280 |
-
}
|
| 281 |
-
|
| 282 |
-
.client-lookup {
|
| 283 |
-
position: relative;
|
| 284 |
-
display: grid;
|
| 285 |
-
gap: 8px;
|
| 286 |
-
}
|
| 287 |
-
|
| 288 |
-
.client-lookup label {
|
| 289 |
-
font-size: 13px;
|
| 290 |
-
font-weight: 600;
|
| 291 |
-
color: var(--muted);
|
| 292 |
-
}
|
| 293 |
-
|
| 294 |
-
.client-lookup input {
|
| 295 |
-
width: 100%;
|
| 296 |
-
}
|
| 297 |
-
|
| 298 |
-
.client-suggestions {
|
| 299 |
-
position: absolute;
|
| 300 |
-
top: 100%;
|
| 301 |
-
left: 0;
|
| 302 |
-
right: 0;
|
| 303 |
-
margin-top: 6px;
|
| 304 |
-
background: #ffffff;
|
| 305 |
-
border-radius: var(--radius-sm);
|
| 306 |
-
border: 1px solid var(--border);
|
| 307 |
-
box-shadow: 0 15px 35px rgba(15, 23, 42, 0.15);
|
| 308 |
-
z-index: 5;
|
| 309 |
-
max-height: 320px;
|
| 310 |
-
overflow-y: auto;
|
| 311 |
-
display: grid;
|
| 312 |
-
}
|
| 313 |
-
|
| 314 |
-
.client-suggestion {
|
| 315 |
-
display: flex;
|
| 316 |
-
flex-direction: column;
|
| 317 |
-
align-items: flex-start;
|
| 318 |
-
gap: 2px;
|
| 319 |
-
padding: 10px 14px;
|
| 320 |
-
border: none;
|
| 321 |
-
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
| 322 |
-
background: none;
|
| 323 |
-
cursor: pointer;
|
| 324 |
-
text-align: left;
|
| 325 |
-
font-size: 14px;
|
| 326 |
-
}
|
| 327 |
-
|
| 328 |
-
.client-suggestion:last-child {
|
| 329 |
-
border-bottom: none;
|
| 330 |
-
}
|
| 331 |
-
|
| 332 |
-
.client-suggestion strong {
|
| 333 |
-
font-size: 14px;
|
| 334 |
-
color: var(--text);
|
| 335 |
-
}
|
| 336 |
-
|
| 337 |
-
.client-suggestion span {
|
| 338 |
-
font-size: 12px;
|
| 339 |
-
color: var(--muted);
|
| 340 |
-
}
|
| 341 |
-
|
| 342 |
-
.client-suggestions-empty {
|
| 343 |
-
padding: 12px 14px;
|
| 344 |
-
font-size: 13px;
|
| 345 |
-
color: var(--muted);
|
| 346 |
-
}
|
| 347 |
-
|
| 348 |
-
.field-grid {
|
| 349 |
-
display: grid;
|
| 350 |
-
gap: 20px 28px;
|
| 351 |
-
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
| 352 |
-
}
|
| 353 |
-
|
| 354 |
-
fieldset {
|
| 355 |
-
border: 1px solid rgba(16, 24, 40, 0.1);
|
| 356 |
-
border-radius: var(--radius);
|
| 357 |
-
padding: 24px 28px 28px;
|
| 358 |
-
display: grid;
|
| 359 |
-
gap: 18px;
|
| 360 |
-
background: var(--surface);
|
| 361 |
-
margin: 0;
|
| 362 |
-
}
|
| 363 |
-
|
| 364 |
-
legend {
|
| 365 |
-
font-weight: 700;
|
| 366 |
-
font-size: 13px;
|
| 367 |
-
text-transform: uppercase;
|
| 368 |
-
letter-spacing: 0.08em;
|
| 369 |
-
color: var(--muted);
|
| 370 |
-
padding: 0 8px;
|
| 371 |
-
}
|
| 372 |
-
|
| 373 |
-
label {
|
| 374 |
-
display: grid;
|
| 375 |
-
gap: 8px;
|
| 376 |
-
font-weight: 600;
|
| 377 |
-
font-size: 14px;
|
| 378 |
-
}
|
| 379 |
-
|
| 380 |
-
input,
|
| 381 |
-
textarea,
|
| 382 |
-
select {
|
| 383 |
-
padding: 12px 16px;
|
| 384 |
-
border-radius: var(--radius-sm);
|
| 385 |
-
border: 1px solid var(--border);
|
| 386 |
-
font-size: 15px;
|
| 387 |
-
background: #ffffff;
|
| 388 |
-
min-height: 48px;
|
| 389 |
-
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
| 390 |
-
}
|
| 391 |
-
|
| 392 |
-
input:focus,
|
| 393 |
-
textarea:focus,
|
| 394 |
-
select:focus {
|
| 395 |
-
outline: none;
|
| 396 |
-
border-color: var(--accent);
|
| 397 |
-
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25);
|
| 398 |
-
background: #ffffff;
|
| 399 |
-
}
|
| 400 |
-
|
| 401 |
-
textarea {
|
| 402 |
-
resize: vertical;
|
| 403 |
-
min-height: 120px;
|
| 404 |
-
}
|
| 405 |
-
|
| 406 |
-
#exemption-note-wrapper {
|
| 407 |
-
display: grid;
|
| 408 |
-
gap: 12px;
|
| 409 |
-
padding: 20px;
|
| 410 |
-
border: 1px dashed rgba(16, 24, 40, 0.15);
|
| 411 |
-
border-radius: var(--radius-sm);
|
| 412 |
-
background: #ffffff;
|
| 413 |
-
}
|
| 414 |
-
|
| 415 |
-
#exemption-note-wrapper textarea[readonly] {
|
| 416 |
-
background: #f4f6fb;
|
| 417 |
-
color: var(--muted);
|
| 418 |
-
}
|
| 419 |
-
|
| 420 |
-
button {
|
| 421 |
-
padding: 13px 28px;
|
| 422 |
-
border-radius: var(--radius-sm);
|
| 423 |
-
border: none;
|
| 424 |
-
font-size: 15px;
|
| 425 |
-
font-weight: 600;
|
| 426 |
-
background: var(--accent);
|
| 427 |
-
color: #ffffff;
|
| 428 |
-
cursor: pointer;
|
| 429 |
-
transition: background 0.2s ease, transform 0.15s ease, box-shadow 0.15s ease;
|
| 430 |
-
box-shadow: 0 15px 30px rgba(37, 99, 235, 0.25);
|
| 431 |
-
}
|
| 432 |
-
|
| 433 |
-
button:hover {
|
| 434 |
-
background: var(--accent-dark);
|
| 435 |
-
transform: translateY(-1px);
|
| 436 |
-
box-shadow: 0 20px 35px rgba(37, 99, 235, 0.3);
|
| 437 |
-
}
|
| 438 |
-
|
| 439 |
-
button:focus-visible {
|
| 440 |
-
outline: 3px solid rgba(37, 99, 235, 0.35);
|
| 441 |
-
outline-offset: 2px;
|
| 442 |
-
}
|
| 443 |
-
|
| 444 |
-
.button {
|
| 445 |
-
display: inline-flex;
|
| 446 |
-
align-items: center;
|
| 447 |
-
justify-content: center;
|
| 448 |
-
gap: 8px;
|
| 449 |
-
padding: 13px 24px;
|
| 450 |
-
border-radius: var(--radius-sm);
|
| 451 |
-
border: none;
|
| 452 |
-
font-size: 15px;
|
| 453 |
-
font-weight: 600;
|
| 454 |
-
background: var(--accent);
|
| 455 |
-
color: #ffffff;
|
| 456 |
-
cursor: pointer;
|
| 457 |
-
transition: background 0.2s ease, transform 0.15s ease, box-shadow 0.15s ease;
|
| 458 |
-
box-shadow: 0 10px 25px rgba(37, 99, 235, 0.2);
|
| 459 |
-
}
|
| 460 |
-
|
| 461 |
-
.button.secondary {
|
| 462 |
-
background: rgba(37, 99, 235, 0.12);
|
| 463 |
-
color: var(--accent);
|
| 464 |
-
border: 1px solid rgba(37, 99, 235, 0.25);
|
| 465 |
-
box-shadow: none;
|
| 466 |
-
}
|
| 467 |
-
|
| 468 |
-
.button.secondary:hover {
|
| 469 |
-
background: rgba(37, 99, 235, 0.2);
|
| 470 |
-
}
|
| 471 |
-
|
| 472 |
-
.button input[type="file"] {
|
| 473 |
-
display: none;
|
| 474 |
-
}
|
| 475 |
-
|
| 476 |
-
button:disabled {
|
| 477 |
-
opacity: 0.55;
|
| 478 |
-
cursor: not-allowed;
|
| 479 |
-
transform: none;
|
| 480 |
-
box-shadow: none;
|
| 481 |
-
}
|
| 482 |
-
|
| 483 |
-
.link-button {
|
| 484 |
-
background: none;
|
| 485 |
-
color: var(--accent);
|
| 486 |
-
padding: 0;
|
| 487 |
-
border-radius: 0;
|
| 488 |
-
box-shadow: none;
|
| 489 |
-
transform: none;
|
| 490 |
-
font-weight: 600;
|
| 491 |
-
transition: color 0.2s ease;
|
| 492 |
-
}
|
| 493 |
-
|
| 494 |
-
.link-button:hover {
|
| 495 |
-
color: var(--accent-dark);
|
| 496 |
-
background: none;
|
| 497 |
-
box-shadow: none;
|
| 498 |
-
transform: none;
|
| 499 |
-
}
|
| 500 |
-
|
| 501 |
-
.hint {
|
| 502 |
-
color: var(--muted);
|
| 503 |
-
font-size: 13px;
|
| 504 |
-
margin: 0;
|
| 505 |
-
}
|
| 506 |
-
|
| 507 |
-
.feedback {
|
| 508 |
-
color: var(--muted);
|
| 509 |
-
min-height: 20px;
|
| 510 |
-
font-size: 14px;
|
| 511 |
-
}
|
| 512 |
-
|
| 513 |
-
.feedback:empty {
|
| 514 |
-
display: none;
|
| 515 |
-
}
|
| 516 |
-
|
| 517 |
-
.feedback.error {
|
| 518 |
-
color: var(--danger);
|
| 519 |
-
}
|
| 520 |
-
|
| 521 |
-
.feedback.success {
|
| 522 |
-
color: #188038;
|
| 523 |
-
}
|
| 524 |
-
|
| 525 |
-
.app-header {
|
| 526 |
-
display: flex;
|
| 527 |
-
justify-content: space-between;
|
| 528 |
-
align-items: center;
|
| 529 |
-
flex-wrap: wrap;
|
| 530 |
-
gap: 16px;
|
| 531 |
-
padding-bottom: 12px;
|
| 532 |
-
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
| 533 |
-
}
|
| 534 |
-
|
| 535 |
-
.login-badge {
|
| 536 |
-
display: inline-flex;
|
| 537 |
-
align-items: center;
|
| 538 |
-
gap: 6px;
|
| 539 |
-
padding: 8px 14px;
|
| 540 |
-
border-radius: 999px;
|
| 541 |
-
border: 1px solid rgba(37, 99, 235, 0.3);
|
| 542 |
-
background: rgba(37, 99, 235, 0.08);
|
| 543 |
-
font-size: 13px;
|
| 544 |
-
color: var(--muted);
|
| 545 |
-
}
|
| 546 |
-
|
| 547 |
-
.badge-label {
|
| 548 |
-
text-transform: uppercase;
|
| 549 |
-
letter-spacing: 0.08em;
|
| 550 |
-
font-weight: 600;
|
| 551 |
-
font-size: 11px;
|
| 552 |
-
color: var(--muted);
|
| 553 |
-
}
|
| 554 |
-
|
| 555 |
-
.badge-value {
|
| 556 |
-
font-weight: 700;
|
| 557 |
-
color: var(--accent);
|
| 558 |
-
}
|
| 559 |
-
|
| 560 |
-
.app-nav {
|
| 561 |
-
display: inline-flex;
|
| 562 |
-
flex-wrap: wrap;
|
| 563 |
-
gap: 6px;
|
| 564 |
-
padding: 6px;
|
| 565 |
-
background: var(--surface);
|
| 566 |
-
border-radius: 999px;
|
| 567 |
-
border: 1px solid rgba(15, 23, 42, 0.08);
|
| 568 |
-
}
|
| 569 |
-
|
| 570 |
-
.app-nav-button {
|
| 571 |
-
background: transparent;
|
| 572 |
-
color: var(--muted);
|
| 573 |
-
border: none;
|
| 574 |
-
padding: 10px 20px;
|
| 575 |
-
border-radius: 999px;
|
| 576 |
-
box-shadow: none;
|
| 577 |
-
transform: none;
|
| 578 |
-
transition: background 0.2s ease, color 0.2s ease;
|
| 579 |
-
}
|
| 580 |
-
|
| 581 |
-
.app-nav-button:hover {
|
| 582 |
-
background: rgba(37, 99, 235, 0.08);
|
| 583 |
-
color: var(--text);
|
| 584 |
-
box-shadow: none;
|
| 585 |
-
transform: none;
|
| 586 |
-
}
|
| 587 |
-
|
| 588 |
-
.app-nav-button.active {
|
| 589 |
-
background: #ffffff;
|
| 590 |
-
color: var(--accent);
|
| 591 |
-
box-shadow: 0 12px 25px rgba(15, 23, 42, 0.12);
|
| 592 |
-
}
|
| 593 |
-
|
| 594 |
-
.app-nav-button.active:hover {
|
| 595 |
-
background: #ffffff;
|
| 596 |
-
}
|
| 597 |
-
|
| 598 |
-
.app-view {
|
| 599 |
-
display: grid;
|
| 600 |
-
gap: 32px;
|
| 601 |
-
}
|
| 602 |
-
|
| 603 |
-
.business-section {
|
| 604 |
-
border: 1px solid rgba(15, 23, 42, 0.08);
|
| 605 |
-
border-radius: var(--radius);
|
| 606 |
-
padding: 28px 32px;
|
| 607 |
-
background: linear-gradient(135deg, #ffffff 0%, #f8faff 100%);
|
| 608 |
-
display: grid;
|
| 609 |
-
gap: 18px;
|
| 610 |
-
}
|
| 611 |
-
|
| 612 |
-
.business-section-header {
|
| 613 |
-
display: flex;
|
| 614 |
-
justify-content: space-between;
|
| 615 |
-
align-items: center;
|
| 616 |
-
margin-bottom: 12px;
|
| 617 |
-
flex-wrap: wrap;
|
| 618 |
-
gap: 12px;
|
| 619 |
-
}
|
| 620 |
-
|
| 621 |
-
.business-actions {
|
| 622 |
-
display: flex;
|
| 623 |
-
gap: 12px;
|
| 624 |
-
flex-wrap: wrap;
|
| 625 |
-
align-items: center;
|
| 626 |
-
}
|
| 627 |
-
|
| 628 |
-
.pill-button {
|
| 629 |
-
display: inline-flex;
|
| 630 |
-
align-items: center;
|
| 631 |
-
justify-content: center;
|
| 632 |
-
border: 1px solid rgba(37, 99, 235, 0.35);
|
| 633 |
-
background: rgba(37, 99, 235, 0.08);
|
| 634 |
-
color: var(--accent);
|
| 635 |
-
padding: 8px 16px;
|
| 636 |
-
border-radius: 999px;
|
| 637 |
-
font-size: 14px;
|
| 638 |
-
font-weight: 600;
|
| 639 |
-
cursor: pointer;
|
| 640 |
-
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
| 641 |
-
min-height: 38px;
|
| 642 |
-
height: 38px;
|
| 643 |
-
}
|
| 644 |
-
|
| 645 |
-
.pill-button:hover,
|
| 646 |
-
.pill-button:focus-visible {
|
| 647 |
-
background: rgba(37, 99, 235, 0.15);
|
| 648 |
-
}
|
| 649 |
-
|
| 650 |
-
.pill-button.secondary {
|
| 651 |
-
background: #ffffff;
|
| 652 |
-
}
|
| 653 |
-
|
| 654 |
-
.pill-button.danger {
|
| 655 |
-
border-color: rgba(220, 38, 38, 0.4);
|
| 656 |
-
color: var(--danger);
|
| 657 |
-
background: rgba(220, 38, 38, 0.08);
|
| 658 |
-
}
|
| 659 |
-
|
| 660 |
-
.pill-button.danger:hover,
|
| 661 |
-
.pill-button.danger:focus-visible {
|
| 662 |
-
background: rgba(220, 38, 38, 0.15);
|
| 663 |
-
}
|
| 664 |
-
|
| 665 |
-
.pill-button input[type="file"] {
|
| 666 |
-
display: none;
|
| 667 |
-
}
|
| 668 |
-
|
| 669 |
-
.business-display {
|
| 670 |
-
font-size: 15px;
|
| 671 |
-
line-height: 1.4;
|
| 672 |
-
}
|
| 673 |
-
|
| 674 |
-
.business-display-grid {
|
| 675 |
-
display: grid;
|
| 676 |
-
gap: 12px;
|
| 677 |
-
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
| 678 |
-
}
|
| 679 |
-
|
| 680 |
-
.business-display-item {
|
| 681 |
-
display: flex;
|
| 682 |
-
flex-direction: column;
|
| 683 |
-
gap: 4px;
|
| 684 |
-
}
|
| 685 |
-
|
| 686 |
-
.business-display-item strong {
|
| 687 |
-
font-weight: 600;
|
| 688 |
-
}
|
| 689 |
-
|
| 690 |
-
.logo-preview {
|
| 691 |
-
margin-top: 16px;
|
| 692 |
-
border: 1px solid var(--border);
|
| 693 |
-
border-radius: var(--radius-sm);
|
| 694 |
-
padding: 16px;
|
| 695 |
-
display: grid;
|
| 696 |
-
gap: 10px;
|
| 697 |
-
max-width: 280px;
|
| 698 |
-
background: #ffffff;
|
| 699 |
-
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
|
| 700 |
-
}
|
| 701 |
-
|
| 702 |
-
.logo-preview-label {
|
| 703 |
-
font-size: 12px;
|
| 704 |
-
text-transform: uppercase;
|
| 705 |
-
letter-spacing: 0.05em;
|
| 706 |
-
color: var(--muted);
|
| 707 |
-
font-weight: 700;
|
| 708 |
-
}
|
| 709 |
-
|
| 710 |
-
.logo-preview img {
|
| 711 |
-
max-width: 100%;
|
| 712 |
-
max-height: 120px;
|
| 713 |
-
object-fit: contain;
|
| 714 |
-
}
|
| 715 |
-
|
| 716 |
-
.form-actions {
|
| 717 |
-
display: flex;
|
| 718 |
-
align-items: center;
|
| 719 |
-
flex-wrap: wrap;
|
| 720 |
-
gap: 16px;
|
| 721 |
-
}
|
| 722 |
-
|
| 723 |
-
.items-section {
|
| 724 |
-
border: 1px solid rgba(15, 23, 42, 0.08);
|
| 725 |
-
border-radius: var(--radius);
|
| 726 |
-
padding: 24px 28px;
|
| 727 |
-
background: #ffffff;
|
| 728 |
-
display: grid;
|
| 729 |
-
gap: 20px;
|
| 730 |
-
}
|
| 731 |
-
|
| 732 |
-
.items-header {
|
| 733 |
-
display: flex;
|
| 734 |
-
justify-content: space-between;
|
| 735 |
-
align-items: center;
|
| 736 |
-
flex-wrap: wrap;
|
| 737 |
-
gap: 16px;
|
| 738 |
-
}
|
| 739 |
-
|
| 740 |
-
.items-table-wrapper {
|
| 741 |
-
overflow-x: auto;
|
| 742 |
-
border-radius: var(--radius);
|
| 743 |
-
border: 1px solid var(--border);
|
| 744 |
-
background: #ffffff;
|
| 745 |
-
}
|
| 746 |
-
|
| 747 |
-
.items-table {
|
| 748 |
-
width: 100%;
|
| 749 |
-
border-collapse: separate;
|
| 750 |
-
border-spacing: 0;
|
| 751 |
-
font-size: 14px;
|
| 752 |
-
}
|
| 753 |
-
|
| 754 |
-
.items-table th,
|
| 755 |
-
.items-table td {
|
| 756 |
-
padding: 12px 14px;
|
| 757 |
-
text-align: left;
|
| 758 |
-
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
| 759 |
-
}
|
| 760 |
-
|
| 761 |
-
.items-table tr:last-child td {
|
| 762 |
-
border-bottom: none;
|
| 763 |
-
}
|
| 764 |
-
|
| 765 |
-
.items-table th {
|
| 766 |
-
background: var(--surface);
|
| 767 |
-
font-weight: 600;
|
| 768 |
-
text-transform: uppercase;
|
| 769 |
-
letter-spacing: 0.05em;
|
| 770 |
-
font-size: 12px;
|
| 771 |
-
color: var(--muted);
|
| 772 |
-
}
|
| 773 |
-
|
| 774 |
-
.items-table tbody tr:nth-child(even) {
|
| 775 |
-
background: rgba(37, 99, 235, 0.03);
|
| 776 |
-
}
|
| 777 |
-
|
| 778 |
-
.items-table tbody tr:hover {
|
| 779 |
-
background: rgba(37, 99, 235, 0.08);
|
| 780 |
-
}
|
| 781 |
-
|
| 782 |
-
.items-table input,
|
| 783 |
-
.items-table select {
|
| 784 |
-
width: 100%;
|
| 785 |
-
min-height: 44px;
|
| 786 |
-
}
|
| 787 |
-
|
| 788 |
-
.items-table .remove-item {
|
| 789 |
-
color: var(--danger);
|
| 790 |
-
background: none;
|
| 791 |
-
padding: 0;
|
| 792 |
-
box-shadow: none;
|
| 793 |
-
}
|
| 794 |
-
|
| 795 |
-
.items-table .remove-item:hover {
|
| 796 |
-
text-decoration: underline;
|
| 797 |
-
background: none;
|
| 798 |
-
}
|
| 799 |
-
|
| 800 |
-
.totals {
|
| 801 |
-
display: grid;
|
| 802 |
-
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
| 803 |
-
gap: 16px;
|
| 804 |
-
font-weight: 600;
|
| 805 |
-
margin-top: 8px;
|
| 806 |
-
padding: 0;
|
| 807 |
-
}
|
| 808 |
-
|
| 809 |
-
.totals span {
|
| 810 |
-
display: block;
|
| 811 |
-
padding: 14px 16px;
|
| 812 |
-
border-radius: var(--radius-sm);
|
| 813 |
-
border: 1px solid var(--border);
|
| 814 |
-
background: var(--surface);
|
| 815 |
-
text-align: center;
|
| 816 |
-
}
|
| 817 |
-
|
| 818 |
-
.rate-summary {
|
| 819 |
-
display: grid;
|
| 820 |
-
gap: 10px;
|
| 821 |
-
margin-top: 8px;
|
| 822 |
-
}
|
| 823 |
-
|
| 824 |
-
.rate-summary-item {
|
| 825 |
-
display: flex;
|
| 826 |
-
flex-wrap: wrap;
|
| 827 |
-
gap: 12px;
|
| 828 |
-
font-weight: 600;
|
| 829 |
-
padding: 12px 16px;
|
| 830 |
-
border: 1px dashed rgba(37, 99, 235, 0.4);
|
| 831 |
-
border-radius: var(--radius-sm);
|
| 832 |
-
background: #ffffff;
|
| 833 |
-
}
|
| 834 |
-
|
| 835 |
-
.invoice-preview {
|
| 836 |
-
display: grid;
|
| 837 |
-
gap: 24px;
|
| 838 |
-
}
|
| 839 |
-
|
| 840 |
-
.invoice-preview-meta {
|
| 841 |
-
display: flex;
|
| 842 |
-
flex-wrap: wrap;
|
| 843 |
-
gap: 24px;
|
| 844 |
-
font-size: 14px;
|
| 845 |
-
}
|
| 846 |
-
|
| 847 |
-
.invoice-preview-meta span {
|
| 848 |
-
display: inline-flex;
|
| 849 |
-
align-items: center;
|
| 850 |
-
gap: 6px;
|
| 851 |
-
}
|
| 852 |
-
|
| 853 |
-
.invoice-preview-header {
|
| 854 |
-
display: flex;
|
| 855 |
-
flex-wrap: wrap;
|
| 856 |
-
gap: 24px;
|
| 857 |
-
}
|
| 858 |
-
|
| 859 |
-
.invoice-preview-card {
|
| 860 |
-
flex: 1 1 280px;
|
| 861 |
-
border: 1px solid var(--border);
|
| 862 |
-
border-radius: var(--radius);
|
| 863 |
-
padding: 16px 20px;
|
| 864 |
-
background: #f9fafc;
|
| 865 |
-
}
|
| 866 |
-
|
| 867 |
-
.invoice-preview-card h4 {
|
| 868 |
-
margin: 0 0 12px;
|
| 869 |
-
font-size: 14px;
|
| 870 |
-
text-transform: uppercase;
|
| 871 |
-
letter-spacing: 0.05em;
|
| 872 |
-
}
|
| 873 |
-
|
| 874 |
-
.invoice-preview-card p {
|
| 875 |
-
margin: 4px 0;
|
| 876 |
-
font-size: 14px;
|
| 877 |
-
}
|
| 878 |
-
|
| 879 |
-
.invoice-preview table {
|
| 880 |
-
width: 100%;
|
| 881 |
-
border-collapse: collapse;
|
| 882 |
-
font-size: 14px;
|
| 883 |
-
}
|
| 884 |
-
|
| 885 |
-
.invoice-preview th,
|
| 886 |
-
.invoice-preview td {
|
| 887 |
-
border: 1px solid var(--border);
|
| 888 |
-
padding: 10px 12px;
|
| 889 |
-
text-align: left;
|
| 890 |
-
}
|
| 891 |
-
|
| 892 |
-
.invoice-preview th {
|
| 893 |
-
background: #f1f3f7;
|
| 894 |
-
font-weight: 600;
|
| 895 |
-
}
|
| 896 |
-
|
| 897 |
-
.invoice-preview-summary {
|
| 898 |
-
display: flex;
|
| 899 |
-
justify-content: flex-end;
|
| 900 |
-
flex-wrap: wrap;
|
| 901 |
-
gap: 16px;
|
| 902 |
-
font-weight: 600;
|
| 903 |
-
font-size: 15px;
|
| 904 |
-
}
|
| 905 |
-
|
| 906 |
-
.invoice-preview-note {
|
| 907 |
-
padding: 12px 16px;
|
| 908 |
-
border-left: 3px solid var(--accent);
|
| 909 |
-
background: #f4f8ff;
|
| 910 |
-
font-size: 14px;
|
| 911 |
-
}
|
| 912 |
-
|
| 913 |
-
.dashboard-header {
|
| 914 |
-
display: flex;
|
| 915 |
-
flex-wrap: wrap;
|
| 916 |
-
align-items: center;
|
| 917 |
-
justify-content: space-between;
|
| 918 |
-
gap: 16px;
|
| 919 |
-
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
| 920 |
-
padding-bottom: 12px;
|
| 921 |
-
}
|
| 922 |
-
|
| 923 |
-
.filters {
|
| 924 |
-
display: flex;
|
| 925 |
-
flex-wrap: wrap;
|
| 926 |
-
gap: 16px;
|
| 927 |
-
align-items: flex-end;
|
| 928 |
-
}
|
| 929 |
-
|
| 930 |
-
.dashboard-summary {
|
| 931 |
-
display: grid;
|
| 932 |
-
gap: 18px;
|
| 933 |
-
margin: 24px 0;
|
| 934 |
-
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
| 935 |
-
}
|
| 936 |
-
|
| 937 |
-
.summary-card {
|
| 938 |
-
border: 1px solid rgba(15, 23, 42, 0.08);
|
| 939 |
-
border-radius: var(--radius);
|
| 940 |
-
background: var(--surface);
|
| 941 |
-
padding: 20px;
|
| 942 |
-
display: grid;
|
| 943 |
-
gap: 8px;
|
| 944 |
-
box-shadow: 0 12px 25px rgba(15, 23, 42, 0.08);
|
| 945 |
-
}
|
| 946 |
-
|
| 947 |
-
.summary-label {
|
| 948 |
-
font-size: 13px;
|
| 949 |
-
font-weight: 600;
|
| 950 |
-
color: var(--muted);
|
| 951 |
-
text-transform: uppercase;
|
| 952 |
-
letter-spacing: 0.05em;
|
| 953 |
-
}
|
| 954 |
-
|
| 955 |
-
.summary-count {
|
| 956 |
-
font-size: 22px;
|
| 957 |
-
font-weight: 700;
|
| 958 |
-
}
|
| 959 |
-
|
| 960 |
-
.summary-amount {
|
| 961 |
-
font-size: 18px;
|
| 962 |
-
font-weight: 600;
|
| 963 |
-
color: var(--accent);
|
| 964 |
-
}
|
| 965 |
-
|
| 966 |
-
.dashboard-chart {
|
| 967 |
-
border: 1px solid rgba(15, 23, 42, 0.08);
|
| 968 |
-
border-radius: var(--radius);
|
| 969 |
-
background: #ffffff;
|
| 970 |
-
padding: 24px;
|
| 971 |
-
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.08);
|
| 972 |
-
}
|
| 973 |
-
|
| 974 |
-
.dashboard-table .items-table th,
|
| 975 |
-
.dashboard-table .items-table td {
|
| 976 |
-
white-space: nowrap;
|
| 977 |
-
}
|
| 978 |
-
|
| 979 |
-
.dashboard-table .items-table td:last-child {
|
| 980 |
-
width: 160px;
|
| 981 |
-
}
|
| 982 |
-
|
| 983 |
-
.table-actions {
|
| 984 |
-
display: flex;
|
| 985 |
-
flex-wrap: wrap;
|
| 986 |
-
gap: 8px;
|
| 987 |
-
}
|
| 988 |
-
|
| 989 |
-
#invoices-empty {
|
| 990 |
-
margin-top: 12px;
|
| 991 |
-
text-align: center;
|
| 992 |
-
padding: 12px 0;
|
| 993 |
-
}
|
| 994 |
-
|
| 995 |
-
@media (max-width: 1024px) {
|
| 996 |
-
.container {
|
| 997 |
-
padding: 40px 24px 64px;
|
| 998 |
-
}
|
| 999 |
-
.hero-columns {
|
| 1000 |
-
grid-template-columns: 1fr;
|
| 1001 |
-
}
|
| 1002 |
-
}
|
| 1003 |
-
|
| 1004 |
-
@media (max-width: 900px) {
|
| 1005 |
-
.hero-columns {
|
| 1006 |
-
grid-template-columns: 1fr;
|
| 1007 |
-
}
|
| 1008 |
-
}
|
| 1009 |
-
|
| 1010 |
-
@media (max-width: 640px) {
|
| 1011 |
-
.container {
|
| 1012 |
-
padding: 32px 16px 56px;
|
| 1013 |
-
}
|
| 1014 |
-
|
| 1015 |
-
.panel {
|
| 1016 |
-
padding: 24px;
|
| 1017 |
-
}
|
| 1018 |
-
|
| 1019 |
-
.brand-logo {
|
| 1020 |
-
max-width: 160px;
|
| 1021 |
-
}
|
| 1022 |
-
|
| 1023 |
-
.app-title {
|
| 1024 |
-
font-size: 24px;
|
| 1025 |
-
}
|
| 1026 |
-
|
| 1027 |
-
.auth-panel {
|
| 1028 |
-
grid-template-columns: 1fr;
|
| 1029 |
-
}
|
| 1030 |
-
|
| 1031 |
-
.app-header {
|
| 1032 |
-
flex-direction: column;
|
| 1033 |
-
gap: 12px;
|
| 1034 |
-
align-items: flex-start;
|
| 1035 |
-
}
|
| 1036 |
-
|
| 1037 |
-
.business-section,
|
| 1038 |
-
.items-section {
|
| 1039 |
-
padding: 20px;
|
| 1040 |
-
}
|
| 1041 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
templates/index.html
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="pl">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Generator faktur</title>
|
| 7 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<main class="container">
|
| 11 |
+
<h1>Generator faktur</h1>
|
| 12 |
+
|
| 13 |
+
<section id="setup-section" class="panel hidden">
|
| 14 |
+
<h2>Konfiguracja danych firmy</h2>
|
| 15 |
+
<form id="setup-form" class="form">
|
| 16 |
+
<div class="field-grid">
|
| 17 |
+
<label>
|
| 18 |
+
Nazwa firmy
|
| 19 |
+
<input type="text" name="company_name" required>
|
| 20 |
+
</label>
|
| 21 |
+
<label>
|
| 22 |
+
Imie i nazwisko wlasciciela
|
| 23 |
+
<input type="text" name="owner_name" required>
|
| 24 |
+
</label>
|
| 25 |
+
<label>
|
| 26 |
+
Ulica i numer
|
| 27 |
+
<input type="text" name="address_line" required>
|
| 28 |
+
</label>
|
| 29 |
+
<label>
|
| 30 |
+
Kod pocztowy
|
| 31 |
+
<input type="text" name="postal_code" required>
|
| 32 |
+
</label>
|
| 33 |
+
<label>
|
| 34 |
+
Miejscowosc
|
| 35 |
+
<input type="text" name="city" required>
|
| 36 |
+
</label>
|
| 37 |
+
<label>
|
| 38 |
+
NIP
|
| 39 |
+
<input type="text" name="tax_id" required>
|
| 40 |
+
</label>
|
| 41 |
+
<label>
|
| 42 |
+
Numer konta bankowego
|
| 43 |
+
<input type="text" name="bank_account" required>
|
| 44 |
+
</label>
|
| 45 |
+
<label>
|
| 46 |
+
Haslo
|
| 47 |
+
<input type="password" name="password" required>
|
| 48 |
+
</label>
|
| 49 |
+
<label>
|
| 50 |
+
Powtorz haslo
|
| 51 |
+
<input type="password" name="confirm_password" required>
|
| 52 |
+
</label>
|
| 53 |
+
</div>
|
| 54 |
+
<button type="submit">Zapisz dane</button>
|
| 55 |
+
<p class="hint">Dane przechowywane sa na serwerze lokalnym.</p>
|
| 56 |
+
</form>
|
| 57 |
+
<p id="setup-feedback" class="feedback"></p>
|
| 58 |
+
</section>
|
| 59 |
+
|
| 60 |
+
<section id="login-section" class="panel hidden">
|
| 61 |
+
<h2>Logowanie</h2>
|
| 62 |
+
<form id="login-form" class="form">
|
| 63 |
+
<label>
|
| 64 |
+
Haslo
|
| 65 |
+
<input type="password" name="password" required>
|
| 66 |
+
</label>
|
| 67 |
+
<button type="submit">Zaloguj</button>
|
| 68 |
+
</form>
|
| 69 |
+
<p id="login-feedback" class="feedback"></p>
|
| 70 |
+
</section>
|
| 71 |
+
|
| 72 |
+
<section id="app-section" class="panel hidden">
|
| 73 |
+
<header class="invoice-header">
|
| 74 |
+
<h2>Panel faktur</h2>
|
| 75 |
+
<button id="logout-button" type="button" class="link-button">Wyloguj</button>
|
| 76 |
+
</header>
|
| 77 |
+
|
| 78 |
+
<section class="business-section">
|
| 79 |
+
<div class="business-section-header">
|
| 80 |
+
<h3>Dane sprzedawcy</h3>
|
| 81 |
+
<button id="toggle-business-form" type="button" class="link-button">Edytuj dane</button>
|
| 82 |
+
</div>
|
| 83 |
+
<div id="business-display" class="business-display"></div>
|
| 84 |
+
<form id="business-form" class="form hidden">
|
| 85 |
+
<div class="field-grid">
|
| 86 |
+
<label>
|
| 87 |
+
Nazwa firmy
|
| 88 |
+
<input type="text" name="company_name" required>
|
| 89 |
+
</label>
|
| 90 |
+
<label>
|
| 91 |
+
Imie i nazwisko wlasciciela
|
| 92 |
+
<input type="text" name="owner_name" required>
|
| 93 |
+
</label>
|
| 94 |
+
<label>
|
| 95 |
+
Ulica i numer
|
| 96 |
+
<input type="text" name="address_line" required>
|
| 97 |
+
</label>
|
| 98 |
+
<label>
|
| 99 |
+
Kod pocztowy
|
| 100 |
+
<input type="text" name="postal_code" required>
|
| 101 |
+
</label>
|
| 102 |
+
<label>
|
| 103 |
+
Miejscowosc
|
| 104 |
+
<input type="text" name="city" required>
|
| 105 |
+
</label>
|
| 106 |
+
<label>
|
| 107 |
+
NIP
|
| 108 |
+
<input type="text" name="tax_id" required>
|
| 109 |
+
</label>
|
| 110 |
+
<label>
|
| 111 |
+
Numer konta bankowego
|
| 112 |
+
<input type="text" name="bank_account" required>
|
| 113 |
+
</label>
|
| 114 |
+
</div>
|
| 115 |
+
<div class="form-actions">
|
| 116 |
+
<button type="submit">Zapisz</button>
|
| 117 |
+
<button id="cancel-business-update" type="button" class="link-button">Anuluj</button>
|
| 118 |
+
</div>
|
| 119 |
+
<p id="business-feedback" class="feedback"></p>
|
| 120 |
+
</form>
|
| 121 |
+
</section>
|
| 122 |
+
|
| 123 |
+
<form id="invoice-form" class="form">
|
| 124 |
+
<fieldset>
|
| 125 |
+
<legend>Informacje o fakturze</legend>
|
| 126 |
+
<label>
|
| 127 |
+
Data sprzedazy / wykonania uslugi
|
| 128 |
+
<input type="date" name="saleDate">
|
| 129 |
+
</label>
|
| 130 |
+
</fieldset>
|
| 131 |
+
|
| 132 |
+
<fieldset>
|
| 133 |
+
<legend>Dane nabywcy</legend>
|
| 134 |
+
<div class="field-grid">
|
| 135 |
+
<label>
|
| 136 |
+
Nazwa / Imie i nazwisko
|
| 137 |
+
<input type="text" name="clientName">
|
| 138 |
+
</label>
|
| 139 |
+
<label>
|
| 140 |
+
NIP
|
| 141 |
+
<input type="text" name="clientTaxId">
|
| 142 |
+
</label>
|
| 143 |
+
<label>
|
| 144 |
+
Ulica i numer
|
| 145 |
+
<input type="text" name="clientAddress">
|
| 146 |
+
</label>
|
| 147 |
+
<label>
|
| 148 |
+
Kod pocztowy
|
| 149 |
+
<input type="text" name="clientPostalCode">
|
| 150 |
+
</label>
|
| 151 |
+
<label>
|
| 152 |
+
Miejscowosc
|
| 153 |
+
<input type="text" name="clientCity">
|
| 154 |
+
</label>
|
| 155 |
+
</div>
|
| 156 |
+
</fieldset>
|
| 157 |
+
|
| 158 |
+
<section class="items-section">
|
| 159 |
+
<header class="items-header">
|
| 160 |
+
<h3>Pozycje faktury</h3>
|
| 161 |
+
<button type="button" id="add-item-button">Dodaj pozycje</button>
|
| 162 |
+
</header>
|
| 163 |
+
<div class="items-table-wrapper">
|
| 164 |
+
<table class="items-table">
|
| 165 |
+
<thead>
|
| 166 |
+
<tr>
|
| 167 |
+
<th>Nazwa towaru/uslugi</th>
|
| 168 |
+
<th>Ilosc</th>
|
| 169 |
+
<th>Cena jedn. brutto (PLN)</th>
|
| 170 |
+
<th>Stawka VAT</th>
|
| 171 |
+
<th>Wartosc brutto (PLN)</th>
|
| 172 |
+
<th></th>
|
| 173 |
+
</tr>
|
| 174 |
+
</thead>
|
| 175 |
+
<tbody id="items-body"></tbody>
|
| 176 |
+
</table>
|
| 177 |
+
</div>
|
| 178 |
+
</section>
|
| 179 |
+
|
| 180 |
+
<div id="totals-container" class="totals">
|
| 181 |
+
<span id="total-net">Suma netto: 0.00 PLN</span>
|
| 182 |
+
<span id="total-vat">Kwota VAT: 0.00 PLN</span>
|
| 183 |
+
<span id="total-gross">Suma brutto: 0.00 PLN</span>
|
| 184 |
+
</div>
|
| 185 |
+
|
| 186 |
+
<section id="rate-summary" class="rate-summary"></section>
|
| 187 |
+
|
| 188 |
+
<div id="exemption-note-wrapper" class="hidden">
|
| 189 |
+
<label>
|
| 190 |
+
Podstawa prawna zwolnienia (stosowana dla pozycji ZW)
|
| 191 |
+
<textarea id="exemption-note" rows="3" placeholder="np. Art. 43 ust. 1 pkt 19 ustawy o VAT"></textarea>
|
| 192 |
+
</label>
|
| 193 |
+
</div>
|
| 194 |
+
|
| 195 |
+
<button type="submit">Generuj fakture</button>
|
| 196 |
+
</form>
|
| 197 |
+
|
| 198 |
+
<section id="invoice-result" class="panel hidden">
|
| 199 |
+
<h3>Podglad faktury</h3>
|
| 200 |
+
<div id="invoice-output" class="invoice-preview"></div>
|
| 201 |
+
<button id="download-button" type="button">Pobierz jako plik PDF</button>
|
| 202 |
+
</section>
|
| 203 |
+
</section>
|
| 204 |
+
</main>
|
| 205 |
+
|
| 206 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js" defer></script>
|
| 207 |
+
<script src="{{ url_for('static', filename='js/main.js') }}" defer></script>
|
| 208 |
+
</body>
|
| 209 |
+
</html>
|
web_invoice_store.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|