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