2 Commits

Author SHA1 Message Date
Mateusz Gruszczyński
172b46ad07 zmiany dla michała 2026-04-10 12:00:46 +02:00
Mateusz Gruszczyński
4c9d665ae2 zmiana waluty w .env 2026-04-02 08:25:07 +02:00
22 changed files with 278 additions and 40 deletions

View File

@@ -195,4 +195,10 @@ UPLOADS_CACHE_CONTROL="max-age=3600, immutable"
# UWAGA: wielkość liter w nazwach jest zachowywana, ale porównywanie odbywa się
# bez rozróżniania wielkości liter (case-insensitive).
# Domyślnie: poniższa lista
DEFAULT_CATEGORIES="Spożywcze,Budowlane,Zabawki,Chemia,Inne,Elektronika,Odzież i obuwie,Artykuły biurowe,Kosmetyki i higiena,Motoryzacja,Ogród i rośliny,Zwierzęta,Sprzęt sportowy,Książki i prasa,Narzędzia i majsterkowanie,RTV / AGD,Apteka i suplementy,Artykuły dekoracyjne,Gry i hobby,Usługi,Pieczywo"
DEFAULT_CATEGORIES="Spożywcze,Budowlane,Zabawki,Chemia,Inne,Elektronika,Odzież i obuwie,Artykuły biurowe,Kosmetyki i higiena,Motoryzacja,Ogród i rośliny,Zwierzęta,Sprzęt sportowy,Książki i prasa,Narzędzia i majsterkowanie,RTV / AGD,Apteka i suplementy,Artykuły dekoracyjne,Gry i hobby,Usługi,Pieczywo"
# Waluta używana w całej aplikacji (kwoty, paragony, analizy)
# Użyj kodu ISO 4217 (np. PLN, EUR, USD, GBP)
# Domyślnie: PLN (jeśli zmienna nie jest ustawiona)
CURRENCY_CODE=PLN

View File

@@ -91,6 +91,8 @@ class Config:
DEBUG_MODE = env_bool("DEBUG_MODE", True)
DISABLE_ROBOTS = env_bool("DISABLE_ROBOTS", False)
CURRENCY_CODE = env_str("CURRENCY_CODE", "PLN").strip().upper() or "PLN"
JS_CACHE_CONTROL = env_str("JS_CACHE_CONTROL", "no-cache")
CSS_CACHE_CONTROL = env_str("CSS_CACHE_CONTROL", "no-cache")
LIB_JS_CACHE_CONTROL = env_str("LIB_JS_CACHE_CONTROL", "max-age=604800")

View File

@@ -2,6 +2,25 @@ from .deps import *
from .app_setup import *
from .models import *
def get_currency_code() -> str:
code = str(app.config.get("CURRENCY_CODE", "PLN") or "PLN").strip().upper()
return code or "PLN"
def format_currency(amount, include_code: bool = True) -> str:
try:
normalized = float(amount or 0)
except (TypeError, ValueError):
normalized = 0.0
formatted = f"{normalized:.2f}"
return f"{formatted} {get_currency_code()}" if include_code else formatted
def currency_placeholder(prefix: str = "Kwota") -> str:
return f"{prefix} ({get_currency_code()})"
def get_setting(key: str, default: str | None = None) -> str | None:
s = db.session.get(AppSetting, key)
return s.value if s else default

View File

