first commit

This commit is contained in:
Mateusz Gruszczyński
2026-03-13 15:17:32 +01:00
commit 986ffb200a
91 changed files with 4423 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
{% extends 'base.html' %}
{% block content %}
<div class="row g-3">
<div class="col-lg-4">
<div class="card shadow-sm"><div class="card-body">
<h1 class="h4">{{ t('budgets.add') }}</h1>
<form method="post">{{ form.hidden_tag() }}
<div class="mb-2">{{ form.category_id.label(class='form-label') }}{{ form.category_id(class='form-select') }}</div>
<div class="row g-2"><div class="col">{{ form.year.label(class='form-label') }}{{ form.year(class='form-control') }}</div><div class="col">{{ form.month.label(class='form-label') }}{{ form.month(class='form-control') }}</div></div>
<div class="mt-2">{{ form.amount.label(class='form-label') }}{{ form.amount(class='form-control') }}</div>
<div class="mt-2">{{ form.alert_percent.label(class='form-label') }}{{ form.alert_percent(class='form-control') }}</div>
<button class="btn btn-primary mt-3">{{ t('common.save') }}</button>
</form>
</div></div>
</div>
<div class="col-lg-8">
<div class="card shadow-sm"><div class="card-body">
<h2 class="h5">{{ t('budgets.title') }}</h2>
<div class="table-responsive"><table class="table"><thead><tr><th>{{ t('expenses.category') }}</th><th>{{ t('common.year') }}</th><th>{{ t('common.month') }}</th><th>{{ t('expenses.amount') }}</th></tr></thead><tbody>
{% for budget in budgets %}<tr><td>{{ budget.category.localized_name(current_language) }}</td><td>{{ budget.year }}</td><td>{{ budget.month }}</td><td>{{ budget.amount }}</td></tr>{% else %}<tr><td colspan="4" class="text-body-secondary">{{ t('expenses.empty') }}</td></tr>{% endfor %}
</tbody></table></div>
</div></div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,73 @@
{% extends 'base.html' %}
{% block content %}
<div class="card mx-auto" style="max-width: 1080px;">
<div class="card-body p-4 p-lg-5">
<div class="app-section-title">
<span class="feature-icon"><i class="fa-solid fa-file-circle-plus"></i></span>
<div>
<h1 class="h3 mb-0">{{ t('expenses.edit') if expense else t('expenses.new') }}</h1>
<div class="text-body-secondary">{{ t('expenses.form_subtitle') }}</div>
</div>
</div>
<form method="post" enctype="multipart/form-data">
{{ form.hidden_tag() }}
<div class="row g-3">
<div class="col-lg-7">
<div class="row g-3">
<div class="col-md-6">{{ form.amount.label(class='form-label') }}{{ form.amount(class='form-control') }}</div>
<div class="col-md-6">{{ form.purchase_date.label(class='form-label') }}{{ form.purchase_date(class='form-control') }}</div>
<div class="col-md-7">{{ form.category_id.label(class='form-label') }}{{ form.category_id(class='form-select') }}</div>
<div class="col-md-5">{{ form.payment_method.label(class='form-label') }}{{ form.payment_method(class='form-select') }}</div>
<div class="col-md-8">{{ form.title.label(class='form-label') }}{{ form.title(class='form-control', placeholder=t('expenses.placeholder_title')) }}</div>
<div class="col-md-4">{{ form.currency.label(class='form-label') }}{{ form.currency(class='form-select') }}</div>
<div class="col-md-6">{{ form.vendor.label(class='form-label') }}{{ form.vendor(class='form-control', placeholder=t('expenses.placeholder_vendor')) }}</div>
<div class="col-md-6">
{{ form.document.label(class='form-label') }}
<div class="upload-actions d-grid gap-2" id="documentUploadActions">
<button class="btn btn-outline-primary d-none" type="button" id="cameraCaptureButton"><i class="fa-solid fa-camera me-2"></i>{{ t('expenses.take_photo') }}</button>
<button class="btn btn-outline-secondary" type="button" id="filePickerButton"><i class="fa-solid fa-upload me-2"></i>{{ t('expenses.select_files') }}</button>
</div>
<div class="drop-upload-zone mt-2 d-none d-lg-flex" id="dropUploadZone"><i class="fa-solid fa-cloud-arrow-up me-2"></i><span>{{ t('expenses.drop_files_here') }}</span></div><div class="form-text mt-2" id="documentInputHint" data-desktop-hint="{{ t('expenses.upload_hint_desktop') }}" data-mobile-hint="{{ t('expenses.upload_hint_mobile') }}">{{ t('expenses.upload_hint_desktop') }}</div>
{{ form.document(class='d-none', id='documentInput', accept='.jpg,.jpeg,.png,.heic,.pdf,image/*,application/pdf', multiple=true) }}
</div>
<div class="col-12">{{ form.description.label(class='form-label') }}{{ form.description(class='form-control', rows='3', placeholder=t('expenses.placeholder_description')) }}</div>
<div class="col-md-5">{{ form.tags.label(class='form-label') }}{{ form.tags(class='form-control', placeholder=t('expenses.placeholder_tags')) }}</div>
<div class="col-md-3">{{ form.status.label(class='form-label') }}{{ form.status(class='form-select') }}</div>
<div class="col-md-4">{{ form.recurring_period.label(class='form-label') }}{{ form.recurring_period(class='form-select') }}</div>
</div>
</div>
<div class="col-lg-5">
<div class="card h-100 document-editor-card"><div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h5 mb-0"><i class="fa-solid fa-wand-magic-sparkles me-2"></i>{{ t('expenses.document_tools') }}</h2>
<span class="badge soft-badge">{{ t('expenses.webp_preview') }}</span>
</div>
<div class="document-editor-toolbar mb-3 d-flex flex-wrap gap-2">
<button class="btn btn-sm btn-outline-secondary js-rotate" type="button" data-step="-90"><i class="fa-solid fa-rotate-left me-1"></i>-90°</button>
<button class="btn btn-sm btn-outline-secondary js-rotate" type="button" data-step="90"><i class="fa-solid fa-rotate-right me-1"></i>+90°</button>
<button class="btn btn-sm btn-outline-secondary js-scale" type="button" data-step="-10"><i class="fa-solid fa-magnifying-glass-minus me-1"></i>-10%</button>
<button class="btn btn-sm btn-outline-secondary js-scale" type="button" data-step="10"><i class="fa-solid fa-magnifying-glass-plus me-1"></i>+10%</button>
<button class="btn btn-sm btn-outline-secondary" type="button" id="editorReset"><i class="fa-solid fa-arrows-rotate me-1"></i>{{ t('common.reset') }}</button>
</div>
<div class="editor-meta row g-2 mb-3">
<div class="col-6">{{ form.rotate.label(class='form-label small') }}{{ form.rotate(class='form-control', readonly=true, id='rotateField') }}</div>
<div class="col-6">{{ form.scale_percent.label(class='form-label small') }}{{ form.scale_percent(class='form-control', readonly=true, id='scaleField') }}</div>
</div>
<div class="document-preview-shell">
<div id="documentPreviewStage" class="document-preview-stage">
<img id="documentPreviewImage" alt="preview" class="d-none">
<div id="documentPreviewEmpty" class="document-preview-empty"><i class="fa-regular fa-image"></i><div>{{ t('expenses.upload_to_edit') }}</div></div>
<div id="cropSelection" class="crop-selection d-none"></div>
</div>
</div>
<div class="footer-note mt-3">{{ t('expenses.crop_note') }}</div>
{% if expense and expense.attachments %}<div class="mt-3"><div class="small text-body-secondary mb-2">Existing files</div><div class="d-flex flex-wrap gap-2">{% for item in expense.attachments %}{% if item.preview_filename %}<a class="d-inline-block" target="_blank" href="{{ url_for('static', filename='previews/' ~ item.preview_filename) }}"><img class="expense-row-thumb" src="{{ url_for('static', filename='previews/' ~ item.preview_filename) }}" alt="preview"></a>{% else %}<span class="badge text-bg-secondary">{{ item.original_filename }}</span>{% endif %}{% endfor %}</div></div>{% endif %}
<div class="d-flex gap-3 mt-3 flex-wrap"><div class="form-check">{{ form.is_refund(class='form-check-input') }} {{ form.is_refund.label(class='form-check-label') }}</div><div class="form-check">{{ form.is_business(class='form-check-input') }} {{ form.is_business.label(class='form-check-label') }}</div></div>
</div></div>
</div>
</div>
<button class="btn btn-primary mt-4 px-4"><i class="fa-solid fa-floppy-disk me-2"></i>{{ t('expenses.save') }}</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,210 @@
{% extends 'base.html' %}
{% block content %}
<div class="hero-panel mb-4">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
<div>
<div class="app-section-title mb-2">
<span class="feature-icon"><i class="fa-solid fa-receipt"></i></span>
<div>
<h1 class="h3 mb-0">{{ t('expenses.list') }}</h1>
<div class="text-body-secondary">{{ selected_year }}-{{ '%02d'|format(selected_month) }}</div>
</div>
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<a class="btn btn-outline-secondary" href="{{ url_for('expenses.export_csv', **request.args) }}"><i class="fa-solid fa-file-csv me-2"></i>{{ t('expenses.export_csv') }}</a>
<a class="btn btn-outline-secondary" href="{{ url_for('expenses.export_pdf', **request.args) }}"><i class="fa-solid fa-file-pdf me-2"></i>{{ t('expenses.export_pdf') }}</a>
<a class="btn btn-primary" href="{{ url_for('expenses.create_expense') }}"><i class="fa-solid fa-plus me-2"></i>{{ t('nav.add_expense') }}</a>
</div>
</div>
<div class="month-switcher mb-3">
<a class="btn btn-outline-secondary" href="{{ url_for('expenses.list_expenses', year=selected_year if selected_month>1 else selected_year-1, month=selected_month-1 if selected_month>1 else 12, category_id=filters.category_id or None, payment_method=filters.payment_method or None, status=filters.status or None, q=filters.q or None, sort_by=filters.sort_by, sort_dir=filters.sort_dir, group_by=filters.group_by) }}"><i class="fa-solid fa-chevron-left me-2"></i>{{ t('common.previous') }}</a>
<form class="center-panel" method="get">
<i class="fa-regular fa-calendar"></i>
<input class="form-control" style="max-width:130px" type="number" name="year" value="{{ selected_year }}">
<input class="form-control" style="max-width:110px" type="number" name="month" value="{{ selected_month }}" min="1" max="12">
<input type="hidden" name="category_id" value="{{ filters.category_id or 0 }}">
<input type="hidden" name="payment_method" value="{{ filters.payment_method }}">
<input type="hidden" name="status" value="{{ filters.status }}">
<input type="hidden" name="q" value="{{ filters.q }}">
<input type="hidden" name="sort_by" value="{{ filters.sort_by }}">
<input type="hidden" name="sort_dir" value="{{ filters.sort_dir }}">
<input type="hidden" name="group_by" value="{{ filters.group_by }}">
<button class="btn btn-outline-primary"><i class="fa-solid fa-arrow-right me-2"></i>OK</button>
</form>
<a class="btn btn-outline-secondary" href="{{ url_for('expenses.list_expenses', year=selected_year if selected_month<12 else selected_year+1, month=selected_month+1 if selected_month<12 else 1, category_id=filters.category_id or None, payment_method=filters.payment_method or None, status=filters.status or None, q=filters.q or None, sort_by=filters.sort_by, sort_dir=filters.sort_dir, group_by=filters.group_by) }}">{{ t('common.next') }}<i class="fa-solid fa-chevron-right ms-2"></i></a>
</div>
<div class="quick-stats expense-list-stats mb-3">
<div class="metric-card">
<div class="metric-label">{{ t('expenses.filtered_total') }}</div>
<div class="metric-value">{{ '%.2f'|format(month_total) }}</div>
<div class="small text-body-secondary">{{ expenses|length }} {{ t('expenses.results') }}</div>
</div>
<div class="metric-card">
<div class="metric-label">{{ t('expenses.active_sort') }}</div>
<div class="metric-value fs-5">{{ dict(sort_options).get(filters.sort_by, t('expenses.date')) }}</div>
<div class="small text-body-secondary">{{ t('expenses.' ~ filters.sort_dir) if filters.sort_dir in ['asc', 'desc'] else filters.sort_dir }}</div>
</div>
<div class="metric-card">
<div class="metric-label">{{ t('expenses.grouping') }}</div>
<div class="metric-value fs-5">{{ t('expenses.group_' ~ filters.group_by) if filters.group_by in ['category','payment_method','status','none'] else filters.group_by }}</div>
<div class="small text-body-secondary">{{ grouped_expenses|length }} {{ t('expenses.sections') }}</div>
</div>
<div class="metric-card">
<div class="metric-label">{{ t('expenses.categories_count') }}</div>
<div class="metric-value">{{ categories|length }}</div>
<div class="small text-body-secondary">{{ t('expenses.month_view') }}</div>
</div>
</div>
<form method="get" class="expense-filters card card-body border-0 shadow-sm">
<div class="row g-3 align-items-end">
<input type="hidden" name="year" value="{{ selected_year }}">
<input type="hidden" name="month" value="{{ selected_month }}">
<div class="col-lg-4">
<label class="form-label small">{{ t('common.search') }}</label>
<div class="search-input-wrap">
<i class="fa-solid fa-magnifying-glass"></i>
<input class="form-control ps-5" name="q" value="{{ filters.q }}" placeholder="{{ t('expenses.search_placeholder') }}">
</div>
</div>
<div class="col-sm-6 col-lg-2">
<label class="form-label small">{{ t('expenses.category') }}</label>
<select class="form-select" name="category_id">
<option value="0">{{ t('common.all') }}</option>
{% for category in categories %}
<option value="{{ category.id }}" {% if filters.category_id==category.id %}selected{% endif %}>{{ category.localized_name(current_language) }}</option>
{% endfor %}
</select>
</div>
<div class="col-sm-6 col-lg-2">
<label class="form-label small">{{ t('expenses.payment_method') }}</label>
<select class="form-select" name="payment_method">
<option value="">{{ t('common.all') }}</option>
<option value="card" {% if filters.payment_method=='card' %}selected{% endif %}>{{ t('expenses.payment_card') }}</option>
<option value="cash" {% if filters.payment_method=='cash' %}selected{% endif %}>{{ t('expenses.payment_cash') }}</option>
<option value="transfer" {% if filters.payment_method=='transfer' %}selected{% endif %}>{{ t('expenses.payment_transfer') }}</option>
<option value="blik" {% if filters.payment_method=='blik' %}selected{% endif %}>{{ t('expenses.payment_blik') }}</option>
</select>
</div>
<div class="col-sm-6 col-lg-2">
<label class="form-label small">{{ t('expenses.status') }}</label>
<select class="form-select" name="status">
<option value="">{{ t('common.all') }}</option>
<option value="new" {% if filters.status=='new' %}selected{% endif %}>{{ t('expenses.status_new') }}</option>
<option value="needs_review" {% if filters.status=='needs_review' %}selected{% endif %}>{{ t('expenses.status_needs_review') }}</option>
<option value="confirmed" {% if filters.status=='confirmed' %}selected{% endif %}>{{ t('expenses.status_confirmed') }}</option>
</select>
</div>
<div class="col-sm-6 col-lg-2">
<label class="form-label small">{{ t('expenses.sort_by') }}</label>
<select class="form-select" name="sort_by">
{% for value, label in sort_options %}
<option value="{{ value }}" {% if filters.sort_by==value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-sm-6 col-lg-2">
<label class="form-label small">{{ t('expenses.sort_direction') }}</label>
<select class="form-select" name="sort_dir">
<option value="desc" {% if filters.sort_dir=='desc' %}selected{% endif %}>{{ t('expenses.desc') }}</option>
<option value="asc" {% if filters.sort_dir=='asc' %}selected{% endif %}>{{ t('expenses.asc') }}</option>
</select>
</div>
<div class="col-sm-6 col-lg-2">
<label class="form-label small">{{ t('expenses.group_by') }}</label>
<select class="form-select" name="group_by">
<option value="category" {% if filters.group_by=='category' %}selected{% endif %}>{{ t('expenses.group_category') }}</option>
<option value="payment_method" {% if filters.group_by=='payment_method' %}selected{% endif %}>{{ t('expenses.group_payment_method') }}</option>
<option value="status" {% if filters.group_by=='status' %}selected{% endif %}>{{ t('expenses.group_status') }}</option>
<option value="none" {% if filters.group_by=='none' %}selected{% endif %}>{{ t('expenses.group_none') }}</option>
</select>
</div>
<div class="col-lg-4 d-flex gap-2">
<button class="btn btn-primary flex-grow-1"><i class="fa-solid fa-filter me-2"></i>{{ t('common.filter') }}</button>
<a class="btn btn-outline-secondary" href="{{ url_for('expenses.list_expenses', year=selected_year, month=selected_month) }}"><i class="fa-solid fa-rotate-left me-2"></i>{{ t('common.reset') }}</a>
</div>
</div>
</form>
</div>
{% if budgets %}<div class="alert alert-info border-0 shadow-sm"><i class="fa-solid fa-bullseye me-2"></i>{{ t('budgets.title') }}: {% for budget in budgets %}{{ budget.category.localized_name(current_language) }} {{ budget.amount }}{% if not loop.last %}, {% endif %}{% endfor %}</div>{% endif %}
{% if expenses %}
<div class="expense-groups d-grid gap-3">
{% for group in grouped_expenses %}
<section class="card expense-group-card">
<div class="card-header expense-group-header">
<div>
<div class="h5 mb-1">{{ group['label'] }}</div>
<div class="small text-body-secondary">{{ group['items']|length }} {{ t('expenses.results') }}</div>
</div>
<div class="text-end">
<div class="small text-body-secondary">{{ t('expenses.filtered_total') }}</div>
<div class="h5 mb-0">{{ '%.2f'|format(group['total']) }}</div>
</div>
</div>
<div class="card-body p-0">
{% for expense in group['items'] %}
<article class="expense-list-item">
<div class="expense-list-main">
<div class="expense-list-thumb-wrap">
{% if expense.preview_filename %}
<img class="expense-row-thumb" src="{{ url_for('static', filename='previews/' ~ expense.preview_filename) }}" alt="preview">
{% else %}
<span class="soft-icon"><i class="fa-solid fa-receipt"></i></span>
{% endif %}
</div>
<div class="expense-list-copy">
<div class="d-flex flex-wrap align-items-center gap-2 mb-1">
<span class="expense-title">{{ expense.title }}</span>
<span class="badge rounded-pill soft-badge">{{ expense.category.localized_name(current_language) if expense.category else t('common.uncategorized') }}</span>
<span class="badge text-bg-light border">{{ expense.purchase_date }}</span>
</div>
<div class="expense-meta-row">
{% if expense.vendor %}<span><i class="fa-solid fa-store me-1"></i>{{ expense.vendor }}</span>{% endif %}
{% if expense.payment_method %}<span><i class="fa-regular fa-credit-card me-1"></i>{{ t('expenses.payment_' ~ expense.payment_method) if expense.payment_method in ['card','cash','transfer','blik'] else expense.payment_method }}</span>{% endif %}
{% if expense.tags %}<span><i class="fa-solid fa-tags me-1"></i>{{ expense.tags }}</span>{% endif %}
{% if expense.status %}<span><i class="fa-solid fa-shield-halved me-1"></i>{{ t('expenses.status_' ~ expense.status) if expense.status in ['new','needs_review','confirmed'] else expense.status }}</span>{% endif %}
</div>
{% if expense.description %}<div class="small text-body-secondary mt-2">{{ expense.description }}</div>{% endif %}
</div>
</div>
<div class="expense-list-side">
<div class="expense-amount">{{ expense.amount }} {{ expense.currency }}</div>
<div class="expense-actions">
{% if expense.all_previews %}
{% for preview_name in expense.all_previews[:3] %}
<button type="button" class="btn btn-sm btn-outline-secondary preview-trigger" data-bs-toggle="modal" data-bs-target="#previewModal" data-preview="{{ url_for('static', filename='previews/' ~ preview_name) }}"><i class="fa-solid fa-image"></i></button>
{% endfor %}
{% endif %}
<a class="btn btn-sm btn-outline-primary" href="{{ url_for('expenses.edit_expense', expense_id=expense.id) }}"><i class="fa-solid fa-pen-to-square me-1"></i>{{ t('expenses.edit') }}</a>
<form method="post" action="{{ url_for('expenses.delete_expense', expense_id=expense.id) }}" class="d-inline">{{ csrf_token() if csrf_token else '' }}<button class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-trash"></i></button></form>
</div>
</div>
</article>
{% endfor %}
</div>
</section>
{% endfor %}
</div>
{% else %}
<div class="card"><div class="empty-state"><i class="fa-solid fa-wallet"></i><div>{{ t('expenses.empty') }}</div></div></div>
{% endif %}
<div class="modal fade" id="previewModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-xl">
<div class="modal-content glass-card">
<div class="modal-header border-0">
<h2 class="h5 mb-0"><i class="fa-solid fa-image me-2"></i>{{ t('expenses.preview') }}</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center">
<img id="previewModalImage" src="" alt="preview" class="img-fluid rounded-4 border">
</div>
</div>
</div>
</div>
{% endblock %}