@@ -453,7 +453,7 @@ def handle_add_expense(data):
)
db.session.add(new_expense)
log_list_activity(list_id, 'expense_added', item_name=None, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość', details=f'kwota: {float(amount):.2f} PLN')
log_list_activity(list_id, 'expense_added', item_name=None, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość', details=f'kwota: {format_currency(amount)}')
db.session.commit()
total = (

View File

@@ -1382,6 +1382,14 @@ input[type="checkbox"].form-check-input,
min-width: 0;
overflow-wrap: break-word;
word-break: normal;
appearance: none;
background: transparent;
border: 0;
padding: 0;
margin: 0;
text-align: left;
font: inherit;
color: inherit;
}
.shopping-item-text .info-line {
@@ -2284,3 +2292,35 @@ body:not(.sorting-active) .drag-handle {
color: rgba(255,255,255,.66);
line-height: 1.35;
}
.endpoint-view_list .shopping-item-name[data-item-menu-trigger],
.endpoint-list .shopping-item-name[data-item-menu-trigger] {
cursor: pointer;
}
.endpoint-view_list .shopping-item-name[data-item-menu-trigger]:hover,
.endpoint-view_list .shopping-item-name[data-item-menu-trigger]:focus-visible,
.endpoint-list .shopping-item-name[data-item-menu-trigger]:hover,
.endpoint-list .shopping-item-name[data-item-menu-trigger]:focus-visible {
text-decoration: none;
}
#desktopItemMenu {
position: fixed;
z-index: 1200;
min-width: 10rem;
display: grid;
gap: .35rem;
padding: .45rem;
border: 1px solid rgba(255, 255, 255, .12);
border-radius: .9rem;
background: rgba(18, 20, 24, .96);
box-shadow: 0 16px 38px rgba(0, 0, 0, .34);
backdrop-filter: blur(10px);
}
#desktopItemMenu[hidden] {
display: none !important;
}

View File

@@ -0,0 +1,126 @@
(function () {
const DESKTOP_QUERY = '(min-width: 992px) and (pointer: fine)';
function isDesktopOwnerList() {
return window.matchMedia(DESKTOP_QUERY).matches
&& !window.IS_SHARE
&& (
document.body.classList.contains('endpoint-view_list')
|| document.body.classList.contains('endpoint-list')
);
}
function getMenu() {
return document.getElementById('desktopItemMenu');
}
function hideDesktopItemMenu() {
const menu = getMenu();
if (!menu) return;
menu.hidden = true;
delete menu.dataset.itemId;
delete menu.dataset.itemName;
delete menu.dataset.itemQuantity;
}
function positionMenu(menu, clickX, clickY) {
const gap = 14;
menu.style.left = '0px';
menu.style.top = '0px';
menu.hidden = false;
const rect = menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = clickX - (rect.width / 2);
let top = clickY - rect.height - gap;
if (top < 12) {
top = Math.min(viewportHeight - rect.height - 12, clickY + gap);
}
left = Math.max(12, Math.min(left, viewportWidth - rect.width - 12));
top = Math.max(12, Math.min(top, viewportHeight - rect.height - 12));
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
}
function showDesktopItemMenu(trigger, event) {
const menu = getMenu();
if (!menu) return;
menu.dataset.itemId = trigger.dataset.itemId || '';
menu.dataset.itemName = trigger.dataset.itemName || '';
menu.dataset.itemQuantity = trigger.dataset.itemQuantity || '1';
let clickX = event.clientX || 0;
let clickY = event.clientY || 0;
if (!clickX && !clickY) {
const rect = trigger.getBoundingClientRect();
clickX = rect.left + (rect.width / 2);
clickY = rect.top;
}
positionMenu(menu, clickX, clickY);
}
document.addEventListener('click', function (event) {
const menu = getMenu();
const trigger = event.target.closest('[data-item-menu-trigger="true"]');
if (trigger && isDesktopOwnerList() && !trigger.disabled) {
event.preventDefault();
event.stopPropagation();
showDesktopItemMenu(trigger, event);
return;
}
if (menu && !menu.hidden && !event.target.closest('#desktopItemMenu')) {
hideDesktopItemMenu();
}
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape') {
hideDesktopItemMenu();
}
});
['scroll', 'resize'].forEach(function (eventName) {
window.addEventListener(eventName, hideDesktopItemMenu, true);
});
document.addEventListener('DOMContentLoaded', function () {
const menu = getMenu();
if (!menu) return;
menu.addEventListener('click', function (event) {
const actionButton = event.target.closest('[data-menu-action]');
if (!actionButton) return;
const itemId = parseInt(menu.dataset.itemId || '', 10);
const itemName = menu.dataset.itemName || '';
const itemQuantity = parseInt(menu.dataset.itemQuantity || '1', 10) || 1;
if (!itemId) {
hideDesktopItemMenu();
return;
}
if (actionButton.dataset.menuAction === 'edit') {
openEditItemModal(event, itemId, itemName, itemQuantity);
}
if (actionButton.dataset.menuAction === 'delete') {
deleteItem(itemId);
}
hideDesktopItemMenu();
});
});
window.hideDesktopItemMenu = hideDesktopItemMenu;
})();

View File

@@ -123,7 +123,7 @@ document.addEventListener("DOMContentLoaded", function () {
data: {
labels: data.labels || [],
datasets: [{
label: "Suma wydatków [PLN]",
label: `Suma wydatków [${getCurrencyCode()}]`,
data: data.expenses || [],
}],
},

View File

@@ -28,7 +28,7 @@ document.addEventListener('DOMContentLoaded', () => {
total += parseFloat(cb.dataset.amount);
}
});
totalEl.textContent = total.toFixed(2) + ' PLN';
totalEl.textContent = formatCurrencyAmount(total);
}
function getISOWeek(date) {

View File

@@ -1,3 +1,19 @@
function getCurrencyCode() {
return window.CURRENCY_CODE || 'PLN';
}
function formatCurrencyAmount(amount, options = {}) {
const includeCode = options.includeCode !== false;
const numeric = Number(amount || 0);
const safe = Number.isFinite(numeric) ? numeric : 0;
const formatted = safe.toFixed(2);
return includeCode ? `${formatted} ${getCurrencyCode()}` : formatted;
}
function currencyLabel(prefix = 'Kwota') {
return `${prefix} (${getCurrencyCode()})`;
}
function updateItemState(itemId, isChecked) {
const checkbox = document.querySelector(`#item-${itemId} input[type='checkbox']`);
if (checkbox) {
@@ -369,8 +385,9 @@ function renderItem(item, isShare = window.IS_SHARE, optionsOrShowEditOnly = fal
const isOwner = window.IS_OWNER === true || window.IS_OWNER === 'true';
const isArchived = window.IS_ARCHIVED === true || window.IS_ARCHIVED === 'true';
const safeName = escapeHtml(item.name || '');
const nameForEdit = JSON.stringify(String(item.name || ''));
const rawName = String(item.name || '');
const safeName = escapeHtml(rawName);
const nameForEdit = JSON.stringify(rawName);
const quantity = Number.isInteger(item.quantity) ? item.quantity : parseInt(item.quantity, 10) || 1;
const quantityBadge = quantity > 1
? `<span class="badge rounded-pill bg-secondary">x${quantity}</span>`
@@ -401,6 +418,15 @@ function renderItem(item, isShare = window.IS_SHARE, optionsOrShowEditOnly = fal
const iconBtn = 'btn btn-outline-light btn-sm shopping-action-btn';
const wideBtn = 'btn btn-outline-light btn-sm shopping-action-btn shopping-action-btn--wide';
const itemNameHtml = canEditListItem
? `<button type="button"
id="name-${item.id}"
class="shopping-item-name text-white"
data-item-id="${item.id}"
data-item-name=${JSON.stringify(rawName)}
data-item-quantity="${quantity}"
${isArchived ? 'disabled aria-disabled="true"' : 'data-item-menu-trigger="true"'}>${safeName}</button>`
: `<span id="name-${item.id}" class="shopping-item-name text-white">${safeName}</span>`;
let actionButtons = '';
if (canEditListItem) {
@@ -438,7 +464,7 @@ function renderItem(item, isShare = window.IS_SHARE, optionsOrShowEditOnly = fal
<div class="shopping-item-content">
<div class="shopping-item-head">
<div class="shopping-item-text">
<span id="name-${item.id}" class="shopping-item-name text-white">${safeName}</span>
${itemNameHtml}
${quantityBadge}
${infoHtml}
</div>

View File

@@ -113,7 +113,7 @@ function setupList(listId, username) {
socket.on('expense_added', data => {
const badgeEl = document.getElementById('total-expense1');
if (badgeEl) {
badgeEl.innerHTML = `💸 ${data.total.toFixed(2)} PLN`;
badgeEl.innerHTML = `💸 ${formatCurrencyAmount(data.total)}`;
badgeEl.classList.remove('bg-secondary');
badgeEl.classList.add('bg-success');
badgeEl.style.display = '';
@@ -121,10 +121,10 @@ function setupList(listId, username) {
const summaryEl = document.getElementById('total-expense2');
if (summaryEl) {
summaryEl.innerHTML = `<b>💸 Łącznie wydano:</b> ${data.total.toFixed(2)} PLN`;
summaryEl.innerHTML = `<b>💸 Łącznie wydano:</b> ${formatCurrencyAmount(data.total)}`;
}
showToast(`Dodano wydatek: ${data.amount.toFixed(2)} PLN`, 'info');
showToast(`Dodano wydatek: ${formatCurrencyAmount(data.amount)}`, 'info');
});

View File

@@ -99,7 +99,7 @@ document.addEventListener("DOMContentLoaded", function () {
summary.innerHTML = `
<p class="mb-1">📦 <strong>${totalCount}</strong> produktów</p>
<p class="mb-1">✅ Kupione: <strong>${purchasedCount}</strong> (${percent}%)</p>
<p class="mb-0">💸 Wydatek: <strong>${totalExpense.toFixed(2)}</strong></p>`;
<p class="mb-0">💸 Wydatek: <strong>${formatCurrencyAmount(totalExpense)}</strong></p>`;
productList.appendChild(summary);
const purchased = createSection("✔️ Kupione");

View File

@@ -22,7 +22,7 @@ async function analyzeReceipts(listId) {
let html = `<div class="card bg-dark text-white border-secondary p-3">`;
html += `<p class="text-secondary"><small>⏱ Czas analizy OCR: ${duration} sek.</small></p>`;
html += `<p><b>📊 Łącznie wykryto:</b> ${data.total.toFixed(2)} PLN</p>`;
html += `<p><b>📊 Łącznie wykryto:</b> ${formatCurrencyAmount(data.total)}</p>`;
data.results.forEach((r, i) => {
const disabled = r.already_added ? "disabled" : "";

View File

@@ -12,7 +12,7 @@ document.addEventListener('DOMContentLoaded', () => {
total += parseFloat(cb.dataset.amount);
}
});
totalEl.textContent = total.toFixed(2) + ' PLN';
totalEl.textContent = formatCurrencyAmount(total);
}
selectAllBtn.addEventListener('click', () => {

View File

@@ -27,7 +27,7 @@
<span class="progress-label main-list-progress__label small fw-bold {% if percent < 51 %}text-white{% else %}text-dark{% endif %}">
Produkty: {{ purchased_count }}/{{ total_count }} ({{ percent|round(0)|int }}%)
{% if total_expense > 0 %} — 💸 {{ '%.2f'|format(total_expense) }} PLN{% endif %}
{% if total_expense > 0 %} — 💸 {{ format_currency(total_expense) }}{% endif %}
</span>
</div>
</div>

View File

@@ -51,7 +51,7 @@
</tr>
<tr>
<td>💸 Średnia kwota na listę</td>
<td class="text-end fw-bold">{{ avg_list_expense }}</td>
<td class="text-end fw-bold">{{ format_currency(avg_list_expense) }}</td>
</tr>
</tbody>
</table>
@@ -115,30 +115,30 @@
<tbody>
<tr>
<td>Wszystkie</td>
<td>{{ '%.2f'|format(expense_summary.all.month) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.all.year) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.all.total) }} PLN</td>
<td>{{ format_currency(expense_summary.all.month) }}</td>
<td>{{ format_currency(expense_summary.all.year) }}</td>
<td>{{ format_currency(expense_summary.all.total) }}</td>
<!-- <td>{{ '%.2f'|format(expense_summary.all.avg) }} PLN</td> -->
</tr>
<tr>
<td>Aktywne</td>
<td>{{ '%.2f'|format(expense_summary.active.month) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.active.year) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.active.total) }} PLN</td>
<td>{{ format_currency(expense_summary.active.month) }}</td>
<td>{{ format_currency(expense_summary.active.year) }}</td>
<td>{{ format_currency(expense_summary.active.total) }}</td>
<!-- <td>{{ '%.2f'|format(expense_summary.active.avg) }} PLN</td> -->
</tr>
<tr>
<td>Archiwalne</td>
<td>{{ '%.2f'|format(expense_summary.archived.month) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.archived.year) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.archived.total) }} PLN</td>
<td>{{ format_currency(expense_summary.archived.month) }}</td>
<td>{{ format_currency(expense_summary.archived.year) }}</td>
<td>{{ format_currency(expense_summary.archived.total) }}</td>
<!-- <td>{{ '%.2f'|format(expense_summary.archived.avg) }} PLN</td> -->
</tr>
<tr>
<td>Wygasłe</td>
<td>{{ '%.2f'|format(expense_summary.expired.month) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.expired.year) }} PLN</td>
<td>{{ '%.2f'|format(expense_summary.expired.total) }} PLN</td>
<td>{{ format_currency(expense_summary.expired.month) }}</td>
<td>{{ format_currency(expense_summary.expired.year) }}</td>
<td>{{ format_currency(expense_summary.expired.total) }}</td>
<!-- <td>{{ '%.2f'|format(expense_summary.expired.avg) }} PLN</td> -->
</tr>
</tbody>
@@ -282,7 +282,7 @@
{% if e.total_expense >= 500 %}text-danger
{% elif e.total_expense > 0 %}text-success{% endif %}">
{% if e.total_expense > 0 %}
{{ '%.2f'|format(e.total_expense) }} PLN
{{ format_currency(e.total_expense) }}
{% else %}
-
{% endif %}

View File

@@ -25,7 +25,7 @@
<!-- Wydatek i właściciel -->
<div class="row mb-3">
<div class="col-md-6">
<label for="amount" class="form-label">💰 Całkowity wydatek (PLN)</label>
<label for="amount" class="form-label">💰 Całkowity wydatek ({{ CURRENCY_CODE }})</label>
<input type="number" step="0.01" min="0" class="form-control bg-dark text-white border-secondary ui-consistent-input"
id="amount" name="amount" value="{{ '%.2f'|format(total_expense) }}">
</div>

View File

@@ -173,6 +173,10 @@
});
</script>
<script>
window.CURRENCY_CODE = {{ CURRENCY_CODE|tojson }};
</script>
{% if request.endpoint != 'system_auth' %}
<script src="{{ static_asset_url('static_bp.serve_js_lib', 'glightbox.min.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js_lib', 'socket.io.min.js') }}"></script>

View File

@@ -88,7 +88,7 @@
<button id="deselectAllBtn" class="btn btn-sm btn-outline-light active" style="display: none;">Odznacz
wszystko</button>
</div>
<h5 class="text-success m-0">💰 Suma: <span id="listsTotal">0.00 PLN</span></h5>
<h5 class="text-success m-0">💰 Suma: <span id="listsTotal">{{ format_currency(0) }}</span></h5>
</div>
<!-- Tabela list z możliwością filtrowania -->
@@ -101,7 +101,7 @@
<th>Nazwa listy</th>
<th>Właściciel</th>
<th>Data</th>
<th>Wydatki (PLN)</th>
<th>Wydatki ({{ CURRENCY_CODE }})</th>
</tr>
</thead>
<tbody id="listsTableBody">

View File

@@ -96,11 +96,11 @@
<br>
{% if total_expense > 0 %}
<div id="total-expense2" class="text-success fw-bold mb-3">
💸 Łącznie wydano: {{ '%.2f'|format(total_expense) }} PLN
💸 Łącznie wydano: {{ format_currency(total_expense) }}
</div>
{% else %}
<div id="total-expense2" class="text-success fw-bold mb-3">
💸 Łącznie wydano: 0.00 PLN
💸 Łącznie wydano: {{ format_currency(0) }}
</div>
{% endif %}
@@ -125,7 +125,13 @@
<div class="shopping-item-content">
<div class="shopping-item-head">
<div class="shopping-item-text">
<span id="name-{{ item.id }}" class="shopping-item-name text-white">{{ item.name }}</span>
<button type="button"
id="name-{{ item.id }}"
class="shopping-item-name text-white"
data-item-id="{{ item.id }}"
data-item-name={{ item.name|tojson }}
data-item-quantity="{{ item.quantity or 1 }}"
{% if not list.is_archived %}data-item-menu-trigger="true"{% else %}disabled aria-disabled="true"{% endif %}>{{ item.name }}</button>
{% if item.quantity and item.quantity > 1 %}
<span class="badge rounded-pill bg-secondary">x{{ item.quantity }}</span>
{% endif %}
@@ -166,6 +172,11 @@
{% endfor %}
</ul>
<div id="desktopItemMenu" hidden>
<button type="button" class="btn btn-outline-light btn-sm w-100 text-start" data-menu-action="edit">✏️ Edytuj</button>
<button type="button" class="btn btn-outline-danger btn-sm w-100 text-start" data-menu-action="delete">🗑️ Usuń</button>
</div>
<div class="modal fade" id="editItemModal" tabindex="-1" aria-labelledby="editItemModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-white">
@@ -541,6 +552,7 @@
<script src="{{ static_asset_url('static_bp.serve_js', 'access_users.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'category_modal.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'notes.js') }}"></script>
<script src="{{ static_asset_url('static_bp.serve_js', 'desktop_item_menu.js') }}"></script>
<script>
setupList({{ list.id }}, '{{ current_user.username if current_user.is_authenticated else 'Gość' }}');

View File

@@ -12,11 +12,11 @@
{% if total_expense > 0 %}
<span id="total-expense1" class="badge rounded-pill bg-success ms-2">
💸 {{ '%.2f'|format(total_expense) }} PLN
💸 {{ format_currency(total_expense) }}
</span>
{% else %}
<span id="total-expense" class="badge rounded-pill bg-secondary ms-2" style="display: none;">
💸 0.00 PLN
💸 {{ format_currency(0) }}
</span>
{% endif %}
@@ -114,7 +114,7 @@
<span>💰 Dodaj wydatek</span>
<span class="badge rounded-pill bg-success" id="total-expense2">
💸 Łączna suma: {{ '%.2f'|format(total_expense) }} PLN
💸 Łączna suma: {{ format_currency(total_expense) }}
</span>
</div>
@@ -123,7 +123,7 @@
<div class="input-group mb-0 shopping-compact-input-group shopping-expense-input-group">
<input id="expenseAmount" type="number" step="0.01" min="0"
class="form-control bg-dark text-white border-secondary shopping-expense-amount-input"
placeholder="Kwota (PLN)">
placeholder="{{ currency_placeholder() }}">
<button onclick="submitExpense({{ list.id }})"
class="btn btn-outline-primary share-submit-btn share-submit-btn--expense shopping-compact-submit">
@@ -135,7 +135,7 @@
{% endif %}
<p id="total-expense2" style="display: none;">
<b>💸 Łącznie wydano:</b> {{ '%.2f'|format(total_expense) }} PLN
<b>💸 Łącznie wydano:</b> {{ format_currency(total_expense) }}
</p>
<button id="toggleReceiptBtn" type="button" class="receipt-disclosure mb-3"

View File

@@ -101,7 +101,7 @@
</div>
<div class="main-summary-stat">
<span class="main-summary-stat__label">Wydatki</span>
<strong>{{ '%.2f'|format(summary.total_expense) }} PLN</strong>
<strong>{{ format_currency(summary.total_expense) }}</strong>
</div>
</div>
</div>

View File

@@ -26,6 +26,9 @@ def inject_version():
return {
"APP_VERSION": app.config["APP_VERSION"],
"CURRENCY_CODE": get_currency_code(),
"format_currency": format_currency,
"currency_placeholder": currency_placeholder,
"static_asset_url": static_asset_url,
}